diff --git a/cmd/tailscaled/depaware.txt b/cmd/tailscaled/depaware.txt index b24eeeb4f..0a9afacb3 100644 --- a/cmd/tailscaled/depaware.txt +++ b/cmd/tailscaled/depaware.txt @@ -228,7 +228,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de tailscale.com/client/tailscale from tailscale.com/derp+ tailscale.com/client/tailscale/apitype from tailscale.com/ipn/ipnlocal+ tailscale.com/client/web from tailscale.com/ipn/ipnlocal - tailscale.com/clientupdate from tailscale.com/ipn/ipnlocal + tailscale.com/clientupdate from tailscale.com/ipn/ipnlocal+ tailscale.com/clientupdate/distsign from tailscale.com/clientupdate tailscale.com/cmd/tailscaled/childproc from tailscale.com/ssh/tailssh+ tailscale.com/control/controlbase from tailscale.com/control/controlclient+ diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index b48c23773..376063acd 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -34,6 +34,7 @@ import ( "gvisor.dev/gvisor/pkg/tcpip" "tailscale.com/appc" "tailscale.com/client/tailscale/apitype" + "tailscale.com/clientupdate" "tailscale.com/control/controlclient" "tailscale.com/control/controlknobs" "tailscale.com/doctor" @@ -265,8 +266,10 @@ type LocalBackend struct { directFileDoFinalRename bool // false on macOS, true on several NAS platforms componentLogUntil map[string]componentLogState // c2nUpdateStatus is the status of c2n-triggered client update. - c2nUpdateStatus updateStatus - currentUser ipnauth.WindowsToken + c2nUpdateStatus updateStatus + currentUser ipnauth.WindowsToken + selfUpdateProgress []ipnstate.UpdateProgress + lastSelfUpdateState ipnstate.SelfUpdateStatus // ServeConfig fields. (also guarded by mu) lastServeConfJSON mem.RO // last JSON that was parsed into serveConfig @@ -374,6 +377,8 @@ func NewLocalBackend(logf logger.Logf, logID logid.PublicID, sys *tsd.System, lo loginFlags: loginFlags, clock: clock, activeWatchSessions: make(set.Set[string]), + selfUpdateProgress: make([]ipnstate.UpdateProgress, 0), + lastSelfUpdateState: ipnstate.UpdateFinished, } netMon := sys.NetMon.Get() @@ -5539,6 +5544,54 @@ func (b *LocalBackend) DebugBreakDERPConns() error { return b.magicConn().DebugBreakDERPConns() } +func (b *LocalBackend) pushSelfUpdateProgress(up ipnstate.UpdateProgress) { + b.mu.Lock() + defer b.mu.Unlock() + b.selfUpdateProgress = append(b.selfUpdateProgress, up) + b.lastSelfUpdateState = up.Status +} + +func (b *LocalBackend) clearSelfUpdateProgress() { + b.mu.Lock() + defer b.mu.Unlock() + b.selfUpdateProgress = make([]ipnstate.UpdateProgress, 0) + b.lastSelfUpdateState = ipnstate.UpdateFinished +} + +func (b *LocalBackend) GetSelfUpdateProgress() []ipnstate.UpdateProgress { + b.mu.Lock() + defer b.mu.Unlock() + res := make([]ipnstate.UpdateProgress, len(b.selfUpdateProgress)) + copy(res, b.selfUpdateProgress) + return res +} + +func (b *LocalBackend) DoSelfUpdate() { + b.mu.Lock() + updateState := b.lastSelfUpdateState + b.mu.Unlock() + // don't start an update if one is already in progress + if updateState == ipnstate.UpdateInProgress { + return + } + b.clearSelfUpdateProgress() + b.pushSelfUpdateProgress(ipnstate.NewUpdateProgress(ipnstate.UpdateInProgress, "")) + up, err := clientupdate.NewUpdater(clientupdate.Arguments{ + Logf: func(format string, args ...any) { + b.pushSelfUpdateProgress(ipnstate.NewUpdateProgress(ipnstate.UpdateInProgress, fmt.Sprintf(format, args...))) + }, + }) + if err != nil { + b.pushSelfUpdateProgress(ipnstate.NewUpdateProgress(ipnstate.UpdateFailed, err.Error())) + } + err = up.Update() + if err != nil { + b.pushSelfUpdateProgress(ipnstate.NewUpdateProgress(ipnstate.UpdateFailed, err.Error())) + } else { + b.pushSelfUpdateProgress(ipnstate.NewUpdateProgress(ipnstate.UpdateFinished, "tailscaled did not restart; please restart Tailscale manually.")) + } +} + // ObserveDNSResponse passes a DNS response from the PeerAPI DNS server to the // App Connector to enable route discovery. func (b *LocalBackend) ObserveDNSResponse(res []byte) { diff --git a/ipn/ipnstate/ipnstate.go b/ipn/ipnstate/ipnstate.go index 07fcd08d1..1a58b0170 100644 --- a/ipn/ipnstate/ipnstate.go +++ b/ipn/ipnstate/ipnstate.go @@ -22,6 +22,7 @@ import ( "tailscale.com/types/ptr" "tailscale.com/types/views" "tailscale.com/util/dnsname" + "tailscale.com/version" ) //go:generate go run tailscale.com/cmd/cloner -clonefunc=false -type=TKAFilteredPeer @@ -710,3 +711,25 @@ type DebugDERPRegionReport struct { Warnings []string Errors []string } + +type SelfUpdateStatus string + +const ( + UpdateFinished SelfUpdateStatus = "UpdateFinished" + UpdateInProgress SelfUpdateStatus = "UpdateInProgress" + UpdateFailed SelfUpdateStatus = "UpdateFailed" +) + +type UpdateProgress struct { + Status SelfUpdateStatus `json:"status,omitempty"` + Message string `json:"message,omitempty"` + Version string `json:"version,omitempty"` +} + +func NewUpdateProgress(ps SelfUpdateStatus, msg string) UpdateProgress { + return UpdateProgress{ + Status: ps, + Message: msg, + Version: version.Short(), + } +} diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index aeb5cd02c..b44cf8d4c 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -27,6 +27,7 @@ import ( "time" "tailscale.com/client/tailscale/apitype" + "tailscale.com/clientupdate" "tailscale.com/envknob" "tailscale.com/health" "tailscale.com/hostinfo" @@ -125,6 +126,9 @@ var handler = map[string]localAPIHandler{ "watch-ipn-bus": (*Handler).serveWatchIPNBus, "whois": (*Handler).serveWhoIs, "query-feature": (*Handler).serveQueryFeature, + "update/check": (*Handler).serveUpdateCheck, + "update/install": (*Handler).serveUpdateInstall, + "update/progress": (*Handler).serveUpdateProgress, } var ( @@ -2418,6 +2422,75 @@ func (h *Handler) serveDebugLog(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +// serveUpdateCheck returns the ClientVersion from Status, which contains +// information on whether an update is available, and if so, what version, +// *if* we support auto-updates on this platform. If we don't, this endpoint +// always returns a ClientVersion saying we're running the newest version. +// Effectively, it tells us whether serveUpdateInstall will be able to install +// an update for us. +func (h *Handler) serveUpdateCheck(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + http.Error(w, "only GET allowed", http.StatusMethodNotAllowed) + return + } + + _, err := clientupdate.NewUpdater(clientupdate.Arguments{ + ForAutoUpdate: true, + }) + + if err != nil { + // if we don't support auto-update, just say that we're up to date + if errors.Is(err, errors.ErrUnsupported) { + json.NewEncoder(w).Encode(tailcfg.ClientVersion{RunningLatest: true}) + } else { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + return + } + + cv := h.b.StatusWithoutPeers().ClientVersion + // ipnstate.Status documentation notes that ClientVersion may be nil on some + // platforms where this information is unavailable. In that case, return a + // ClientVersion that says we're up to date, since we have no information on + // whether an update is possible. + if cv == nil { + cv = &tailcfg.ClientVersion{RunningLatest: true} + } + + json.NewEncoder(w).Encode(cv) +} + +// 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 (h *Handler) serveUpdateInstall(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + http.Error(w, "only POST allowed", http.StatusMethodNotAllowed) + return + } + + w.WriteHeader(http.StatusAccepted) + + go h.b.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 (h *Handler) serveUpdateProgress(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + http.Error(w, "only GET allowed", http.StatusMethodNotAllowed) + return + } + + ups := h.b.GetSelfUpdateProgress() + + json.NewEncoder(w).Encode(ups) +} + var ( metricInvalidRequests = clientmetric.NewCounter("localapi_invalid_requests")