Browse Source

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 <[email protected]>
Brad Fitzpatrick 5 months ago
parent
commit
a208cb9fd5

+ 3 - 0
control/controlclient/direct.go

@@ -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

+ 13 - 0
feature/buildfeatures/feature_advertiseexitnode_disabled.go

@@ -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

+ 13 - 0
feature/buildfeatures/feature_advertiseexitnode_enabled.go

@@ -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

+ 13 - 0
feature/buildfeatures/feature_advertiseroutes_disabled.go

@@ -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

+ 13 - 0
feature/buildfeatures/feature_advertiseroutes_enabled.go

@@ -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

+ 13 - 0
feature/buildfeatures/feature_c2n_disabled.go

@@ -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

+ 13 - 0
feature/buildfeatures/feature_c2n_enabled.go

@@ -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

+ 13 - 0
feature/buildfeatures/feature_peerapiclient_disabled.go

@@ -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

+ 13 - 0
feature/buildfeatures/feature_peerapiclient_enabled.go

@@ -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

+ 13 - 0
feature/buildfeatures/feature_peerapiserver_disabled.go

@@ -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

+ 13 - 0
feature/buildfeatures/feature_peerapiserver_enabled.go

@@ -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

+ 13 - 0
feature/buildfeatures/feature_useexitnode_disabled.go

@@ -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

+ 13 - 0
feature/buildfeatures/feature_useexitnode_enabled.go

@@ -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

+ 13 - 0
feature/buildfeatures/feature_useroutes_disabled.go

@@ -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

+ 13 - 0
feature/buildfeatures/feature_useroutes_enabled.go

@@ -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

+ 67 - 8
feature/featuretags/featuretags.go

@@ -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.",

+ 12 - 4
ipn/ipnlocal/c2n.go

@@ -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))

+ 112 - 45
ipn/ipnlocal/local.go

@@ -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
-				}
-				s, err := shrinkDefaultRoute(r, localInterfaceRoutes, hostIPs)
-				if err != nil {
-					b.logf("computing default route filter: %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)
 				}
-				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)

+ 17 - 12
ipn/ipnlocal/node_backend.go

@@ -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)
 		}

+ 14 - 0
ipn/ipnlocal/peerapi.go

@@ -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)

+ 4 - 0
ipn/ipnlocal/prefs_metrics.go

@@ -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 {

+ 19 - 5
ipn/localapi/localapi.go

@@ -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

+ 4 - 0
ipn/prefs.go

@@ -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
 	}

+ 4 - 0
net/dns/resolver/forwarder.go

@@ -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://") {

+ 4 - 0
net/netmon/interfaces_linux.go

@@ -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

+ 4 - 0
net/netmon/netmon.go

@@ -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
 	}

+ 3 - 0
net/netmon/state.go

@@ -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 {

+ 8 - 4
net/portmapper/portmapper.go

@@ -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)

+ 12 - 1
net/tsdial/tsdial.go

@@ -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