summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorJason O'Donnell <2160810+jasonodonnell@users.noreply.github.com>2026-04-08 18:47:52 -0400
committerGitHub <noreply@github.com>2026-04-08 18:47:52 -0400
commitd948b78b23af036d15dc8670f1f832af62fefe0a (patch)
tree100f7dc2471ff32b1541cf3abb0120070a56d61b
parent647deed2d987e747d68f026a551ea45285852b89 (diff)
downloadtailscale-d948b78b23af036d15dc8670f1f832af62fefe0a.tar.xz
tailscale-d948b78b23af036d15dc8670f1f832af62fefe0a.zip
tsweb: add TS_DEBUG_TRUSTED_CIDRS envknob to debug (#19283)
Add a new envknob that allows connections from trusted CIDR ranges to access debug endpoints without Tailscale authentication. This is useful for in-cluster scrapers like Prometheus that are not on a tailnet, do not have static IP addresses and cannot use debug keys. Fixes #19282 Signed-off-by: Jason O'Donnell <2160810+jasonodonnell@users.noreply.github.com>
-rw-r--r--tsweb/debug_test.go81
-rw-r--r--tsweb/tsweb.go48
2 files changed, 129 insertions, 0 deletions
diff --git a/tsweb/debug_test.go b/tsweb/debug_test.go
index b46a3a3f3..79c686b6b 100644
--- a/tsweb/debug_test.go
+++ b/tsweb/debug_test.go
@@ -8,7 +8,9 @@ import (
"io"
"net/http"
"net/http/httptest"
+ "net/netip"
"runtime"
+ "slices"
"strings"
"testing"
)
@@ -206,3 +208,82 @@ func ExampleDebugHandler_Section() {
fmt.Fprintf(w, "<code>%#v</code>", r)
})
}
+
+func TestParseTrustedCIDRs(t *testing.T) {
+ tests := []struct {
+ name string
+ raw string
+ want []netip.Prefix
+ }{
+ {
+ name: "empty",
+ raw: "",
+ want: nil,
+ },
+ {
+ name: "single_v4",
+ raw: "10.0.0.0/8",
+ want: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
+ },
+ {
+ name: "multiple",
+ raw: "10.0.0.0/8,172.16.0.0/12",
+ want: []netip.Prefix{
+ netip.MustParsePrefix("10.0.0.0/8"),
+ netip.MustParsePrefix("172.16.0.0/12"),
+ },
+ },
+ {
+ name: "spaces_trimmed",
+ raw: " 10.0.0.0/8 , 192.168.0.0/16 ",
+ want: []netip.Prefix{
+ netip.MustParsePrefix("10.0.0.0/8"),
+ netip.MustParsePrefix("192.168.0.0/16"),
+ },
+ },
+ {
+ name: "ipv6",
+ raw: "fd00::/8",
+ want: []netip.Prefix{netip.MustParsePrefix("fd00::/8")},
+ },
+ {
+ name: "trailing_comma",
+ raw: "10.0.0.0/8,",
+ want: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := parseTrustedCIDRs(tt.raw)
+ if !slices.Equal(got, tt.want) {
+ t.Fatalf("got %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestAllowDebugAccessTrustedCIDRContains(t *testing.T) {
+ // Verify that parsed CIDRs correctly match/reject IPs.
+ cidrs := parseTrustedCIDRs("10.0.0.0/8,192.168.1.0/24,fd00::/8")
+
+ tests := []struct {
+ ip string
+ want bool
+ }{
+ {"10.1.2.3", true},
+ {"10.255.255.255", true},
+ {"192.168.1.50", true},
+ {"192.168.2.1", false},
+ {"172.16.0.1", false},
+ {"8.8.8.8", false},
+ {"fd00::1", true},
+ {"fe80::1", false},
+ }
+ for _, tt := range tests {
+ ip := netip.MustParseAddr(tt.ip)
+ if got := cidrsContain(cidrs, ip); got != tt.want {
+ t.Errorf("CIDRs contain %s = %v, want %v", tt.ip, got, tt.want)
+ }
+ }
+}
diff --git a/tsweb/tsweb.go b/tsweb/tsweb.go
index c73010783..101512b89 100644
--- a/tsweb/tsweb.go
+++ b/tsweb/tsweb.go
@@ -13,6 +13,7 @@ import (
"expvar"
"fmt"
"io"
+ "log"
"maps"
"net"
"net/http"
@@ -54,6 +55,50 @@ func IsProd443(addr string) bool {
return port == "443" || port == "https"
}
+// debugTrustedCIDRs is the envknob for TS_DEBUG_TRUSTED_CIDRS, a
+// comma-separated list of CIDR ranges (e.g. "10.0.0.0/8,172.16.0.0/12")
+// whose source IPs are allowed to access debug endpoints without Tailscale
+// authentication. This will supersede both IsTailscaleIP() and
+// TS_ALLOW_DEBUG_IP.
+var debugTrustedCIDRs = envknob.RegisterString("TS_DEBUG_TRUSTED_CIDRS")
+
+// trustedCIDRs returns the parsed CIDR prefixes from TS_DEBUG_TRUSTED_CIDRS.
+var trustedCIDRs = sync.OnceValue(func() []netip.Prefix {
+ return parseTrustedCIDRs(debugTrustedCIDRs())
+})
+
+// parseTrustedCIDRs parses a comma-separated list of CIDR prefixes.
+// It fatals on invalid entries, consistent with other envknob parsing.
+func parseTrustedCIDRs(raw string) []netip.Prefix {
+ if raw == "" {
+ return nil
+ }
+ var prefixes []netip.Prefix
+ for _, s := range strings.Split(raw, ",") {
+ s = strings.TrimSpace(s)
+ if s == "" {
+ continue
+ }
+ pfx, err := netip.ParsePrefix(s)
+ if err != nil {
+ log.Fatalf("invalid CIDR in TS_DEBUG_TRUSTED_CIDRS: %q: %v", s, err)
+ }
+ prefixes = append(prefixes, pfx)
+ }
+ return prefixes
+}
+
+// cidrsContain checks if the source IP is associated with one of the
+// provided cidrs.
+func cidrsContain(cidrs []netip.Prefix, ip netip.Addr) bool {
+ for _, pfx := range cidrs {
+ if pfx.Contains(ip) {
+ return true
+ }
+ }
+ return false
+}
+
// AllowDebugAccess reports whether r should be permitted to access
// various debug endpoints.
func AllowDebugAccess(r *http.Request) bool {
@@ -75,6 +120,9 @@ func AllowDebugAccess(r *http.Request) bool {
if tsaddr.IsTailscaleIP(ip) || ip.IsLoopback() || ipStr == envknob.String("TS_ALLOW_DEBUG_IP") {
return true
}
+ if cidrsContain(trustedCIDRs(), ip) {
+ return true
+ }
return false
}