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>
pull/11254/head
Sonia Appasamy 2 years ago committed by Sonia Appasamy
parent 9aa704a05d
commit 95f26565db

@ -11,6 +11,7 @@ import (
"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
capFeatureSubnet capFeature = "subnet" // grants peer subnet routes management capFeatureSubnets capFeature = "subnets" // grants peer subnet routes management
capFeatureExitNode capFeature = "exitnode" // grants peer ability to advertise-as and use exit nodes capFeatureExitNodes capFeature = "exitnodes" // 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

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

@ -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,7 +54,62 @@ export default function LoginToggle({
onOpenChange={setOpen} onOpenChange={setOpen}
asChild asChild
> >
{!auth.canManageNode ? ( <div>
{auth.authorized ? (
<TriggerWhenManaging auth={auth} open={open} setOpen={setOpen} />
) : (
<TriggerWhenReading auth={auth} open={open} setOpen={setOpen} />
)}
</div>
</Popover>
)
}
/**
* 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 <button
className={cx( className={cx(
"pl-3 py-1 bg-gray-700 rounded-full flex justify-start items-center h-[34px]", "pl-3 py-1 bg-gray-700 rounded-full flex justify-start items-center h-[34px]",
@ -54,149 +128,143 @@ export default function LoginToggle({
/> />
)} )}
</button> </button>
) : ( )
<div }
className={cx(
"w-[34px] h-[34px] p-1 rounded-full justify-center items-center inline-flex hover:bg-gray-300", /**
{ * PopoverContentHeader is the header for the login popover.
"bg-transparent": !open, */
"bg-gray-300": open, function PopoverContentHeader({ auth }: { auth: AuthResponse }) {
} return (
)} <div className="text-black text-sm font-medium leading-tight mb-1">
> {auth.authorized ? "Managing" : "Viewing"}
<button onClick={() => setOpen(!open)}> {auth.viewerIdentity && ` as ${auth.viewerIdentity.loginName}`}
<ProfilePic
size="medium"
url={auth.viewerIdentity?.profilePicUrl}
/>
</button>
</div> </div>
)}
</Popover>
) )
} }
function LoginPopoverContent({ /**
* 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, auth,
newSession, tsWebConnected,
checkTSWebConnection,
}: { }: {
node: NodeData node: NodeData
auth: AuthResponse auth: AuthResponse
newSession: () => Promise<void> tsWebConnected: boolean
checkTSWebConnection: () => void
}) { }) {
/** const https = isHTTPS()
* canConnectOverTS indicates whether the current viewer // We can't run the ts web connection test when the webpage is loaded
* is able to hit the node's web client that's being served // over HTTPS. So in this case, we default to presenting a login button
* at http://${node.IP}:5252. If false, this means that the // with some helper text reminding the user to check their connection
* viewer must connect to the correct tailnet before being // themselves.
* able to sign in. const hasACLAccess = https || tsWebConnected
*/
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(() => { const hasEditCaps = useMemo(() => {
if (auth.viewerIdentity || isHTTPS) { if (!auth.viewerIdentity) {
// Skip the connectivity check if we either already know we're connected over Tailscale, // If not connected to login client over tailscale, we won't know the viewer's
// or know the connectivity check will fail because the current page is loaded over HTTPS. // identity. So we must assume they may be able to edit something and have the
setCanConnectOverTS(true) // management client handle permissions once the user gets there.
return return true
}
// Otherwise, test connection to the ts IP.
if (isRunningCheck) {
return // already checking
} }
setIsRunningCheck(true) return hasAnyEditCapabilities(auth)
fetch(`http://${node.IPv4}:5252/ok`, { mode: "no-cors" }) }, [auth])
.then(() => {
setCanConnectOverTS(true)
setIsRunningCheck(false)
})
.catch(() => setIsRunningCheck(false))
}, [auth.viewerIdentity, isRunningCheck, node.IPv4, isHTTPS])
/** const handleLogin = useCallback(() => {
* 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 {
// Must be connected over Tailscale to log in. // Must be connected over Tailscale to log in.
// Send user to Tailscale IP and start check mode // Send user to Tailscale IP and start check mode
const manageURL = `http://${node.IPv4}:5252/?check=now` const manageURL = `http://${node.IPv4}:5252/?check=now`
if (window.self !== window.top) { if (window.self !== window.top) {
// if we're inside an iframe, open management client in new window // If we're inside an iframe, open management client in new window.
window.open(manageURL, "_blank") window.open(manageURL, "_blank")
} else { } else {
window.location.href = manageURL window.location.href = manageURL
} }
} }, [node.IPv4])
}, [auth.viewerIdentity, auth.serverMode, newSession, node.IPv4])
return ( return (
<div onMouseEnter={!canConnectOverTS ? checkTSConnection : undefined}> <div
<div className="text-black text-sm font-medium leading-tight mb-1"> onMouseEnter={
{!auth.canManageNode ? "Viewing" : "Managing"} hasEditCaps && !hasACLAccess ? checkTSWebConnection : undefined
{auth.viewerIdentity && ` as ${auth.viewerIdentity.loginName}`} }
</div>
{!auth.canManageNode && (
<>
{auth.serverMode === "readonly" ? (
<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; <PopoverContentHeader auth={auth} />
</a> {!hasACLAccess || !hasEditCaps ? (
</p>
) : !auth.viewerIdentity ? (
// User is not connected over Tailscale.
// These states are only possible on the login client.
<>
{!canConnectOverTS ? (
<> <>
<p className="text-gray-500 text-xs"> <p className="text-gray-500 text-xs">
{!node.ACLAllowsAnyIncomingTraffic ? ( {!hasEditCaps ? (
// Tailnet ACLs don't allow access. // 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.
<> <>
The current tailnet policy file does not allow You dont have permission to make changes to this device, but
connecting to this device. 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 allow access, but user can't connect. // ACLs don't allow access to this user specifically.
<> <>
Cannot access this devices Tailscale IP. Make sure you Cannot access this devices Tailscale IP. Make sure you are
are connected to your tailnet, and that your policy file connected to your tailnet, and that your policy file allows
allows access. access.
</> </>
)}{" "} )}{" "}
<a <a
href="https://tailscale.com/s/web-client-connection" href="https://tailscale.com/s/web-client-access"
className="text-blue-700" className="text-blue-700"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
@ -209,54 +277,80 @@ function LoginPopoverContent({
// User can connect to Tailcale IP; sign in when ready. // User can connect to Tailcale IP; sign in when ready.
<> <>
<p className="text-gray-500 text-xs"> <p className="text-gray-500 text-xs">
You can see most of this devices details. To make changes, You can see most of this devices details. To make changes, you need
you need to sign in. to sign in.
</p> </p>
{isHTTPS && ( {https && (
// we don't know if the user can connect over TS, so // we don't know if the user can connect over TS, so
// provide extra tips in case they have trouble. // provide extra tips in case they have trouble.
<p className="text-gray-500 text-xs font-semibold pt-2"> <p className="text-gray-500 text-xs font-semibold pt-2">
Make sure you are connected to your tailnet, and that your Make sure you are connected to your tailnet, and that your policy
policy file allows access. file allows access.
</p> </p>
)} )}
<SignInButton auth={auth} onClick={handleSignInClick} /> <SignInButton auth={auth} onClick={handleLogin} />
</> </>
)} )}
</> <PopoverContentFooter auth={auth} />
) : auth.authNeeded === AuthType.tailscale ? ( </div>
)
}
/**
* ManageModeContent is the body of the login popover when the web
* client is being run in "manage" server mode.
*/
function ManageModeContent({
auth,
newSession,
}: {
node: NodeData
auth: AuthResponse
newSession: () => void
}) {
const handleLogin = useCallback(() => {
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()
}
}, [newSession])
const hasAnyPermissions = useMemo(() => hasAnyEditCapabilities(auth), [auth])
return (
<>
<PopoverContentHeader auth={auth} />
{!auth.authorized &&
(hasAnyPermissions ? (
// User is connected over Tailscale, but needs to complete check mode. // User is connected over Tailscale, but needs to complete check mode.
<> <>
<p className="text-gray-500 text-xs"> <p className="text-gray-500 text-xs">
To make changes, sign in to confirm your identity. This extra To make changes, sign in to confirm your identity. This extra step
step helps us keep your device secure. helps us keep your device secure.
</p> </p>
<SignInButton auth={auth} onClick={handleSignInClick} /> <SignInButton auth={auth} onClick={handleLogin} />
</> </>
) : ( ) : (
// User is connected over tailscale, but doesn't have permission to manage. // User is connected over tailscale, but doesn't have permission to manage.
<p className="text-gray-500 text-xs"> <p className="text-gray-500 text-xs">
You dont have permission to make changes to this device, but you You dont have permission to make changes to this device, but you
can view most of its details. can view most of its details.{" "}
</p> <a
)} href="https://tailscale.com/s/web-client-access"
</> className="text-blue-700"
)} target="_blank"
{auth.viewerIdentity && ( rel="noreferrer"
<> >
<hr className="my-2" /> Learn more &rarr;
<div className="flex items-center"> </a>
<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> </p>
</div> ))}
<PopoverContentFooter auth={auth} />
</> </>
)}
</div>
) )
} }

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

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

@ -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,8 +58,7 @@ 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) => {
@ -42,8 +66,7 @@ export default function useAuth() {
setRanSynoAuth(true) setRanSynoAuth(true)
setLoading(false) setLoading(false)
}) })
break } else {
default:
setLoading(false) 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()

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

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

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

@ -622,7 +622,7 @@ func TestServeAuth(t *testing.T) {
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 TestServeAuth(t *testing.T) {
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 TestServeAuth(t *testing.T) {
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,32 +1257,34 @@ 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,
}, },
}, },
@ -1287,35 +1292,36 @@ func TestPeerCapabilities(t *testing.T) {
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) {
@ -1348,10 +1367,9 @@ func TestPeerCapabilities(t *testing.T) {
caps: nil, caps: nil,
wantCanEdit: map[capFeature]bool{ wantCanEdit: map[capFeature]bool{
capFeatureAll: false, capFeatureAll: false,
capFeatureFunnel: false,
capFeatureSSH: false, capFeatureSSH: false,
capFeatureSubnet: false, capFeatureSubnets: false,
capFeatureExitNode: false, capFeatureExitNodes: false,
capFeatureAccount: false, capFeatureAccount: false,
}, },
}, },
@ -1360,10 +1378,9 @@ func TestPeerCapabilities(t *testing.T) {
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,
capFeatureSubnet: false, capFeatureSubnets: false,
capFeatureExitNode: false, capFeatureExitNodes: false,
capFeatureAccount: true, capFeatureAccount: true,
}, },
}, },
@ -1372,10 +1389,9 @@ func TestPeerCapabilities(t *testing.T) {
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,
capFeatureSubnet: true, capFeatureSubnets: true,
capFeatureExitNode: true, capFeatureExitNodes: true,
capFeatureAccount: true, capFeatureAccount: true,
}, },
}, },

Loading…
Cancel
Save