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"}},