Something went wrong. Try again.
forked from tailscale/tailscale
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539// Copyright (c) Tailscale Inc & contributors// 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() { if !feature.Register("clientupdate") { return } 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())) return } 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 !h.PermitWrite { http.Error(w, "update access denied", http.StatusForbidden) return } 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 heldfunc (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) } }}