From bc4c8b65c7a7c161ad81ee4b1a82af3f529e710b Mon Sep 17 00:00:00 2001 From: Andrew Lytvynov Date: Mon, 3 Jun 2024 19:24:53 -0700 Subject: [PATCH] ipn/ipnlocal: periodically run auto-updates when "offline" (#12118) When the client is disconnected from control for any reason (typically just turned off), we should still attempt to update if auto-updates are enabled. This may help users who turn tailscale on infrequently for accessing resources. RELNOTE: Apply auto-updates even if the node is down or disconnected from the coordination server. Updates #12117 Signed-off-by: Andrew Lytvynov --- ipn/ipnlocal/autoupdate.go | 60 +++++++++++++++++++++++ ipn/ipnlocal/autoupdate_disabled.go | 18 +++++++ ipn/ipnlocal/c2n.go | 54 +-------------------- ipn/ipnlocal/local.go | 75 +++++++++++++++++++++++++++++ 4 files changed, 155 insertions(+), 52 deletions(-) create mode 100644 ipn/ipnlocal/autoupdate.go create mode 100644 ipn/ipnlocal/autoupdate_disabled.go diff --git a/ipn/ipnlocal/autoupdate.go b/ipn/ipnlocal/autoupdate.go new file mode 100644 index 000000000..b12fbb67d --- /dev/null +++ b/ipn/ipnlocal/autoupdate.go @@ -0,0 +1,60 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +//go:build linux || windows + +package ipnlocal + +import ( + "context" + "time" + + "tailscale.com/clientupdate" + "tailscale.com/ipn" +) + +func (b *LocalBackend) stopOfflineAutoUpdate() { + if b.offlineAutoUpdateCancel != nil { + b.logf("offline auto-update: stopping update checks") + b.offlineAutoUpdateCancel() + b.offlineAutoUpdateCancel = nil + } +} + +func (b *LocalBackend) maybeStartOfflineAutoUpdate(prefs ipn.PrefsView) { + if !prefs.AutoUpdate().Apply.EqualBool(true) { + return + } + // AutoUpdate.Apply field in prefs can only be true for platforms that + // support auto-updates. But check it here again, just in case. + if !clientupdate.CanAutoUpdate() { + return + } + + if b.offlineAutoUpdateCancel != nil { + // Already running. + return + } + ctx, cancel := context.WithCancel(context.Background()) + b.offlineAutoUpdateCancel = cancel + + b.logf("offline auto-update: starting update checks") + go b.offlineAutoUpdate(ctx) +} + +const offlineAutoUpdateCheckPeriod = time.Hour + +func (b *LocalBackend) offlineAutoUpdate(ctx context.Context) { + t := time.NewTicker(offlineAutoUpdateCheckPeriod) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + } + if err := b.startAutoUpdate("offline auto-update"); err != nil { + b.logf("offline auto-update: failed: %v", err) + } + } +} diff --git a/ipn/ipnlocal/autoupdate_disabled.go b/ipn/ipnlocal/autoupdate_disabled.go new file mode 100644 index 000000000..88ed68c95 --- /dev/null +++ b/ipn/ipnlocal/autoupdate_disabled.go @@ -0,0 +1,18 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +//go:build !(linux || windows) + +package ipnlocal + +import ( + "tailscale.com/ipn" +) + +func (b *LocalBackend) stopOfflineAutoUpdate() { + // Not supported on this platform. +} + +func (b *LocalBackend) maybeStartOfflineAutoUpdate(prefs ipn.PrefsView) { + // Not supported on this platform. +} diff --git a/ipn/ipnlocal/c2n.go b/ipn/ipnlocal/c2n.go index de6ed73b3..f90f73423 100644 --- a/ipn/ipnlocal/c2n.go +++ b/ipn/ipnlocal/c2n.go @@ -4,7 +4,6 @@ package ipnlocal import ( - "bytes" "crypto/x509" "encoding/json" "encoding/pem" @@ -307,60 +306,11 @@ func handleC2NUpdatePost(b *LocalBackend, w http.ResponseWriter, r *http.Request return } - // Check if update was already started, and mark as started. - if !b.trySetC2NUpdateStarted() { - res.Err = "update already started" - return - } - defer func() { - // Clear the started flag if something failed. - if res.Err != "" { - b.setC2NUpdateStarted(false) - } - }() - - cmdTS, err := findCmdTailscale() - if err != nil { - res.Err = fmt.Sprintf("failed to find cmd/tailscale binary: %v", err) - return - } - var ver struct { - Long string `json:"long"` - } - out, err := exec.Command(cmdTS, "version", "--json").Output() - if err != nil { - res.Err = fmt.Sprintf("failed to find cmd/tailscale binary: %v", err) - return - } - if err := json.Unmarshal(out, &ver); err != nil { - res.Err = "invalid JSON from cmd/tailscale version --json" - return - } - if ver.Long != version.Long() { - res.Err = "cmd/tailscale version mismatch" - return - } - - cmd := tailscaleUpdateCmd(cmdTS) - buf := new(bytes.Buffer) - cmd.Stdout = buf - cmd.Stderr = buf - b.logf("c2n: running %q", strings.Join(cmd.Args, " ")) - if err := cmd.Start(); err != nil { - res.Err = fmt.Sprintf("failed to start cmd/tailscale update: %v", err) + if err := b.startAutoUpdate("c2n"); err != nil { + res.Err = err.Error() return } res.Started = true - - // Run update asynchronously and respond that it started. - go func() { - if err := cmd.Wait(); err != nil { - b.logf("c2n: update command failed: %v, output: %s", err, buf) - } else { - b.logf("c2n: update complete") - } - b.setC2NUpdateStarted(false) - }() } func handleC2NPostureIdentityGet(b *LocalBackend, w http.ResponseWriter, r *http.Request) { diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index c711d7234..f3042d4b2 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -4,6 +4,7 @@ package ipnlocal import ( + "bytes" "cmp" "context" "encoding/base64" @@ -20,6 +21,7 @@ "net/netip" "net/url" "os" + "os/exec" "os/user" "path/filepath" "runtime" @@ -291,6 +293,13 @@ type LocalBackend struct { // capForcedNetfilter is the netfilter that control instructs Linux clients // to use, unless overridden locally. capForcedNetfilter string + // offlineAutoUpdateCancel stops offline auto-updates when called. It + // should be used via stopOfflineAutoUpdate and + // maybeStartOfflineAutoUpdate. It is nil when offline auto-updates are + // note running. + // + //lint:ignore U1000 only used in Linux and Windows builds in autoupdate.go + offlineAutoUpdateCancel func() // ServeConfig fields. (also guarded by mu) lastServeConfJSON mem.RO // last JSON that was parsed into serveConfig @@ -3327,6 +3336,14 @@ func (b *LocalBackend) setPrefsLockedOnEntry(newp *ipn.Prefs, unlock unlockOnce) b.logf("failed to save new controlclient state: %v", err) } + if newp.AutoUpdate.Apply.EqualBool(true) { + if b.state != ipn.Running { + b.maybeStartOfflineAutoUpdate(newp.View()) + } + } else { + b.stopOfflineAutoUpdate() + } + unlock.UnlockEarly() if oldp.ShieldsUp() != newp.ShieldsUp || hostInfoChanged { @@ -4347,6 +4364,12 @@ func (b *LocalBackend) enterStateLockedOnEntry(newState ipn.State, unlock unlock } b.pauseOrResumeControlClientLocked() + if newState == ipn.Running { + b.stopOfflineAutoUpdate() + } else { + b.maybeStartOfflineAutoUpdate(prefs) + } + unlock.UnlockEarly() // prefs may change irrespective of state; WantRunning should be explicitly @@ -6661,3 +6684,55 @@ func longLatDistance(fromLat, fromLong, toLat, toLong float64) float64 { c := 2 * math.Atan2(math.Sqrt(a), math.Sqrt(1-a)) return earthRadiusMeters * c } + +// startAutoUpdate triggers an auto-update attempt. The actual update happens +// asynchronously. If another update is in progress, an error is returned. +func (b *LocalBackend) startAutoUpdate(logPrefix string) (retErr error) { + // Check if update was already started, and mark as started. + if !b.trySetC2NUpdateStarted() { + return errors.New("update already started") + } + defer func() { + // Clear the started flag if something failed. + if retErr != nil { + b.setC2NUpdateStarted(false) + } + }() + + cmdTS, err := findCmdTailscale() + if err != nil { + return fmt.Errorf("failed to find cmd/tailscale binary: %w", err) + } + var ver struct { + Long string `json:"long"` + } + out, err := exec.Command(cmdTS, "version", "--json").Output() + if err != nil { + return fmt.Errorf("failed to find cmd/tailscale binary: %w", err) + } + if err := json.Unmarshal(out, &ver); err != nil { + return fmt.Errorf("invalid JSON from cmd/tailscale version --json: %w", err) + } + if ver.Long != version.Long() { + return fmt.Errorf("cmd/tailscale version %q does not match tailscaled version %q", ver.Long, version.Long()) + } + + cmd := tailscaleUpdateCmd(cmdTS) + buf := new(bytes.Buffer) + cmd.Stdout = buf + cmd.Stderr = buf + b.logf("%s: running %q", logPrefix, strings.Join(cmd.Args, " ")) + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start cmd/tailscale update: %w", err) + } + + go func() { + if err := cmd.Wait(); err != nil { + b.logf("%s: update command failed: %v, output: %s", logPrefix, err, buf) + } else { + b.logf("%s: update attempt complete", logPrefix) + } + b.setC2NUpdateStarted(false) + }() + return nil +}