feature/clientupdate: move clientupdate to a modular feature, disabled for tsnet
Updates #12614 Change-Id: I5f685dec84a5396b7c2b66f2788ae3d286e1ddc6 Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
committed by
Brad Fitzpatrick
parent
69c79cb9f3
commit
038cdb4640
@@ -0,0 +1,530 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user