(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