WIP: rebase fork onto upstream/main (v1.103.0) #15
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -103,6 +103,7 @@ var _PrefsCloneNeedsRegeneration = Prefs(struct {
|
||||
AppConnector AppConnectorPrefs
|
||||
PostureChecking bool
|
||||
NetfilterKind string
|
||||
RemoteConfig bool
|
||||
DriveShares []*drive.Share
|
||||
RelayServerPort *uint16
|
||||
RelayServerStaticEndpoints []netip.AddrPort
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
+11
-1
@@ -188,7 +188,8 @@ type CapabilityVersion int
|
||||
// - 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
|
||||
|
||||
@@ -167,6 +167,7 @@ var _HostinfoCloneNeedsRegeneration = Hostinfo(struct {
|
||||
ShieldsUp bool
|
||||
ShareeNode bool
|
||||
NoLogsNoSupport bool
|
||||
RemoteConfig bool
|
||||
WireIngress bool
|
||||
IngressEnabled bool
|
||||
AllowsUpdate bool
|
||||
|
||||
@@ -51,6 +51,7 @@ func TestHostinfoEqual(t *testing.T) {
|
||||
"ShieldsUp",
|
||||
"ShareeNode",
|
||||
"NoLogsNoSupport",
|
||||
"RemoteConfig",
|
||||
"WireIngress",
|
||||
"IngressEnabled",
|
||||
"AllowsUpdate",
|
||||
|
||||
@@ -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 @@ var _HostinfoViewNeedsRegeneration = Hostinfo(struct {
|
||||
ShieldsUp bool
|
||||
ShareeNode bool
|
||||
NoLogsNoSupport bool
|
||||
RemoteConfig bool
|
||||
WireIngress bool
|
||||
IngressEnabled bool
|
||||
AllowsUpdate bool
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user