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>
pull/10442/head
Sonia Appasamy 12 months ago committed by Sonia Appasamy
parent 7d61b827e8
commit 7a4ba609d9

@ -11,7 +11,11 @@ import SSHView from "src/components/views/ssh-view"
import SubnetRouterView from "src/components/views/subnet-router-view" import SubnetRouterView from "src/components/views/subnet-router-view"
import { UpdatingView } from "src/components/views/updating-view" import { UpdatingView } from "src/components/views/updating-view"
import useAuth, { AuthResponse } from "src/hooks/auth" 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" import { Link, Route, Router, Switch, useLocation } from "wouter"
export default function App() { export default function App() {
@ -63,27 +67,27 @@ function WebClient({
<Route path="/details"> <Route path="/details">
<DeviceDetailsView readonly={!auth.canManageNode} node={data} /> <DeviceDetailsView readonly={!auth.canManageNode} node={data} />
</Route> </Route>
<Route path="/subnets"> <FeatureRoute path="/subnets" feature="advertise-routes" node={data}>
<SubnetRouterView <SubnetRouterView
readonly={!auth.canManageNode} readonly={!auth.canManageNode}
node={data} node={data}
nodeUpdaters={nodeUpdaters} nodeUpdaters={nodeUpdaters}
/> />
</Route> </FeatureRoute>
<Route path="/ssh"> <FeatureRoute path="/ssh" feature="ssh" node={data}>
<SSHView <SSHView
readonly={!auth.canManageNode} readonly={!auth.canManageNode}
node={data} node={data}
nodeUpdaters={nodeUpdaters} nodeUpdaters={nodeUpdaters}
/> />
</Route> </FeatureRoute>
<Route path="/serve">{/* TODO */}Share local content</Route> <Route path="/serve">{/* TODO */}Share local content</Route>
<Route path="/update"> <FeatureRoute path="/update" feature="auto-update" node={data}>
<UpdatingView <UpdatingView
versionInfo={data.ClientVersion} versionInfo={data.ClientVersion}
currentVersion={data.IPNVersion} currentVersion={data.IPNVersion}
/> />
</Route> </FeatureRoute>
<Route> <Route>
<h2 className="mt-8">Page not found</h2> <h2 className="mt-8">Page not found</h2>
</Route> </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({ function Header({
node, node,
auth, auth,

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

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

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

@ -3,6 +3,7 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import { apiFetch } from "src/api" import { apiFetch } from "src/api"
import { NodeData } from "src/hooks/node-data"
export type ExitNode = { export type ExitNode = {
ID: string ID: string
@ -28,7 +29,7 @@ export type ExitNodeGroup = {
nodes: ExitNode[] nodes: ExitNode[]
} }
export default function useExitNodes(tailnetName: string, filter?: string) { export default function useExitNodes(node: NodeData, filter?: string) {
const [data, setData] = useState<ExitNode[]>([]) const [data, setData] = useState<ExitNode[]>([])
useEffect(() => { useEffect(() => {
@ -47,6 +48,14 @@ export default function useExitNodes(tailnetName: string, filter?: string) {
let tailnetNodes: ExitNode[] = [] let tailnetNodes: ExitNode[] = []
const locationNodes = new Map<CountryCode, Map<CityCode, 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) => { data?.forEach((n) => {
const loc = n.Location const loc = n.Location
if (!loc) { if (!loc) {
@ -55,7 +64,7 @@ export default function useExitNodes(tailnetName: string, filter?: string) {
// Only Mullvad exit nodes have locations filled. // Only Mullvad exit nodes have locations filled.
tailnetNodes.push({ tailnetNodes.push({
...n, ...n,
Name: trimDNSSuffix(n.Name, tailnetName), Name: trimDNSSuffix(n.Name, node.TailnetName),
}) })
return return
} }
@ -70,12 +79,15 @@ export default function useExitNodes(tailnetName: string, filter?: string) {
tailnetNodesSorted: tailnetNodes.sort(compareByName), tailnetNodesSorted: tailnetNodes.sort(compareByName),
locationNodesMap: locationNodes, locationNodesMap: locationNodes,
} }
}, [data, tailnetName]) }, [data, node.Features, node.TailnetName])
const hasFilter = Boolean(filter) const hasFilter = Boolean(filter)
const mullvadNodesSorted = useMemo(() => { const mullvadNodesSorted = useMemo(() => {
const nodes: ExitNode[] = [] const nodes: ExitNode[] = []
if (!node.Features["use-exit-node"]) {
return nodes // early-return
}
// addBestMatchNode adds the node with the "higest priority" // addBestMatchNode adds the node with the "higest priority"
// match from a list of exit node `options` to `nodes`. // 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) return nodes.sort(compareByName)
}, [hasFilter, locationNodesMap]) }, [hasFilter, locationNodesMap, node.Features])
// Ordered and filtered grouping of exit nodes. // Ordered and filtered grouping of exit nodes.
const exitNodeGroups = useMemo(() => { const exitNodeGroups = useMemo(() => {
const filterLower = !filter ? undefined : filter.toLowerCase() 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 [ return [
{ id: "self", nodes: filter ? [] : [noExitNode, runAsExitNode] }, selfGroup,
{ {
id: "tailnet", id: "tailnet",
nodes: filterLower nodes: filterLower
@ -149,7 +174,7 @@ export default function useExitNodes(tailnetName: string, filter?: string) {
: mullvadNodesSorted, : mullvadNodesSorted,
}, },
] ]
}, [tailnetNodesSorted, mullvadNodesSorted, filter]) }, [filter, node.Features, tailnetNodesSorted, mullvadNodesSorted])
return { data: exitNodeGroups } return { data: exitNodeGroups }
} }

@ -5,6 +5,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"
import { apiFetch, setUnraidCsrfToken } from "src/api" import { apiFetch, setUnraidCsrfToken } from "src/api"
import { ExitNode, noExitNode, runAsExitNode } from "src/hooks/exit-nodes" import { ExitNode, noExitNode, runAsExitNode } from "src/hooks/exit-nodes"
import { VersionInfo } from "src/hooks/self-update" import { VersionInfo } from "src/hooks/self-update"
import { assertNever } from "src/util"
export type NodeData = { export type NodeData = {
Profile: UserProfile Profile: UserProfile
@ -34,6 +35,7 @@ export type NodeData = {
RunningSSHServer: boolean RunningSSHServer: boolean
ControlAdminURL: string ControlAdminURL: string
LicensesURL: string LicensesURL: string
Features: { [key in Feature]: boolean } // value is true if given feature is available on this client
} }
type NodeState = type NodeState =
@ -55,6 +57,30 @@ export type SubnetRoute = {
Approved: boolean 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. * NodeUpdaters provides a set of mutation functions for a node.
* *

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

@ -24,6 +24,7 @@ import (
"github.com/gorilla/csrf" "github.com/gorilla/csrf"
"tailscale.com/client/tailscale" "tailscale.com/client/tailscale"
"tailscale.com/client/tailscale/apitype" "tailscale.com/client/tailscale/apitype"
"tailscale.com/clientupdate"
"tailscale.com/envknob" "tailscale.com/envknob"
"tailscale.com/ipn" "tailscale.com/ipn"
"tailscale.com/ipn/ipnstate" "tailscale.com/ipn/ipnstate"
@ -563,6 +564,15 @@ type nodeData struct {
ControlAdminURL string ControlAdminURL string
LicensesURL 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 { type subnetRoute struct {
@ -599,6 +609,7 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
URLPrefix: strings.TrimSuffix(s.pathPrefix, "/"), URLPrefix: strings.TrimSuffix(s.pathPrefix, "/"),
ControlAdminURL: prefs.AdminPageURL(), ControlAdminURL: prefs.AdminPageURL(),
LicensesURL: licenses.LicensesURL(), LicensesURL: licenses.LicensesURL(),
Features: availableFeatures(),
} }
cv, err := s.lc.CheckUpdate(r.Context()) cv, err := s.lc.CheckUpdate(r.Context())
@ -671,6 +682,16 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
writeJSON(w, *data) 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 { type exitNode struct {
ID tailcfg.StableNodeID ID tailcfg.StableNodeID
Name string Name string

@ -222,6 +222,13 @@ func (up *Updater) getUpdateFunction() (fn updateFunction, canAutoUpdate bool) {
return nil, false 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. // 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 // On Windows, this copies the calling binary and re-executes it to apply the

@ -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) return fmt.Errorf("failed to enable automatic updates: %v, %q", err, out)
} }
} else { } else {
_, err := clientupdate.NewUpdater(clientupdate.Arguments{ForAutoUpdate: true}) if !clientupdate.CanAutoUpdate() {
if errors.Is(err, errors.ErrUnsupported) {
return errors.New("automatic updates are not supported on this platform") return errors.New("automatic updates are not supported on this platform")
} }
} }

@ -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 from tailscale.com/cmd/tailscale/cli+
tailscale.com/client/tailscale/apitype 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/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/clientupdate/distsign from tailscale.com/clientupdate
tailscale.com/cmd/tailscale/cli from tailscale.com/cmd/tailscale tailscale.com/cmd/tailscale/cli from tailscale.com/cmd/tailscale
tailscale.com/control/controlbase from tailscale.com/control/controlhttp tailscale.com/control/controlbase from tailscale.com/control/controlhttp

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

@ -348,10 +348,9 @@ func (b *LocalBackend) newC2NUpdateResponse() tailcfg.C2NUpdateResponse {
// Note that we create the Updater solely to check for errors; we do not // 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. // invoke it here. For this purpose, it is ok to pass it a zero Arguments.
prefs := b.Prefs().AutoUpdate() prefs := b.Prefs().AutoUpdate()
_, err := clientupdate.NewUpdater(clientupdate.Arguments{ForAutoUpdate: true})
return tailcfg.C2NUpdateResponse{ return tailcfg.C2NUpdateResponse{
Enabled: envknob.AllowsRemoteUpdate() || prefs.Apply, Enabled: envknob.AllowsRemoteUpdate() || prefs.Apply,
Supported: err == nil, Supported: clientupdate.CanAutoUpdate(),
} }
} }

@ -2893,27 +2893,11 @@ func (b *LocalBackend) checkSSHPrefsLocked(p *ipn.Prefs) error {
if !p.RunSSH { if !p.RunSSH {
return nil return nil
} }
switch runtime.GOOS { if err := envknob.CanRunTailscaleSSH(); err != nil {
case "linux": return err
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 runtime.GOOS == "linux" {
b.updateSELinuxHealthWarning() 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() != "" { if envknob.SSHIgnoreTailnetPolicy() || envknob.SSHPolicyFile() != "" {
return nil return nil

Loading…
Cancel
Save