From 5cf27272255e4db70e2db87932fe9f07fa6e730c Mon Sep 17 00:00:00 2001 From: Adriano Sela Aviles Date: Fri, 30 Oct 2026 11:19:39 -0700 Subject: [PATCH] control,ipn: add Tailnet hint for device registration Add an ipn.Prefs.Tailnet field, passed through controlclient.Options into tailcfg.RegisterRequest.Tailnet, so a client can recommend or require a tailnet at registration (e.g. to skip the tailnet picker when the intent is already known) without impersonating MDM. Previously this value could only come from the "Tailnet" syspolicy setting. That setting still takes precedence as an administrative override; the prefs value is used only when no policy is set. Updates #22093 Signed-off-by: Adriano Sela Aviles --- cmd/tailscale/cli/cli_test.go | 4 +++ control/controlclient/direct.go | 10 +++---- ipn/ipn_clone.go | 1 + ipn/ipn_view.go | 23 ++++++++++++++++ ipn/ipnlocal/local.go | 6 +++++ ipn/ipnlocal/local_test.go | 47 +++++++++++++++++++++++++++++++++ ipn/prefs.go | 27 +++++++++++++++++++ ipn/prefs_test.go | 11 ++++++++ 8 files changed, 123 insertions(+), 6 deletions(-) diff --git a/cmd/tailscale/cli/cli_test.go b/cmd/tailscale/cli/cli_test.go index e8608723c..3a0dd6d1f 100644 --- a/cmd/tailscale/cli/cli_test.go +++ b/cmd/tailscale/cli/cli_test.go @@ -1051,6 +1051,10 @@ func TestPrefFlagMapping(t *testing.T) { case "AutoExitNode": // Handled by tailscale {set,up} --exit-node=auto:any. continue + case "Tailnet": + // Registration-only hint for the tailnet to join, consumed when + // building the RegisterRequest. Not editable via a CLI flag. + continue } t.Errorf("unexpected new ipn.Pref field %q is not handled by up.go (see addPrefFlagMapping and checkForAccidentalSettingReverts)", prefName) } diff --git a/control/controlclient/direct.go b/control/controlclient/direct.go index 00941c5ed..2405881fb 100644 --- a/control/controlclient/direct.go +++ b/control/controlclient/direct.go @@ -62,7 +62,6 @@ import ( "tailscale.com/util/eventbus" "tailscale.com/util/httpbody" "tailscale.com/util/singleflight" - "tailscale.com/util/syspolicy/pkey" "tailscale.com/util/syspolicy/policyclient" "tailscale.com/util/testenv" "tailscale.com/util/vizerror" @@ -110,6 +109,7 @@ type Direct struct { persist persist.PersistView authKey string + tailnet string // optional recommended/required tailnet for registration; see [tailcfg.RegisterRequest.Tailnet] tryingNewKey key.NodePrivate expiry time.Time // or zero value if none/unknown hostinfo *tailcfg.Hostinfo // always non-nil @@ -140,6 +140,7 @@ type Options struct { GetMachinePrivateKey func() (key.MachinePrivate, error) // returns the machine key to use ServerURL string // URL of the tailcontrol server AuthKey string // optional node auth key for auto registration + Tailnet string // optional recommended/required tailnet for registration; see [tailcfg.RegisterRequest.Tailnet] Clock tstime.Clock Hostinfo *tailcfg.Hostinfo // non-nil passes ownership, nil means to use default using os.Hostname, etc DiscoPublicKey key.DiscoPublic @@ -376,6 +377,7 @@ func NewDirect(opts Options) (*Direct, error) { logf: opts.Logf, persist: opts.Persist.View(), authKey: opts.AuthKey, + tailnet: opts.Tailnet, debugFlags: opts.DebugFlags, netMon: netMon, health: opts.HealthTracker, @@ -756,10 +758,6 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new return regen, opt.URL, nil, err } - tailnet, err := c.polc.GetString(pkey.Tailnet, "") - if err != nil { - c.logf("unable to provide Tailnet field in register request. err: %v", err) - } now := c.clock.Now().Round(time.Second) request := tailcfg.RegisterRequest{ Version: 1, @@ -771,7 +769,7 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new Timestamp: &now, Ephemeral: (opt.Flags & LoginEphemeral) != 0, NodeKeySignature: nodeKeySignature, - Tailnet: tailnet, + Tailnet: c.tailnet, } if opt.Logout { request.Expiry = time.Unix(123, 0) // far in the past diff --git a/ipn/ipn_clone.go b/ipn/ipn_clone.go index 6a5353aed..2ea583032 100644 --- a/ipn/ipn_clone.go +++ b/ipn/ipn_clone.go @@ -104,6 +104,7 @@ var _PrefsCloneNeedsRegeneration = Prefs(struct { AppConnector AppConnectorPrefs PostureChecking bool NetfilterKind string + Tailnet string RemoteConfig bool DriveShares []*drive.Share RelayServerPort *uint16 diff --git a/ipn/ipn_view.go b/ipn/ipn_view.go index eeb977b7a..b8cd5bf1b 100644 --- a/ipn/ipn_view.go +++ b/ipn/ipn_view.go @@ -442,6 +442,28 @@ func (v PrefsView) PostureChecking() bool { return v.ж.PostureChecking } // Linux-only. func (v PrefsView) NetfilterKind() string { return v.ж.NetfilterKind } +// Tailnet is an optional identifier for the recommended or required tailnet +// the node should join at registration, mirroring +// [tailcfg.RegisterRequest.Tailnet]. It is a comma-separated list of tailnet +// identifiers (a stable tailnet ID, stable org ID, or tailnet SID). The +// server treats the list as a set and restricts login to any one of the +// listed tailnets. +// +// A single leading "required:" prefix (e.g. "required:tn1,tn2") governs the +// whole set: when present, the server forces login to one of the listed +// tailnets rather than merely recommending them. The prefix is not repeated +// per entry. If empty, no recommendation is sent and the login page offers +// all tailnets. +// +// The value is passed to the server verbatim; the client does not interpret +// it. The syspolicy "Tailnet" setting overrides this value when set, so +// administrators can enforce a tailnet via MDM. +// +// It is only consumed at registration time (when building the +// RegisterRequest), so like ControlURL it must be set in the initial prefs +// passed to Start rather than changed later via EditPrefs. +func (v PrefsView) Tailnet() string { return v.ж.Tailnet } + // 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 @@ -521,6 +543,7 @@ var _PrefsViewNeedsRegeneration = Prefs(struct { AppConnector AppConnectorPrefs PostureChecking bool NetfilterKind string + Tailnet string RemoteConfig bool DriveShares []*drive.Share RelayServerPort *uint16 diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index 4e02bfc94..ad32a15b8 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -2295,6 +2295,11 @@ func (b *LocalBackend) applySysPolicyLocked(prefs *ipn.Prefs) (anyChange bool) { anyChange = true } + if tailnet, err := b.polc.GetString(pkey.Tailnet, prefs.Tailnet); err == nil && prefs.Tailnet != tailnet { + prefs.Tailnet = tailnet + anyChange = true + } + const sentinel = "HostnameDefaultValue" hostnameFromPolicy, _ := b.polc.GetString(pkey.Hostname, sentinel) switch hostnameFromPolicy { @@ -3325,6 +3330,7 @@ func (b *LocalBackend) startLocked(opts ipn.Options) error { Persist: *persistv, ServerURL: serverURL, AuthKey: opts.AuthKey, + Tailnet: prefs.Tailnet(), Hostinfo: b.hostInfoWithServicesLocked(), HTTPTestClient: httpTestClient, DiscoPublicKey: discoPublic, diff --git a/ipn/ipnlocal/local_test.go b/ipn/ipnlocal/local_test.go index 1cf25cd40..dc822ef32 100644 --- a/ipn/ipnlocal/local_test.go +++ b/ipn/ipnlocal/local_test.go @@ -4786,6 +4786,53 @@ func TestApplySysPolicy(t *testing.T) { pkey.ControlURL: "set", }, }, + { + name: "Tailnet", + wantPrefs: ipn.Prefs{ + Tailnet: "required:tn123", + }, + wantAnyChange: true, + stringPolicies: map[pkey.Key]string{ + pkey.Tailnet: "required:tn123", + }, + }, + { + // The value is passed through verbatim, including a comma-separated + // list and a single leading "required:" prefix governing the set. + name: "Tailnet-comma-separated-required", + wantPrefs: ipn.Prefs{ + Tailnet: "required:tn123,tn456", + }, + wantAnyChange: true, + stringPolicies: map[pkey.Key]string{ + pkey.Tailnet: "required:tn123,tn456", + }, + }, + { + // A comma-separated list without the prefix is a recommendation + // over the set, again passed through unchanged. + name: "Tailnet-comma-separated-recommended", + wantPrefs: ipn.Prefs{ + Tailnet: "tn123,tn456", + }, + wantAnyChange: true, + stringPolicies: map[pkey.Key]string{ + pkey.Tailnet: "tn123,tn456", + }, + }, + { + name: "Tailnet-overrides-prefs", + prefs: ipn.Prefs{ + Tailnet: "tn123", + }, + wantPrefs: ipn.Prefs{ + Tailnet: "required:tn456", + }, + wantAnyChange: true, + stringPolicies: map[pkey.Key]string{ + pkey.Tailnet: "required:tn456", + }, + }, { name: "enable-apply-keeps-check", prefs: ipn.Prefs{ diff --git a/ipn/prefs.go b/ipn/prefs.go index f97fafad0..ac9dd173c 100644 --- a/ipn/prefs.go +++ b/ipn/prefs.go @@ -278,6 +278,28 @@ type Prefs struct { // Linux-only. NetfilterKind string + // Tailnet is an optional identifier for the recommended or required tailnet + // the node should join at registration, mirroring + // [tailcfg.RegisterRequest.Tailnet]. It is a comma-separated list of tailnet + // identifiers (a stable tailnet ID, stable org ID, or tailnet SID). The + // server treats the list as a set and restricts login to any one of the + // listed tailnets. + // + // A single leading "required:" prefix (e.g. "required:tn1,tn2") governs the + // whole set: when present, the server forces login to one of the listed + // tailnets rather than merely recommending them. The prefix is not repeated + // per entry. If empty, no recommendation is sent and the login page offers + // all tailnets. + // + // The value is passed to the server verbatim; the client does not interpret + // it. The syspolicy "Tailnet" setting overrides this value when set, so + // administrators can enforce a tailnet via MDM. + // + // It is only consumed at registration time (when building the + // RegisterRequest), so like ControlURL it must be set in the initial prefs + // passed to Start rather than changed later via EditPrefs. + Tailnet string `json:",omitzero"` + // 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 @@ -386,6 +408,7 @@ type MaskedPrefs struct { AppConnectorSet bool `json:",omitempty"` PostureCheckingSet bool `json:",omitempty"` NetfilterKindSet bool `json:",omitempty"` + TailnetSet bool `json:",omitzero"` RemoteConfigSet bool `json:",omitempty"` DriveSharesSet bool `json:",omitempty"` RelayServerPortSet bool `json:",omitempty"` @@ -622,6 +645,9 @@ func (p *Prefs) pretty(goos string) string { if p.NetfilterKind != "" { fmt.Fprintf(&sb, "netfilterKind=%s ", p.NetfilterKind) } + if p.Tailnet != "" { + fmt.Fprintf(&sb, "tailnet=%q ", p.Tailnet) + } if buildfeatures.HasClientUpdate { sb.WriteString(p.AutoUpdate.Pretty()) } @@ -698,6 +724,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.Tailnet == p2.Tailnet && 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 3a960a798..de08775cf 100644 --- a/ipn/prefs_test.go +++ b/ipn/prefs_test.go @@ -67,6 +67,7 @@ func TestPrefsEqual(t *testing.T) { "AppConnector", "PostureChecking", "NetfilterKind", + "Tailnet", "RemoteConfig", "DriveShares", "RelayServerPort", @@ -360,6 +361,16 @@ func TestPrefsEqual(t *testing.T) { &Prefs{NetfilterKind: ""}, false, }, + { + &Prefs{Tailnet: "required:tn123"}, + &Prefs{Tailnet: "required:tn123"}, + true, + }, + { + &Prefs{Tailnet: "required:tn123"}, + &Prefs{Tailnet: "tn123"}, + false, + }, { &Prefs{AdvertiseServices: []string{"svc:tux", "svc:xenia"}}, &Prefs{AdvertiseServices: []string{"svc:tux", "svc:xenia"}},