client/web: show features based on platform support

Hiding/disabling UI features when not available on the running
client.

Updates #10261

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
This commit is contained in:
Sonia Appasamy 2023-11-30 13:01:29 -05:00 committed by Sonia Appasamy
parent 7d61b827e8
commit 7a4ba609d9
14 changed files with 220 additions and 68 deletions

View File

@ -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({
<Route path="/details">
<DeviceDetailsView readonly={!auth.canManageNode} node={data} />
</Route>
<Route path="/subnets">
<FeatureRoute path="/subnets" feature="advertise-routes" node={data}>
<SubnetRouterView
readonly={!auth.canManageNode}
node={data}
nodeUpdaters={nodeUpdaters}
/>
</Route>
<Route path="/ssh">
</FeatureRoute>
<FeatureRoute path="/ssh" feature="ssh" node={data}>
<SSHView
readonly={!auth.canManageNode}
node={data}
nodeUpdaters={nodeUpdaters}
/>
</Route>
</FeatureRoute>
<Route path="/serve">{/* TODO */}Share local content</Route>
<Route path="/update">
<FeatureRoute path="/update" feature="auto-update" node={data}>
<UpdatingView
versionInfo={data.ClientVersion}
currentVersion={data.IPNVersion}
/>
</Route>
</FeatureRoute>
<Route>
<h2 className="mt-8">Page not found</h2>
</Route>
@ -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 (
<Route path={path}>
{!node.Features[feature] ? (
<h2 className="mt-8">
{featureDescription(feature)} not available on this device.
</h2>
) : (
children
)}
</Route>
)
}
function Header({
node,
auth,

View File

@ -175,7 +175,7 @@ function ExitNodeSelectorInner({
onSelect: (node: ExitNode) => void
}) {
const [filter, setFilter] = useState<string>("")
const { data: exitNodes } = useExitNodes(node.TailnetName, filter)
const { data: exitNodes } = useExitNodes(node, filter)
const listRef = useRef<HTMLDivElement>(null)
const hasNodes = useMemo(

View File

@ -50,9 +50,10 @@ export default function DeviceDetailsView({
</button>
</div>
</div>
{node.ClientVersion &&
!node.ClientVersion.RunningLatest &&
!readonly && (
{node.Features["auto-update"] &&
!readonly &&
node.ClientVersion &&
!node.ClientVersion.RunningLatest && (
<UpdateAvailableNotification details={node.ClientVersion} />
)}
<div className="-mx-5 card">

View File

@ -33,23 +33,29 @@ export default function HomeView({
</div>
<p className="text-gray-800 text-lg leading-[25.20px]">{node.IP}</p>
</div>
{(node.Features["advertise-exit-node"] ||
node.Features["use-exit-node"]) && (
<ExitNodeSelector
className="mb-5"
node={node}
nodeUpdaters={nodeUpdaters}
disabled={readonly}
/>
)}
<Link className="text-blue-500 font-medium leading-snug" to="/details">
View device details &rarr;
</Link>
</div>
<h2 className="mb-3">Settings</h2>
{node.Features["advertise-routes"] && (
<SettingsCard
link="/subnets"
className="mb-3"
title="Subnet router"
body="Add devices to your tailnet without installing Tailscale on them."
/>
)}
{node.Features["ssh"] && (
<SettingsCard
link="/ssh"
className="mb-3"
@ -64,6 +70,7 @@ export default function HomeView({
: undefined
}
/>
)}
{/* TODO(sonia,will): hiding unimplemented settings pages until implemented */}
{/* <SettingsCard
link="/serve"

View File

@ -3,6 +3,7 @@
import { useEffect, useMemo, useState } from "react"
import { apiFetch } from "src/api"
import { NodeData } from "src/hooks/node-data"
export type ExitNode = {
ID: string
@ -28,7 +29,7 @@ export type ExitNodeGroup = {
nodes: ExitNode[]
}
export default function useExitNodes(tailnetName: string, filter?: string) {
export default function useExitNodes(node: NodeData, filter?: string) {
const [data, setData] = useState<ExitNode[]>([])
useEffect(() => {
@ -47,6 +48,14 @@ export default function useExitNodes(tailnetName: string, filter?: string) {
let tailnetNodes: ExitNode[] = []
const locationNodes = new Map<CountryCode, Map<CityCode, ExitNode[]>>()
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 }
}

View File

@ -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.
*

10
client/web/src/util.ts Normal file
View File

@ -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
}

View File

@ -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

View File

@ -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

View File

@ -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")
}
}

View File

@ -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

39
envknob/features.go Normal file
View File

@ -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
}

View File

@ -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(),
}
}

View File

@ -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.")
if err := envknob.CanRunTailscaleSSH(); err != nil {
return err
}
if runtime.GOOS == "linux" {
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 !envknob.CanSSHD() {
return errors.New("The Tailscale SSH server has been administratively disabled.")
}
if envknob.SSHIgnoreTailnetPolicy() || envknob.SSHPolicyFile() != "" {
return nil