// Copyright (c) Tailscale Inc & AUTHORS // SPDX-License-Identifier: BSD-3-Clause // Package clientupdate enables the client update feature. package clientupdate import ( "bytes" "context" "encoding/json" "errors" "fmt" "net/http" "os" "os/exec" "path/filepath" "runtime" "strconv" "strings" "sync" "time" "tailscale.com/clientupdate" "tailscale.com/envknob" "tailscale.com/feature" "tailscale.com/ipn" "tailscale.com/ipn/ipnext" "tailscale.com/ipn/ipnlocal" "tailscale.com/ipn/ipnstate" "tailscale.com/ipn/localapi" "tailscale.com/tailcfg" "tailscale.com/types/logger" "tailscale.com/util/httpm" "tailscale.com/version" "tailscale.com/version/distro" ) func init() { ipnext.RegisterExtension("clientupdate", newExt) // C2N ipnlocal.RegisterC2N("GET /update", handleC2NUpdateGet) ipnlocal.RegisterC2N("POST /update", handleC2NUpdatePost) // LocalAPI: localapi.Register("update/install", serveUpdateInstall) localapi.Register("update/progress", serveUpdateProgress) } func newExt(logf logger.Logf, sb ipnext.SafeBackend) (ipnext.Extension, error) { return &extension{ logf: logf, sb: sb, lastSelfUpdateState: ipnstate.UpdateFinished, }, nil } type extension struct { logf logger.Logf sb ipnext.SafeBackend mu sync.Mutex // c2nUpdateStatus is the status of c2n-triggered client update. c2nUpdateStatus updateStatus prefs ipn.PrefsView state ipn.State lastSelfUpdateState ipnstate.SelfUpdateStatus selfUpdateProgress []ipnstate.UpdateProgress // offlineAutoUpdateCancel stops offline auto-updates when called. It // should be used via stopOfflineAutoUpdate and // maybeStartOfflineAutoUpdate. It is nil when offline auto-updates are // not running. // //lint:ignore U1000 only used in Linux and Windows builds in autoupdate.go offlineAutoUpdateCancel func() } func (e *extension) Name() string { return "clientupdate" } func (e *extension) Init(h ipnext.Host) error { h.Hooks().ProfileStateChange.Add(e.onChangeProfile) h.Hooks().BackendStateChange.Add(e.onBackendStateChange) // TODO(nickkhyl): remove this after the profileManager refactoring. // See tailscale/tailscale#15974. // This same workaround appears in feature/portlist/portlist.go. profile, prefs := h.Profiles().CurrentProfileState() e.onChangeProfile(profile, prefs, false) return nil } func (e *extension) Shutdown() error { e.stopOfflineAutoUpdate() return nil } func (e *extension) onBackendStateChange(newState ipn.State) { e.mu.Lock() defer e.mu.Unlock() e.state = newState e.updateOfflineAutoUpdateLocked() } func (e *extension) onChangeProfile(profile ipn.LoginProfileView, prefs ipn.PrefsView, sameNode bool) { e.mu.Lock() defer e.mu.Unlock() e.prefs = prefs e.updateOfflineAutoUpdateLocked() } func (e *extension) updateOfflineAutoUpdateLocked() { want := e.prefs.Valid() && e.prefs.AutoUpdate().Apply.EqualBool(true) && e.state != ipn.Running && e.state != ipn.Starting cur := e.offlineAutoUpdateCancel != nil if want && !cur { e.maybeStartOfflineAutoUpdateLocked(e.prefs) } else if !want && cur { e.stopOfflineAutoUpdateLocked() } } type updateStatus struct { started bool } func (e *extension) clearSelfUpdateProgress() { e.mu.Lock() defer e.mu.Unlock() e.selfUpdateProgress = make([]ipnstate.UpdateProgress, 0) e.lastSelfUpdateState = ipnstate.UpdateFinished } func (e *extension) GetSelfUpdateProgress() []ipnstate.UpdateProgress { e.mu.Lock() defer e.mu.Unlock() res := make([]ipnstate.UpdateProgress, len(e.selfUpdateProgress)) copy(res, e.selfUpdateProgress) return res } func (e *extension) DoSelfUpdate() { e.mu.Lock() updateState := e.lastSelfUpdateState e.mu.Unlock() // don't start an update if one is already in progress if updateState == ipnstate.UpdateInProgress { return } e.clearSelfUpdateProgress() e.pushSelfUpdateProgress(ipnstate.NewUpdateProgress(ipnstate.UpdateInProgress, "")) up, err := clientupdate.NewUpdater(clientupdate.Arguments{ Logf: func(format string, args ...any) { e.pushSelfUpdateProgress(ipnstate.NewUpdateProgress(ipnstate.UpdateInProgress, fmt.Sprintf(format, args...))) }, }) if err != nil { e.pushSelfUpdateProgress(ipnstate.NewUpdateProgress(ipnstate.UpdateFailed, err.Error())) } err = up.Update() if err != nil { e.pushSelfUpdateProgress(ipnstate.NewUpdateProgress(ipnstate.UpdateFailed, err.Error())) } else { e.pushSelfUpdateProgress(ipnstate.NewUpdateProgress(ipnstate.UpdateFinished, "tailscaled did not restart; please restart Tailscale manually.")) } } // serveUpdateInstall sends a request to the LocalBackend to start a Tailscale // self-update. A successful response does not indicate whether the update // succeeded, only that the request was accepted. Clients should use // serveUpdateProgress after pinging this endpoint to check how the update is // going. func serveUpdateInstall(h *localapi.Handler, w http.ResponseWriter, r *http.Request) { if r.Method != httpm.POST { http.Error(w, "only POST allowed", http.StatusMethodNotAllowed) return } b := h.LocalBackend() ext, ok := ipnlocal.GetExt[*extension](b) if !ok { http.Error(w, "clientupdate extension not found", http.StatusInternalServerError) return } w.WriteHeader(http.StatusAccepted) go ext.DoSelfUpdate() } // serveUpdateProgress returns the status of an in-progress Tailscale self-update. // This is provided as a slice of ipnstate.UpdateProgress structs with various // log messages in order from oldest to newest. If an update is not in progress, // the returned slice will be empty. func serveUpdateProgress(h *localapi.Handler, w http.ResponseWriter, r *http.Request) { if r.Method != httpm.GET { http.Error(w, "only GET allowed", http.StatusMethodNotAllowed) return } b := h.LocalBackend() ext, ok := ipnlocal.GetExt[*extension](b) if !ok { http.Error(w, "clientupdate extension not found", http.StatusInternalServerError) return } ups := ext.GetSelfUpdateProgress() json.NewEncoder(w).Encode(ups) } func (e *extension) pushSelfUpdateProgress(up ipnstate.UpdateProgress) { e.mu.Lock() defer e.mu.Unlock() e.selfUpdateProgress = append(e.selfUpdateProgress, up) e.lastSelfUpdateState = up.Status } func handleC2NUpdateGet(b *ipnlocal.LocalBackend, w http.ResponseWriter, r *http.Request) { e, ok := ipnlocal.GetExt[*extension](b) if !ok { http.Error(w, "clientupdate extension not found", http.StatusInternalServerError) return } e.logf("c2n: GET /update received") res := e.newC2NUpdateResponse() res.Started = e.c2nUpdateStarted() w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(res) } func handleC2NUpdatePost(b *ipnlocal.LocalBackend, w http.ResponseWriter, r *http.Request) { e, ok := ipnlocal.GetExt[*extension](b) if !ok { http.Error(w, "clientupdate extension not found", http.StatusInternalServerError) return } e.logf("c2n: POST /update received") res := e.newC2NUpdateResponse() defer func() { if res.Err != "" { e.logf("c2n: POST /update failed: %s", res.Err) } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(res) }() if !res.Enabled { res.Err = "not enabled" return } if !res.Supported { res.Err = "not supported" return } // Do not update if we have active inbound SSH connections. Control can set // force=true query parameter to override this. if r.FormValue("force") != "true" && b.ActiveSSHConns() > 0 { res.Err = "not updating due to active SSH connections" return } if err := e.startAutoUpdate("c2n"); err != nil { res.Err = err.Error() return } res.Started = true } func (e *extension) newC2NUpdateResponse() tailcfg.C2NUpdateResponse { e.mu.Lock() defer e.mu.Unlock() // If NewUpdater does not return an error, we can update the installation. // // 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. var upPref ipn.AutoUpdatePrefs if e.prefs.Valid() { upPref = e.prefs.AutoUpdate() } return tailcfg.C2NUpdateResponse{ Enabled: envknob.AllowsRemoteUpdate() || upPref.Apply.EqualBool(true), Supported: feature.CanAutoUpdate() && !version.IsMacSysExt(), } } func (e *extension) c2nUpdateStarted() bool { e.mu.Lock() defer e.mu.Unlock() return e.c2nUpdateStatus.started } func (e *extension) setC2NUpdateStarted(v bool) { e.mu.Lock() defer e.mu.Unlock() e.c2nUpdateStatus.started = v } func (e *extension) trySetC2NUpdateStarted() bool { e.mu.Lock() defer e.mu.Unlock() if e.c2nUpdateStatus.started { return false } e.c2nUpdateStatus.started = true return true } // findCmdTailscale looks for the cmd/tailscale that corresponds to the // currently running cmd/tailscaled. It's up to the caller to verify that the // two match, but this function does its best to find the right one. Notably, it // doesn't use $PATH for security reasons. func findCmdTailscale() (string, error) { self, err := os.Executable() if err != nil { return "", err } var ts string switch runtime.GOOS { case "linux": if self == "/usr/sbin/tailscaled" || self == "/usr/bin/tailscaled" { ts = "/usr/bin/tailscale" } if self == "/usr/local/sbin/tailscaled" || self == "/usr/local/bin/tailscaled" { ts = "/usr/local/bin/tailscale" } switch distro.Get() { case distro.QNAP: // The volume under /share/ where qpkg are installed is not // predictable. But the rest of the path is. ok, err := filepath.Match("/share/*/.qpkg/Tailscale/tailscaled", self) if err == nil && ok { ts = filepath.Join(filepath.Dir(self), "tailscale") } case distro.Unraid: if self == "/usr/local/emhttp/plugins/tailscale/bin/tailscaled" { ts = "/usr/local/emhttp/plugins/tailscale/bin/tailscale" } } case "windows": ts = filepath.Join(filepath.Dir(self), "tailscale.exe") case "freebsd", "openbsd": if self == "/usr/local/bin/tailscaled" { ts = "/usr/local/bin/tailscale" } default: return "", fmt.Errorf("unsupported OS %v", runtime.GOOS) } if ts != "" && regularFileExists(ts) { return ts, nil } return "", errors.New("tailscale executable not found in expected place") } func tailscaleUpdateCmd(cmdTS string) *exec.Cmd { defaultCmd := exec.Command(cmdTS, "update", "--yes") if runtime.GOOS != "linux" { return defaultCmd } if _, err := exec.LookPath("systemd-run"); err != nil { return defaultCmd } // When systemd-run is available, use it to run the update command. This // creates a new temporary unit separate from the tailscaled unit. When // tailscaled is restarted during the update, systemd won't kill this // temporary update unit, which could cause unexpected breakage. // // We want to use a few optional flags: // * --wait, to block the update command until completion (added in systemd 232) // * --pipe, to collect stdout/stderr (added in systemd 235) // * --collect, to clean up failed runs from memory (added in systemd 236) // // We need to check the version of systemd to figure out if those flags are // available. // // The output will look like: // // systemd 255 (255.7-1-arch) // +PAM +AUDIT ... other feature flags ... systemdVerOut, err := exec.Command("systemd-run", "--version").Output() if err != nil { return defaultCmd } parts := strings.Fields(string(systemdVerOut)) if len(parts) < 2 || parts[0] != "systemd" { return defaultCmd } systemdVer, err := strconv.Atoi(parts[1]) if err != nil { return defaultCmd } if systemdVer >= 236 { return exec.Command("systemd-run", "--wait", "--pipe", "--collect", cmdTS, "update", "--yes") } else if systemdVer >= 235 { return exec.Command("systemd-run", "--wait", "--pipe", cmdTS, "update", "--yes") } else if systemdVer >= 232 { return exec.Command("systemd-run", "--wait", cmdTS, "update", "--yes") } else { return exec.Command("systemd-run", cmdTS, "update", "--yes") } } func regularFileExists(path string) bool { fi, err := os.Stat(path) return err == nil && fi.Mode().IsRegular() } // startAutoUpdate triggers an auto-update attempt. The actual update happens // asynchronously. If another update is in progress, an error is returned. func (e *extension) startAutoUpdate(logPrefix string) (retErr error) { // Check if update was already started, and mark as started. if !e.trySetC2NUpdateStarted() { return errors.New("update already started") } defer func() { // Clear the started flag if something failed. if retErr != nil { e.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 e.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 { e.logf("%s: update command failed: %v, output: %s", logPrefix, err, buf) } else { e.logf("%s: update attempt complete", logPrefix) } e.setC2NUpdateStarted(false) }() return nil } func (e *extension) stopOfflineAutoUpdate() { e.mu.Lock() defer e.mu.Unlock() e.stopOfflineAutoUpdateLocked() } func (e *extension) stopOfflineAutoUpdateLocked() { if e.offlineAutoUpdateCancel == nil { return } e.logf("offline auto-update: stopping update checks") e.offlineAutoUpdateCancel() e.offlineAutoUpdateCancel = nil } // e.mu must be held func (e *extension) maybeStartOfflineAutoUpdateLocked(prefs ipn.PrefsView) { if !prefs.Valid() || !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 !feature.CanAutoUpdate() { return } // On macsys, auto-updates are managed by Sparkle. if version.IsMacSysExt() { return } if e.offlineAutoUpdateCancel != nil { // Already running. return } ctx, cancel := context.WithCancel(context.Background()) e.offlineAutoUpdateCancel = cancel e.logf("offline auto-update: starting update checks") go e.offlineAutoUpdate(ctx) } const offlineAutoUpdateCheckPeriod = time.Hour func (e *extension) offlineAutoUpdate(ctx context.Context) { t := time.NewTicker(offlineAutoUpdateCheckPeriod) defer t.Stop() for { select { case <-ctx.Done(): return case <-t.C: } if err := e.startAutoUpdate("offline auto-update"); err != nil { e.logf("offline auto-update: failed: %v", err) } } }