diff --git a/client/tailscale/localclient.go b/client/tailscale/localclient.go
index e5d42dd9c..73e93dfbb 100644
--- a/client/tailscale/localclient.go
+++ b/client/tailscale/localclient.go
@@ -1394,6 +1394,21 @@ func (lc *LocalClient) WatchIPNBus(ctx context.Context, mask ipn.NotifyWatchOpt)
}, nil
}
+// CheckUpdate returns a tailcfg.ClientVersion indicating whether or not an update is available
+// to be installed via the LocalAPI. In case the LocalAPI can't install updates, it returns a
+// ClientVersion that says that we are up to date.
+func (lc *LocalClient) CheckUpdate(ctx context.Context) (*tailcfg.ClientVersion, error) {
+ body, err := lc.get200(ctx, "/localapi/v0/update/check")
+ if err != nil {
+ return nil, err
+ }
+ cv, err := decodeJSON[tailcfg.ClientVersion](body)
+ if err != nil {
+ return nil, err
+ }
+ return &cv, nil
+}
+
// IPNBusWatcher is an active subscription (watch) of the local tailscaled IPN bus.
// It's returned by LocalClient.WatchIPNBus.
//
diff --git a/client/web/src/components/app.tsx b/client/web/src/components/app.tsx
index 1f2f9d4f6..c9bf441be 100644
--- a/client/web/src/components/app.tsx
+++ b/client/web/src/components/app.tsx
@@ -6,6 +6,7 @@ import HomeView from "src/components/views/home-view"
import LegacyClientView from "src/components/views/legacy-client-view"
import LoginClientView from "src/components/views/login-client-view"
import SSHView from "src/components/views/ssh-view"
+import { UpdatingView } from "src/components/views/updating-view"
import useAuth, { AuthResponse } from "src/hooks/auth"
import useNodeData, { NodeData } from "src/hooks/node-data"
import { ReactComponent as TailscaleIcon } from "src/icons/tailscale-icon.svg"
@@ -81,6 +82,12 @@ function WebClient({
/>
{/* TODO */}Share local content
+
+
+
Page not found
@@ -112,7 +119,7 @@ function Header({
- {loc !== "/" && (
+ {loc !== "/" && loc !== "/update" && (
+
+ Update available{" "}
+ {details.LatestVersion && `(v${details.LatestVersion})`}
+
+
+ {details.LatestVersion
+ ? `Version ${details.LatestVersion}`
+ : "A new update"}{" "}
+ is now available.
+
+
+ Update now
+
+
+ )
+}
+
+// isStableTrack takes a Tailscale version string
+// of form X.Y.Z (or vX.Y.Z) and returns whether
+// it is a stable release (even value of Y)
+// or unstable (odd value of Y).
+// eg. isStableTrack("1.48.0") === true
+// eg. isStableTrack("1.49.112") === false
+function isStableTrack(ver: string): boolean {
+ const middle = ver.split(".")[1]
+ if (middle && Number(middle) % 2 === 0) {
+ return true
+ }
+ return false
+}
+
+export function ChangelogText({ version }: { version?: string }) {
+ if (!version || !isStableTrack(version)) {
+ return null
+ }
+ return (
+ <>
+ Check out the{" "}
+
+ release notes
+ {" "}
+ to find out what's new!
+ >
+ )
+}
diff --git a/client/web/src/components/views/device-details-view.tsx b/client/web/src/components/views/device-details-view.tsx
index 7942d770c..d792372fe 100644
--- a/client/web/src/components/views/device-details-view.tsx
+++ b/client/web/src/components/views/device-details-view.tsx
@@ -1,6 +1,7 @@
import cx from "classnames"
import React from "react"
import { apiFetch } from "src/api"
+import { UpdateAvailableNotification } from "src/components/update-available"
import { NodeData } from "src/hooks/node-data"
import { useLocation } from "wouter"
import ACLTag from "../acl-tag"
@@ -45,6 +46,11 @@ export default function DeviceDetailsView({
+ {node.ClientVersion &&
+ !node.ClientVersion.RunningLatest &&
+ !readonly && (
+
+ )}
General
diff --git a/client/web/src/components/views/updating-view.tsx b/client/web/src/components/views/updating-view.tsx
new file mode 100644
index 000000000..8abd43bda
--- /dev/null
+++ b/client/web/src/components/views/updating-view.tsx
@@ -0,0 +1,90 @@
+import React from "react"
+import { ChangelogText } from "src/components/update-available"
+import {
+ UpdateState,
+ useInstallUpdate,
+ VersionInfo,
+} from "src/hooks/self-update"
+import { ReactComponent as CheckCircleIcon } from "src/icons/check-circle.svg"
+import { ReactComponent as XCircleIcon } from "src/icons/x-circle.svg"
+import Spinner from "src/ui/spinner"
+import { Link } from "wouter"
+
+/**
+ * UpdatingView is rendered when the user initiates a Tailscale update, and
+ * the update is in-progress, failed, or completed.
+ */
+export function UpdatingView({
+ versionInfo,
+ currentVersion,
+}: {
+ versionInfo?: VersionInfo
+ currentVersion: string
+}) {
+ const { updateState, updateLog } = useInstallUpdate(
+ currentVersion,
+ versionInfo
+ )
+ return (
+ <>
+
+ {updateState === UpdateState.InProgress ? (
+ <>
+
+
Update in progress
+
+ The update shouldn't take more than a couple of minutes. Once it's
+ completed, you will be asked to log in again.
+
+ >
+ ) : updateState === UpdateState.Complete ? (
+ <>
+
+
Update complete!
+
+ You updated Tailscale
+ {versionInfo && versionInfo.LatestVersion
+ ? ` to ${versionInfo.LatestVersion}`
+ : null}
+ .
+
+
+ Log in to access
+
+ >
+ ) : updateState === UpdateState.UpToDate ? (
+ <>
+
+
Up to date!
+
+ You are already running Tailscale {currentVersion}, which is the
+ newest version available.
+
+
+ Return
+
+ >
+ ) : (
+ /* TODO(naman,sonia): Figure out the body copy and design for this view. */
+ <>
+
+
Update failed
+
+ Update
+ {versionInfo && versionInfo.LatestVersion
+ ? ` to ${versionInfo.LatestVersion}`
+ : null}{" "}
+ failed.
+
+
+ Return
+
+ >
+ )}
+
+ {updateLog}
+
+
+ >
+ )
+}
diff --git a/client/web/src/hooks/node-data.ts b/client/web/src/hooks/node-data.ts
index 434ba02e4..c1dfee607 100644
--- a/client/web/src/hooks/node-data.ts
+++ b/client/web/src/hooks/node-data.ts
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from "react"
import { apiFetch, setUnraidCsrfToken } from "src/api"
+import { VersionInfo } from "src/hooks/self-update"
export type NodeData = {
Profile: UserProfile
@@ -20,6 +21,7 @@ export type NodeData = {
IsUnraid: boolean
UnraidToken: string
IPNVersion: string
+ ClientVersion?: VersionInfo
URLPrefix: string
DomainName: string
TailnetName: string
diff --git a/client/web/src/hooks/self-update.ts b/client/web/src/hooks/self-update.ts
new file mode 100644
index 000000000..8c12a3709
--- /dev/null
+++ b/client/web/src/hooks/self-update.ts
@@ -0,0 +1,135 @@
+import { useCallback, useEffect, useState } from "react"
+import { apiFetch } from "src/api"
+
+// this type is deserialized from tailcfg.ClientVersion,
+// so it should not include fields not included in that type.
+export type VersionInfo = {
+ RunningLatest: boolean
+ LatestVersion?: string
+}
+
+// see ipnstate.UpdateProgress
+export type UpdateProgress = {
+ status: "UpdateFinished" | "UpdateInProgress" | "UpdateFailed"
+ message: string
+ version: string
+}
+
+export enum UpdateState {
+ UpToDate,
+ Available,
+ InProgress,
+ Complete,
+ Failed,
+}
+
+// useInstallUpdate initiates and tracks a Tailscale self-update via the LocalAPI,
+// and returns state messages showing the progress of the update.
+export function useInstallUpdate(currentVersion: string, cv?: VersionInfo) {
+ if (!cv) {
+ return {
+ updateState: UpdateState.UpToDate,
+ updateLog: "",
+ }
+ }
+
+ const [updateState, setUpdateState] = useState(
+ cv.RunningLatest ? UpdateState.UpToDate : UpdateState.Available
+ )
+
+ const [updateLog, setUpdateLog] = useState("")
+
+ const appendUpdateLog = useCallback(
+ (msg: string) => {
+ setUpdateLog(updateLog + msg + "\n")
+ },
+ [updateLog, setUpdateLog]
+ )
+
+ useEffect(() => {
+ if (updateState !== UpdateState.Available) {
+ // useEffect cleanup function
+ return () => {}
+ }
+
+ setUpdateState(UpdateState.InProgress)
+
+ apiFetch("/local/v0/update/install", "POST").catch((err) => {
+ console.error(err)
+ setUpdateState(UpdateState.Failed)
+ })
+
+ let tsAwayForPolls = 0
+ let updateMessagesRead = 0
+
+ let timer = 0
+
+ function poll() {
+ apiFetch("/local/v0/update/progress", "GET")
+ .then((res) => res.json())
+ .then((res: UpdateProgress[]) => {
+ // res contains a list of UpdateProgresses that is strictly increasing
+ // in size, so updateMessagesRead keeps track (across calls of poll())
+ // of how many of those we have already read. This is why it is not
+ // initialized to zero here and we don't just use res.forEach()
+ for (; updateMessagesRead < res.length; ++updateMessagesRead) {
+ const up = res[updateMessagesRead]
+ if (up.status === "UpdateFailed") {
+ setUpdateState(UpdateState.Failed)
+ if (up.message) appendUpdateLog("ERROR: " + up.message)
+ return
+ }
+
+ if (up.status === "UpdateFinished") {
+ // if update finished and tailscaled did not go away (ie. did not restart),
+ // then the version being the same might not be an error, it might just require
+ // the user to restart Tailscale manually (this is required in some cases in the
+ // clientupdate package).
+ if (up.version === currentVersion && tsAwayForPolls > 0) {
+ setUpdateState(UpdateState.Failed)
+ appendUpdateLog(
+ "ERROR: Update failed, still running Tailscale " + up.version
+ )
+ if (up.message) appendUpdateLog("ERROR: " + up.message)
+ } else {
+ setUpdateState(UpdateState.Complete)
+ if (up.message) appendUpdateLog("INFO: " + up.message)
+ }
+ return
+ }
+
+ setUpdateState(UpdateState.InProgress)
+ if (up.message) appendUpdateLog("INFO: " + up.message)
+ }
+
+ // If we have gone through the entire loop without returning out of the function,
+ // the update is still in progress. So we want to poll again for further status
+ // updates.
+ timer = setTimeout(poll, 1000)
+ })
+ .catch((err) => {
+ ++tsAwayForPolls
+ if (tsAwayForPolls >= 5 * 60) {
+ setUpdateState(UpdateState.Failed)
+ appendUpdateLog(
+ "ERROR: tailscaled went away but did not come back!"
+ )
+ appendUpdateLog("ERROR: last error received:")
+ appendUpdateLog(err.toString())
+ } else {
+ timer = setTimeout(poll, 1000)
+ }
+ })
+ }
+
+ poll()
+
+ // useEffect cleanup function
+ return () => {
+ if (timer) clearTimeout(timer)
+ timer = 0
+ }
+ }, [])
+
+ return { updateState, updateLog }
+}
diff --git a/client/web/src/icons/arrow-up-circle.svg b/client/web/src/icons/arrow-up-circle.svg
new file mode 100644
index 000000000..e9d009eb6
--- /dev/null
+++ b/client/web/src/icons/arrow-up-circle.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/client/web/src/icons/check-circle.svg b/client/web/src/icons/check-circle.svg
new file mode 100644
index 000000000..4daeed514
--- /dev/null
+++ b/client/web/src/icons/check-circle.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/client/web/src/icons/x-circle.svg b/client/web/src/icons/x-circle.svg
new file mode 100644
index 000000000..49afc5a03
--- /dev/null
+++ b/client/web/src/icons/x-circle.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/client/web/src/index.css b/client/web/src/index.css
index 4863dc97b..7293d6350 100644
--- a/client/web/src/index.css
+++ b/client/web/src/index.css
@@ -260,3 +260,23 @@ html {
background-color: #b22d30;
border-color: #b22d30;
}
+
+/**
+ * .spinner creates a circular animated spinner, most often used to indicate a
+ * loading state. The .spinner element must define a width, height, and
+ * border-width for the spinner to apply.
+ */
+
+@keyframes spin {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+
+.spinner {
+ @apply border-transparent border-t-current border-l-current rounded-full;
+ animation: spin 700ms linear infinite;
+}
diff --git a/client/web/src/ui/spinner.tsx b/client/web/src/ui/spinner.tsx
new file mode 100644
index 000000000..989d56b23
--- /dev/null
+++ b/client/web/src/ui/spinner.tsx
@@ -0,0 +1,29 @@
+import cx from "classnames"
+import React, { HTMLAttributes } from "react"
+
+type Props = {
+ className?: string
+ size: "sm" | "md"
+} & HTMLAttributes
+
+export default function Spinner(props: Props) {
+ const { className, size, ...rest } = props
+
+ return (
+
+ )
+}
+
+Spinner.defaultProps = {
+ size: "md",
+}
diff --git a/client/web/web.go b/client/web/web.go
index 7e56c365d..fc98ba747 100644
--- a/client/web/web.go
+++ b/client/web/web.go
@@ -541,6 +541,8 @@ type nodeData struct {
AdvertiseRoutes string
RunningSSHServer bool
+ ClientVersion *tailcfg.ClientVersion
+
LicensesURL string
DebugMode string // empty when not running in any debug mode
@@ -582,6 +584,12 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
LicensesURL: licenses.LicensesURL(),
DebugMode: debugMode, // TODO(sonia,will): just pass back s.mode directly?
}
+ cv, err := s.lc.CheckUpdate(r.Context())
+ if err != nil {
+ s.logf("could not check for updates: %v", err)
+ } else {
+ data.ClientVersion = cv
+ }
for _, ip := range st.TailscaleIPs {
if ip.Is4() {
data.IP = ip.String()
@@ -807,6 +815,9 @@ func (s *Server) proxyRequestToLocalAPI(w http.ResponseWriter, r *http.Request)
var localapiAllowlist = []string{
"/v0/logout",
"/v0/prefs",
+ "/v0/update/check",
+ "/v0/update/install",
+ "/v0/update/progress",
}
// csrfKey returns a key that can be used for CSRF protection.