tailscale/feature/clientupdate/clientupdate.go
Brad Fitzpatrick 038cdb4640 feature/clientupdate: move clientupdate to a modular feature, disabled for tsnet
Updates #12614

Change-Id: I5f685dec84a5396b7c2b66f2788ae3d286e1ddc6
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
2025-09-29 16:08:54 -07:00

531 lines
15 KiB
Go

// 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)
}
}
}