diff --git a/cmd/tailscale/cli/get.go b/cmd/tailscale/cli/get.go index f9cf3b1c3..b896dcb52 100644 --- a/cmd/tailscale/cli/get.go +++ b/cmd/tailscale/cli/get.go @@ -190,6 +190,8 @@ func prefValue(flagName string, prefs *ipn.Prefs, st *ipnstate.Status) any { return prefs.ForceDaemon case "sync": return prefs.Sync.EqualBool(true) + case "remote-config": + return prefs.RemoteConfig case "relay-server-port": if prefs.RelayServerPort != nil { return fmt.Sprint(*prefs.RelayServerPort) diff --git a/cmd/tailscale/cli/set.go b/cmd/tailscale/cli/set.go index e6a467bf5..95ee35bb1 100644 --- a/cmd/tailscale/cli/set.go +++ b/cmd/tailscale/cli/set.go @@ -62,6 +62,7 @@ type setArgsT struct { updateCheck bool updateApply bool reportPosture bool + remoteConfig bool snat bool statefulFiltering bool sync bool @@ -88,6 +89,7 @@ func newSetFlagSet(goos string, setArgs *setArgsT) *flag.FlagSet { setf.BoolVar(&setArgs.updateApply, "auto-update", false, "automatically update to the latest available version") setf.BoolVar(&setArgs.reportPosture, "report-posture", false, "allow management plane to gather device posture information") setf.BoolVar(&setArgs.runWebClient, "webclient", false, "expose the web interface for managing this node over Tailscale at port 5252") + setf.BoolVar(&setArgs.remoteConfig, "remote-config", false, hidden+"delegate FULL remote control of this node's prefs and LocalAPI to the tailnet admin, bypassing Tailscale's per-feature double opt-in; only use when the tailnet admin owns or is fully trusted with this machine") setf.BoolVar(&setArgs.sync, "sync", false, hidden+"actively sync configuration from the control plane (set to false only for network failure testing)") setf.StringVar(&setArgs.relayServerPort, "relay-server-port", "", "UDP port number (0 will pick a random unused port) for the relay server to bind to, on all interfaces, or empty string to disable relay server functionality") setf.StringVar(&setArgs.relayServerStaticEndpoints, "relay-server-static-endpoints", "", "static IP:port endpoints to advertise as candidates for relay connections (comma-separated, e.g. \"[2001:db8::1]:40000,192.0.2.1:40000\") or empty string to not advertise any static endpoints") @@ -163,6 +165,7 @@ func runSet(ctx context.Context, args []string) (retErr error) { Advertise: setArgs.advertiseConnector, }, PostureChecking: setArgs.reportPosture, + RemoteConfig: setArgs.remoteConfig, NoStatefulFiltering: opt.NewBool(!setArgs.statefulFiltering), }, } diff --git a/cmd/tailscale/cli/up.go b/cmd/tailscale/cli/up.go index b39ad009c..428badc04 100644 --- a/cmd/tailscale/cli/up.go +++ b/cmd/tailscale/cli/up.go @@ -917,6 +917,7 @@ func init() { addPrefFlagMapping("auto-update", "AutoUpdate.Apply") addPrefFlagMapping("advertise-connector", "AppConnector") addPrefFlagMapping("report-posture", "PostureChecking") + addPrefFlagMapping("remote-config", "RemoteConfig") addPrefFlagMapping("relay-server-port", "RelayServerPort") addPrefFlagMapping("sync", "Sync") addPrefFlagMapping("relay-server-static-endpoints", "RelayServerStaticEndpoints") diff --git a/cmd/tailscaled/depaware.txt b/cmd/tailscaled/depaware.txt index d124e7366..2d19823f0 100644 --- a/cmd/tailscaled/depaware.txt +++ b/cmd/tailscaled/depaware.txt @@ -310,6 +310,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de tailscale.com/feature/portmapper from tailscale.com/feature/condregister/portmapper tailscale.com/feature/posture from tailscale.com/feature/condregister tailscale.com/feature/relayserver from tailscale.com/feature/condregister + tailscale.com/feature/remoteconfig from tailscale.com/feature/condregister tailscale.com/feature/routecheck from tailscale.com/feature/condregister tailscale.com/feature/runtimemetrics from tailscale.com/feature/condregister L tailscale.com/feature/sdnotify from tailscale.com/feature/condregister diff --git a/cmd/vet/jsontags_allowlist b/cmd/vet/jsontags_allowlist index b9f91d562..8d44c6e6c 100644 --- a/cmd/vet/jsontags_allowlist +++ b/cmd/vet/jsontags_allowlist @@ -195,6 +195,7 @@ OmitEmptyUnsupportedInV2 tailscale.com/ipn.MaskedPrefs.OperatorUserSet OmitEmptyUnsupportedInV2 tailscale.com/ipn.MaskedPrefs.PostureCheckingSet OmitEmptyUnsupportedInV2 tailscale.com/ipn.MaskedPrefs.ProfileNameSet OmitEmptyUnsupportedInV2 tailscale.com/ipn.MaskedPrefs.RelayServerPortSet +OmitEmptyUnsupportedInV2 tailscale.com/ipn.MaskedPrefs.RemoteConfigSet OmitEmptyUnsupportedInV2 tailscale.com/ipn.MaskedPrefs.RouteAllSet OmitEmptyUnsupportedInV2 tailscale.com/ipn.MaskedPrefs.RunSSHSet OmitEmptyUnsupportedInV2 tailscale.com/ipn.MaskedPrefs.RunWebClientSet diff --git a/feature/buildfeatures/feature_remoteconfig_disabled.go b/feature/buildfeatures/feature_remoteconfig_disabled.go new file mode 100644 index 000000000..710a7bba2 --- /dev/null +++ b/feature/buildfeatures/feature_remoteconfig_disabled.go @@ -0,0 +1,13 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +// Code generated by gen.go; DO NOT EDIT. + +//go:build ts_omit_remoteconfig + +package buildfeatures + +// HasRemoteConfig is whether the binary was built with support for modular feature "Full remote configuration of this node by the tailnet admin, opting out of Tailscale's per-feature double opt-in in favor of a single client-side trust decision". +// Specifically, it's whether the binary was NOT built with the "ts_omit_remoteconfig" build tag. +// It's a const so it can be used for dead code elimination. +const HasRemoteConfig = false diff --git a/feature/buildfeatures/feature_remoteconfig_enabled.go b/feature/buildfeatures/feature_remoteconfig_enabled.go new file mode 100644 index 000000000..746fb21d7 --- /dev/null +++ b/feature/buildfeatures/feature_remoteconfig_enabled.go @@ -0,0 +1,13 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +// Code generated by gen.go; DO NOT EDIT. + +//go:build !ts_omit_remoteconfig + +package buildfeatures + +// HasRemoteConfig is whether the binary was built with support for modular feature "Full remote configuration of this node by the tailnet admin, opting out of Tailscale's per-feature double opt-in in favor of a single client-side trust decision". +// Specifically, it's whether the binary was NOT built with the "ts_omit_remoteconfig" build tag. +// It's a const so it can be used for dead code elimination. +const HasRemoteConfig = true diff --git a/feature/condregister/maybe_remoteconfig.go b/feature/condregister/maybe_remoteconfig.go new file mode 100644 index 000000000..a4958430b --- /dev/null +++ b/feature/condregister/maybe_remoteconfig.go @@ -0,0 +1,8 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !ts_omit_remoteconfig + +package condregister + +import _ "tailscale.com/feature/remoteconfig" diff --git a/feature/feature.go b/feature/feature.go index 5bd79db45..305f31a12 100644 --- a/feature/feature.go +++ b/feature/feature.go @@ -21,6 +21,14 @@ // not accessed concurrently with calls to Register. func Registered() map[string]bool { return in } +// IsRegistered reports whether the named feature package's init +// function has run and registered itself via [Register] in this +// binary. It is distinct from the compile-time [buildfeatures] +// constants: a feature package can be present in the binary but not +// imported (e.g. tsnet deliberately does not import many features), +// in which case its init does not run. +func IsRegistered(name string) bool { return in[name] } + // Register notes that the named feature is linked into the binary. func Register(name string) { if _, ok := in[name]; ok { diff --git a/feature/featuretags/featuretags.go b/feature/featuretags/featuretags.go index 8ca4c2831..fe8ed0fb9 100644 --- a/feature/featuretags/featuretags.go +++ b/feature/featuretags/featuretags.go @@ -231,6 +231,11 @@ type FeatureMeta struct { }, "qrcodes": {Sym: "QRCodes", Desc: "QR codes in tailscale CLI"}, "relayserver": {Sym: "RelayServer", Desc: "Relay server"}, + "remoteconfig": { + Sym: "RemoteConfig", + Desc: "Full remote configuration of this node by the tailnet admin, opting out of Tailscale's per-feature double opt-in in favor of a single client-side trust decision", + Deps: []FeatureTag{"c2n"}, + }, "resolved": { Sym: "Resolved", Desc: "Linux systemd-resolved integration", diff --git a/feature/remoteconfig/integration_test.go b/feature/remoteconfig/integration_test.go new file mode 100644 index 000000000..871dd951d --- /dev/null +++ b/feature/remoteconfig/integration_test.go @@ -0,0 +1,181 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package remoteconfig_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "testing" + "time" + + "tailscale.com/ipn" + "tailscale.com/tstest" + "tailscale.com/tstest/integration" +) + +// TestRemoteConfigIntegration verifies that the /remoteapi/localapi/* +// c2n proxy handler is gated on Prefs.RemoteConfig and, when enabled, +// exposes the LocalAPI to the control plane with full permission. +func TestRemoteConfigIntegration(t *testing.T) { + tstest.Parallel(t) + env := integration.NewTestEnv(t) + + n := integration.NewTestNode(t, env) + d := n.StartDaemon() + defer d.MustCleanShutdown(t) + + n.AwaitListening() + n.MustUp() + n.AwaitRunning() + + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + defer cancel() + + nodeKey := n.MustStatus().Self.PublicKey + if err := tstest.WaitFor(5*time.Second, func() error { + return env.Control.AwaitNodeInMapRequest(ctx, nodeKey) + }); err != nil { + t.Fatal(err) + } + + rt := env.Control.NodeRoundTripper(nodeKey) + + // doReq issues a c2n request and retries on error. The testcontrol + // serveMap loop can race with the initial MapResponse delivery and + // silently drop the first PingRequest, so we retry with a shorter + // per-attempt deadline rather than relying on a single 30s call. + doReq := func(method, path string, body []byte) *http.Response { + t.Helper() + var lastErr error + for try := range 5 { + reqCtx, reqCancel := context.WithTimeout(ctx, 5*time.Second) + var r io.Reader + if body != nil { + r = bytes.NewReader(body) + } + req, err := http.NewRequestWithContext(reqCtx, method, path, r) + if err != nil { + reqCancel() + t.Fatalf("NewRequest(%s %s): %v", method, path, err) + } + resp, err := rt.RoundTrip(req) + reqCancel() + if err == nil { + return resp + } + lastErr = err + t.Logf("RoundTrip(%s %s) try %d: %v", method, path, try+1, err) + } + t.Fatalf("RoundTrip(%s %s) failed after retries: %v", method, path, lastErr) + return nil + } + + readBody := func(r *http.Response) string { + t.Helper() + defer r.Body.Close() + b, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + return string(b) + } + + // Case 1: RemoteConfig is off by default. The proxy must reject with 403. + resp := doReq("GET", "/remoteapi/localapi/v0/prefs", nil) + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("with RemoteConfig=false, GET /remoteapi/localapi/v0/prefs: got %d %q; want 403", resp.StatusCode, readBody(resp)) + } + resp.Body.Close() + + // Enable RemoteConfig locally. + c := n.LocalClient() + if _, err := c.EditPrefs(ctx, &ipn.MaskedPrefs{ + RemoteConfigSet: true, + Prefs: ipn.Prefs{RemoteConfig: true}, + }); err != nil { + t.Fatalf("EditPrefs(RemoteConfig=true): %v", err) + } + + // Case 2: With RemoteConfig on, the proxy should serve LocalAPI. GET prefs + // must return the current prefs including RemoteConfig=true. + resp = doReq("GET", "/remoteapi/localapi/v0/prefs", nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("with RemoteConfig=true, GET /remoteapi/localapi/v0/prefs: got %d %q; want 200", resp.StatusCode, readBody(resp)) + } + var gotPrefs ipn.Prefs + if err := json.NewDecoder(resp.Body).Decode(&gotPrefs); err != nil { + t.Fatalf("decode prefs from c2n proxy: %v", err) + } + resp.Body.Close() + if !gotPrefs.RemoteConfig { + t.Errorf("c2n GET prefs: RemoteConfig=false; want true") + } + + // Case 3: PATCH prefs through the proxy toggling Hostname. + const wantHostname = "remoteconfig-integration-test" + patch, err := json.Marshal(&ipn.MaskedPrefs{ + HostnameSet: true, + Prefs: ipn.Prefs{Hostname: wantHostname}, + }) + if err != nil { + t.Fatalf("marshal patch: %v", err) + } + resp = doReq("PATCH", "/remoteapi/localapi/v0/prefs", patch) + if resp.StatusCode != http.StatusOK { + t.Fatalf("PATCH /remoteapi/localapi/v0/prefs: got %d %q; want 200", resp.StatusCode, readBody(resp)) + } + resp.Body.Close() + + if err := tstest.WaitFor(5*time.Second, func() error { + cur, err := c.GetPrefs(ctx) + if err != nil { + return err + } + if cur.Hostname != wantHostname { + return fmt.Errorf("Hostname = %q; want %q", cur.Hostname, wantHostname) + } + return nil + }); err != nil { + t.Fatalf("PATCH did not land: %v", err) + } + + // Case 4: Turn RemoteConfig off via the c2n proxy itself. Subsequent c2n + // calls must be rejected as soon as the pref flips. + patch, err = json.Marshal(&ipn.MaskedPrefs{ + RemoteConfigSet: true, + Prefs: ipn.Prefs{RemoteConfig: false}, + }) + if err != nil { + t.Fatalf("marshal patch off: %v", err) + } + resp = doReq("PATCH", "/remoteapi/localapi/v0/prefs", patch) + if resp.StatusCode != http.StatusOK { + t.Fatalf("PATCH RemoteConfig=false via proxy: got %d %q; want 200", resp.StatusCode, readBody(resp)) + } + resp.Body.Close() + + if err := tstest.WaitFor(5*time.Second, func() error { + resp := doReq("GET", "/remoteapi/localapi/v0/prefs", nil) + defer resp.Body.Close() + if resp.StatusCode == http.StatusForbidden { + return nil + } + return fmt.Errorf("still not 403; got %d", resp.StatusCode) + }); err != nil { + t.Fatalf("after disabling RemoteConfig, proxy did not start rejecting: %v", err) + } + + // Case 5: A path outside the /remoteapi/localapi/ prefix should fall + // through the router. The router returns "unknown c2n path" with 400. + resp = doReq("GET", "/remoteapi/nope", nil) + if resp.StatusCode == http.StatusOK { + body := readBody(resp) + t.Errorf("unexpected 200 for /remoteapi/nope; body=%q", body) + } + resp.Body.Close() +} diff --git a/feature/remoteconfig/remoteconfig.go b/feature/remoteconfig/remoteconfig.go new file mode 100644 index 000000000..07baa9c94 --- /dev/null +++ b/feature/remoteconfig/remoteconfig.go @@ -0,0 +1,94 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +// Package remoteconfig registers a c2n endpoint that lets the control +// plane invoke this node's LocalAPI when Prefs.RemoteConfig is true. +// +// # Trust model +// +// Tailscale's default posture is per-feature double opt-in: the tailnet +// admin can request something server-side, but the local machine owner +// still has to consent (via CLI, GUI, or LocalAPI) for each individual +// setting. RemoteConfig is a different, more permissive posture: a +// single client-side "I trust the tailnet admin" switch. Once +// Prefs.RemoteConfig is true, the control plane can invoke any of this +// node's LocalAPI endpoints (which includes read/write of every pref) +// with no further local consent. +// +// This is appropriate when the tailnet admin owns the machine (e.g. a +// corporate fleet device) or when the local user has explicitly +// delegated full control to the tailnet admin. It should NOT be used +// on personal or BYOD devices where the tailnet admin is not fully +// trusted. +package remoteconfig + +import ( + "net/http" + "strings" + + "tailscale.com/feature" + "tailscale.com/ipn/ipnauth" + "tailscale.com/ipn/ipnlocal" + "tailscale.com/ipn/localapi" +) + +// c2nPrefix is the c2n URL path prefix under which requests are +// proxied to this node's LocalAPI at /localapi/*, regardless of the +// LocalAPI version (v0, v1, ...). +const c2nPrefix = "/remoteapi/localapi/" + +// localAPIStrip is the portion of c2nPrefix that must be stripped from +// the incoming c2n path to yield the LocalAPI path. +const localAPIStrip = "/remoteapi" + +func init() { + feature.Register("remoteconfig") + ipnlocal.RegisterC2NPrefix(c2nPrefix, handleC2NRemoteAPI) +} + +// handleC2NRemoteAPI proxies c2n requests under /remoteapi/localapi/* +// to this node's LocalAPI at /localapi/*, with full read/write +// permission, when the local machine has opted in via Prefs.RemoteConfig. +// +// See the package doc for the trust model this handler represents. +func handleC2NRemoteAPI(b *ipnlocal.LocalBackend, w http.ResponseWriter, r *http.Request) { + prefs := b.Prefs() + if !prefs.Valid() || !prefs.RemoteConfig() { + http.Error(w, "remote config not enabled by local machine", http.StatusForbidden) + return + } + if !strings.HasPrefix(r.URL.Path, c2nPrefix) { + http.Error(w, "unexpected remote-config path", http.StatusBadRequest) + return + } + + // Rewrite the URL from /remoteapi/localapi/X to /localapi/X on a + // shallow clone so we don't mutate the caller's Request. + u := *r.URL + u.Path = strings.TrimPrefix(r.URL.Path, localAPIStrip) + if r.URL.RawPath != "" { + u.RawPath = strings.TrimPrefix(r.URL.RawPath, localAPIStrip) + } + r2 := r.WithContext(r.Context()) + r2.URL = &u + if u.Path == r.URL.Path { + // Prefix strip did nothing; refuse rather than looping. + http.Error(w, "unexpected remote-config path", http.StatusBadRequest) + return + } + if !strings.HasPrefix(u.Path, "/localapi/") { + http.Error(w, "unexpected remote-config path", http.StatusBadRequest) + return + } + + lah := localapi.NewHandler(localapi.HandlerConfig{ + Actor: ipnauth.Self, + Backend: b, + Logf: b.Logger(), + LogID: b.BackendLogID(), + EventBus: b.Sys().Bus.Get(), + }) + lah.PermitRead = true + lah.PermitWrite = true + lah.ServeHTTP(w, r2) +} diff --git a/ipn/ipn_clone.go b/ipn/ipn_clone.go index e7c262c1b..4b1f84e1e 100644 --- a/ipn/ipn_clone.go +++ b/ipn/ipn_clone.go @@ -103,6 +103,7 @@ func (src *Prefs) Clone() *Prefs { AppConnector AppConnectorPrefs PostureChecking bool NetfilterKind string + RemoteConfig bool DriveShares []*drive.Share RelayServerPort *uint16 RelayServerStaticEndpoints []netip.AddrPort diff --git a/ipn/ipn_view.go b/ipn/ipn_view.go index 9fb5fbb8a..8d30fd5b9 100644 --- a/ipn/ipn_view.go +++ b/ipn/ipn_view.go @@ -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 @@ func (v PrefsView) Persist() persist.PersistView { return v.ж.Persist.View() } AppConnector AppConnectorPrefs PostureChecking bool NetfilterKind string + RemoteConfig bool DriveShares []*drive.Share RelayServerPort *uint16 RelayServerStaticEndpoints []netip.AddrPort diff --git a/ipn/ipnlocal/c2n.go b/ipn/ipnlocal/c2n.go index 8284872b9..0a1fb685e 100644 --- a/ipn/ipnlocal/c2n.go +++ b/ipn/ipnlocal/c2n.go @@ -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 { diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 085cdc60c..5fafc9043 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -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 { diff --git a/ipn/prefs.go b/ipn/prefs.go index 6ac421eb0..588bffc1d 100644 --- a/ipn/prefs.go +++ b/ipn/prefs.go @@ -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) } diff --git a/ipn/prefs_test.go b/ipn/prefs_test.go index 97ed33c23..3a960a798 100644 --- a/ipn/prefs_test.go +++ b/ipn/prefs_test.go @@ -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", diff --git a/tailcfg/tailcfg.go b/tailcfg/tailcfg.go index 01cce2fda..6614567b5 100644 --- a/tailcfg/tailcfg.go +++ b/tailcfg/tailcfg.go @@ -188,7 +188,8 @@ // - 139: 2026-05-22: Client understands [NodeAttrEmitRuntimeMetrics] // - 140: 2026-05-27: Client understands [NodeAttrDisableUDPGRO], [NodeAttrDisableUDPGSO], [NodeAttrDisableTUNUDPGRO], [NodeAttrDisableTUNTCPGRO] // - 141: 2026-05-28: Client understands [NodeAttrNeverGSOEqualTail] -const CurrentCapabilityVersion CapabilityVersion = 141 +// - 142: 2026-07-06: Client understands c2n /remoteapi/localapi/* proxy +const CurrentCapabilityVersion CapabilityVersion = 142 // ID is an integer ID for a user, node, or login allocated by the // control plane. @@ -896,6 +897,15 @@ type Hostinfo struct { ShieldsUp bool `json:",omitzero"` // indicates whether the host is blocking incoming connections ShareeNode bool `json:",omitzero"` // indicates this node exists in netmap because it's owned by a shared-to user NoLogsNoSupport bool `json:",omitzero"` // indicates that the user has opted out of sending logs and support + + // RemoteConfig is whether the node has both linked + // feature/remoteconfig into its binary and enabled + // Prefs.RemoteConfig: it has delegated full remote management of + // its prefs and LocalAPI to the tailnet admin via the + // /remoteapi/localapi/* c2n endpoint. See feature/remoteconfig for + // the trust model. + RemoteConfig bool `json:",omitzero"` + // WireIngress indicates that the node would like to be wired up server-side // (DNS, etc) to be able to use Tailscale Funnel, even if it's not currently // enabled. For example, the user might only use it for intermittent diff --git a/tailcfg/tailcfg_clone.go b/tailcfg/tailcfg_clone.go index df2d6d9aa..e1d5551cd 100644 --- a/tailcfg/tailcfg_clone.go +++ b/tailcfg/tailcfg_clone.go @@ -167,6 +167,7 @@ func (src *Hostinfo) Clone() *Hostinfo { ShieldsUp bool ShareeNode bool NoLogsNoSupport bool + RemoteConfig bool WireIngress bool IngressEnabled bool AllowsUpdate bool diff --git a/tailcfg/tailcfg_test.go b/tailcfg/tailcfg_test.go index 989bf9c19..5f70648a4 100644 --- a/tailcfg/tailcfg_test.go +++ b/tailcfg/tailcfg_test.go @@ -51,6 +51,7 @@ func TestHostinfoEqual(t *testing.T) { "ShieldsUp", "ShareeNode", "NoLogsNoSupport", + "RemoteConfig", "WireIngress", "IngressEnabled", "AllowsUpdate", diff --git a/tailcfg/tailcfg_view.go b/tailcfg/tailcfg_view.go index 3418c2599..c2403c9ba 100644 --- a/tailcfg/tailcfg_view.go +++ b/tailcfg/tailcfg_view.go @@ -550,6 +550,14 @@ func (v HostinfoView) ShareeNode() bool { return v.ж.ShareeNode } // indicates that the user has opted out of sending logs and support func (v HostinfoView) NoLogsNoSupport() bool { return v.ж.NoLogsNoSupport } +// RemoteConfig is whether the node has both linked +// feature/remoteconfig into its binary and enabled +// Prefs.RemoteConfig: it has delegated full remote management of +// its prefs and LocalAPI to the tailnet admin via the +// /remoteapi/localapi/* c2n endpoint. See feature/remoteconfig for +// the trust model. +func (v HostinfoView) RemoteConfig() bool { return v.ж.RemoteConfig } + // WireIngress indicates that the node would like to be wired up server-side // (DNS, etc) to be able to use Tailscale Funnel, even if it's not currently // enabled. For example, the user might only use it for intermittent @@ -649,6 +657,7 @@ func (v HostinfoView) Equal(v2 HostinfoView) bool { return v.ж.Equal(v2.ж) } ShieldsUp bool ShareeNode bool NoLogsNoSupport bool + RemoteConfig bool WireIngress bool IngressEnabled bool AllowsUpdate bool diff --git a/tsnet/tsnet_test.go b/tsnet/tsnet_test.go index 4fd3a7765..6a8aff4f2 100644 --- a/tsnet/tsnet_test.go +++ b/tsnet/tsnet_test.go @@ -3409,6 +3409,7 @@ func TestDeps(t *testing.T) { BadDeps: map[string]string{ "golang.org/x/crypto/ssh": "tsnet should not depend on SSH", "golang.org/x/crypto/ssh/internal/bcrypt_pbkdf": "tsnet should not depend on SSH", + "tailscale.com/feature/remoteconfig": "tsnet should not depend on feature/remoteconfig", "tailscale.com/feature/syspolicy": "tsnet should not depend on syspolicy", "tailscale.com/ipn/store/awsstore": "tsnet callers wanting AWS state storage should import awsstore themselves", "tailscale.com/ipn/store/kubestore": "tsnet callers wanting Kubernetes state storage should import kubestore themselves",