cmd/tailscale, ipn, feature/remoteconfig: add remote-config support

Add a new Prefs.RemoteConfig bool. When true, a c2n endpoint at
/remoteapi/localapi/* proxies into this node's LocalAPI at
/localapi/* with full read/write permission, giving the tailnet
admin the same API surface a local root/admin user has via the
tailscale CLI. All LocalAPI versions (v0, v1, ...) proxy through.

RemoteConfig is an alternative to Tailscale's default per-feature
double opt-in, in which both the tailnet admin and the local machine
owner must consent to each individual setting change. It is a single
client-side "I trust the tailnet admin" switch that, once on, hands
over full remote management of this node's settings and LocalAPI
without any further local prompt or confirmation.

This is only appropriate when the tailnet admin already owns the
machine (e.g. a corporate fleet device) or the local user has
explicitly delegated full control. It should never be enabled on a
personal/BYOD device with an untrusted tailnet admin. The trust
model is documented on the pref, on the hidden --remote-config CLI
flag, and on the feature/remoteconfig package.

The node advertises its RemoteConfig state to the control plane via
a new Hostinfo.RemoteConfig bool. This is only true when the feature
is both compiled in (buildfeatures.HasRemoteConfig) and its init
actually ran (feature.IsRegistered("remoteconfig")); tsnet builds
have the former but not the latter and correctly report false.

The handler lives in feature/remoteconfig and can be omitted with the
ts_omit_remoteconfig build tag. tsnet's TestDeps guards against
accidentally pulling it in.

Updates tailscale/corp#18043

Change-Id: I72ce10a90a0e4e738c72c940af3af64c986160b2
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
Brad Fitzpatrick
2026-07-07 12:10:34 -07:00
committed by Brad Fitzpatrick
parent 2051c5f358
commit c1ae2bb1f8
23 changed files with 444 additions and 1 deletions
+1
View File
@@ -103,6 +103,7 @@ var _PrefsCloneNeedsRegeneration = Prefs(struct {
AppConnector AppConnectorPrefs
PostureChecking bool
NetfilterKind string
RemoteConfig bool
DriveShares []*drive.Share
RelayServerPort *uint16
RelayServerStaticEndpoints []netip.AddrPort
+19
View File
@@ -440,6 +440,24 @@ func (v PrefsView) PostureChecking() bool { return v.ж.PostureChecking }
// Linux-only.
func (v PrefsView) NetfilterKind() string { return v.ж.NetfilterKind }
// RemoteConfig, if true, delegates full remote control of this node's
// prefs and LocalAPI to the tailnet admin via the control plane. When
// enabled, the control server can read and edit any of this node's
// prefs at any time, and invoke any LocalAPI endpoint on this node,
// without any further local consent (no CLI or GUI confirmation).
//
// This is an alternative to Tailscale's default per-feature double
// opt-in model, in which both the tailnet admin and the local machine
// owner must agree to each individual setting change. RemoteConfig is
// a single client-side "I trust the tailnet admin" switch that hands
// over full remote management of this node.
//
// Only enable this when the tailnet admin owns the machine (e.g. a
// corporate fleet device) or the local user has explicitly delegated
// full control to the tailnet admin. Do NOT enable this on personal
// or BYOD devices where the tailnet admin is not fully trusted.
func (v PrefsView) RemoteConfig() bool { return v.ж.RemoteConfig }
// DriveShares are the configured DriveShares, stored in increasing order
// by name.
func (v PrefsView) DriveShares() views.SliceView[*drive.Share, drive.ShareView] {
@@ -501,6 +519,7 @@ var _PrefsViewNeedsRegeneration = Prefs(struct {
AppConnector AppConnectorPrefs
PostureChecking bool
NetfilterKind string
RemoteConfig bool
DriveShares []*drive.Share
RelayServerPort *uint16
RelayServerStaticEndpoints []netip.AddrPort
+30
View File
@@ -83,6 +83,29 @@ func RegisterC2N(pattern string, h func(*LocalBackend, http.ResponseWriter, *htt
c2nHandlers[k] = h
}
// RegisterC2NPrefix registers h as the c2n handler for all paths starting
// with prefix. prefix must end in "/". Prefix matches are tried after all
// exact-path handlers registered via [RegisterC2N] fail to match.
func RegisterC2NPrefix(prefix string, h func(*LocalBackend, http.ResponseWriter, *http.Request)) {
if !buildfeatures.HasC2N {
return
}
if prefix == "" || !strings.HasSuffix(prefix, "/") {
panic(fmt.Sprintf("c2n: prefix %q must be non-empty and end with /", prefix))
}
c2nPrefixHandlers = append(c2nPrefixHandlers, c2nPrefixHandler{prefix, h})
}
// c2nPrefixHandler is a c2n handler that matches all paths starting with prefix.
type c2nPrefixHandler struct {
prefix string
h c2nHandler
}
// c2nPrefixHandlers are c2n handlers matched by URL path prefix rather than
// exact path. See [RegisterC2NPrefix].
var c2nPrefixHandlers []c2nPrefixHandler
type c2nHandler func(*LocalBackend, http.ResponseWriter, *http.Request)
type methodAndPath struct {
@@ -118,6 +141,13 @@ func (b *LocalBackend) handleC2N(w http.ResponseWriter, r *http.Request) {
h(b, w, r)
return
}
// Then try prefix matches.
for _, ph := range c2nPrefixHandlers {
if strings.HasPrefix(r.URL.Path, ph.prefix) {
ph.h(b, w, r)
return
}
}
if c2nHandlerPaths.Contains(r.URL.Path) {
http.Error(w, "bad method", http.StatusMethodNotAllowed)
} else {
+12
View File
@@ -501,6 +501,10 @@ func (b *LocalBackend) HealthTracker() *health.Tracker { return b.health }
// Logger returns the logger for the backend.
func (b *LocalBackend) Logger() logger.Logf { return b.logf }
// BackendLogID returns the backend's log ID, or the zero value if logging is
// not in use.
func (b *LocalBackend) BackendLogID() logid.PublicID { return b.backendLogID }
// UserMetricsRegistry returns the usermetrics registry for the backend
func (b *LocalBackend) UserMetricsRegistry() *usermetric.Registry {
return b.sys.UserMetricsRegistry()
@@ -6615,6 +6619,14 @@ func (b *LocalBackend) applyPrefsToHostinfoLocked(hi *tailcfg.Hostinfo, prefs ip
hi.RoutableIPs = prefs.AdvertiseRoutes().AsSlice()
hi.RequestTags = prefs.AdvertiseTags().AsSlice()
hi.ShieldsUp = prefs.ShieldsUp()
// Only advertise RemoteConfig to control when the feature is both
// compiled in (buildfeatures.HasRemoteConfig; a const so the whole
// expression dead-code eliminates when ts_omit_remoteconfig is set)
// and its init actually ran to wire up the c2n handler. tsnet
// builds are the interesting case: they do not import
// feature/remoteconfig even though ts_omit_remoteconfig is not
// set, so we must not claim RemoteConfig is active there.
hi.RemoteConfig = buildfeatures.HasRemoteConfig && prefs.RemoteConfig() && feature.IsRegistered("remoteconfig")
hi.AllowsUpdate = buildfeatures.HasClientUpdate && (envknob.AllowsRemoteUpdate() || prefs.AutoUpdate().Apply.EqualBool(true))
if buildfeatures.HasAdvertiseRoutes {
+23
View File
@@ -277,6 +277,24 @@ type Prefs struct {
// Linux-only.
NetfilterKind string
// RemoteConfig, if true, delegates full remote control of this node's
// prefs and LocalAPI to the tailnet admin via the control plane. When
// enabled, the control server can read and edit any of this node's
// prefs at any time, and invoke any LocalAPI endpoint on this node,
// without any further local consent (no CLI or GUI confirmation).
//
// This is an alternative to Tailscale's default per-feature double
// opt-in model, in which both the tailnet admin and the local machine
// owner must agree to each individual setting change. RemoteConfig is
// a single client-side "I trust the tailnet admin" switch that hands
// over full remote management of this node.
//
// Only enable this when the tailnet admin owns the machine (e.g. a
// corporate fleet device) or the local user has explicitly delegated
// full control to the tailnet admin. Do NOT enable this on personal
// or BYOD devices where the tailnet admin is not fully trusted.
RemoteConfig bool
// DriveShares are the configured DriveShares, stored in increasing order
// by name.
DriveShares []*drive.Share
@@ -367,6 +385,7 @@ type MaskedPrefs struct {
AppConnectorSet bool `json:",omitempty"`
PostureCheckingSet bool `json:",omitempty"`
NetfilterKindSet bool `json:",omitempty"`
RemoteConfigSet bool `json:",omitempty"`
DriveSharesSet bool `json:",omitempty"`
RelayServerPortSet bool `json:",omitempty"`
RelayServerStaticEndpointsSet bool `json:",omitzero"`
@@ -553,6 +572,9 @@ func (p *Prefs) pretty(goos string) string {
if p.ShieldsUp {
sb.WriteString("shields=true ")
}
if p.RemoteConfig {
sb.WriteString("remoteconfig=true ")
}
if buildfeatures.HasUseExitNode {
if p.ExitNodeIP.IsValid() {
fmt.Fprintf(&sb, "exit=%v lan=%t ", p.ExitNodeIP, p.ExitNodeAllowLANAccess)
@@ -675,6 +697,7 @@ func (p *Prefs) Equals(p2 *Prefs) bool {
p.PostureChecking == p2.PostureChecking &&
slices.EqualFunc(p.DriveShares, p2.DriveShares, drive.SharesEqual) &&
p.NetfilterKind == p2.NetfilterKind &&
p.RemoteConfig == p2.RemoteConfig &&
compareUint16Ptrs(p.RelayServerPort, p2.RelayServerPort) &&
slices.Equal(p.RelayServerStaticEndpoints, p2.RelayServerStaticEndpoints)
}
+6
View File
@@ -67,6 +67,7 @@ func TestPrefsEqual(t *testing.T) {
"AppConnector",
"PostureChecking",
"NetfilterKind",
"RemoteConfig",
"DriveShares",
"RelayServerPort",
"RelayServerStaticEndpoints",
@@ -523,6 +524,11 @@ func TestPrefsPretty(t *testing.T) {
"windows",
"Prefs{ra=false dns=false want=false shields=true update=off Persist=nil}",
},
{
Prefs{RemoteConfig: true},
"windows",
"Prefs{ra=false dns=false want=false remoteconfig=true update=off Persist=nil}",
},
{
Prefs{},
"windows",