mirror of
https://github.com/tailscale/tailscale.git
synced 2024-11-25 11:05:45 +00:00
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:
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 { 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,
|
||||
|
@ -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(
|
||||
|
@ -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">
|
||||
|
@ -33,37 +33,44 @@ export default function HomeView({
|
||||
</div>
|
||||
<p className="text-gray-800 text-lg leading-[25.20px]">{node.IP}</p>
|
||||
</div>
|
||||
<ExitNodeSelector
|
||||
className="mb-5"
|
||||
node={node}
|
||||
nodeUpdaters={nodeUpdaters}
|
||||
disabled={readonly}
|
||||
/>
|
||||
{(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 →
|
||||
</Link>
|
||||
</div>
|
||||
<h2 className="mb-3">Settings</h2>
|
||||
<SettingsCard
|
||||
link="/subnets"
|
||||
className="mb-3"
|
||||
title="Subnet router"
|
||||
body="Add devices to your tailnet without installing Tailscale on them."
|
||||
/>
|
||||
<SettingsCard
|
||||
link="/ssh"
|
||||
className="mb-3"
|
||||
title="Tailscale SSH server"
|
||||
body="Run a Tailscale SSH server on this device and allow other devices in your tailnet to SSH into it."
|
||||
badge={
|
||||
node.RunningSSHServer
|
||||
? {
|
||||
text: "Running",
|
||||
icon: <div className="w-2 h-2 bg-emerald-500 rounded-full" />,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{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"
|
||||
title="Tailscale SSH server"
|
||||
body="Run a Tailscale SSH server on this device and allow other devices in your tailnet to SSH into it."
|
||||
badge={
|
||||
node.RunningSSHServer
|
||||
? {
|
||||
text: "Running",
|
||||
icon: <div className="w-2 h-2 bg-emerald-500 rounded-full" />,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{/* TODO(sonia,will): hiding unimplemented settings pages until implemented */}
|
||||
{/* <SettingsCard
|
||||
link="/serve"
|
||||
|
@ -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 }
|
||||
}
|
||||
|
@ -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
10
client/web/src/util.ts
Normal 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
|
||||
}
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
@ -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
39
envknob/features.go
Normal 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
|
||||
}
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user