mirror of
https://github.com/tailscale/tailscale.git
synced 2024-11-29 04:55:31 +00:00
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:
parent
9aa704a05d
commit
95f26565db
@ -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
|
||||||
|
@ -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,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 →
|
||||||
|
</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 don’t 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 device’s 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 →
|
||||||
|
</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 device’s 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 →
|
|
||||||
</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 don’t 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 →
|
||||||
// ACLs allow access, but user can't connect.
|
</a>
|
||||||
<>
|
</p>
|
||||||
Cannot access this device’s 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 →
|
|
||||||
</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 device’s 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 don’t 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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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,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()
|
||||||
|
46
client/web/src/hooks/ts-web-connected.ts
Normal file
46
client/web/src/hooks/ts-web-connected.ts
Normal 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 }
|
||||||
|
}
|
@ -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() *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,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user