فهرست منبع

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 <[email protected]>
Sonia Appasamy 2 سال پیش
والد
کامیت
95f26565db

+ 45 - 8
client/web/auth.go

@@ -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
-	capFeatureFunnel   capFeature = "funnel"   // grants peer serve/funnel management
-	capFeatureSSH      capFeature = "ssh"      // grants peer SSH server management
-	capFeatureSubnet   capFeature = "subnet"   // grants peer subnet routes management
-	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
+	capFeatureAll       capFeature = "*"         // grants peer management of all features
+	capFeatureSSH       capFeature = "ssh"       // grants peer SSH server management
+	capFeatureSubnets   capFeature = "subnets"   // grants peer subnet routes management
+	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
 )
 )
 
 
+// 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

+ 8 - 5
client/web/src/components/app.tsx

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

+ 296 - 202
client/web/src/components/login-toggle.tsx

@@ -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,231 +54,306 @@ export default function LoginToggle({
       onOpenChange={setOpen}
       onOpenChange={setOpen}
       asChild
       asChild
     >
     >
-      {!auth.canManageNode ? (
-        <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>
-      ) : (
-        <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>
-      )}
+      <div>
+        {auth.authorized ? (
+          <TriggerWhenManaging auth={auth} open={open} setOpen={setOpen} />
+        ) : (
+          <TriggerWhenReading auth={auth} open={open} setOpen={setOpen} />
+        )}
+      </div>
     </Popover>
     </Popover>
   )
   )
 }
 }
 
 
-function LoginPopoverContent({
+/**
+ * TriggerWhenManaging is displayed as the trigger for the login popover
+ * when the user has an active authorized managment session.
+ */
+function TriggerWhenManaging({
+  auth,
+  open,
+  setOpen,
+}: {
+  auth: AuthResponse
+  open: boolean
+  setOpen: (next: boolean) => void
+}) {
+  return (
+    <div
+      className={cx(
+        "w-[34px] h-[34px] p-1 rounded-full justify-center items-center inline-flex hover:bg-gray-300",
+        {
+          "bg-transparent": !open,
+          "bg-gray-300": open,
+        }
+      )}
+    >
+      <button onClick={() => setOpen(!open)}>
+        <ProfilePic size="medium" url={auth.viewerIdentity?.profilePicUrl} />
+      </button>
+    </div>
+  )
+}
+
+/**
+ * TriggerWhenReading is displayed as the trigger for the login popover
+ * when the user is currently in read mode (doesn't have an authorized
+ * management session).
+ */
+function TriggerWhenReading({
+  auth,
+  open,
+  setOpen,
+}: {
+  auth: AuthResponse
+  open: boolean
+  setOpen: (next: boolean) => void
+}) {
+  return (
+    <button
+      className={cx(
+        "pl-3 py-1 bg-gray-700 rounded-full flex justify-start items-center h-[34px]",
+        { "pr-1": auth.viewerIdentity, "pr-3": !auth.viewerIdentity }
+      )}
+      onClick={() => setOpen(!open)}
+    >
+      <Eye />
+      <div className="text-white leading-snug ml-2 mr-1">Viewing</div>
+      <ChevronDown className="stroke-white w-[15px] h-[15px]" />
+      {auth.viewerIdentity && (
+        <ProfilePic
+          className="ml-2"
+          size="medium"
+          url={auth.viewerIdentity.profilePicUrl}
+        />
+      )}
+    </button>
+  )
+}
+
+/**
+ * PopoverContentHeader is the header for the login popover.
+ */
+function PopoverContentHeader({ auth }: { auth: AuthResponse }) {
+  return (
+    <div className="text-black text-sm font-medium leading-tight mb-1">
+      {auth.authorized ? "Managing" : "Viewing"}
+      {auth.viewerIdentity && ` as ${auth.viewerIdentity.loginName}`}
+    </div>
+  )
+}
+
+/**
+ * PopoverContentFooter is the footer for the login popover.
+ */
+function PopoverContentFooter({ auth }: { auth: AuthResponse }) {
+  return auth.viewerIdentity ? (
+    <>
+      <hr className="my-2" />
+      <div className="flex items-center">
+        <User className="flex-shrink-0" />
+        <p className="text-gray-500 text-xs ml-2">
+          We recognize you because you are accessing this page from{" "}
+          <span className="font-medium">
+            {auth.viewerIdentity.nodeName || auth.viewerIdentity.nodeIP}
+          </span>
+        </p>
+      </div>
+    </>
+  ) : null
+}
+
+/**
+ * ReadonlyModeContent is the body of the login popover when the web
+ * client is being run in "readonly" server mode.
+ */
+function ReadonlyModeContent({ auth }: { auth: AuthResponse }) {
+  return (
+    <>
+      <PopoverContentHeader auth={auth} />
+      <p className="text-gray-500 text-xs">
+        This web interface is running in read-only mode.{" "}
+        <a
+          href="https://tailscale.com/s/web-client-read-only"
+          className="text-blue-700"
+          target="_blank"
+          rel="noreferrer"
+        >
+          Learn more &rarr;
+        </a>
+      </p>
+      <PopoverContentFooter auth={auth} />
+    </>
+  )
+}
+
+/**
+ * LoginModeContent is the body of the login popover when the web
+ * client is being run in "login" server mode.
+ */
+function LoginModeContent({
   node,
   node,
   auth,
   auth,
-  newSession,
+  tsWebConnected,
+  checkTSWebConnection,
 }: {
 }: {
   node: NodeData
   node: NodeData
   auth: AuthResponse
   auth: AuthResponse
-  newSession: () => Promise<void>
+  tsWebConnected: boolean
+  checkTSWebConnection: () => void
 }) {
 }) {
-  /**
-   * canConnectOverTS indicates whether the current viewer
-   * is able to hit the node's web client that's being served
-   * at http://${node.IP}:5252. If false, this means that the
-   * viewer must connect to the correct tailnet before being
-   * able to sign in.
-   */
-  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 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 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
+  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
     }
     }
-    // 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(), [])
+    return hasAnyEditCapabilities(auth)
+  }, [auth])
 
 
-  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()
-      }
+  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 {
     } else {
-      // 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
-      }
+      window.location.href = manageURL
     }
     }
-  }, [auth.viewerIdentity, auth.serverMode, newSession, node.IPv4])
+  }, [node.IPv4])
 
 
   return (
   return (
-    <div onMouseEnter={!canConnectOverTS ? checkTSConnection : undefined}>
-      <div className="text-black text-sm font-medium leading-tight mb-1">
-        {!auth.canManageNode ? "Viewing" : "Managing"}
-        {auth.viewerIdentity && ` as ${auth.viewerIdentity.loginName}`}
-      </div>
-      {!auth.canManageNode && (
+    <div
+      onMouseEnter={
+        hasEditCaps && !hasACLAccess ? checkTSWebConnection : undefined
+      }
+    >
+      <PopoverContentHeader auth={auth} />
+      {!hasACLAccess || !hasEditCaps ? (
         <>
         <>
-          {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;
-              </a>
-            </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">
-                    {!node.ACLAllowsAnyIncomingTraffic ? (
-                      // Tailnet ACLs don't allow access.
-                      <>
-                        The current tailnet policy file does not allow
-                        connecting to this device.
-                      </>
-                    ) : (
-                      // ACLs allow access, but user can't connect.
-                      <>
-                        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-connection"
-                      className="text-blue-700"
-                      target="_blank"
-                      rel="noreferrer"
-                    >
-                      Learn more &rarr;
-                    </a>
-                  </p>
-                </>
-              ) : (
-                // User can connect to Tailcale IP; sign in when ready.
-                <>
-                  <p className="text-gray-500 text-xs">
-                    You can see most of this 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>
-          )}
+          <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 &rarr;
+            </a>
+          </p>
         </>
         </>
-      )}
-      {auth.viewerIdentity && (
+      ) : (
+        // User can connect to Tailcale IP; sign in when ready.
         <>
         <>
-          <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 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>
             </p>
-          </div>
+          )}
+          <SignInButton auth={auth} onClick={handleLogin} />
         </>
         </>
       )}
       )}
+      <PopoverContentFooter auth={auth} />
     </div>
     </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.
+          <>
+            <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={handleLogin} />
+          </>
+        ) : (
+          // 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.{" "}
+            <a
+              href="https://tailscale.com/s/web-client-access"
+              className="text-blue-700"
+              target="_blank"
+              rel="noreferrer"
+            >
+              Learn more &rarr;
+            </a>
+          </p>
+        ))}
+      <PopoverContentFooter auth={auth} />
+    </>
+  )
+}
+
 function SignInButton({
 function SignInButton({
   auth,
   auth,
   onClick,
   onClick,

+ 5 - 4
client/web/src/components/views/device-details-view.tsx

@@ -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 - 3
client/web/src/components/views/home-view.tsx

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

+ 51 - 23
client/web/src/hooks/auth.ts

@@ -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
-  canManageNode: boolean
-  serverMode: "login" | "readonly" | "manage"
+  serverMode: AuthServerMode
+  authorized: boolean
   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
-// for the web client.
+export type AuthServerMode = "login" | "readonly" | "manage"
+
+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) {
-          case AuthType.synology:
-            fetch("/webman/login.cgi")
-              .then((r) => r.json())
-              .then((a) => {
-                setSynoToken(a.SynoToken)
-                setRanSynoAuth(true)
-                setLoading(false)
-              })
-            break
-          default:
-            setLoading(false)
+        if (d.needsSynoAuth) {
+          fetch("/webman/login.cgi")
+            .then((r) => r.json())
+            .then((a) => {
+              setSynoToken(a.SynoToken)
+              setRanSynoAuth(true)
+              setLoading(false)
+            })
+        } else {
+          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 - 0
client/web/src/hooks/ts-web-connected.ts

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

+ 7 - 0
client/web/src/utils/util.ts

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

+ 27 - 26
client/web/web.go

@@ -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
-		resp.AuthNeeded = ""
+		// User has a valid session. They're now authorized to edit if they
+		// 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)

+ 55 - 39
client/web/web_test.go

@@ -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,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,
-				capFeatureSubnet: true,
+				capFeatureSSH:     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\":[\"subnet\",\"exitnode\",\"*\"]}",
+						"{\"canEdit\":[\"ssh\",\"subnets\"]}",
+						"{\"canEdit\":[\"subnets\",\"exitnodes\",\"*\"]}",
 					},
 					},
 				},
 				},
 			},
 			},
 			wantCaps: peerCapabilities{
 			wantCaps: peerCapabilities{
-				capFeatureSSH:      true,
-				capFeatureSubnet:   true,
-				capFeatureExitNode: true,
-				capFeatureAll:      true,
+				capFeatureSSH:       true,
+				capFeatureSubnets:   true,
+				capFeatureExitNodes: 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,
-				capFeatureSubnet: true,
+				capFeatureSSH:     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{
-				"unknown-feature": true,
-			},
+			wantCaps: peerCapabilities{},
 		},
 		},
 		{
 		{
 			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,
-				capFeatureFunnel:   false,
-				capFeatureSSH:      false,
-				capFeatureSubnet:   false,
-				capFeatureExitNode: false,
-				capFeatureAccount:  false,
+				capFeatureAll:       false,
+				capFeatureSSH:       false,
+				capFeatureSubnets:   false,
+				capFeatureExitNodes: 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,
-				capFeatureFunnel:   false,
-				capFeatureSSH:      true,
-				capFeatureSubnet:   false,
-				capFeatureExitNode: false,
-				capFeatureAccount:  true,
+				capFeatureAll:       false,
+				capFeatureSSH:       true,
+				capFeatureSubnets:   false,
+				capFeatureExitNodes: false,
+				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,
-				capFeatureFunnel:   true,
-				capFeatureSSH:      true,
-				capFeatureSubnet:   true,
-				capFeatureExitNode: true,
-				capFeatureAccount:  true,
+				capFeatureAll:       true,
+				capFeatureSSH:       true,
+				capFeatureSubnets:   true,
+				capFeatureExitNodes: true,
+				capFeatureAccount:   true,
 			},
 			},
 		},
 		},
 	}
 	}