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
@@ -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
@@ -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
@@ -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"
+8
View File
@@ -21,6 +21,14 @@ var in = map[string]bool{}
// 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 {
+5
View File
@@ -231,6 +231,11 @@ var Features = map[FeatureTag]FeatureMeta{
},
"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",
+181
View File
@@ -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()
}
+94
View File
@@ -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)
}