diff --git a/client/web/src/components/app.tsx b/client/web/src/components/app.tsx index f15a8c19e..cf4a3b622 100644 --- a/client/web/src/components/app.tsx +++ b/client/web/src/components/app.tsx @@ -11,7 +11,11 @@ import SSHView from "src/components/views/ssh-view" import SubnetRouterView from "src/components/views/subnet-router-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 useNodeData, { + Feature, + featureDescription, + NodeData, +} from "src/hooks/node-data" import { Link, Route, Router, Switch, useLocation } from "wouter" export default function App() { @@ -63,27 +67,27 @@ function WebClient({ - + - - + + - + {/* TODO */}Share local content - + - +

Page not found

@@ -93,6 +97,36 @@ function WebClient({ ) } +/** + * FeatureRoute renders a Route component, + * but only displays the child view if the specified feature is + * available for use on this node's platform. If not available, + * a not allowed view is rendered instead. + */ +function FeatureRoute({ + path, + node, + feature, + children, +}: { + path: string + node: NodeData // TODO: once we have swr, just call useNodeData within FeatureView + feature: Feature + children: React.ReactNode +}) { + return ( + + {!node.Features[feature] ? ( +

+ {featureDescription(feature)} not available on this device. +

+ ) : ( + children + )} +
+ ) +} + function Header({ node, auth, diff --git a/client/web/src/components/exit-node-selector.tsx b/client/web/src/components/exit-node-selector.tsx index 9440869d1..9e12c84a1 100644 --- a/client/web/src/components/exit-node-selector.tsx +++ b/client/web/src/components/exit-node-selector.tsx @@ -175,7 +175,7 @@ function ExitNodeSelectorInner({ onSelect: (node: ExitNode) => void }) { const [filter, setFilter] = useState("") - const { data: exitNodes } = useExitNodes(node.TailnetName, filter) + const { data: exitNodes } = useExitNodes(node, filter) const listRef = useRef(null) const hasNodes = useMemo( diff --git a/client/web/src/components/views/device-details-view.tsx b/client/web/src/components/views/device-details-view.tsx index 7e8d48290..e8b549f35 100644 --- a/client/web/src/components/views/device-details-view.tsx +++ b/client/web/src/components/views/device-details-view.tsx @@ -50,9 +50,10 @@ export default function DeviceDetailsView({ - {node.ClientVersion && - !node.ClientVersion.RunningLatest && - !readonly && ( + {node.Features["auto-update"] && + !readonly && + node.ClientVersion && + !node.ClientVersion.RunningLatest && ( )}
diff --git a/client/web/src/components/views/home-view.tsx b/client/web/src/components/views/home-view.tsx index ae17ccc91..86a1827de 100644 --- a/client/web/src/components/views/home-view.tsx +++ b/client/web/src/components/views/home-view.tsx @@ -33,37 +33,44 @@ export default function HomeView({

{node.IP}

- + {(node.Features["advertise-exit-node"] || + node.Features["use-exit-node"]) && ( + + )} View device details →

Settings

- - , - } - : undefined - } - /> + {node.Features["advertise-routes"] && ( + + )} + {node.Features["ssh"] && ( + , + } + : undefined + } + /> + )} {/* TODO(sonia,will): hiding unimplemented settings pages until implemented */} {/* ([]) useEffect(() => { @@ -47,6 +48,14 @@ export default function useExitNodes(tailnetName: string, filter?: string) { let tailnetNodes: ExitNode[] = [] const locationNodes = new Map>() + if (!node.Features["use-exit-node"]) { + // early-return + return { + tailnetNodesSorted: tailnetNodes, + locationNodesMap: locationNodes, + } + } + data?.forEach((n) => { const loc = n.Location if (!loc) { @@ -55,7 +64,7 @@ export default function useExitNodes(tailnetName: string, filter?: string) { // Only Mullvad exit nodes have locations filled. tailnetNodes.push({ ...n, - Name: trimDNSSuffix(n.Name, tailnetName), + Name: trimDNSSuffix(n.Name, node.TailnetName), }) return } @@ -70,12 +79,15 @@ export default function useExitNodes(tailnetName: string, filter?: string) { tailnetNodesSorted: tailnetNodes.sort(compareByName), locationNodesMap: locationNodes, } - }, [data, tailnetName]) + }, [data, node.Features, node.TailnetName]) const hasFilter = Boolean(filter) const mullvadNodesSorted = useMemo(() => { const nodes: ExitNode[] = [] + if (!node.Features["use-exit-node"]) { + return nodes // early-return + } // addBestMatchNode adds the node with the "higest priority" // match from a list of exit node `options` to `nodes`. @@ -123,14 +135,27 @@ export default function useExitNodes(tailnetName: string, filter?: string) { } return nodes.sort(compareByName) - }, [hasFilter, locationNodesMap]) + }, [hasFilter, locationNodesMap, node.Features]) // Ordered and filtered grouping of exit nodes. const exitNodeGroups = useMemo(() => { const filterLower = !filter ? undefined : filter.toLowerCase() + const selfGroup = { + id: "self", + name: undefined, + nodes: filter + ? [] + : !node.Features["advertise-exit-node"] + ? [noExitNode] // don't show "runAsExitNode" option + : [noExitNode, runAsExitNode], + } + + if (!node.Features["use-exit-node"]) { + return [selfGroup] + } return [ - { id: "self", nodes: filter ? [] : [noExitNode, runAsExitNode] }, + selfGroup, { id: "tailnet", nodes: filterLower @@ -149,7 +174,7 @@ export default function useExitNodes(tailnetName: string, filter?: string) { : mullvadNodesSorted, }, ] - }, [tailnetNodesSorted, mullvadNodesSorted, filter]) + }, [filter, node.Features, tailnetNodesSorted, mullvadNodesSorted]) return { data: exitNodeGroups } } diff --git a/client/web/src/hooks/node-data.ts b/client/web/src/hooks/node-data.ts index 981ff9bcc..149da7025 100644 --- a/client/web/src/hooks/node-data.ts +++ b/client/web/src/hooks/node-data.ts @@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useState } from "react" import { apiFetch, setUnraidCsrfToken } from "src/api" import { ExitNode, noExitNode, runAsExitNode } from "src/hooks/exit-nodes" import { VersionInfo } from "src/hooks/self-update" +import { assertNever } from "src/util" export type NodeData = { Profile: UserProfile @@ -34,6 +35,7 @@ export type NodeData = { RunningSSHServer: boolean ControlAdminURL: string LicensesURL: string + Features: { [key in Feature]: boolean } // value is true if given feature is available on this client } type NodeState = @@ -55,6 +57,30 @@ export type SubnetRoute = { Approved: boolean } +export type Feature = + | "advertise-exit-node" + | "advertise-routes" + | "use-exit-node" + | "ssh" + | "auto-update" + +export const featureDescription = (f: Feature) => { + switch (f) { + case "advertise-exit-node": + return "Advertising as an exit node" + case "advertise-routes": + return "Advertising subnet routes" + case "use-exit-node": + return "Using an exit node" + case "ssh": + return "Running a Tailscale SSH server" + case "auto-update": + return "Auto updating client versions" + default: + assertNever(f) + } +} + /** * NodeUpdaters provides a set of mutation functions for a node. * diff --git a/client/web/src/util.ts b/client/web/src/util.ts new file mode 100644 index 000000000..57c46d75e --- /dev/null +++ b/client/web/src/util.ts @@ -0,0 +1,10 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +/** + * assertNever ensures a branch of code can never be reached, + * resulting in a Typescript error if it can. + */ +export function assertNever(a: never): never { + return a +} diff --git a/client/web/web.go b/client/web/web.go index 5bc45898d..8e642fbda 100644 --- a/client/web/web.go +++ b/client/web/web.go @@ -24,6 +24,7 @@ "github.com/gorilla/csrf" "tailscale.com/client/tailscale" "tailscale.com/client/tailscale/apitype" + "tailscale.com/clientupdate" "tailscale.com/envknob" "tailscale.com/ipn" "tailscale.com/ipn/ipnstate" @@ -563,6 +564,15 @@ type nodeData struct { ControlAdminURL string LicensesURL string + + // Features is the set of available features for use on the + // current platform. e.g. "ssh", "advertise-exit-node", etc. + // Map value is true if the given feature key is available. + // + // See web.availableFeatures func for population of this field. + // Contents are expected to match values defined in node-data.ts + // on the frontend. + Features map[string]bool } type subnetRoute struct { @@ -599,6 +609,7 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) { URLPrefix: strings.TrimSuffix(s.pathPrefix, "/"), ControlAdminURL: prefs.AdminPageURL(), LicensesURL: licenses.LicensesURL(), + Features: availableFeatures(), } cv, err := s.lc.CheckUpdate(r.Context()) @@ -671,6 +682,16 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) { writeJSON(w, *data) } +func availableFeatures() map[string]bool { + return map[string]bool{ + "advertise-exit-node": true, // available on all platforms + "advertise-routes": true, // available on all platforms + "use-exit-node": distro.Get() != distro.Synology, // see https://github.com/tailscale/tailscale/issues/1995 + "ssh": envknob.CanRunTailscaleSSH() == nil, + "auto-update": clientupdate.CanAutoUpdate(), + } +} + type exitNode struct { ID tailcfg.StableNodeID Name string diff --git a/clientupdate/clientupdate.go b/clientupdate/clientupdate.go index e2079a113..c01378d0f 100644 --- a/clientupdate/clientupdate.go +++ b/clientupdate/clientupdate.go @@ -222,6 +222,13 @@ func (up *Updater) getUpdateFunction() (fn updateFunction, canAutoUpdate bool) { return nil, false } +// CanAutoUpdate reports whether auto-updating via the clientupdate package +// is supported for the current os/distro. +func CanAutoUpdate() bool { + _, canAutoUpdate := (&Updater{}).getUpdateFunction() + return canAutoUpdate +} + // Update runs a single update attempt using the platform-specific mechanism. // // On Windows, this copies the calling binary and re-executes it to apply the diff --git a/cmd/tailscale/cli/set.go b/cmd/tailscale/cli/set.go index f908178a4..b436b4a9a 100644 --- a/cmd/tailscale/cli/set.go +++ b/cmd/tailscale/cli/set.go @@ -180,8 +180,7 @@ func runSet(ctx context.Context, args []string) (retErr error) { return fmt.Errorf("failed to enable automatic updates: %v, %q", err, out) } } else { - _, err := clientupdate.NewUpdater(clientupdate.Arguments{ForAutoUpdate: true}) - if errors.Is(err, errors.ErrUnsupported) { + if !clientupdate.CanAutoUpdate() { return errors.New("automatic updates are not supported on this platform") } } diff --git a/cmd/tailscale/depaware.txt b/cmd/tailscale/depaware.txt index 0baee818c..b5e4e2865 100644 --- a/cmd/tailscale/depaware.txt +++ b/cmd/tailscale/depaware.txt @@ -76,7 +76,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep tailscale.com/client/tailscale from tailscale.com/cmd/tailscale/cli+ tailscale.com/client/tailscale/apitype from tailscale.com/cmd/tailscale/cli+ tailscale.com/client/web from tailscale.com/cmd/tailscale/cli - 💣 tailscale.com/clientupdate from tailscale.com/cmd/tailscale/cli + 💣 tailscale.com/clientupdate from tailscale.com/cmd/tailscale/cli+ tailscale.com/clientupdate/distsign from tailscale.com/clientupdate tailscale.com/cmd/tailscale/cli from tailscale.com/cmd/tailscale tailscale.com/control/controlbase from tailscale.com/control/controlhttp diff --git a/envknob/features.go b/envknob/features.go new file mode 100644 index 000000000..9e5909de3 --- /dev/null +++ b/envknob/features.go @@ -0,0 +1,39 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package envknob + +import ( + "errors" + "runtime" + + "tailscale.com/version" + "tailscale.com/version/distro" +) + +// CanRunTailscaleSSH reports whether serving a Tailscale SSH server is +// supported for the current os/distro. +func CanRunTailscaleSSH() error { + switch runtime.GOOS { + case "linux": + if distro.Get() == distro.Synology && !UseWIPCode() { + return errors.New("The Tailscale SSH server does not run on Synology.") + } + if distro.Get() == distro.QNAP && !UseWIPCode() { + return errors.New("The Tailscale SSH server does not run on QNAP.") + } + // otherwise okay + case "darwin": + // okay only in tailscaled mode for now. + if version.IsSandboxedMacOS() { + return errors.New("The Tailscale SSH server does not run in sandboxed Tailscale GUI builds.") + } + case "freebsd", "openbsd": + default: + return errors.New("The Tailscale SSH server is not supported on " + runtime.GOOS) + } + if !CanSSHD() { + return errors.New("The Tailscale SSH server has been administratively disabled.") + } + return nil +} diff --git a/ipn/ipnlocal/c2n.go b/ipn/ipnlocal/c2n.go index 807f1184c..7bf6316d9 100644 --- a/ipn/ipnlocal/c2n.go +++ b/ipn/ipnlocal/c2n.go @@ -348,10 +348,9 @@ func (b *LocalBackend) newC2NUpdateResponse() tailcfg.C2NUpdateResponse { // 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. prefs := b.Prefs().AutoUpdate() - _, err := clientupdate.NewUpdater(clientupdate.Arguments{ForAutoUpdate: true}) return tailcfg.C2NUpdateResponse{ Enabled: envknob.AllowsRemoteUpdate() || prefs.Apply, - Supported: err == nil, + Supported: clientupdate.CanAutoUpdate(), } } diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index eadf78f49..8dad79198 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -2893,27 +2893,11 @@ func (b *LocalBackend) checkSSHPrefsLocked(p *ipn.Prefs) error { if !p.RunSSH { return nil } - switch runtime.GOOS { - case "linux": - if distro.Get() == distro.Synology && !envknob.UseWIPCode() { - return errors.New("The Tailscale SSH server does not run on Synology.") - } - if distro.Get() == distro.QNAP && !envknob.UseWIPCode() { - return errors.New("The Tailscale SSH server does not run on QNAP.") - } - b.updateSELinuxHealthWarning() - // otherwise okay - case "darwin": - // okay only in tailscaled mode for now. - if version.IsSandboxedMacOS() { - return errors.New("The Tailscale SSH server does not run in sandboxed Tailscale GUI builds.") - } - case "freebsd", "openbsd": - default: - return errors.New("The Tailscale SSH server is not supported on " + runtime.GOOS) + if err := envknob.CanRunTailscaleSSH(); err != nil { + return err } - if !envknob.CanSSHD() { - return errors.New("The Tailscale SSH server has been administratively disabled.") + if runtime.GOOS == "linux" { + b.updateSELinuxHealthWarning() } if envknob.SSHIgnoreTailnetPolicy() || envknob.SSHPolicyFile() != "" { return nil