client/web: use grants on web UI frontend

Starts using peer capabilities to restrict the management client
on a per-view basis. This change also includes a bulky cleanup
of the login-toggle.tsx file, which was getting pretty unwieldy
in its previous form.

Updates tailscale/corp#16695

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
This commit is contained in:
Sonia Appasamy 2024-02-22 14:24:34 -05:00 committed by Sonia Appasamy
parent 9aa704a05d
commit 95f26565db
10 changed files with 555 additions and 317 deletions

View File

@ -11,6 +11,7 @@
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"slices"
"strings" "strings"
"time" "time"
@ -238,6 +239,7 @@ func (s *Server) newSessionID() (string, error) {
// peer is allowed to edit via the web UI. // peer is allowed to edit via the web UI.
// //
// map value is true if the peer can edit the given feature. // map value is true if the peer can edit the given feature.
// Only capFeatures included in validCaps will be included.
type peerCapabilities map[capFeature]bool type peerCapabilities map[capFeature]bool
// canEdit is true if the peerCapabilities grant edit access // canEdit is true if the peerCapabilities grant edit access
@ -252,21 +254,47 @@ func (p peerCapabilities) canEdit(feature capFeature) bool {
return p[feature] return p[feature]
} }
// isEmpty is true if p is either nil or has no capabilities
// with value true.
func (p peerCapabilities) isEmpty() bool {
if p == nil {
return true
}
for _, v := range p {
if v == true {
return false
}
}
return true
}
type capFeature string type capFeature string
const ( const (
// The following values should not be edited. // The following values should not be edited.
// New caps can be added, but existing ones should not be changed, // New caps can be added, but existing ones should not be changed,
// as these exact values are used by users in tailnet policy files. // as these exact values are used by users in tailnet policy files.
//
// IMPORTANT: When adding a new cap, also update validCaps slice below.
capFeatureAll capFeature = "*" // grants peer management of all features capFeatureAll capFeature = "*" // grants peer management of all features
capFeatureFunnel capFeature = "funnel" // grants peer serve/funnel management capFeatureSSH capFeature = "ssh" // grants peer SSH server management
capFeatureSSH capFeature = "ssh" // grants peer SSH server management capFeatureSubnets capFeature = "subnets" // grants peer subnet routes management
capFeatureSubnet capFeature = "subnet" // grants peer subnet routes management capFeatureExitNodes capFeature = "exitnodes" // grants peer ability to advertise-as and use exit nodes
capFeatureExitNode capFeature = "exitnode" // grants peer ability to advertise-as and use exit nodes capFeatureAccount capFeature = "account" // grants peer ability to turn on auto updates and log out of node
capFeatureAccount capFeature = "account" // grants peer ability to turn on auto updates and log out of node
) )
// validCaps contains the list of valid capabilities used in the web client.
// Any capabilities included in a peer's grants that do not fall into this
// list will be ignored.
var validCaps []capFeature = []capFeature{
capFeatureAll,
capFeatureSSH,
capFeatureSubnets,
capFeatureExitNodes,
capFeatureAccount,
}
type capRule struct { type capRule struct {
CanEdit []string `json:"canEdit,omitempty"` // list of features peer is allowed to edit CanEdit []string `json:"canEdit,omitempty"` // list of features peer is allowed to edit
} }
@ -274,7 +302,13 @@ type capRule struct {
// toPeerCapabilities parses out the web ui capabilities from the // toPeerCapabilities parses out the web ui capabilities from the
// given whois response. // given whois response.
func toPeerCapabilities(status *ipnstate.Status, whois *apitype.WhoIsResponse) (peerCapabilities, error) { func toPeerCapabilities(status *ipnstate.Status, whois *apitype.WhoIsResponse) (peerCapabilities, error) {
if whois == nil { if whois == nil || status == nil {
return peerCapabilities{}, nil
}
if whois.Node.IsTagged() {
// We don't allow management *from* tagged nodes, so ignore caps.
// The web client auth flow relies on having a true user identity
// that can be verified through login.
return peerCapabilities{}, nil return peerCapabilities{}, nil
} }
@ -295,7 +329,10 @@ func toPeerCapabilities(status *ipnstate.Status, whois *apitype.WhoIsResponse) (
} }
for _, c := range rules { for _, c := range rules {
for _, f := range c.CanEdit { for _, f := range c.CanEdit {
caps[capFeature(strings.ToLower(f))] = true cap := capFeature(strings.ToLower(f))
if slices.Contains(validCaps, cap) {
caps[cap] = true
}
} }
} }
return caps, nil return caps, nil

View File

@ -11,7 +11,7 @@ import LoginView from "src/components/views/login-view"
import SSHView from "src/components/views/ssh-view" 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, canEdit } from "src/hooks/auth"
import { Feature, featureDescription, NodeData } from "src/types" import { Feature, featureDescription, NodeData } from "src/types"
import Card from "src/ui/card" import Card from "src/ui/card"
import EmptyState from "src/ui/empty-state" import EmptyState from "src/ui/empty-state"
@ -56,16 +56,19 @@ function WebClient({
<Header node={node} auth={auth} newSession={newSession} /> <Header node={node} auth={auth} newSession={newSession} />
<Switch> <Switch>
<Route path="/"> <Route path="/">
<HomeView readonly={!auth.canManageNode} node={node} /> <HomeView node={node} auth={auth} />
</Route> </Route>
<Route path="/details"> <Route path="/details">
<DeviceDetailsView readonly={!auth.canManageNode} node={node} /> <DeviceDetailsView node={node} auth={auth} />
</Route> </Route>
<FeatureRoute path="/subnets" feature="advertise-routes" node={node}> <FeatureRoute path="/subnets" feature="advertise-routes" node={node}>
<SubnetRouterView readonly={!auth.canManageNode} node={node} /> <SubnetRouterView
readonly={!canEdit("subnets", auth)}
node={node}
/>
</FeatureRoute> </FeatureRoute>
<FeatureRoute path="/ssh" feature="ssh" node={node}> <FeatureRoute path="/ssh" feature="ssh" node={node}>
<SSHView readonly={!auth.canManageNode} node={node} /> <SSHView readonly={!canEdit("ssh", auth)} node={node} />
</FeatureRoute> </FeatureRoute>
{/* <Route path="/serve">Share local content</Route> */} {/* <Route path="/serve">Share local content</Route> */}
<FeatureRoute path="/update" feature="auto-update" node={node}> <FeatureRoute path="/update" feature="auto-update" node={node}>

View File

@ -2,15 +2,17 @@
// SPDX-License-Identifier: BSD-3-Clause // SPDX-License-Identifier: BSD-3-Clause
import cx from "classnames" import cx from "classnames"
import React, { useCallback, useEffect, useState } from "react" import React, { useCallback, useMemo, useState } from "react"
import ChevronDown from "src/assets/icons/chevron-down.svg?react" import ChevronDown from "src/assets/icons/chevron-down.svg?react"
import Eye from "src/assets/icons/eye.svg?react" import Eye from "src/assets/icons/eye.svg?react"
import User from "src/assets/icons/user.svg?react" import User from "src/assets/icons/user.svg?react"
import { AuthResponse, AuthType } from "src/hooks/auth" import { AuthResponse, hasAnyEditCapabilities } from "src/hooks/auth"
import { useTSWebConnected } from "src/hooks/ts-web-connected"
import { NodeData } from "src/types" import { NodeData } from "src/types"
import Button from "src/ui/button" import Button from "src/ui/button"
import Popover from "src/ui/popover" import Popover from "src/ui/popover"
import ProfilePic from "src/ui/profile-pic" import ProfilePic from "src/ui/profile-pic"
import { assertNever, isHTTPS } from "src/utils/util"
export default function LoginToggle({ export default function LoginToggle({
node, node,
@ -22,12 +24,29 @@ export default function LoginToggle({
newSession: () => Promise<void> newSession: () => Promise<void>
}) { }) {
const [open, setOpen] = useState<boolean>(false) const [open, setOpen] = useState<boolean>(false)
const { tsWebConnected, checkTSWebConnection } = useTSWebConnected(
auth.serverMode,
node.IPv4
)
return ( return (
<Popover <Popover
className="p-3 bg-white rounded-lg shadow flex flex-col gap-2 max-w-[317px]" className="p-3 bg-white rounded-lg shadow flex flex-col max-w-[317px]"
content={ content={
<LoginPopoverContent node={node} auth={auth} newSession={newSession} /> auth.serverMode === "readonly" ? (
<ReadonlyModeContent auth={auth} />
) : auth.serverMode === "login" ? (
<LoginModeContent
auth={auth}
node={node}
tsWebConnected={tsWebConnected}
checkTSWebConnection={checkTSWebConnection}
/>
) : auth.serverMode === "manage" ? (
<ManageModeContent auth={auth} node={node} newSession={newSession} />
) : (
assertNever(auth.serverMode)
)
} }
side="bottom" side="bottom"
align="end" align="end"
@ -35,228 +54,303 @@ export default function LoginToggle({
onOpenChange={setOpen} onOpenChange={setOpen}
asChild asChild
> >
{!auth.canManageNode ? ( <div>
<button {auth.authorized ? (
className={cx( <TriggerWhenManaging auth={auth} open={open} setOpen={setOpen} />
"pl-3 py-1 bg-gray-700 rounded-full flex justify-start items-center h-[34px]", ) : (
{ "pr-1": auth.viewerIdentity, "pr-3": !auth.viewerIdentity } <TriggerWhenReading auth={auth} open={open} setOpen={setOpen} />
)} )}
onClick={() => setOpen(!open)} </div>
>
<Eye />
<div className="text-white leading-snug ml-2 mr-1">Viewing</div>
<ChevronDown className="stroke-white w-[15px] h-[15px]" />
{auth.viewerIdentity && (
<ProfilePic
className="ml-2"
size="medium"
url={auth.viewerIdentity.profilePicUrl}
/>
)}
</button>
) : (
<div
className={cx(
"w-[34px] h-[34px] p-1 rounded-full justify-center items-center inline-flex hover:bg-gray-300",
{
"bg-transparent": !open,
"bg-gray-300": open,
}
)}
>
<button onClick={() => setOpen(!open)}>
<ProfilePic
size="medium"
url={auth.viewerIdentity?.profilePicUrl}
/>
</button>
</div>
)}
</Popover> </Popover>
) )
} }
function LoginPopoverContent({ /**
* TriggerWhenManaging is displayed as the trigger for the login popover
* when the user has an active authorized managment session.
*/
function TriggerWhenManaging({
auth,
open,
setOpen,
}: {
auth: AuthResponse
open: boolean
setOpen: (next: boolean) => void
}) {
return (
<div
className={cx(
"w-[34px] h-[34px] p-1 rounded-full justify-center items-center inline-flex hover:bg-gray-300",
{
"bg-transparent": !open,
"bg-gray-300": open,
}
)}
>
<button onClick={() => setOpen(!open)}>
<ProfilePic size="medium" url={auth.viewerIdentity?.profilePicUrl} />
</button>
</div>
)
}
/**
* TriggerWhenReading is displayed as the trigger for the login popover
* when the user is currently in read mode (doesn't have an authorized
* management session).
*/
function TriggerWhenReading({
auth,
open,
setOpen,
}: {
auth: AuthResponse
open: boolean
setOpen: (next: boolean) => void
}) {
return (
<button
className={cx(
"pl-3 py-1 bg-gray-700 rounded-full flex justify-start items-center h-[34px]",
{ "pr-1": auth.viewerIdentity, "pr-3": !auth.viewerIdentity }
)}
onClick={() => setOpen(!open)}
>
<Eye />
<div className="text-white leading-snug ml-2 mr-1">Viewing</div>
<ChevronDown className="stroke-white w-[15px] h-[15px]" />
{auth.viewerIdentity && (
<ProfilePic
className="ml-2"
size="medium"
url={auth.viewerIdentity.profilePicUrl}
/>
)}
</button>
)
}
/**
* PopoverContentHeader is the header for the login popover.
*/
function PopoverContentHeader({ auth }: { auth: AuthResponse }) {
return (
<div className="text-black text-sm font-medium leading-tight mb-1">
{auth.authorized ? "Managing" : "Viewing"}
{auth.viewerIdentity && ` as ${auth.viewerIdentity.loginName}`}
</div>
)
}
/**
* PopoverContentFooter is the footer for the login popover.
*/
function PopoverContentFooter({ auth }: { auth: AuthResponse }) {
return auth.viewerIdentity ? (
<>
<hr className="my-2" />
<div className="flex items-center">
<User className="flex-shrink-0" />
<p className="text-gray-500 text-xs ml-2">
We recognize you because you are accessing this page from{" "}
<span className="font-medium">
{auth.viewerIdentity.nodeName || auth.viewerIdentity.nodeIP}
</span>
</p>
</div>
</>
) : null
}
/**
* ReadonlyModeContent is the body of the login popover when the web
* client is being run in "readonly" server mode.
*/
function ReadonlyModeContent({ auth }: { auth: AuthResponse }) {
return (
<>
<PopoverContentHeader auth={auth} />
<p className="text-gray-500 text-xs">
This web interface is running in read-only mode.{" "}
<a
href="https://tailscale.com/s/web-client-read-only"
className="text-blue-700"
target="_blank"
rel="noreferrer"
>
Learn more &rarr;
</a>
</p>
<PopoverContentFooter auth={auth} />
</>
)
}
/**
* LoginModeContent is the body of the login popover when the web
* client is being run in "login" server mode.
*/
function LoginModeContent({
node, node,
auth,
tsWebConnected,
checkTSWebConnection,
}: {
node: NodeData
auth: AuthResponse
tsWebConnected: boolean
checkTSWebConnection: () => void
}) {
const https = isHTTPS()
// We can't run the ts web connection test when the webpage is loaded
// over HTTPS. So in this case, we default to presenting a login button
// with some helper text reminding the user to check their connection
// themselves.
const hasACLAccess = https || tsWebConnected
const hasEditCaps = useMemo(() => {
if (!auth.viewerIdentity) {
// If not connected to login client over tailscale, we won't know the viewer's
// identity. So we must assume they may be able to edit something and have the
// management client handle permissions once the user gets there.
return true
}
return hasAnyEditCapabilities(auth)
}, [auth])
const handleLogin = useCallback(() => {
// Must be connected over Tailscale to log in.
// Send user to Tailscale IP and start check mode
const manageURL = `http://${node.IPv4}:5252/?check=now`
if (window.self !== window.top) {
// If we're inside an iframe, open management client in new window.
window.open(manageURL, "_blank")
} else {
window.location.href = manageURL
}
}, [node.IPv4])
return (
<div
onMouseEnter={
hasEditCaps && !hasACLAccess ? checkTSWebConnection : undefined
}
>
<PopoverContentHeader auth={auth} />
{!hasACLAccess || !hasEditCaps ? (
<>
<p className="text-gray-500 text-xs">
{!hasEditCaps ? (
// ACLs allow access, but user isn't allowed to edit any features,
// restricted to readonly. No point in sending them over to the
// tailscaleIP:5252 address.
<>
You dont have permission to make changes to this device, but
you can view most of its details.
</>
) : !node.ACLAllowsAnyIncomingTraffic ? (
// Tailnet ACLs don't allow access to anyone.
<>
The current tailnet policy file does not allow connecting to
this device.
</>
) : (
// ACLs don't allow access to this user specifically.
<>
Cannot access this devices Tailscale IP. Make sure you are
connected to your tailnet, and that your policy file allows
access.
</>
)}{" "}
<a
href="https://tailscale.com/s/web-client-access"
className="text-blue-700"
target="_blank"
rel="noreferrer"
>
Learn more &rarr;
</a>
</p>
</>
) : (
// User can connect to Tailcale IP; sign in when ready.
<>
<p className="text-gray-500 text-xs">
You can see most of this devices details. To make changes, you need
to sign in.
</p>
{https && (
// we don't know if the user can connect over TS, so
// provide extra tips in case they have trouble.
<p className="text-gray-500 text-xs font-semibold pt-2">
Make sure you are connected to your tailnet, and that your policy
file allows access.
</p>
)}
<SignInButton auth={auth} onClick={handleLogin} />
</>
)}
<PopoverContentFooter auth={auth} />
</div>
)
}
/**
* ManageModeContent is the body of the login popover when the web
* client is being run in "manage" server mode.
*/
function ManageModeContent({
auth, auth,
newSession, newSession,
}: { }: {
node: NodeData node: NodeData
auth: AuthResponse auth: AuthResponse
newSession: () => Promise<void> newSession: () => void
}) { }) {
/** const handleLogin = useCallback(() => {
* canConnectOverTS indicates whether the current viewer if (window.self !== window.top) {
* is able to hit the node's web client that's being served // If we're inside an iframe, start session in new window.
* at http://${node.IP}:5252. If false, this means that the let url = new URL(window.location.href)
* viewer must connect to the correct tailnet before being url.searchParams.set("check", "now")
* able to sign in. window.open(url, "_blank")
*/
const [canConnectOverTS, setCanConnectOverTS] = useState<boolean>(false)
const [isRunningCheck, setIsRunningCheck] = useState<boolean>(false)
// Whether the current page is loaded over HTTPS.
// If it is, then the connectivity check to the management client
// will fail with a mixed-content error.
const isHTTPS = window.location.protocol === "https:"
const checkTSConnection = useCallback(() => {
if (auth.viewerIdentity || isHTTPS) {
// Skip the connectivity check if we either already know we're connected over Tailscale,
// or know the connectivity check will fail because the current page is loaded over HTTPS.
setCanConnectOverTS(true)
return
}
// Otherwise, test connection to the ts IP.
if (isRunningCheck) {
return // already checking
}
setIsRunningCheck(true)
fetch(`http://${node.IPv4}:5252/ok`, { mode: "no-cors" })
.then(() => {
setCanConnectOverTS(true)
setIsRunningCheck(false)
})
.catch(() => setIsRunningCheck(false))
}, [auth.viewerIdentity, isRunningCheck, node.IPv4, isHTTPS])
/**
* Checking connection for first time on page load.
*
* While not connected, we check again whenever the mouse
* enters the popover component, to pick up on the user
* leaving to turn on Tailscale then returning to the view.
* See `onMouseEnter` on the div below.
*/
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => checkTSConnection(), [])
const handleSignInClick = useCallback(() => {
if (auth.viewerIdentity && auth.serverMode === "manage") {
if (window.self !== window.top) {
// if we're inside an iframe, start session in new window
let url = new URL(window.location.href)
url.searchParams.set("check", "now")
window.open(url, "_blank")
} else {
newSession()
}
} else { } else {
// Must be connected over Tailscale to log in. newSession()
// Send user to Tailscale IP and start check mode
const manageURL = `http://${node.IPv4}:5252/?check=now`
if (window.self !== window.top) {
// if we're inside an iframe, open management client in new window
window.open(manageURL, "_blank")
} else {
window.location.href = manageURL
}
} }
}, [auth.viewerIdentity, auth.serverMode, newSession, node.IPv4]) }, [newSession])
const hasAnyPermissions = useMemo(() => hasAnyEditCapabilities(auth), [auth])
return ( return (
<div onMouseEnter={!canConnectOverTS ? checkTSConnection : undefined}> <>
<div className="text-black text-sm font-medium leading-tight mb-1"> <PopoverContentHeader auth={auth} />
{!auth.canManageNode ? "Viewing" : "Managing"} {!auth.authorized &&
{auth.viewerIdentity && ` as ${auth.viewerIdentity.loginName}`} (hasAnyPermissions ? (
</div> // User is connected over Tailscale, but needs to complete check mode.
{!auth.canManageNode && ( <>
<>
{auth.serverMode === "readonly" ? (
<p className="text-gray-500 text-xs"> <p className="text-gray-500 text-xs">
This web interface is running in read-only mode.{" "} To make changes, sign in to confirm your identity. This extra step
<a helps us keep your device secure.
href="https://tailscale.com/s/web-client-read-only"
className="text-blue-700"
target="_blank"
rel="noreferrer"
>
Learn more &rarr;
</a>
</p> </p>
) : !auth.viewerIdentity ? ( <SignInButton auth={auth} onClick={handleLogin} />
// User is not connected over Tailscale. </>
// These states are only possible on the login client. ) : (
<> // User is connected over tailscale, but doesn't have permission to manage.
{!canConnectOverTS ? ( <p className="text-gray-500 text-xs">
<> You dont have permission to make changes to this device, but you
<p className="text-gray-500 text-xs"> can view most of its details.{" "}
{!node.ACLAllowsAnyIncomingTraffic ? ( <a
// Tailnet ACLs don't allow access. href="https://tailscale.com/s/web-client-access"
<> className="text-blue-700"
The current tailnet policy file does not allow target="_blank"
connecting to this device. rel="noreferrer"
</> >
) : ( Learn more &rarr;
// ACLs allow access, but user can't connect. </a>
<> </p>
Cannot access this devices Tailscale IP. Make sure you ))}
are connected to your tailnet, and that your policy file <PopoverContentFooter auth={auth} />
allows access. </>
</>
)}{" "}
<a
href="https://tailscale.com/s/web-client-connection"
className="text-blue-700"
target="_blank"
rel="noreferrer"
>
Learn more &rarr;
</a>
</p>
</>
) : (
// User can connect to Tailcale IP; sign in when ready.
<>
<p className="text-gray-500 text-xs">
You can see most of this devices details. To make changes,
you need to sign in.
</p>
{isHTTPS && (
// we don't know if the user can connect over TS, so
// provide extra tips in case they have trouble.
<p className="text-gray-500 text-xs font-semibold pt-2">
Make sure you are connected to your tailnet, and that your
policy file allows access.
</p>
)}
<SignInButton auth={auth} onClick={handleSignInClick} />
</>
)}
</>
) : auth.authNeeded === AuthType.tailscale ? (
// User is connected over Tailscale, but needs to complete check mode.
<>
<p className="text-gray-500 text-xs">
To make changes, sign in to confirm your identity. This extra
step helps us keep your device secure.
</p>
<SignInButton auth={auth} onClick={handleSignInClick} />
</>
) : (
// User is connected over tailscale, but doesn't have permission to manage.
<p className="text-gray-500 text-xs">
You dont have permission to make changes to this device, but you
can view most of its details.
</p>
)}
</>
)}
{auth.viewerIdentity && (
<>
<hr className="my-2" />
<div className="flex items-center">
<User className="flex-shrink-0" />
<p className="text-gray-500 text-xs ml-2">
We recognize you because you are accessing this page from{" "}
<span className="font-medium">
{auth.viewerIdentity.nodeName || auth.viewerIdentity.nodeIP}
</span>
</p>
</div>
</>
)}
</div>
) )
} }

View File

@ -8,6 +8,7 @@ import ACLTag from "src/components/acl-tag"
import * as Control from "src/components/control-components" import * as Control from "src/components/control-components"
import NiceIP from "src/components/nice-ip" import NiceIP from "src/components/nice-ip"
import { UpdateAvailableNotification } from "src/components/update-available" import { UpdateAvailableNotification } from "src/components/update-available"
import { AuthResponse, canEdit } from "src/hooks/auth"
import { NodeData } from "src/types" import { NodeData } from "src/types"
import Button from "src/ui/button" import Button from "src/ui/button"
import Card from "src/ui/card" import Card from "src/ui/card"
@ -16,11 +17,11 @@ import QuickCopy from "src/ui/quick-copy"
import { useLocation } from "wouter" import { useLocation } from "wouter"
export default function DeviceDetailsView({ export default function DeviceDetailsView({
readonly,
node, node,
auth,
}: { }: {
readonly: boolean
node: NodeData node: NodeData
auth: AuthResponse
}) { }) {
return ( return (
<> <>
@ -37,11 +38,11 @@ export default function DeviceDetailsView({
})} })}
/> />
</div> </div>
{!readonly && <DisconnectDialog />} {canEdit("account", auth) && <DisconnectDialog />}
</div> </div>
</Card> </Card>
{node.Features["auto-update"] && {node.Features["auto-update"] &&
!readonly && canEdit("account", auth) &&
node.ClientVersion && node.ClientVersion &&
!node.ClientVersion.RunningLatest && ( !node.ClientVersion.RunningLatest && (
<UpdateAvailableNotification details={node.ClientVersion} /> <UpdateAvailableNotification details={node.ClientVersion} />

View File

@ -8,17 +8,18 @@ import ArrowRight from "src/assets/icons/arrow-right.svg?react"
import Machine from "src/assets/icons/machine.svg?react" import Machine from "src/assets/icons/machine.svg?react"
import AddressCard from "src/components/address-copy-card" import AddressCard from "src/components/address-copy-card"
import ExitNodeSelector from "src/components/exit-node-selector" import ExitNodeSelector from "src/components/exit-node-selector"
import { AuthResponse, canEdit } from "src/hooks/auth"
import { NodeData } from "src/types" import { NodeData } from "src/types"
import Card from "src/ui/card" import Card from "src/ui/card"
import { pluralize } from "src/utils/util" import { pluralize } from "src/utils/util"
import { Link, useLocation } from "wouter" import { Link, useLocation } from "wouter"
export default function HomeView({ export default function HomeView({
readonly,
node, node,
auth,
}: { }: {
readonly: boolean
node: NodeData node: NodeData
auth: AuthResponse
}) { }) {
const [allSubnetRoutes, pendingSubnetRoutes] = useMemo( const [allSubnetRoutes, pendingSubnetRoutes] = useMemo(
() => [ () => [
@ -63,7 +64,11 @@ export default function HomeView({
</div> </div>
{(node.Features["advertise-exit-node"] || {(node.Features["advertise-exit-node"] ||
node.Features["use-exit-node"]) && ( node.Features["use-exit-node"]) && (
<ExitNodeSelector className="mb-5" node={node} disabled={readonly} /> <ExitNodeSelector
className="mb-5"
node={node}
disabled={!canEdit("exitnodes", auth)}
/>
)} )}
<Link <Link
className="link font-medium" className="link font-medium"

View File

@ -4,25 +4,50 @@
import { useCallback, useEffect, useState } from "react" import { useCallback, useEffect, useState } from "react"
import { apiFetch, setSynoToken } from "src/api" import { apiFetch, setSynoToken } from "src/api"
export enum AuthType {
synology = "synology",
tailscale = "tailscale",
}
export type AuthResponse = { export type AuthResponse = {
authNeeded?: AuthType serverMode: AuthServerMode
canManageNode: boolean authorized: boolean
serverMode: "login" | "readonly" | "manage"
viewerIdentity?: { viewerIdentity?: {
loginName: string loginName: string
nodeName: string nodeName: string
nodeIP: string nodeIP: string
profilePicUrl?: string profilePicUrl?: string
capabilities: { [key in PeerCapability]: boolean }
} }
needsSynoAuth?: boolean
} }
// useAuth reports and refreshes Tailscale auth status export type AuthServerMode = "login" | "readonly" | "manage"
// for the web client.
export type PeerCapability = "*" | "ssh" | "subnets" | "exitnodes" | "account"
/**
* canEdit reports whether the given auth response specifies that the viewer
* has the ability to edit the given capability.
*/
export function canEdit(cap: PeerCapability, auth: AuthResponse): boolean {
if (!auth.authorized || !auth.viewerIdentity) {
return false
}
if (auth.viewerIdentity.capabilities["*"] === true) {
return true // can edit all features
}
return auth.viewerIdentity.capabilities[cap] === true
}
/**
* hasAnyEditCapabilities reports whether the given auth response specifies
* that the viewer has at least one edit capability. If this is true, the
* user is able to go through the auth flow to authenticate a management
* session.
*/
export function hasAnyEditCapabilities(auth: AuthResponse): boolean {
return Object.values(auth.viewerIdentity?.capabilities || {}).includes(true)
}
/**
* useAuth reports and refreshes Tailscale auth status for the web client.
*/
export default function useAuth() { export default function useAuth() {
const [data, setData] = useState<AuthResponse>() const [data, setData] = useState<AuthResponse>()
const [loading, setLoading] = useState<boolean>(true) const [loading, setLoading] = useState<boolean>(true)
@ -33,18 +58,16 @@ export default function useAuth() {
return apiFetch<AuthResponse>("/auth", "GET") return apiFetch<AuthResponse>("/auth", "GET")
.then((d) => { .then((d) => {
setData(d) setData(d)
switch (d.authNeeded) { if (d.needsSynoAuth) {
case AuthType.synology: fetch("/webman/login.cgi")
fetch("/webman/login.cgi") .then((r) => r.json())
.then((r) => r.json()) .then((a) => {
.then((a) => { setSynoToken(a.SynoToken)
setSynoToken(a.SynoToken) setRanSynoAuth(true)
setRanSynoAuth(true) setLoading(false)
setLoading(false) })
}) } else {
break setLoading(false)
default:
setLoading(false)
} }
return d return d
}) })
@ -72,8 +95,13 @@ export default function useAuth() {
useEffect(() => { useEffect(() => {
loadAuth().then((d) => { loadAuth().then((d) => {
if (!d) {
return
}
if ( if (
!d?.canManageNode && !d.authorized &&
hasAnyEditCapabilities(d) &&
// Start auth flow immediately if browser has requested it.
new URLSearchParams(window.location.search).get("check") === "now" new URLSearchParams(window.location.search).get("check") === "now"
) { ) {
newSession() newSession()

View File

@ -0,0 +1,46 @@
// Copyright (c) Tailscale Inc & AUTHORS
// SPDX-License-Identifier: BSD-3-Clause
import { useCallback, useEffect, useState } from "react"
import { isHTTPS } from "src/utils/util"
import { AuthServerMode } from "./auth"
/**
* useTSWebConnected hook is used to check whether the browser is able to
* connect to the web client served at http://${nodeIPv4}:5252
*/
export function useTSWebConnected(mode: AuthServerMode, nodeIPv4: string) {
const [tsWebConnected, setTSWebConnected] = useState<boolean>(
mode === "manage" // browser already on the web client
)
const [isLoading, setIsLoading] = useState<boolean>(false)
const checkTSWebConnection = useCallback(() => {
if (mode === "manage") {
// Already connected to the web client.
setTSWebConnected(true)
return
}
if (isHTTPS()) {
// When page is loaded over HTTPS, the connectivity check will always
// fail with a mixed-content error. In this case don't bother doing
// the check.
return
}
if (isLoading) {
return // already checking
}
setIsLoading(true)
fetch(`http://${nodeIPv4}:5252/ok`, { mode: "no-cors" })
.then(() => {
setTSWebConnected(true)
setIsLoading(false)
})
.catch(() => setIsLoading(false))
}, [isLoading, mode, nodeIPv4])
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => checkTSWebConnection(), []) // checking connection for first time on page load
return { tsWebConnected, checkTSWebConnection, isLoading }
}

View File

@ -49,3 +49,10 @@ export function isPromise<T = unknown>(val: unknown): val is Promise<T> {
} }
return typeof val === "object" && "then" in val return typeof val === "object" && "then" in val
} }
/**
* isHTTPS reports whether the current page is loaded over HTTPS.
*/
export function isHTTPS() {
return window.location.protocol === "https:"
}

View File

@ -568,9 +568,9 @@ func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) {
return return
case path == "/routes" && r.Method == httpm.POST: case path == "/routes" && r.Method == httpm.POST:
peerAllowed := func(d postRoutesRequest, p peerCapabilities) bool { peerAllowed := func(d postRoutesRequest, p peerCapabilities) bool {
if d.SetExitNode && !p.canEdit(capFeatureExitNode) { if d.SetExitNode && !p.canEdit(capFeatureExitNodes) {
return false return false
} else if d.SetRoutes && !p.canEdit(capFeatureSubnet) { } else if d.SetRoutes && !p.canEdit(capFeatureSubnets) {
return false return false
} }
return true return true
@ -622,18 +622,11 @@ func (s *Server) serveAPI(w http.ResponseWriter, r *http.Request) {
http.Error(w, "invalid endpoint", http.StatusNotFound) http.Error(w, "invalid endpoint", http.StatusNotFound)
} }
type authType string
var (
synoAuth authType = "synology" // user needs a SynoToken for subsequent API calls
tailscaleAuth authType = "tailscale" // user needs to complete Tailscale check mode
)
type authResponse struct { type authResponse struct {
AuthNeeded authType `json:"authNeeded,omitempty"` // filled when user needs to complete a specific type of auth
CanManageNode bool `json:"canManageNode"`
ViewerIdentity *viewerIdentity `json:"viewerIdentity,omitempty"`
ServerMode ServerMode `json:"serverMode"` ServerMode ServerMode `json:"serverMode"`
Authorized bool `json:"authorized"` // has an authorized management session
ViewerIdentity *viewerIdentity `json:"viewerIdentity,omitempty"`
NeedsSynoAuth bool `json:"needsSynoAuth,omitempty"`
} }
// viewerIdentity is the Tailscale identity of the source node // viewerIdentity is the Tailscale identity of the source node
@ -652,9 +645,11 @@ func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) {
var resp authResponse var resp authResponse
resp.ServerMode = s.mode resp.ServerMode = s.mode
session, whois, status, sErr := s.getSession(r) session, whois, status, sErr := s.getSession(r)
var caps peerCapabilities
if whois != nil { if whois != nil {
caps, err := toPeerCapabilities(status, whois) var err error
caps, err = toPeerCapabilities(status, whois)
if err != nil { if err != nil {
http.Error(w, sErr.Error(), http.StatusInternalServerError) http.Error(w, sErr.Error(), http.StatusInternalServerError)
return return
@ -681,7 +676,7 @@ func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) {
return return
} }
if !authorized { if !authorized {
resp.AuthNeeded = synoAuth resp.NeedsSynoAuth = true
writeJSON(w, resp) writeJSON(w, resp)
return return
} }
@ -697,21 +692,17 @@ func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) {
switch { switch {
case sErr != nil && errors.Is(sErr, errNotUsingTailscale): case sErr != nil && errors.Is(sErr, errNotUsingTailscale):
// Restricted to the readonly view, no auth action to take.
s.lc.IncrementCounter(r.Context(), "web_client_viewing_local", 1) s.lc.IncrementCounter(r.Context(), "web_client_viewing_local", 1)
resp.AuthNeeded = "" resp.Authorized = false // restricted to the readonly view
case sErr != nil && errors.Is(sErr, errNotOwner): case sErr != nil && errors.Is(sErr, errNotOwner):
// Restricted to the readonly view, no auth action to take.
s.lc.IncrementCounter(r.Context(), "web_client_viewing_not_owner", 1) s.lc.IncrementCounter(r.Context(), "web_client_viewing_not_owner", 1)
resp.AuthNeeded = "" resp.Authorized = false // restricted to the readonly view
case sErr != nil && errors.Is(sErr, errTaggedLocalSource): case sErr != nil && errors.Is(sErr, errTaggedLocalSource):
// Restricted to the readonly view, no auth action to take.
s.lc.IncrementCounter(r.Context(), "web_client_viewing_local_tag", 1) s.lc.IncrementCounter(r.Context(), "web_client_viewing_local_tag", 1)
resp.AuthNeeded = "" resp.Authorized = false // restricted to the readonly view
case sErr != nil && errors.Is(sErr, errTaggedRemoteSource): case sErr != nil && errors.Is(sErr, errTaggedRemoteSource):
// Restricted to the readonly view, no auth action to take.
s.lc.IncrementCounter(r.Context(), "web_client_viewing_remote_tag", 1) s.lc.IncrementCounter(r.Context(), "web_client_viewing_remote_tag", 1)
resp.AuthNeeded = "" resp.Authorized = false // restricted to the readonly view
case sErr != nil && !errors.Is(sErr, errNoSession): case sErr != nil && !errors.Is(sErr, errNoSession):
// Any other error. // Any other error.
http.Error(w, sErr.Error(), http.StatusInternalServerError) http.Error(w, sErr.Error(), http.StatusInternalServerError)
@ -722,16 +713,26 @@ func (s *Server) serveAPIAuth(w http.ResponseWriter, r *http.Request) {
} else { } else {
s.lc.IncrementCounter(r.Context(), "web_client_managing_remote", 1) s.lc.IncrementCounter(r.Context(), "web_client_managing_remote", 1)
} }
resp.CanManageNode = true // User has a valid session. They're now authorized to edit if they
resp.AuthNeeded = "" // have any edit capabilities. In practice, they won't be sent through
// the auth flow if they don't have edit caps, but their ACL granted
// permissions may change at any time. The frontend views and backend
// endpoints are always restricted to their current capabilities in
// addition to a valid session.
//
// But, we also check the caps here for a better user experience on
// the frontend login toggle, which uses resp.Authorized to display
// "viewing" vs "managing" copy. If they don't have caps, we want to
// display "viewing" even if they have a valid session.
resp.Authorized = !caps.isEmpty()
default: default:
// whois being nil implies local as the request did not come over Tailscale
if whois == nil || (whois.Node.StableID == status.Self.ID) { if whois == nil || (whois.Node.StableID == status.Self.ID) {
// whois being nil implies local as the request did not come over Tailscale.
s.lc.IncrementCounter(r.Context(), "web_client_viewing_local", 1) s.lc.IncrementCounter(r.Context(), "web_client_viewing_local", 1)
} else { } else {
s.lc.IncrementCounter(r.Context(), "web_client_viewing_remote", 1) s.lc.IncrementCounter(r.Context(), "web_client_viewing_remote", 1)
} }
resp.AuthNeeded = tailscaleAuth resp.Authorized = false // not yet authorized
} }
writeJSON(w, resp) writeJSON(w, resp)

View File

@ -622,7 +622,7 @@ func() *ipn.Prefs {
name: "no-session", name: "no-session",
path: "/api/auth", path: "/api/auth",
wantStatus: http.StatusOK, wantStatus: http.StatusOK,
wantResp: &authResponse{AuthNeeded: tailscaleAuth, ViewerIdentity: vi, ServerMode: ManageServerMode}, wantResp: &authResponse{ViewerIdentity: vi, ServerMode: ManageServerMode},
wantNewCookie: false, wantNewCookie: false,
wantSession: nil, wantSession: nil,
}, },
@ -647,7 +647,7 @@ func() *ipn.Prefs {
path: "/api/auth", path: "/api/auth",
cookie: successCookie, cookie: successCookie,
wantStatus: http.StatusOK, wantStatus: http.StatusOK,
wantResp: &authResponse{AuthNeeded: tailscaleAuth, ViewerIdentity: vi, ServerMode: ManageServerMode}, wantResp: &authResponse{ViewerIdentity: vi, ServerMode: ManageServerMode},
wantSession: &browserSession{ wantSession: &browserSession{
ID: successCookie, ID: successCookie,
SrcNode: remoteNode.Node.ID, SrcNode: remoteNode.Node.ID,
@ -695,7 +695,7 @@ func() *ipn.Prefs {
path: "/api/auth", path: "/api/auth",
cookie: successCookie, cookie: successCookie,
wantStatus: http.StatusOK, wantStatus: http.StatusOK,
wantResp: &authResponse{CanManageNode: true, ViewerIdentity: vi, ServerMode: ManageServerMode}, wantResp: &authResponse{Authorized: true, ViewerIdentity: vi, ServerMode: ManageServerMode},
wantSession: &browserSession{ wantSession: &browserSession{
ID: successCookie, ID: successCookie,
SrcNode: remoteNode.Node.ID, SrcNode: remoteNode.Node.ID,
@ -1219,9 +1219,10 @@ func TestPeerCapabilities(t *testing.T) {
status: userOwnedStatus, status: userOwnedStatus,
whois: &apitype.WhoIsResponse{ whois: &apitype.WhoIsResponse{
UserProfile: &tailcfg.UserProfile{ID: tailcfg.UserID(2)}, UserProfile: &tailcfg.UserProfile{ID: tailcfg.UserID(2)},
Node: &tailcfg.Node{ID: tailcfg.NodeID(1)},
CapMap: tailcfg.PeerCapMap{ CapMap: tailcfg.PeerCapMap{
tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{ tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
"{\"canEdit\":[\"ssh\",\"subnet\"]}", "{\"canEdit\":[\"ssh\",\"subnets\"]}",
}, },
}, },
}, },
@ -1232,9 +1233,10 @@ func TestPeerCapabilities(t *testing.T) {
status: userOwnedStatus, status: userOwnedStatus,
whois: &apitype.WhoIsResponse{ whois: &apitype.WhoIsResponse{
UserProfile: &tailcfg.UserProfile{ID: tailcfg.UserID(1)}, UserProfile: &tailcfg.UserProfile{ID: tailcfg.UserID(1)},
Node: &tailcfg.Node{ID: tailcfg.NodeID(1)},
CapMap: tailcfg.PeerCapMap{ CapMap: tailcfg.PeerCapMap{
tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{ tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
"{\"canEdit\":[\"ssh\",\"subnet\"]}", "{\"canEdit\":[\"ssh\",\"subnets\"]}",
}, },
}, },
}, },
@ -1244,6 +1246,7 @@ func TestPeerCapabilities(t *testing.T) {
name: "tag-owned-no-webui-caps", name: "tag-owned-no-webui-caps",
status: tagOwnedStatus, status: tagOwnedStatus,
whois: &apitype.WhoIsResponse{ whois: &apitype.WhoIsResponse{
Node: &tailcfg.Node{ID: tailcfg.NodeID(1)},
CapMap: tailcfg.PeerCapMap{ CapMap: tailcfg.PeerCapMap{
tailcfg.PeerCapabilityDebugPeer: []tailcfg.RawMessage{}, tailcfg.PeerCapabilityDebugPeer: []tailcfg.RawMessage{},
}, },
@ -1254,68 +1257,71 @@ func TestPeerCapabilities(t *testing.T) {
name: "tag-owned-one-webui-cap", name: "tag-owned-one-webui-cap",
status: tagOwnedStatus, status: tagOwnedStatus,
whois: &apitype.WhoIsResponse{ whois: &apitype.WhoIsResponse{
Node: &tailcfg.Node{ID: tailcfg.NodeID(1)},
CapMap: tailcfg.PeerCapMap{ CapMap: tailcfg.PeerCapMap{
tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{ tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
"{\"canEdit\":[\"ssh\",\"subnet\"]}", "{\"canEdit\":[\"ssh\",\"subnets\"]}",
}, },
}, },
}, },
wantCaps: peerCapabilities{ wantCaps: peerCapabilities{
capFeatureSSH: true, capFeatureSSH: true,
capFeatureSubnet: true, capFeatureSubnets: true,
}, },
}, },
{ {
name: "tag-owned-multiple-webui-cap", name: "tag-owned-multiple-webui-cap",
status: tagOwnedStatus, status: tagOwnedStatus,
whois: &apitype.WhoIsResponse{ whois: &apitype.WhoIsResponse{
Node: &tailcfg.Node{ID: tailcfg.NodeID(1)},
CapMap: tailcfg.PeerCapMap{ CapMap: tailcfg.PeerCapMap{
tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{ tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
"{\"canEdit\":[\"ssh\",\"subnet\"]}", "{\"canEdit\":[\"ssh\",\"subnets\"]}",
"{\"canEdit\":[\"subnet\",\"exitnode\",\"*\"]}", "{\"canEdit\":[\"subnets\",\"exitnodes\",\"*\"]}",
}, },
}, },
}, },
wantCaps: peerCapabilities{ wantCaps: peerCapabilities{
capFeatureSSH: true, capFeatureSSH: true,
capFeatureSubnet: true, capFeatureSubnets: true,
capFeatureExitNode: true, capFeatureExitNodes: true,
capFeatureAll: true, capFeatureAll: true,
}, },
}, },
{ {
name: "tag-owned-case-insensitive-caps", name: "tag-owned-case-insensitive-caps",
status: tagOwnedStatus, status: tagOwnedStatus,
whois: &apitype.WhoIsResponse{ whois: &apitype.WhoIsResponse{
Node: &tailcfg.Node{ID: tailcfg.NodeID(1)},
CapMap: tailcfg.PeerCapMap{ CapMap: tailcfg.PeerCapMap{
tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{ tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
"{\"canEdit\":[\"SSH\",\"sUBnet\"]}", "{\"canEdit\":[\"SSH\",\"sUBnets\"]}",
}, },
}, },
}, },
wantCaps: peerCapabilities{ wantCaps: peerCapabilities{
capFeatureSSH: true, capFeatureSSH: true,
capFeatureSubnet: true, capFeatureSubnets: true,
}, },
}, },
{ {
name: "tag-owned-random-canEdit-contents-dont-error", name: "tag-owned-random-canEdit-contents-get-dropped",
status: tagOwnedStatus, status: tagOwnedStatus,
whois: &apitype.WhoIsResponse{ whois: &apitype.WhoIsResponse{
Node: &tailcfg.Node{ID: tailcfg.NodeID(1)},
CapMap: tailcfg.PeerCapMap{ CapMap: tailcfg.PeerCapMap{
tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{ tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
"{\"canEdit\":[\"unknown-feature\"]}", "{\"canEdit\":[\"unknown-feature\"]}",
}, },
}, },
}, },
wantCaps: peerCapabilities{ wantCaps: peerCapabilities{},
"unknown-feature": true,
},
}, },
{ {
name: "tag-owned-no-canEdit-section", name: "tag-owned-no-canEdit-section",
status: tagOwnedStatus, status: tagOwnedStatus,
whois: &apitype.WhoIsResponse{ whois: &apitype.WhoIsResponse{
Node: &tailcfg.Node{ID: tailcfg.NodeID(1)},
CapMap: tailcfg.PeerCapMap{ CapMap: tailcfg.PeerCapMap{
tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{ tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
"{\"canDoSomething\":[\"*\"]}", "{\"canDoSomething\":[\"*\"]}",
@ -1324,6 +1330,19 @@ func TestPeerCapabilities(t *testing.T) {
}, },
wantCaps: peerCapabilities{}, wantCaps: peerCapabilities{},
}, },
{
name: "tagged-source-caps-ignored",
status: tagOwnedStatus,
whois: &apitype.WhoIsResponse{
Node: &tailcfg.Node{ID: tailcfg.NodeID(1), Tags: tags.AsSlice()},
CapMap: tailcfg.PeerCapMap{
tailcfg.PeerCapabilityWebUI: []tailcfg.RawMessage{
"{\"canEdit\":[\"ssh\",\"subnets\"]}",
},
},
},
wantCaps: peerCapabilities{},
},
} }
for _, tt := range toPeerCapsTests { for _, tt := range toPeerCapsTests {
t.Run("toPeerCapabilities-"+tt.name, func(t *testing.T) { t.Run("toPeerCapabilities-"+tt.name, func(t *testing.T) {
@ -1347,36 +1366,33 @@ func TestPeerCapabilities(t *testing.T) {
name: "empty-caps", name: "empty-caps",
caps: nil, caps: nil,
wantCanEdit: map[capFeature]bool{ wantCanEdit: map[capFeature]bool{
capFeatureAll: false, capFeatureAll: false,
capFeatureFunnel: false, capFeatureSSH: false,
capFeatureSSH: false, capFeatureSubnets: false,
capFeatureSubnet: false, capFeatureExitNodes: false,
capFeatureExitNode: false, capFeatureAccount: false,
capFeatureAccount: false,
}, },
}, },
{ {
name: "some-caps", name: "some-caps",
caps: peerCapabilities{capFeatureSSH: true, capFeatureAccount: true}, caps: peerCapabilities{capFeatureSSH: true, capFeatureAccount: true},
wantCanEdit: map[capFeature]bool{ wantCanEdit: map[capFeature]bool{
capFeatureAll: false, capFeatureAll: false,
capFeatureFunnel: false, capFeatureSSH: true,
capFeatureSSH: true, capFeatureSubnets: false,
capFeatureSubnet: false, capFeatureExitNodes: false,
capFeatureExitNode: false, capFeatureAccount: true,
capFeatureAccount: true,
}, },
}, },
{ {
name: "wildcard-in-caps", name: "wildcard-in-caps",
caps: peerCapabilities{capFeatureAll: true, capFeatureAccount: true}, caps: peerCapabilities{capFeatureAll: true, capFeatureAccount: true},
wantCanEdit: map[capFeature]bool{ wantCanEdit: map[capFeature]bool{
capFeatureAll: true, capFeatureAll: true,
capFeatureFunnel: true, capFeatureSSH: true,
capFeatureSSH: true, capFeatureSubnets: true,
capFeatureSubnet: true, capFeatureExitNodes: true,
capFeatureExitNode: true, capFeatureAccount: true,
capFeatureAccount: true,
}, },
}, },
} }