From c1ae2bb1f8d49945b74c9716e7ce1bfdc63e01a9 Mon Sep 17 00:00:00 2001 From: Brad Fitzpatrick Date: Mon, 6 Jul 2026 21:07:51 +0000 Subject: [PATCH] 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 --- cmd/tailscale/cli/get.go | 2 + cmd/tailscale/cli/set.go | 3 + cmd/tailscale/cli/up.go | 1 + cmd/tailscaled/depaware.txt | 1 + cmd/vet/jsontags_allowlist | 1 + .../feature_remoteconfig_disabled.go | 13 ++ .../feature_remoteconfig_enabled.go | 13 ++ feature/condregister/maybe_remoteconfig.go | 8 + feature/feature.go | 8 + feature/featuretags/featuretags.go | 5 + feature/remoteconfig/integration_test.go | 181 ++++++++++++++++++ feature/remoteconfig/remoteconfig.go | 94 +++++++++ ipn/ipn_clone.go | 1 + ipn/ipn_view.go | 19 ++ ipn/ipnlocal/c2n.go | 30 +++ ipn/ipnlocal/local.go | 12 ++ ipn/prefs.go | 23 +++ ipn/prefs_test.go | 6 + tailcfg/tailcfg.go | 12 +- tailcfg/tailcfg_clone.go | 1 + tailcfg/tailcfg_test.go | 1 + tailcfg/tailcfg_view.go | 9 + tsnet/tsnet_test.go | 1 + 23 files changed, 444 insertions(+), 1 deletion(-) create mode 100644 feature/buildfeatures/feature_remoteconfig_disabled.go create mode 100644 feature/buildfeatures/feature_remoteconfig_enabled.go create mode 100644 feature/condregister/maybe_remoteconfig.go create mode 100644 feature/remoteconfig/integration_test.go create mode 100644 feature/remoteconfig/remoteconfig.go 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",