diff --git a/cmd/tailscale/cli/cli_test.go b/cmd/tailscale/cli/cli_test.go index 12a1b0638..91d65c0f9 100644 --- a/cmd/tailscale/cli/cli_test.go +++ b/cmd/tailscale/cli/cli_test.go @@ -556,6 +556,10 @@ func TestPrefsFromUpArgs(t *testing.T) { NetfilterMode: preftype.NetfilterOn, CorpDNS: true, AllowSingleHosts: true, + AutoUpdate: ipn.AutoUpdatePrefs{ + Check: true, + Apply: false, + }, }, }, { @@ -569,6 +573,10 @@ func TestPrefsFromUpArgs(t *testing.T) { AllowSingleHosts: true, RouteAll: true, NetfilterMode: preftype.NetfilterOn, + AutoUpdate: ipn.AutoUpdatePrefs{ + Check: true, + Apply: false, + }, }, }, { @@ -584,6 +592,10 @@ func TestPrefsFromUpArgs(t *testing.T) { netip.MustParsePrefix("::/0"), }, NetfilterMode: preftype.NetfilterOn, + AutoUpdate: ipn.AutoUpdatePrefs{ + Check: true, + Apply: false, + }, }, }, { @@ -670,6 +682,10 @@ func TestPrefsFromUpArgs(t *testing.T) { WantRunning: true, NetfilterMode: preftype.NetfilterNoDivert, NoSNAT: true, + AutoUpdate: ipn.AutoUpdatePrefs{ + Check: true, + Apply: false, + }, }, }, { @@ -683,6 +699,10 @@ func TestPrefsFromUpArgs(t *testing.T) { WantRunning: true, NetfilterMode: preftype.NetfilterOff, NoSNAT: true, + AutoUpdate: ipn.AutoUpdatePrefs{ + Check: true, + Apply: false, + }, }, }, { @@ -698,6 +718,10 @@ func TestPrefsFromUpArgs(t *testing.T) { AdvertiseRoutes: []netip.Prefix{ netip.MustParsePrefix("fd7a:115c:a1e0:b1a::bb:10.0.0.0/112"), }, + AutoUpdate: ipn.AutoUpdatePrefs{ + Check: true, + Apply: false, + }, }, }, { diff --git a/cmd/tailscale/cli/set.go b/cmd/tailscale/cli/set.go index fa0f0da9a..d589da3ae 100644 --- a/cmd/tailscale/cli/set.go +++ b/cmd/tailscale/cli/set.go @@ -11,6 +11,7 @@ "net/netip" "github.com/peterbourgon/ff/v3/ffcli" + "tailscale.com/clientupdate" "tailscale.com/ipn" "tailscale.com/net/netutil" "tailscale.com/net/tsaddr" @@ -46,6 +47,8 @@ type setArgsT struct { acceptedRisks string profileName string forceDaemon bool + updateCheck bool + updateApply bool } func newSetFlagSet(goos string, setArgs *setArgsT) *flag.FlagSet { @@ -61,6 +64,8 @@ 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.updateCheck, "update-check", true, "HIDDEN: notify about available Tailscale updates") + setf.BoolVar(&setArgs.updateApply, "auto-update", false, "HIDDEN: automatically update to the latest available version") if safesocket.GOOSUsesPeerCreds(goos) { setf.StringVar(&setArgs.opUser, "operator", "", "Unix username to allow to operate on tailscaled without sudo") } @@ -99,6 +104,10 @@ func runSet(ctx context.Context, args []string) (retErr error) { Hostname: setArgs.hostname, OperatorUser: setArgs.opUser, ForceDaemon: setArgs.forceDaemon, + AutoUpdate: ipn.AutoUpdatePrefs{ + Check: setArgs.updateCheck, + Apply: setArgs.updateApply, + }, }, } @@ -143,6 +152,12 @@ func runSet(ctx context.Context, args []string) (retErr error) { return err } } + if maskedPrefs.AutoUpdateSet { + _, err := clientupdate.NewUpdater(clientupdate.Arguments{}) + if errors.Is(err, errors.ErrUnsupported) { + return errors.New("automatic updates are not supported on this platform") + } + } checkPrefs := curPrefs.Clone() checkPrefs.ApplyEdits(maskedPrefs) if err := localClient.CheckPrefs(ctx, checkPrefs); err != nil { diff --git a/cmd/tailscale/cli/up.go b/cmd/tailscale/cli/up.go index d0cded47e..1ff498214 100644 --- a/cmd/tailscale/cli/up.go +++ b/cmd/tailscale/cli/up.go @@ -97,6 +97,8 @@ func newUpFlagSet(goos string, upArgs *upArgsT, cmd string) *flag.FlagSet { } upf := newFlagSet(cmd) + // When adding new flags, prefer to put them under "tailscale set" instead + // of here. Setting preferences via "tailscale up" is deprecated. upf.BoolVar(&upArgs.qr, "qr", false, "show QR code for login URLs") upf.StringVar(&upArgs.authKeyOrFile, "auth-key", "", `node authorization key; if it begins with "file:", then it's a path to a file containing the authkey`) @@ -712,6 +714,8 @@ func init() { addPrefFlagMapping("operator", "OperatorUser") addPrefFlagMapping("ssh", "RunSSH") addPrefFlagMapping("nickname", "ProfileName") + addPrefFlagMapping("update-check", "AutoUpdate") + addPrefFlagMapping("auto-update", "AutoUpdate") } func addPrefFlagMapping(flagName string, prefNames ...string) { diff --git a/ipn/ipn_clone.go b/ipn/ipn_clone.go index 5377705bb..90718fb8d 100644 --- a/ipn/ipn_clone.go +++ b/ipn/ipn_clone.go @@ -51,6 +51,7 @@ func (src *Prefs) Clone() *Prefs { NetfilterMode preftype.NetfilterMode OperatorUser string ProfileName string + AutoUpdate AutoUpdatePrefs Persist *persist.Persist }{}) diff --git a/ipn/ipn_view.go b/ipn/ipn_view.go index 8a04c2c32..0e22544dd 100644 --- a/ipn/ipn_view.go +++ b/ipn/ipn_view.go @@ -86,6 +86,7 @@ func (v PrefsView) NoSNAT() bool { return v.ж.NoSNAT } func (v PrefsView) NetfilterMode() preftype.NetfilterMode { return v.ж.NetfilterMode } 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) Persist() persist.PersistView { return v.ж.Persist.View() } // A compilation failure here means this code must be regenerated, with the command at the top of this file. @@ -111,6 +112,7 @@ func (v PrefsView) Persist() persist.PersistView { return v.ж.Persist. NetfilterMode preftype.NetfilterMode OperatorUser string ProfileName string + AutoUpdate AutoUpdatePrefs Persist *persist.Persist }{}) diff --git a/ipn/ipnlocal/c2n.go b/ipn/ipnlocal/c2n.go index 3ce5c9cdd..4408f4316 100644 --- a/ipn/ipnlocal/c2n.go +++ b/ipn/ipnlocal/c2n.go @@ -127,9 +127,10 @@ func (b *LocalBackend) handleC2NUpdate(w http.ResponseWriter, r *http.Request) { // // Note that we create the Updater solely to check for errors; we do not // invoke it here. For this purpose, it is ok to pass it a zero Arguments. + prefs := b.Prefs().AutoUpdate() _, err := clientupdate.NewUpdater(clientupdate.Arguments{}) res := tailcfg.C2NUpdateResponse{ - Enabled: envknob.AllowsRemoteUpdate(), + Enabled: envknob.AllowsRemoteUpdate() || prefs.Apply, Supported: err == nil && !version.IsMacSysExt(), } diff --git a/ipn/prefs.go b/ipn/prefs.go index 59bc373a7..b8589fe90 100644 --- a/ipn/prefs.go +++ b/ipn/prefs.go @@ -196,6 +196,10 @@ type Prefs struct { // and CLI. ProfileName string `json:",omitempty"` + // AutoUpdate sets the auto-update preferences for the node agent. See + // AutoUpdatePrefs docs for more details. + AutoUpdate AutoUpdatePrefs + // The Persist field is named 'Config' in the file for backward // compatibility with earlier versions. // TODO(apenwarr): We should move this out of here, it's not a pref. @@ -204,6 +208,18 @@ type Prefs struct { Persist *persist.Persist `json:"Config"` } +// AutoUpdatePrefs are the auto update settings for the node agent. +type AutoUpdatePrefs struct { + // Check specifies whether background checks for updates are enabled. When + // enabled, tailscaled will periodically check for available updates and + // notify the user about them. + Check bool + // Apply specifies whether background auto-updates are enabled. When + // enabled, tailscaled will apply available updates in the background. + // Check must also be set when Apply is set. + Apply bool +} + // MaskedPrefs is a Prefs with an associated bitmask of which fields are set. type MaskedPrefs struct { Prefs @@ -229,6 +245,7 @@ type MaskedPrefs struct { NetfilterModeSet bool `json:",omitempty"` OperatorUserSet bool `json:",omitempty"` ProfileNameSet bool `json:",omitempty"` + AutoUpdateSet bool `json:",omitempty"` } // ApplyEdits mutates p, assigning fields from m.Prefs for each MaskedPrefs @@ -284,6 +301,12 @@ func (m *MaskedPrefs) Pretty() string { if v.Type().Elem().Kind() == reflect.String { return "%s=%q" } + case reflect.Struct: + return "%s=%+v" + case reflect.Pointer: + if v.Type().Elem().Kind() == reflect.Struct { + return "%s=%+v" + } } return "%s=%v" } @@ -360,6 +383,7 @@ func (p *Prefs) pretty(goos string) string { if p.OperatorUser != "" { fmt.Fprintf(&sb, "op=%q ", p.OperatorUser) } + sb.WriteString(p.AutoUpdate.Pretty()) if p.Persist != nil { sb.WriteString(p.Persist.Pretty()) } else { @@ -414,7 +438,18 @@ func (p *Prefs) Equals(p2 *Prefs) bool { compareIPNets(p.AdvertiseRoutes, p2.AdvertiseRoutes) && compareStrings(p.AdvertiseTags, p2.AdvertiseTags) && p.Persist.Equals(p2.Persist) && - p.ProfileName == p2.ProfileName + p.ProfileName == p2.ProfileName && + p.AutoUpdate == p2.AutoUpdate +} + +func (au AutoUpdatePrefs) Pretty() string { + if au.Apply { + return "update=on " + } + if au.Check { + return "update=check " + } + return "update=off " } func compareIPNets(a, b []netip.Prefix) bool { @@ -459,6 +494,10 @@ func NewPrefs() *Prefs { CorpDNS: true, WantRunning: false, NetfilterMode: preftype.NetfilterOn, + AutoUpdate: AutoUpdatePrefs{ + Check: true, + Apply: false, + }, } } diff --git a/ipn/prefs_test.go b/ipn/prefs_test.go index 150d74098..e963d3265 100644 --- a/ipn/prefs_test.go +++ b/ipn/prefs_test.go @@ -56,6 +56,7 @@ func TestPrefsEqual(t *testing.T) { "NetfilterMode", "OperatorUser", "ProfileName", + "AutoUpdate", "Persist", } if have := fieldsOf(reflect.TypeOf(Prefs{})); !reflect.DeepEqual(have, prefsHandles) { @@ -288,6 +289,21 @@ func TestPrefsEqual(t *testing.T) { &Prefs{ProfileName: "home"}, false, }, + { + &Prefs{AutoUpdate: AutoUpdatePrefs{Check: true, Apply: false}}, + &Prefs{AutoUpdate: AutoUpdatePrefs{Check: false, Apply: false}}, + false, + }, + { + &Prefs{AutoUpdate: AutoUpdatePrefs{Check: true, Apply: true}}, + &Prefs{AutoUpdate: AutoUpdatePrefs{Check: true, Apply: false}}, + false, + }, + { + &Prefs{AutoUpdate: AutoUpdatePrefs{Check: true, Apply: false}}, + &Prefs{AutoUpdate: AutoUpdatePrefs{Check: true, Apply: false}}, + true, + }, } for i, tt := range tests { got := tt.a.Equals(tt.b) @@ -372,22 +388,22 @@ func TestPrefsPretty(t *testing.T) { { Prefs{}, "linux", - "Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off Persist=nil}", + "Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=off Persist=nil}", }, { Prefs{}, "windows", - "Prefs{ra=false mesh=false dns=false want=false Persist=nil}", + "Prefs{ra=false mesh=false dns=false want=false update=off Persist=nil}", }, { Prefs{ShieldsUp: true}, "windows", - "Prefs{ra=false mesh=false dns=false want=false shields=true Persist=nil}", + "Prefs{ra=false mesh=false dns=false want=false shields=true update=off Persist=nil}", }, { Prefs{AllowSingleHosts: true}, "windows", - "Prefs{ra=false dns=false want=false Persist=nil}", + "Prefs{ra=false dns=false want=false update=off Persist=nil}", }, { Prefs{ @@ -395,7 +411,7 @@ func TestPrefsPretty(t *testing.T) { AllowSingleHosts: true, }, "windows", - "Prefs{ra=false dns=false want=false notepad=true Persist=nil}", + "Prefs{ra=false dns=false want=false notepad=true update=off Persist=nil}", }, { Prefs{ @@ -404,7 +420,7 @@ func TestPrefsPretty(t *testing.T) { ForceDaemon: true, // server mode }, "windows", - "Prefs{ra=false dns=false want=true server=true Persist=nil}", + "Prefs{ra=false dns=false want=true server=true update=off Persist=nil}", }, { Prefs{ @@ -414,14 +430,14 @@ func TestPrefsPretty(t *testing.T) { AdvertiseTags: []string{"tag:foo", "tag:bar"}, }, "darwin", - `Prefs{ra=false dns=false want=true tags=tag:foo,tag:bar url="http://localhost:1234" Persist=nil}`, + `Prefs{ra=false dns=false want=true tags=tag:foo,tag:bar url="http://localhost:1234" update=off Persist=nil}`, }, { Prefs{ Persist: &persist.Persist{}, }, "linux", - `Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off Persist{lm=, o=, n= u=""}}`, + `Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=off Persist{lm=, o=, n= u=""}}`, }, { Prefs{ @@ -430,21 +446,21 @@ func TestPrefsPretty(t *testing.T) { }, }, "linux", - `Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off Persist{lm=, o=, n=[B1VKl] u=""}}`, + `Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=off Persist{lm=, o=, n=[B1VKl] u=""}}`, }, { Prefs{ ExitNodeIP: netip.MustParseAddr("1.2.3.4"), }, "linux", - `Prefs{ra=false mesh=false dns=false want=false exit=1.2.3.4 lan=false routes=[] nf=off Persist=nil}`, + `Prefs{ra=false mesh=false dns=false want=false exit=1.2.3.4 lan=false routes=[] nf=off update=off Persist=nil}`, }, { Prefs{ ExitNodeID: tailcfg.StableNodeID("myNodeABC"), }, "linux", - `Prefs{ra=false mesh=false dns=false want=false exit=myNodeABC lan=false routes=[] nf=off Persist=nil}`, + `Prefs{ra=false mesh=false dns=false want=false exit=myNodeABC lan=false routes=[] nf=off update=off Persist=nil}`, }, { Prefs{ @@ -452,21 +468,41 @@ func TestPrefsPretty(t *testing.T) { ExitNodeAllowLANAccess: true, }, "linux", - `Prefs{ra=false mesh=false dns=false want=false exit=myNodeABC lan=true routes=[] nf=off Persist=nil}`, + `Prefs{ra=false mesh=false dns=false want=false exit=myNodeABC lan=true routes=[] nf=off update=off Persist=nil}`, }, { Prefs{ ExitNodeAllowLANAccess: true, }, "linux", - `Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off Persist=nil}`, + `Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=off Persist=nil}`, }, { Prefs{ Hostname: "foo", }, "linux", - `Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off host="foo" Persist=nil}`, + `Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off host="foo" update=off Persist=nil}`, + }, + { + Prefs{ + AutoUpdate: AutoUpdatePrefs{ + Check: true, + Apply: false, + }, + }, + "linux", + `Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=check Persist=nil}`, + }, + { + Prefs{ + AutoUpdate: AutoUpdatePrefs{ + Check: true, + Apply: true, + }, + }, + "linux", + `Prefs{ra=false mesh=false dns=false want=false routes=[] nf=off update=on Persist=nil}`, }, } for i, tt := range tests {