feature/featuretags: add features for c2n, peerapi, advertise/use routes/exit nodes

Saves 262 KB so far. I'm sure I missed some places, but shotizam says
these were the low hanging fruit.

Updates #12614

Change-Id: Ia31c01b454f627e6d0470229aae4e19d615e45e3
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
Brad Fitzpatrick
2025-10-01 19:18:46 -07:00
committed by Brad Fitzpatrick
parent 2cd518a8b6
commit a208cb9fd5
29 changed files with 469 additions and 79 deletions

View File

@@ -1409,6 +1409,9 @@ func (c *Direct) answerPing(pr *tailcfg.PingRequest) {
answerHeadPing(c.logf, httpc, pr)
return
case "c2n":
if !buildfeatures.HasC2N {
return
}
if !useNoise && !envknob.Bool("TS_DEBUG_PERMIT_HTTP_C2N") {
c.logf("refusing to answer c2n ping without noise")
return

View File

@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build ts_omit_advertiseexitnode
package buildfeatures
// HasAdvertiseExitNode is whether the binary was built with support for modular feature "Run an exit node".
// Specifically, it's whether the binary was NOT built with the "ts_omit_advertiseexitnode" build tag.
// It's a const so it can be used for dead code elimination.
const HasAdvertiseExitNode = false

View File

@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build !ts_omit_advertiseexitnode
package buildfeatures
// HasAdvertiseExitNode is whether the binary was built with support for modular feature "Run an exit node".
// Specifically, it's whether the binary was NOT built with the "ts_omit_advertiseexitnode" build tag.
// It's a const so it can be used for dead code elimination.
const HasAdvertiseExitNode = true

View File

@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build ts_omit_advertiseroutes
package buildfeatures
// HasAdvertiseRoutes is whether the binary was built with support for modular feature "Advertise routes for other nodes to use".
// Specifically, it's whether the binary was NOT built with the "ts_omit_advertiseroutes" build tag.
// It's a const so it can be used for dead code elimination.
const HasAdvertiseRoutes = false

View File

@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build !ts_omit_advertiseroutes
package buildfeatures
// HasAdvertiseRoutes is whether the binary was built with support for modular feature "Advertise routes for other nodes to use".
// Specifically, it's whether the binary was NOT built with the "ts_omit_advertiseroutes" build tag.
// It's a const so it can be used for dead code elimination.
const HasAdvertiseRoutes = true

View File

@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build ts_omit_c2n
package buildfeatures
// HasC2N is whether the binary was built with support for modular feature "Control-to-node (C2N) support".
// Specifically, it's whether the binary was NOT built with the "ts_omit_c2n" build tag.
// It's a const so it can be used for dead code elimination.
const HasC2N = false

View File

@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build !ts_omit_c2n
package buildfeatures
// HasC2N is whether the binary was built with support for modular feature "Control-to-node (C2N) support".
// Specifically, it's whether the binary was NOT built with the "ts_omit_c2n" build tag.
// It's a const so it can be used for dead code elimination.
const HasC2N = true

View File

@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build ts_omit_peerapiclient
package buildfeatures
// HasPeerAPIClient is whether the binary was built with support for modular feature "PeerAPI client support".
// Specifically, it's whether the binary was NOT built with the "ts_omit_peerapiclient" build tag.
// It's a const so it can be used for dead code elimination.
const HasPeerAPIClient = false

View File

@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build !ts_omit_peerapiclient
package buildfeatures
// HasPeerAPIClient is whether the binary was built with support for modular feature "PeerAPI client support".
// Specifically, it's whether the binary was NOT built with the "ts_omit_peerapiclient" build tag.
// It's a const so it can be used for dead code elimination.
const HasPeerAPIClient = true

View File

@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build ts_omit_peerapiserver
package buildfeatures
// HasPeerAPIServer is whether the binary was built with support for modular feature "PeerAPI server support".
// Specifically, it's whether the binary was NOT built with the "ts_omit_peerapiserver" build tag.
// It's a const so it can be used for dead code elimination.
const HasPeerAPIServer = false

View File

@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build !ts_omit_peerapiserver
package buildfeatures
// HasPeerAPIServer is whether the binary was built with support for modular feature "PeerAPI server support".
// Specifically, it's whether the binary was NOT built with the "ts_omit_peerapiserver" build tag.
// It's a const so it can be used for dead code elimination.
const HasPeerAPIServer = true

View File

@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build ts_omit_useexitnode
package buildfeatures
// HasUseExitNode is whether the binary was built with support for modular feature "Use exit nodes".
// Specifically, it's whether the binary was NOT built with the "ts_omit_useexitnode" build tag.
// It's a const so it can be used for dead code elimination.
const HasUseExitNode = false

View File

@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build !ts_omit_useexitnode
package buildfeatures
// HasUseExitNode is whether the binary was built with support for modular feature "Use exit nodes".
// Specifically, it's whether the binary was NOT built with the "ts_omit_useexitnode" build tag.
// It's a const so it can be used for dead code elimination.
const HasUseExitNode = true

View File

@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build ts_omit_useroutes
package buildfeatures
// HasUseRoutes is whether the binary was built with support for modular feature "Use routes advertised by other nodes".
// Specifically, it's whether the binary was NOT built with the "ts_omit_useroutes" build tag.
// It's a const so it can be used for dead code elimination.
const HasUseRoutes = false

View File

@@ -0,0 +1,13 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
// Code generated by gen.go; DO NOT EDIT.
//go:build !ts_omit_useroutes
package buildfeatures
// HasUseRoutes is whether the binary was built with support for modular feature "Use routes advertised by other nodes".
// Specifically, it's whether the binary was NOT built with the "ts_omit_useroutes" build tag.
// It's a const so it can be used for dead code elimination.
const HasUseRoutes = true

View File

@@ -82,6 +82,12 @@ type FeatureMeta struct {
Sym string // exported Go symbol for boolean const
Desc string // human-readable description
Deps []FeatureTag // other features this feature requires
// ImplementationDetail is whether the feature is an internal implementation
// detail. That is, it's not something a user wuold care about having or not
// having, but we'd like to able to omit from builds if no other
// user-visible features depend on it.
ImplementationDetail bool
}
// Features are the known Tailscale features that can be selectively included or
@@ -90,17 +96,45 @@ var Features = map[FeatureTag]FeatureMeta{
"acme": {Sym: "ACME", Desc: "ACME TLS certificate management"},
"appconnectors": {Sym: "AppConnectors", Desc: "App Connectors support"},
"aws": {Sym: "AWS", Desc: "AWS integration"},
"bakedroots": {Sym: "BakedRoots", Desc: "Embed CA (LetsEncrypt) x509 roots to use as fallback"},
"bird": {Sym: "Bird", Desc: "Bird BGP integration"},
"advertiseexitnode": {
Sym: "AdvertiseExitNode",
Desc: "Run an exit node",
Deps: []FeatureTag{
"peerapiserver", // to run the ExitDNS server
"advertiseroutes",
},
},
"advertiseroutes": {
Sym: "AdvertiseRoutes",
Desc: "Advertise routes for other nodes to use",
Deps: []FeatureTag{
"c2n", // for control plane to probe health for HA subnet router leader election
},
},
"bakedroots": {Sym: "BakedRoots", Desc: "Embed CA (LetsEncrypt) x509 roots to use as fallback"},
"bird": {Sym: "Bird", Desc: "Bird BGP integration"},
"c2n": {
Sym: "C2N",
Desc: "Control-to-node (C2N) support",
ImplementationDetail: true,
},
"captiveportal": {Sym: "CaptivePortal", Desc: "Captive portal detection"},
"capture": {Sym: "Capture", Desc: "Packet capture"},
"cloud": {Sym: "Cloud", Desc: "detect cloud environment to learn instances IPs and DNS servers"},
"cli": {Sym: "CLI", Desc: "embed the CLI into the tailscaled binary"},
"cliconndiag": {Sym: "CLIConnDiag", Desc: "CLI connection error diagnostics"},
"clientmetrics": {Sym: "ClientMetrics", Desc: "Client metrics support"},
"clientupdate": {Sym: "ClientUpdate", Desc: "Client auto-update support"},
"completion": {Sym: "Completion", Desc: "CLI shell completion"},
"dbus": {Sym: "DBus", Desc: "Linux DBus support"},
"clientupdate": {
Sym: "ClientUpdate",
Desc: "Client auto-update support",
Deps: []FeatureTag{"c2n"},
},
"completion": {Sym: "Completion", Desc: "CLI shell completion"},
"cloud": {Sym: "Cloud", Desc: "detect cloud environment to learn instances IPs and DNS servers"},
"dbus": {
Sym: "DBus",
Desc: "Linux DBus support",
ImplementationDetail: true,
},
"debug": {Sym: "Debug", Desc: "various debug support, for things that don't have or need their own more specific feature"},
"debugeventbus": {Sym: "DebugEventBus", Desc: "eventbus debug support"},
"debugportmapper": {
@@ -144,6 +178,16 @@ var Features = map[FeatureTag]FeatureMeta{
// by some other feature are missing, then it's an error by default unless you accept
// that it's okay to proceed without that meta feature.
},
"peerapiclient": {
Sym: "PeerAPIClient",
Desc: "PeerAPI client support",
ImplementationDetail: true,
},
"peerapiserver": {
Sym: "PeerAPIServer",
Desc: "PeerAPI server support",
ImplementationDetail: true,
},
"portlist": {Sym: "PortList", Desc: "Optionally advertise listening service ports"},
"portmapper": {Sym: "PortMapper", Desc: "NAT-PMP/PCP/UPnP port mapping support"},
"posture": {Sym: "Posture", Desc: "Device posture checking support"},
@@ -180,7 +224,7 @@ var Features = map[FeatureTag]FeatureMeta{
"ssh": {
Sym: "SSH",
Desc: "Tailscale SSH support",
Deps: []FeatureTag{"dbus", "netstack"},
Deps: []FeatureTag{"c2n", "dbus", "netstack"},
},
"synology": {
Sym: "Synology",
@@ -192,7 +236,13 @@ var Features = map[FeatureTag]FeatureMeta{
Desc: "Linux system tray",
Deps: []FeatureTag{"dbus"},
},
"taildrop": {Sym: "Taildrop", Desc: "Taildrop (file sending) support"},
"taildrop": {
Sym: "Taildrop",
Desc: "Taildrop (file sending) support",
Deps: []FeatureTag{
"peerapiclient", "peerapiserver", // assume Taildrop is both sides for now
},
},
"tailnetlock": {Sym: "TailnetLock", Desc: "Tailnet Lock support"},
"tap": {Sym: "Tap", Desc: "Experimental Layer 2 (ethernet) support"},
"tpm": {Sym: "TPM", Desc: "TPM support"},
@@ -200,6 +250,15 @@ var Features = map[FeatureTag]FeatureMeta{
Sym: "UnixSocketIdentity",
Desc: "differentiate between users accessing the LocalAPI over unix sockets (if omitted, all users have full access)",
},
"useroutes": {
Sym: "UseRoutes",
Desc: "Use routes advertised by other nodes",
},
"useexitnode": {
Sym: "UseExitNode",
Desc: "Use exit nodes",
Deps: []FeatureTag{"peerapiclient", "useroutes"},
},
"useproxy": {
Sym: "UseProxy",
Desc: "Support using system proxies as specified by env vars or the system configuration to reach Tailscale servers.",

View File

@@ -32,12 +32,17 @@ import (
// c2nHandlers maps an HTTP method and URI path (without query parameters) to
// its handler. The exact method+path match is preferred, but if no entry
// exists for that, a map entry with an empty method is used as a fallback.
var c2nHandlers = map[methodAndPath]c2nHandler{
// Debug.
req("/echo"): handleC2NEcho,
}
var c2nHandlers map[methodAndPath]c2nHandler
func init() {
c2nHandlers = map[methodAndPath]c2nHandler{}
if buildfeatures.HasC2N {
// Echo is the basic "ping" handler as used by the control plane to probe
// whether a node is reachable. In particular, it's important for
// high-availability subnet routers for the control plane to probe which of
// several candidate nodes is reachable and actually alive.
RegisterC2N("/echo", handleC2NEcho)
}
if buildfeatures.HasSSH {
RegisterC2N("/ssh/usernames", handleC2NSSHUsernames)
}
@@ -69,6 +74,9 @@ func init() {
// A pattern is like "GET /foo" (specific to an HTTP method) or "/foo" (all
// methods). It panics if the pattern is already registered.
func RegisterC2N(pattern string, h func(*LocalBackend, http.ResponseWriter, *http.Request)) {
if !buildfeatures.HasC2N {
return
}
k := req(pattern)
if _, ok := c2nHandlers[k]; ok {
panic(fmt.Sprintf("c2n: duplicate handler for %q", pattern))

View File

@@ -550,10 +550,12 @@ func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, lo
// Following changes are triggered via the eventbus.
b.linkChange(&netmon.ChangeDelta{New: netMon.InterfaceState()})
if tunWrap, ok := b.sys.Tun.GetOK(); ok {
tunWrap.PeerAPIPort = b.GetPeerAPIPort
} else {
b.logf("[unexpected] failed to wire up PeerAPI port for engine %T", e)
if buildfeatures.HasPeerAPIServer {
if tunWrap, ok := b.sys.Tun.GetOK(); ok {
tunWrap.PeerAPIPort = b.GetPeerAPIPort
} else {
b.logf("[unexpected] failed to wire up PeerAPI port for engine %T", e)
}
}
if buildfeatures.HasDebug {
@@ -972,15 +974,17 @@ func (b *LocalBackend) linkChange(delta *netmon.ChangeDelta) {
b.updateFilterLocked(prefs)
updateExitNodeUsageWarning(prefs, delta.New, b.health)
cn := b.currentNode()
nm := cn.NetMap()
if peerAPIListenAsync && nm != nil && b.state == ipn.Running {
want := nm.GetAddresses().Len()
have := len(b.peerAPIListeners)
b.logf("[v1] linkChange: have %d peerAPIListeners, want %d", have, want)
if have < want {
b.logf("linkChange: peerAPIListeners too low; trying again")
b.goTracker.Go(b.initPeerAPIListener)
if buildfeatures.HasPeerAPIServer {
cn := b.currentNode()
nm := cn.NetMap()
if peerAPIListenAsync && nm != nil && b.state == ipn.Running {
want := nm.GetAddresses().Len()
have := len(b.peerAPIListeners)
b.logf("[v1] linkChange: have %d peerAPIListeners, want %d", have, want)
if have < want {
b.logf("linkChange: peerAPIListeners too low; trying again")
b.goTracker.Go(b.initPeerAPIListener)
}
}
}
}
@@ -1368,7 +1372,7 @@ func peerStatusFromNode(ps *ipnstate.PeerStatus, n tailcfg.NodeView) {
ps.PublicKey = n.Key()
ps.ID = n.StableID()
ps.Created = n.Created()
ps.ExitNodeOption = tsaddr.ContainsExitRoutes(n.AllowedIPs())
ps.ExitNodeOption = buildfeatures.HasUseExitNode && tsaddr.ContainsExitRoutes(n.AllowedIPs())
if n.Tags().Len() != 0 {
v := n.Tags()
ps.Tags = &v
@@ -1897,6 +1901,9 @@ func (b *LocalBackend) applySysPolicyLocked(prefs *ipn.Prefs) (anyChange bool) {
//
// b.mu must be held.
func (b *LocalBackend) applyExitNodeSysPolicyLocked(prefs *ipn.Prefs) (anyChange bool) {
if !buildfeatures.HasUseExitNode {
return false
}
if exitNodeIDStr, _ := b.polc.GetString(pkey.ExitNodeID, ""); exitNodeIDStr != "" {
exitNodeID := tailcfg.StableNodeID(exitNodeIDStr)
@@ -2002,7 +2009,7 @@ func (b *LocalBackend) sysPolicyChanged(policy policyclient.PolicyChange) {
b.mu.Unlock()
}
if policy.HasChanged(pkey.AllowedSuggestedExitNodes) {
if buildfeatures.HasUseExitNode && policy.HasChanged(pkey.AllowedSuggestedExitNodes) {
b.refreshAllowedSuggestions()
// Re-evaluate exit node suggestion now that the policy setting has changed.
if _, err := b.SuggestExitNode(); err != nil && !errors.Is(err, ErrNoPreferredDERP) {
@@ -2073,6 +2080,9 @@ func (b *LocalBackend) UpdateNetmapDelta(muts []netmap.NodeMutation) (handled bo
// mustationsAreWorthyOfRecalculatingSuggestedExitNode reports whether any mutation type in muts is
// worthy of recalculating the suggested exit node.
func mutationsAreWorthyOfRecalculatingSuggestedExitNode(muts []netmap.NodeMutation, cn *nodeBackend, sid tailcfg.StableNodeID) bool {
if !buildfeatures.HasUseExitNode {
return false
}
for _, m := range muts {
n, ok := cn.NodeByID(m.NodeIDBeingMutated())
if !ok {
@@ -2126,6 +2136,9 @@ func mutationsAreWorthyOfTellingIPNBus(muts []netmap.NodeMutation) bool {
//
// b.mu must be held.
func (b *LocalBackend) resolveAutoExitNodeLocked(prefs *ipn.Prefs) (prefsChanged bool) {
if !buildfeatures.HasUseExitNode {
return false
}
// As of 2025-07-08, the only supported auto exit node expression is [ipn.AnyExitNode].
//
// However, to maintain forward compatibility with future auto exit node expressions,
@@ -2170,6 +2183,9 @@ func (b *LocalBackend) resolveAutoExitNodeLocked(prefs *ipn.Prefs) (prefsChanged
//
// b.mu must be held.
func (b *LocalBackend) resolveExitNodeIPLocked(prefs *ipn.Prefs) (prefsChanged bool) {
if !buildfeatures.HasUseExitNode {
return false
}
// If we have a desired IP on file, try to find the corresponding node.
if !prefs.ExitNodeIP.IsValid() {
return false
@@ -2455,6 +2471,11 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
}
}
var c2nHandler http.Handler
if buildfeatures.HasC2N {
c2nHandler = http.HandlerFunc(b.handleC2N)
}
// TODO(apenwarr): The only way to change the ServerURL is to
// re-run b.Start, because this is the only place we create a
// new controlclient. EditPrefs allows you to overwrite ServerURL,
@@ -2475,7 +2496,7 @@ func (b *LocalBackend) Start(opts ipn.Options) error {
PopBrowserURL: b.tellClientToBrowseToURL,
Dialer: b.Dialer(),
Observer: b,
C2NHandler: http.HandlerFunc(b.handleC2N),
C2NHandler: c2nHandler,
DialPlan: &b.dialPlan, // pointer because it can't be copied
ControlKnobs: b.sys.ControlKnobs(),
Shutdown: ccShutdown,
@@ -2623,31 +2644,33 @@ func (b *LocalBackend) updateFilterLocked(prefs ipn.PrefsView) {
}
}
if prefs.Valid() {
for _, r := range prefs.AdvertiseRoutes().All() {
if r.Bits() == 0 {
// When offering a default route to the world, we
// filter out locally reachable LANs, so that the
// default route effectively appears to be a "guest
// wifi": you get internet access, but to additionally
// get LAN access the LAN(s) need to be offered
// explicitly as well.
localInterfaceRoutes, hostIPs, err := interfaceRoutes()
if err != nil {
b.logf("getting local interface routes: %v", err)
continue
if buildfeatures.HasAdvertiseRoutes {
for _, r := range prefs.AdvertiseRoutes().All() {
if r.Bits() == 0 {
// When offering a default route to the world, we
// filter out locally reachable LANs, so that the
// default route effectively appears to be a "guest
// wifi": you get internet access, but to additionally
// get LAN access the LAN(s) need to be offered
// explicitly as well.
localInterfaceRoutes, hostIPs, err := interfaceRoutes()
if err != nil {
b.logf("getting local interface routes: %v", err)
continue
}
s, err := shrinkDefaultRoute(r, localInterfaceRoutes, hostIPs)
if err != nil {
b.logf("computing default route filter: %v", err)
continue
}
localNetsB.AddSet(s)
} else {
localNetsB.AddPrefix(r)
// When advertising a non-default route, we assume
// this is a corporate subnet that should be present
// in the audit logs.
logNetsB.AddPrefix(r)
}
s, err := shrinkDefaultRoute(r, localInterfaceRoutes, hostIPs)
if err != nil {
b.logf("computing default route filter: %v", err)
continue
}
localNetsB.AddSet(s)
} else {
localNetsB.AddPrefix(r)
// When advertising a non-default route, we assume
// this is a corporate subnet that should be present
// in the audit logs.
logNetsB.AddPrefix(r)
}
}
@@ -2658,7 +2681,7 @@ func (b *LocalBackend) updateFilterLocked(prefs ipn.PrefsView) {
// The correct filter rules are synthesized by the coordination server
// and sent down, but the address needs to be part of the 'local net' for the
// filter package to even bother checking the filter rules, so we set them here.
if prefs.AppConnector().Advertise {
if buildfeatures.HasAppConnectors && prefs.AppConnector().Advertise {
localNetsB.Add(netip.MustParseAddr("0.0.0.0"))
localNetsB.Add(netip.MustParseAddr("::0"))
}
@@ -3712,6 +3735,9 @@ func (b *LocalBackend) Ping(ctx context.Context, ip netip.Addr, pingType tailcfg
}
func (b *LocalBackend) pingPeerAPI(ctx context.Context, ip netip.Addr) (peer tailcfg.NodeView, peerBase string, err error) {
if !buildfeatures.HasPeerAPIClient {
return peer, peerBase, feature.ErrUnavailable
}
var zero tailcfg.NodeView
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
@@ -4051,6 +4077,9 @@ var exitNodeMisconfigurationWarnable = health.Register(&health.Warnable{
// updateExitNodeUsageWarning updates a warnable meant to notify users of
// configuration issues that could break exit node usage.
func updateExitNodeUsageWarning(p ipn.PrefsView, state *netmon.State, healthTracker *health.Tracker) {
if !buildfeatures.HasUseExitNode {
return
}
var msg string
if p.ExitNodeIP().IsValid() || p.ExitNodeID() != "" {
warn, _ := netutil.CheckReversePathFiltering(state)
@@ -4070,6 +4099,9 @@ func (b *LocalBackend) checkExitNodePrefsLocked(p *ipn.Prefs) error {
if !tryingToUseExitNode {
return nil
}
if !buildfeatures.HasUseExitNode {
return feature.ErrUnavailable
}
if err := featureknob.CanUseExitNode(); err != nil {
return err
@@ -4110,6 +4142,9 @@ func (b *LocalBackend) SetUseExitNodeEnabled(actor ipnauth.Actor, v bool) (ipn.P
defer unlock()
p0 := b.pm.CurrentPrefs()
if !buildfeatures.HasUseExitNode {
return p0, nil
}
if v && p0.ExitNodeID() != "" {
// Already on.
return p0, nil
@@ -4240,6 +4275,9 @@ func (b *LocalBackend) checkEditPrefsAccessLocked(actor ipnauth.Actor, prefs ipn
//
// b.mu must be held.
func (b *LocalBackend) changeDisablesExitNodeLocked(prefs ipn.PrefsView, change *ipn.MaskedPrefs) bool {
if !buildfeatures.HasUseExitNode {
return false
}
if !change.AutoExitNodeSet && !change.ExitNodeIDSet && !change.ExitNodeIPSet {
// The change does not affect exit node usage.
return false
@@ -4577,6 +4615,9 @@ func (b *LocalBackend) setPrefsLockedOnEntry(newp *ipn.Prefs, unlock unlockOnce)
// GetPeerAPIPort returns the port number for the peerapi server
// running on the provided IP.
func (b *LocalBackend) GetPeerAPIPort(ip netip.Addr) (port uint16, ok bool) {
if !buildfeatures.HasPeerAPIServer {
return 0, false
}
b.mu.Lock()
defer b.mu.Unlock()
for _, pln := range b.peerAPIListeners {
@@ -4936,10 +4977,12 @@ func (b *LocalBackend) authReconfig() {
// Keep the dialer updated about whether we're supposed to use
// an exit node's DNS server (so SOCKS5/HTTP outgoing dials
// can use it for name resolution)
if dohURLOK {
b.dialer.SetExitDNSDoH(dohURL)
} else {
b.dialer.SetExitDNSDoH("")
if buildfeatures.HasUseExitNode {
if dohURLOK {
b.dialer.SetExitDNSDoH(dohURL)
} else {
b.dialer.SetExitDNSDoH("")
}
}
cfg, err := nmcfg.WGCfg(nm, b.logf, flags, prefs.ExitNodeID())
@@ -5064,6 +5107,9 @@ func (b *LocalBackend) TailscaleVarRoot() string {
//
// b.mu must be held.
func (b *LocalBackend) closePeerAPIListenersLocked() {
if !buildfeatures.HasPeerAPIServer {
return
}
b.peerAPIServer = nil
for _, pln := range b.peerAPIListeners {
pln.Close()
@@ -5079,6 +5125,9 @@ func (b *LocalBackend) closePeerAPIListenersLocked() {
const peerAPIListenAsync = runtime.GOOS == "windows" || runtime.GOOS == "android"
func (b *LocalBackend) initPeerAPIListener() {
if !buildfeatures.HasPeerAPIServer {
return
}
b.logf("[v1] initPeerAPIListener: entered")
b.mu.Lock()
defer b.mu.Unlock()
@@ -5903,6 +5952,9 @@ func (b *LocalBackend) setNetInfo(ni *tailcfg.NetInfo) {
// RefreshExitNode determines which exit node to use based on the current
// prefs and netmap and switches to it if needed.
func (b *LocalBackend) RefreshExitNode() {
if !buildfeatures.HasUseExitNode {
return
}
if b.resolveExitNode() {
b.authReconfig()
}
@@ -5918,6 +5970,9 @@ func (b *LocalBackend) RefreshExitNode() {
//
// b.mu must not be held.
func (b *LocalBackend) resolveExitNode() (changed bool) {
if !buildfeatures.HasUseExitNode {
return false
}
b.mu.Lock()
defer b.mu.Unlock()
@@ -6468,6 +6523,9 @@ func (b *LocalBackend) SetDeviceAttrs(ctx context.Context, attrs tailcfg.AttrUpd
//
// If exitNodeID is the zero valid, it returns "", false.
func exitNodeCanProxyDNS(nm *netmap.NetworkMap, peers map[tailcfg.NodeID]tailcfg.NodeView, exitNodeID tailcfg.StableNodeID) (dohURL string, ok bool) {
if !buildfeatures.HasUseExitNode {
return "", false
}
if exitNodeID.IsZero() {
return "", false
}
@@ -7084,6 +7142,9 @@ var ErrNoPreferredDERP = errors.New("no preferred DERP, try again later")
//
// b.mu.lock() must be held.
func (b *LocalBackend) suggestExitNodeLocked() (response apitype.ExitNodeSuggestionResponse, err error) {
if !buildfeatures.HasUseExitNode {
return response, feature.ErrUnavailable
}
lastReport := b.MagicConn().GetLastNetcheckReport(b.ctx)
prevSuggestion := b.lastSuggestedExitNode
@@ -7101,6 +7162,9 @@ func (b *LocalBackend) suggestExitNodeLocked() (response apitype.ExitNodeSuggest
}
func (b *LocalBackend) SuggestExitNode() (response apitype.ExitNodeSuggestionResponse, err error) {
if !buildfeatures.HasUseExitNode {
return response, feature.ErrUnavailable
}
b.mu.Lock()
defer b.mu.Unlock()
return b.suggestExitNodeLocked()
@@ -7117,6 +7181,9 @@ func (b *LocalBackend) getAllowedSuggestions() set.Set[tailcfg.StableNodeID] {
// refreshAllowedSuggestions rebuilds the set of permitted exit nodes
// from the current [pkey.AllowedSuggestedExitNodes] value.
func (b *LocalBackend) refreshAllowedSuggestions() {
if !buildfeatures.HasUseExitNode {
return
}
b.allowedSuggestedExitNodesMu.Lock()
defer b.allowedSuggestedExitNodesMu.Unlock()
b.allowedSuggestedExitNodes = fillAllowedSuggestions(b.polc)

View File

@@ -530,6 +530,9 @@ func (nb *nodeBackend) dnsConfigForNetmap(prefs ipn.PrefsView, selfExpired bool,
}
func (nb *nodeBackend) exitNodeCanProxyDNS(exitNodeID tailcfg.StableNodeID) (dohURL string, ok bool) {
if !buildfeatures.HasUseExitNode {
return "", false
}
nb.mu.Lock()
defer nb.mu.Unlock()
return exitNodeCanProxyDNS(nb.netMap, nb.peers, exitNodeID)
@@ -769,18 +772,20 @@ func dnsConfigForNetmap(nm *netmap.NetworkMap, peers map[tailcfg.NodeID]tailcfg.
// If we're using an exit node and that exit node is new enough (1.19.x+)
// to run a DoH DNS proxy, then send all our DNS traffic through it,
// unless we find resolvers with UseWithExitNode set, in which case we use that.
if dohURL, ok := exitNodeCanProxyDNS(nm, peers, prefs.ExitNodeID()); ok {
filtered := useWithExitNodeResolvers(nm.DNS.Resolvers)
if len(filtered) > 0 {
addDefault(filtered)
} else {
// If no default global resolvers with the override
// are configured, configure the exit node's resolver.
addDefault([]*dnstype.Resolver{{Addr: dohURL}})
}
if buildfeatures.HasUseExitNode {
if dohURL, ok := exitNodeCanProxyDNS(nm, peers, prefs.ExitNodeID()); ok {
filtered := useWithExitNodeResolvers(nm.DNS.Resolvers)
if len(filtered) > 0 {
addDefault(filtered)
} else {
// If no default global resolvers with the override
// are configured, configure the exit node's resolver.
addDefault([]*dnstype.Resolver{{Addr: dohURL}})
}
addSplitDNSRoutes(useWithExitNodeRoutes(nm.DNS.Routes))
return dcfg
addSplitDNSRoutes(useWithExitNodeRoutes(nm.DNS.Routes))
return dcfg
}
}
// If the user has set default resolvers ("override local DNS"), prefer to
@@ -788,7 +793,7 @@ func dnsConfigForNetmap(nm *netmap.NetworkMap, peers map[tailcfg.NodeID]tailcfg.
// node resolvers, use those as the default.
if len(nm.DNS.Resolvers) > 0 {
addDefault(nm.DNS.Resolvers)
} else {
} else if buildfeatures.HasUseExitNode {
if resolvers, ok := wireguardExitNodeDNSResolvers(nm, peers, prefs.ExitNodeID()); ok {
addDefault(resolvers)
}

View File

@@ -26,6 +26,7 @@ import (
"golang.org/x/net/dns/dnsmessage"
"golang.org/x/net/http/httpguts"
"tailscale.com/envknob"
"tailscale.com/feature"
"tailscale.com/feature/buildfeatures"
"tailscale.com/health"
"tailscale.com/hostinfo"
@@ -131,6 +132,9 @@ type peerAPIListener struct {
}
func (pln *peerAPIListener) Close() error {
if !buildfeatures.HasPeerAPIServer {
return nil
}
if pln.ln != nil {
return pln.ln.Close()
}
@@ -138,6 +142,9 @@ func (pln *peerAPIListener) Close() error {
}
func (pln *peerAPIListener) serve() {
if !buildfeatures.HasPeerAPIServer {
return
}
if pln.ln == nil {
return
}
@@ -319,6 +326,9 @@ func peerAPIRequestShouldGetSecurityHeaders(r *http.Request) bool {
//
// It panics if the path is already registered.
func RegisterPeerAPIHandler(path string, f func(PeerAPIHandler, http.ResponseWriter, *http.Request)) {
if !buildfeatures.HasPeerAPIServer {
return
}
if _, ok := peerAPIHandlers[path]; ok {
panic(fmt.Sprintf("duplicate PeerAPI handler %q", path))
}
@@ -337,6 +347,10 @@ var (
)
func (h *peerAPIHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if !buildfeatures.HasPeerAPIServer {
http.Error(w, feature.ErrUnavailable.Error(), http.StatusNotImplemented)
return
}
if err := h.validatePeerAPIRequest(r); err != nil {
metricInvalidRequests.Add(1)
h.logf("invalid request from %v: %v", h.remoteAddr, err)

View File

@@ -6,6 +6,7 @@ package ipnlocal
import (
"errors"
"tailscale.com/feature/buildfeatures"
"tailscale.com/ipn"
"tailscale.com/tailcfg"
"tailscale.com/util/clientmetric"
@@ -85,6 +86,9 @@ func (e *prefsMetricsEditEvent) record() error {
// false otherwise. The caller is responsible for ensuring that the id belongs to
// an exit node.
func (e *prefsMetricsEditEvent) exitNodeType(id tailcfg.StableNodeID) (props []exitNodeProperty, isNode bool) {
if !buildfeatures.HasUseExitNode {
return nil, false
}
var peer tailcfg.NodeView
if peer, isNode = e.node.PeerByStableID(id); isNode {

View File

@@ -72,7 +72,6 @@ var handler = map[string]LocalAPIHandler{
// The other /localapi/v0/NAME handlers are exact matches and contain only NAME
// without a trailing slash:
"alpha-set-device-attrs": (*Handler).serveSetDeviceAttrs, // see tailscale/corp#24690
"bugreport": (*Handler).serveBugReport,
"check-ip-forwarding": (*Handler).serveCheckIPForwarding,
"check-prefs": (*Handler).serveCheckPrefs,
"check-reverse-path-filtering": (*Handler).serveCheckReversePathFiltering,
@@ -90,21 +89,17 @@ var handler = map[string]LocalAPIHandler{
"logtap": (*Handler).serveLogTap,
"metrics": (*Handler).serveMetrics,
"ping": (*Handler).servePing,
"pprof": (*Handler).servePprof,
"prefs": (*Handler).servePrefs,
"query-feature": (*Handler).serveQueryFeature,
"reload-config": (*Handler).reloadConfig,
"reset-auth": (*Handler).serveResetAuth,
"set-dns": (*Handler).serveSetDNS,
"set-expiry-sooner": (*Handler).serveSetExpirySooner,
"set-gui-visible": (*Handler).serveSetGUIVisible,
"set-push-device-token": (*Handler).serveSetPushDeviceToken,
"set-udp-gro-forwarding": (*Handler).serveSetUDPGROForwarding,
"set-use-exit-node-enabled": (*Handler).serveSetUseExitNodeEnabled,
"shutdown": (*Handler).serveShutdown,
"start": (*Handler).serveStart,
"status": (*Handler).serveStatus,
"suggest-exit-node": (*Handler).serveSuggestExitNode,
"update/check": (*Handler).serveUpdateCheck,
"upload-client-metrics": (*Handler).serveUploadClientMetrics,
"usermetrics": (*Handler).serveUserMetrics,
@@ -116,6 +111,17 @@ func init() {
if buildfeatures.HasAppConnectors {
Register("appc-route-info", (*Handler).serveGetAppcRouteInfo)
}
if buildfeatures.HasUseExitNode {
Register("suggest-exit-node", (*Handler).serveSuggestExitNode)
Register("set-use-exit-node-enabled", (*Handler).serveSetUseExitNodeEnabled)
}
if buildfeatures.HasACME {
Register("set-dns", (*Handler).serveSetDNS)
}
if buildfeatures.HasDebug {
Register("bugreport", (*Handler).serveBugReport)
Register("pprof", (*Handler).servePprof)
}
}
// Register registers a new LocalAPI handler for the given name.
@@ -1291,6 +1297,10 @@ func (h *Handler) serveSetGUIVisible(w http.ResponseWriter, r *http.Request) {
}
func (h *Handler) serveSetUseExitNodeEnabled(w http.ResponseWriter, r *http.Request) {
if !buildfeatures.HasUseExitNode {
http.Error(w, feature.ErrUnavailable.Error(), http.StatusNotImplemented)
return
}
if r.Method != httpm.POST {
http.Error(w, "use POST", http.StatusMethodNotAllowed)
return
@@ -1629,6 +1639,10 @@ func dnsMessageTypeForString(s string) (t dnsmessage.Type, err error) {
// serveSuggestExitNode serves a POST endpoint for returning a suggested exit node.
func (h *Handler) serveSuggestExitNode(w http.ResponseWriter, r *http.Request) {
if !buildfeatures.HasUseExitNode {
http.Error(w, feature.ErrUnavailable.Error(), http.StatusNotImplemented)
return
}
if r.Method != httpm.GET {
http.Error(w, "only GET allowed", http.StatusMethodNotAllowed)
return

View File

@@ -20,6 +20,7 @@ import (
"tailscale.com/atomicfile"
"tailscale.com/drive"
"tailscale.com/feature/buildfeatures"
"tailscale.com/ipn/ipnstate"
"tailscale.com/net/netaddr"
"tailscale.com/net/tsaddr"
@@ -787,6 +788,9 @@ func (p *Prefs) AdvertisesExitNode() bool {
// SetAdvertiseExitNode mutates p (if non-nil) to add or remove the two
// /0 exit node routes.
func (p *Prefs) SetAdvertiseExitNode(runExit bool) {
if !buildfeatures.HasAdvertiseExitNode {
return
}
if p == nil {
return
}

View File

@@ -27,6 +27,7 @@ import (
dns "golang.org/x/net/dns/dnsmessage"
"tailscale.com/control/controlknobs"
"tailscale.com/envknob"
"tailscale.com/feature"
"tailscale.com/feature/buildfeatures"
"tailscale.com/health"
"tailscale.com/net/dns/publicdns"
@@ -530,6 +531,9 @@ func (f *forwarder) send(ctx context.Context, fq *forwardQuery, rr resolverAndDe
}()
}
if strings.HasPrefix(rr.name.Addr, "http://") {
if !buildfeatures.HasPeerAPIClient {
return nil, feature.ErrUnavailable
}
return f.sendDoH(ctx, rr.name.Addr, f.dialer.PeerAPIHTTPClient(), fq.packet)
}
if strings.HasPrefix(rr.name.Addr, "https://") {

View File

@@ -22,6 +22,7 @@ import (
"github.com/mdlayher/netlink"
"go4.org/mem"
"golang.org/x/sys/unix"
"tailscale.com/feature/buildfeatures"
"tailscale.com/net/netaddr"
"tailscale.com/util/lineiter"
)
@@ -41,6 +42,9 @@ ens18 00000000 0100000A 0003 0 0 0 00000000
ens18 0000000A 00000000 0001 0 0 0 0000FFFF 0 0 0
*/
func likelyHomeRouterIPLinux() (ret netip.Addr, myIP netip.Addr, ok bool) {
if !buildfeatures.HasPortMapper {
return
}
if procNetRouteErr.Load() {
// If we failed to read /proc/net/route previously, don't keep trying.
return ret, myIP, false

View File

@@ -14,6 +14,7 @@ import (
"sync"
"time"
"tailscale.com/feature/buildfeatures"
"tailscale.com/types/logger"
"tailscale.com/util/clientmetric"
"tailscale.com/util/eventbus"
@@ -181,6 +182,9 @@ func (m *Monitor) SetTailscaleInterfaceName(ifName string) {
// It's the same as interfaces.LikelyHomeRouterIP, but it caches the
// result until the monitor detects a network change.
func (m *Monitor) GatewayAndSelfIP() (gw, myIP netip.Addr, ok bool) {
if !buildfeatures.HasPortMapper {
return
}
if m.static {
return
}

View File

@@ -573,6 +573,9 @@ var disableLikelyHomeRouterIPSelf = envknob.RegisterBool("TS_DEBUG_DISABLE_LIKEL
// the LAN using that gateway.
// This is used as the destination for UPnP, NAT-PMP, PCP, etc queries.
func LikelyHomeRouterIP() (gateway, myIP netip.Addr, ok bool) {
if !buildfeatures.HasPortMapper {
return
}
// If we don't have a way to get the home router IP, then we can't do
// anything; just return.
if likelyHomeRouterIP == nil {

View File

@@ -20,6 +20,7 @@ import (
"go4.org/mem"
"tailscale.com/envknob"
"tailscale.com/feature/buildfeatures"
"tailscale.com/net/netaddr"
"tailscale.com/net/neterror"
"tailscale.com/net/netmon"
@@ -262,10 +263,13 @@ func NewClient(c Config) *Client {
panic("nil EventBus")
}
ret := &Client{
logf: c.Logf,
netMon: c.NetMon,
ipAndGateway: netmon.LikelyHomeRouterIP, // TODO(bradfitz): move this to method on netMon
onChange: c.OnChange,
logf: c.Logf,
netMon: c.NetMon,
onChange: c.OnChange,
}
if buildfeatures.HasPortMapper {
// TODO(bradfitz): move this to method on netMon
ret.ipAndGateway = netmon.LikelyHomeRouterIP
}
ret.pubClient = c.EventBus.Client("portmapper")
ret.updates = eventbus.Publish[portmappertype.Mapping](ret.pubClient)

View File

@@ -19,6 +19,8 @@ import (
"time"
"github.com/gaissmai/bart"
"tailscale.com/feature"
"tailscale.com/feature/buildfeatures"
"tailscale.com/net/dnscache"
"tailscale.com/net/netknob"
"tailscale.com/net/netmon"
@@ -135,6 +137,9 @@ func (d *Dialer) TUNName() string {
//
// For example, "http://100.68.82.120:47830/dns-query".
func (d *Dialer) SetExitDNSDoH(doh string) {
if !buildfeatures.HasUseExitNode {
return
}
d.mu.Lock()
defer d.mu.Unlock()
if d.exitDNSDoHBase == doh {
@@ -372,7 +377,7 @@ func (d *Dialer) userDialResolve(ctx context.Context, network, addr string) (net
}
var r net.Resolver
if exitDNSDoH != "" {
if buildfeatures.HasUseExitNode && buildfeatures.HasPeerAPIClient && exitDNSDoH != "" {
r.PreferGo = true
r.Dial = func(ctx context.Context, network, address string) (net.Conn, error) {
return &dohConn{
@@ -509,6 +514,9 @@ func (d *Dialer) UserDial(ctx context.Context, network, addr string) (net.Conn,
// network must a "tcp" type, and addr must be an ip:port. Name resolution
// is not supported.
func (d *Dialer) dialPeerAPI(ctx context.Context, network, addr string) (net.Conn, error) {
if !buildfeatures.HasPeerAPIClient {
return nil, feature.ErrUnavailable
}
switch network {
case "tcp", "tcp6", "tcp4":
default:
@@ -551,6 +559,9 @@ func (d *Dialer) getPeerDialer() *net.Dialer {
// The returned Client must not be mutated; it's owned by the Dialer
// and shared by callers.
func (d *Dialer) PeerAPIHTTPClient() *http.Client {
if !buildfeatures.HasPeerAPIClient {
panic("unreachable")
}
d.peerClientOnce.Do(func() {
t := http.DefaultTransport.(*http.Transport).Clone()
t.Dial = nil