diff --git a/cmd/tailscale/cli/cli_test.go b/cmd/tailscale/cli/cli_test.go index dca90c9da..38df92fe0 100644 --- a/cmd/tailscale/cli/cli_test.go +++ b/cmd/tailscale/cli/cli_test.go @@ -893,6 +893,7 @@ func TestUpdatePrefs(t *testing.T) { AdvertiseRoutesSet: true, AdvertiseTagsSet: true, AllowSingleHostsSet: true, + AppConnectorSet: true, ControlURLSet: true, CorpDNSSet: true, ExitNodeAllowLANAccessSet: true, @@ -1131,6 +1132,49 @@ func TestUpdatePrefs(t *testing.T) { wantJustEditMP: nil, env: upCheckEnv{backendState: "Running"}, }, + { + name: "advertise_connector", + flags: []string{"--advertise-connector"}, + curPrefs: &ipn.Prefs{ + ControlURL: ipn.DefaultControlURL, + AllowSingleHosts: true, + CorpDNS: true, + NetfilterMode: preftype.NetfilterOn, + }, + wantJustEditMP: &ipn.MaskedPrefs{ + AppConnectorSet: true, + WantRunningSet: true, + }, + env: upCheckEnv{backendState: "Running"}, + checkUpdatePrefsMutations: func(t *testing.T, newPrefs *ipn.Prefs) { + if !newPrefs.AppConnector.Advertise { + t.Errorf("prefs.AppConnector.Advertise not set") + } + }, + }, + { + name: "no_advertise_connector", + flags: []string{"--advertise-connector=false"}, + curPrefs: &ipn.Prefs{ + ControlURL: ipn.DefaultControlURL, + AllowSingleHosts: true, + CorpDNS: true, + NetfilterMode: preftype.NetfilterOn, + AppConnector: ipn.AppConnectorPrefs{ + Advertise: true, + }, + }, + wantJustEditMP: &ipn.MaskedPrefs{ + AppConnectorSet: true, + WantRunningSet: true, + }, + env: upCheckEnv{backendState: "Running"}, + checkUpdatePrefsMutations: func(t *testing.T, newPrefs *ipn.Prefs) { + if newPrefs.AppConnector.Advertise { + t.Errorf("prefs.AppConnector.Advertise not unset") + } + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/cmd/tailscale/cli/set.go b/cmd/tailscale/cli/set.go index e5c17bf62..7dbb5b5c8 100644 --- a/cmd/tailscale/cli/set.go +++ b/cmd/tailscale/cli/set.go @@ -45,6 +45,7 @@ type setArgsT struct { hostname string advertiseRoutes string advertiseDefaultRoute bool + advertiseConnector bool opUser string acceptedRisks string profileName string @@ -67,6 +68,7 @@ func newSetFlagSet(goos string, setArgs *setArgsT) *flag.FlagSet { setf.StringVar(&setArgs.hostname, "hostname", "", "hostname to use instead of the one provided by the OS") setf.StringVar(&setArgs.advertiseRoutes, "advertise-routes", "", "routes to advertise to other nodes (comma-separated, e.g. \"10.0.0.0/8,192.168.0.0/24\") or empty string to not advertise routes") setf.BoolVar(&setArgs.advertiseDefaultRoute, "advertise-exit-node", false, "offer to be an exit node for internet traffic for the tailnet") + setf.BoolVar(&setArgs.advertiseConnector, "advertise-connector", false, "offer to be an exit node for internet traffic for the tailnet") setf.BoolVar(&setArgs.updateCheck, "update-check", true, "notify about available Tailscale updates") setf.BoolVar(&setArgs.updateApply, "auto-update", false, "automatically update to the latest available version") setf.BoolVar(&setArgs.postureChecking, "posture-checking", false, "HIDDEN: allow management plane to gather device posture information") @@ -113,6 +115,9 @@ func runSet(ctx context.Context, args []string) (retErr error) { Check: setArgs.updateCheck, Apply: setArgs.updateApply, }, + AppConnector: ipn.AppConnectorPrefs{ + Advertise: setArgs.advertiseConnector, + }, PostureChecking: setArgs.postureChecking, }, } diff --git a/cmd/tailscale/cli/up.go b/cmd/tailscale/cli/up.go index ffc75814f..df1e98285 100644 --- a/cmd/tailscale/cli/up.go +++ b/cmd/tailscale/cli/up.go @@ -113,6 +113,7 @@ func newUpFlagSet(goos string, upArgs *upArgsT, cmd string) *flag.FlagSet { upf.StringVar(&upArgs.advertiseTags, "advertise-tags", "", "comma-separated ACL tags to request; each must start with \"tag:\" (e.g. \"tag:eng,tag:montreal,tag:ssh\")") upf.StringVar(&upArgs.hostname, "hostname", "", "hostname to use instead of the one provided by the OS") upf.StringVar(&upArgs.advertiseRoutes, "advertise-routes", "", "routes to advertise to other nodes (comma-separated, e.g. \"10.0.0.0/8,192.168.0.0/24\") or empty string to not advertise routes") + upf.BoolVar(&upArgs.advertiseConnector, "advertise-connector", false, "advertise this node as an app connector") upf.BoolVar(&upArgs.advertiseDefaultRoute, "advertise-exit-node", false, "offer to be an exit node for internet traffic for the tailnet") if safesocket.GOOSUsesPeerCreds(goos) { @@ -165,6 +166,7 @@ type upArgsT struct { advertiseRoutes string advertiseDefaultRoute bool advertiseTags string + advertiseConnector bool snat bool netfilterMode string authKeyOrFile string // "secret" or "file:/path/to/secret" @@ -283,6 +285,7 @@ func prefsFromUpArgs(upArgs upArgsT, warnf logger.Logf, st *ipnstate.Status, goo prefs.ForceDaemon = upArgs.forceDaemon prefs.OperatorUser = upArgs.opUser prefs.ProfileName = upArgs.profileName + prefs.AppConnector.Advertise = upArgs.advertiseConnector if goos == "linux" { prefs.NoSNAT = !upArgs.snat @@ -730,6 +733,7 @@ func init() { addPrefFlagMapping("nickname", "ProfileName") addPrefFlagMapping("update-check", "AutoUpdate") addPrefFlagMapping("auto-update", "AutoUpdate") + addPrefFlagMapping("advertise-connector", "AppConnector") addPrefFlagMapping("posture-checking", "PostureChecking") } @@ -965,6 +969,8 @@ func prefsToFlags(env upCheckEnv, prefs *ipn.Prefs) (flagVal map[string]any) { set(sb.String()) case "advertise-exit-node": set(hasExitNodeRoutes(prefs.AdvertiseRoutes)) + case "advertise-connector": + set(prefs.AppConnector.Advertise) case "snat-subnet-routes": set(!prefs.NoSNAT) case "netfilter-mode": diff --git a/ipn/ipn_clone.go b/ipn/ipn_clone.go index 3d0dbda34..f2c738cdd 100644 --- a/ipn/ipn_clone.go +++ b/ipn/ipn_clone.go @@ -53,6 +53,7 @@ var _PrefsCloneNeedsRegeneration = Prefs(struct { OperatorUser string ProfileName string AutoUpdate AutoUpdatePrefs + AppConnector AppConnectorPrefs PostureChecking bool Persist *persist.Persist }{}) diff --git a/ipn/ipn_view.go b/ipn/ipn_view.go index af5870f49..45e2c07bb 100644 --- a/ipn/ipn_view.go +++ b/ipn/ipn_view.go @@ -88,6 +88,7 @@ func (v PrefsView) NetfilterMode() preftype.NetfilterMode { return v.ж.Netfilte func (v PrefsView) OperatorUser() string { return v.ж.OperatorUser } func (v PrefsView) ProfileName() string { return v.ж.ProfileName } func (v PrefsView) AutoUpdate() AutoUpdatePrefs { return v.ж.AutoUpdate } +func (v PrefsView) AppConnector() AppConnectorPrefs { return v.ж.AppConnector } func (v PrefsView) PostureChecking() bool { return v.ж.PostureChecking } func (v PrefsView) Persist() persist.PersistView { return v.ж.Persist.View() } @@ -116,6 +117,7 @@ var _PrefsViewNeedsRegeneration = Prefs(struct { OperatorUser string ProfileName string AutoUpdate AutoUpdatePrefs + AppConnector AppConnectorPrefs PostureChecking bool Persist *persist.Persist }{}) diff --git a/ipn/prefs.go b/ipn/prefs.go index 01b3d620e..6f4d5fb06 100644 --- a/ipn/prefs.go +++ b/ipn/prefs.go @@ -205,6 +205,10 @@ type Prefs struct { // AutoUpdatePrefs docs for more details. AutoUpdate AutoUpdatePrefs + // AppConnector sets the app connector preferences for the node agent. See + // AppConnectorPrefs docs for more details. + AppConnector AppConnectorPrefs + // PostureChecking enables the collection of information used for device // posture checks. PostureChecking bool @@ -229,6 +233,13 @@ type AutoUpdatePrefs struct { Apply bool } +// AppConnectorPrefs are the app connector settings for the node agent. +type AppConnectorPrefs struct { + // Advertise specifies whether the app connector subsystem is advertising + // this node as a connector. + Advertise bool +} + // MaskedPrefs is a Prefs with an associated bitmask of which fields are set. type MaskedPrefs struct { Prefs @@ -256,6 +267,7 @@ type MaskedPrefs struct { OperatorUserSet bool `json:",omitempty"` ProfileNameSet bool `json:",omitempty"` AutoUpdateSet bool `json:",omitempty"` + AppConnectorSet bool `json:",omitempty"` PostureCheckingSet bool `json:",omitempty"` } @@ -398,6 +410,7 @@ func (p *Prefs) pretty(goos string) string { fmt.Fprintf(&sb, "op=%q ", p.OperatorUser) } sb.WriteString(p.AutoUpdate.Pretty()) + sb.WriteString(p.AppConnector.Pretty()) if p.Persist != nil { sb.WriteString(p.Persist.Pretty()) } else { @@ -455,6 +468,7 @@ func (p *Prefs) Equals(p2 *Prefs) bool { p.Persist.Equals(p2.Persist) && p.ProfileName == p2.ProfileName && p.AutoUpdate == p2.AutoUpdate && + p.AppConnector == p2.AppConnector && p.PostureChecking == p2.PostureChecking } @@ -468,6 +482,13 @@ func (au AutoUpdatePrefs) Pretty() string { return "update=off " } +func (ap AppConnectorPrefs) Pretty() string { + if ap.Advertise { + return "appconnector=advertise " + } + return "" +} + func compareIPNets(a, b []netip.Prefix) bool { if len(a) != len(b) { return false diff --git a/ipn/prefs_test.go b/ipn/prefs_test.go index 4fe24a8e7..489749909 100644 --- a/ipn/prefs_test.go +++ b/ipn/prefs_test.go @@ -58,6 +58,7 @@ func TestPrefsEqual(t *testing.T) { "OperatorUser", "ProfileName", "AutoUpdate", + "AppConnector", "PostureChecking", "Persist", } @@ -306,6 +307,16 @@ func TestPrefsEqual(t *testing.T) { &Prefs{AutoUpdate: AutoUpdatePrefs{Check: true, Apply: false}}, true, }, + { + &Prefs{AppConnector: AppConnectorPrefs{Advertise: true}}, + &Prefs{AppConnector: AppConnectorPrefs{Advertise: true}}, + true, + }, + { + &Prefs{AppConnector: AppConnectorPrefs{Advertise: true}}, + &Prefs{AppConnector: AppConnectorPrefs{Advertise: false}}, + false, + }, { &Prefs{PostureChecking: true}, &Prefs{PostureChecking: true}, @@ -516,6 +527,24 @@ func TestPrefsPretty(t *testing.T) { "linux", `Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=on Persist=nil}`, }, + { + Prefs{ + AppConnector: AppConnectorPrefs{ + Advertise: true, + }, + }, + "linux", + `Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=off appconnector=advertise Persist=nil}`, + }, + { + Prefs{ + AppConnector: AppConnectorPrefs{ + Advertise: false, + }, + }, + "linux", + `Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=off Persist=nil}`, + }, } for i, tt := range tests { got := tt.p.pretty(tt.os)