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 <adriano@tailscale.com>
This commit is contained in:
Adriano Sela Aviles authored and Adriano Sela Aviles committed 2026-11-02 09:22:43 -07:00
1 parent 57ca6e1e2a
commit 5cf2727225
8 files changed
+123 -6

No files matched your search

+4
View File
@@ -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)
}
+4 -6
View File
@@ -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
+1
View File
@@ -104,6 +104,7 @@ var _PrefsCloneNeedsRegeneration = Prefs(struct {
AppConnector AppConnectorPrefs
PostureChecking bool
NetfilterKind string
Tailnet string
RemoteConfig bool
DriveShares []*drive.Share
RelayServerPort *uint16
+23
View File
@@ -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
+6
View File
@@ -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,
+47
View File
@@ -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{
+27
View File
@@ -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)
+11
View File
@@ -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"}},