Преглед изворни кода

client/web: add Tailscale SSH view

Updates tailscale/corp#14335

Signed-off-by: Sonia Appasamy <[email protected]>
Sonia Appasamy пре 2 година
родитељ
комит
c9bfb7c683

+ 1 - 1
client/web/src/api.ts

@@ -10,7 +10,7 @@ let unraidCsrfToken: string | undefined // required for unraid POST requests (#8
 // (i.e. provide `/data` rather than `api/data`).
 export function apiFetch(
   endpoint: string,
-  method: "GET" | "POST",
+  method: "GET" | "POST" | "PATCH",
   body?: any,
   params?: Record<string, string>
 ): Promise<Response> {

+ 9 - 2
client/web/src/components/app.tsx

@@ -5,6 +5,7 @@ import DeviceDetailsView from "src/components/views/device-details-view"
 import HomeView from "src/components/views/home-view"
 import LegacyClientView from "src/components/views/legacy-client-view"
 import LoginClientView from "src/components/views/login-client-view"
+import SSHView from "src/components/views/ssh-view"
 import useAuth, { AuthResponse } from "src/hooks/auth"
 import useNodeData, { NodeData } from "src/hooks/node-data"
 import { ReactComponent as TailscaleIcon } from "src/icons/tailscale-icon.svg"
@@ -31,7 +32,7 @@ function WebClient({
   auth: AuthResponse
   newSession: () => Promise<void>
 }) {
-  const { data, refreshData, updateNode } = useNodeData()
+  const { data, refreshData, updateNode, updatePrefs } = useNodeData()
   useEffect(() => {
     refreshData()
   }, [auth, refreshData])
@@ -72,7 +73,13 @@ function WebClient({
             <DeviceDetailsView readonly={!auth.canManageNode} node={data} />
           </Route>
           <Route path="/subnets">{/* TODO */}Subnet router</Route>
-          <Route path="/ssh">{/* TODO */}Tailscale SSH server</Route>
+          <Route path="/ssh">
+            <SSHView
+              readonly={!auth.canManageNode}
+              runningSSH={data.RunningSSHServer}
+              updatePrefs={updatePrefs}
+            />
+          </Route>
           <Route path="/serve">{/* TODO */}Share local content</Route>
           <Route>
             <h2 className="mt-8">Page not found</h2>

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

@@ -15,7 +15,7 @@ export default function DeviceDetailsView({
   const [, setLocation] = useLocation()
 
   return (
-    <div>
+    <>
       <h1 className="mb-10">Device details</h1>
       <div className="flex flex-col gap-4">
         <div className="card">
@@ -123,6 +123,6 @@ export default function DeviceDetailsView({
           in the admin console.
         </p>
       </div>
-    </div>
+    </>
   )
 }

+ 26 - 3
client/web/src/components/views/home-view.tsx

@@ -57,6 +57,14 @@ export default function HomeView({
         className="mb-3"
         title="Tailscale SSH server"
         body="Run a Tailscale SSH server on this device and allow other devices in your tailnet to SSH into it."
+        badge={
+          node.RunningSSHServer
+            ? {
+                text: "Running",
+                icon: <div className="w-2 h-2 bg-emerald-500 rounded-full" />,
+              }
+            : undefined
+        }
       />
       <SettingsCard
         link="/serve"
@@ -71,11 +79,16 @@ function SettingsCard({
   title,
   link,
   body,
+  badge,
   className,
 }: {
   title: string
   link: string
   body: string
+  badge?: {
+    text: string
+    icon?: JSX.Element
+  }
   className?: string
 }) {
   return (
@@ -87,9 +100,19 @@ function SettingsCard({
       )}
     >
       <div>
-        <p className="text-neutral-800 font-medium leading-tight mb-2">
-          {title}
-        </p>
+        <div className="flex gap-2">
+          <p className="text-neutral-800 font-medium leading-tight mb-2">
+            {title}
+          </p>
+          {badge && (
+            <div className="h-5 px-2 bg-stone-100 rounded-full flex items-center gap-2">
+              {badge.icon}
+              <div className="text-neutral-500 text-xs font-medium">
+                {badge.text}
+              </div>
+            </div>
+          )}
+        </div>
         <p className="text-neutral-500 text-sm leading-tight">{body}</p>
       </div>
       <div>

+ 51 - 0
client/web/src/components/views/ssh-view.tsx

@@ -0,0 +1,51 @@
+import React from "react"
+import { PrefsUpdate } from "src/hooks/node-data"
+import Toggle from "src/ui/toggle"
+
+export default function SSHView({
+  readonly,
+  runningSSH,
+  updatePrefs,
+}: {
+  readonly: boolean
+  runningSSH: boolean
+  updatePrefs: (p: PrefsUpdate) => Promise<void>
+}) {
+  return (
+    <>
+      <h1 className="mb-1">Tailscale SSH server</h1>
+      <p className="description mb-10">
+        Run a Tailscale SSH server on this device and allow other devices in
+        your tailnet to SSH into it.{" "}
+        <a
+          href="https://tailscale.com/kb/1193/tailscale-ssh/"
+          className="text-indigo-700"
+          target="_blank"
+        >
+          Learn more &rarr;
+        </a>
+      </p>
+      <div className="-mx-5 px-4 py-3 bg-white rounded-lg border border-gray-200 flex gap-2.5 mb-3">
+        <Toggle
+          checked={runningSSH}
+          onChange={() => updatePrefs({ RunSSHSet: true, RunSSH: !runningSSH })}
+          disabled={readonly}
+        />
+        <div className="text-black text-sm font-medium leading-tight">
+          Run Tailscale SSH server
+        </div>
+      </div>
+      <p className="text-neutral-500 text-sm leading-tight">
+        Remember to make sure that the{" "}
+        <a
+          href="https://login.tailscale.com/admin/acls/"
+          className="text-indigo-700"
+          target="_blank"
+        >
+          tailnet policy file
+        </a>{" "}
+        allows other devices to SSH into this device.
+      </p>
+    </>
+  )
+}

+ 38 - 1
client/web/src/hooks/node-data.ts

@@ -25,6 +25,7 @@ export type NodeData = {
   TailnetName: string
   IsTagged: boolean
   Tags: string[]
+  RunningSSHServer: boolean
 
   DebugMode: "" | "login" | "full" // empty when not running in any debug mode
 }
@@ -50,6 +51,11 @@ export type NodeUpdate = {
   ForceLogout?: boolean
 }
 
+export type PrefsUpdate = {
+  RunSSHSet?: boolean
+  RunSSH?: boolean
+}
+
 // useNodeData returns basic data about the current node.
 export default function useNodeData() {
   const [data, setData] = useState<NodeData>()
@@ -108,6 +114,7 @@ export default function useNodeData() {
           refreshData()
         })
         .catch((err) => {
+          setIsPosting(false)
           alert("Failed operation: " + err.message)
           throw err
         })
@@ -115,6 +122,36 @@ export default function useNodeData() {
     [data]
   )
 
+  const updatePrefs = useCallback(
+    (p: PrefsUpdate) => {
+      setIsPosting(true)
+      if (data) {
+        const optimisticUpdates = data
+        if (p.RunSSHSet) {
+          optimisticUpdates.RunningSSHServer = Boolean(p.RunSSH)
+        }
+        // Reflect the pref change immediatley on the frontend,
+        // then make the prefs PATCH. If the request fails,
+        // data will be updated to it's previous value in
+        // onComplete below.
+        setData(optimisticUpdates)
+      }
+
+      const onComplete = () => {
+        setIsPosting(false)
+        refreshData() // refresh data after PATCH finishes
+      }
+
+      return apiFetch("/local/v0/prefs", "PATCH", p)
+        .then(onComplete)
+        .catch(() => {
+          onComplete()
+          alert("Failed to update prefs")
+        })
+    },
+    [setIsPosting, refreshData, setData, data]
+  )
+
   useEffect(
     () => {
       // Initial data load.
@@ -134,5 +171,5 @@ export default function useNodeData() {
     []
   )
 
-  return { data, refreshData, updateNode, isPosting }
+  return { data, refreshData, updateNode, updatePrefs, isPosting }
 }

+ 101 - 0
client/web/src/index.css

@@ -31,6 +31,107 @@
   .card td:last-child {
     @apply text-neutral-800 text-sm leading-tight;
   }
+
+  .description {
+    @apply text-neutral-500 leading-snug
+  }
+
+  /**
+   * .toggle applies "Toggle" UI styles to input[type="checkbox"] form elements.
+   * You can use the -large and -small modifiers for size variants.
+   */
+  .toggle {
+    @apply appearance-none relative w-10 h-5 rounded-full bg-neutral-300 cursor-pointer;
+    transition: background-color 200ms ease-in-out;
+  }
+
+  .toggle:disabled {
+    @apply bg-neutral-200;
+    @apply cursor-not-allowed;
+  }
+
+  .toggle:checked {
+    @apply bg-indigo-500;
+  }
+
+  .toggle:checked:disabled {
+    @apply bg-indigo-300;
+  }
+
+  .toggle:focus {
+    @apply outline-none ring;
+  }
+
+  .toggle::after {
+    @apply absolute bg-white rounded-full will-change-[width];
+    @apply w-3.5 h-3.5 m-[0.1875rem] translate-x-0;
+    content: " ";
+    transition: width 200ms ease, transform 200ms ease;
+  }
+
+  .toggle:checked::after {
+    @apply translate-x-5;
+  }
+
+  .toggle:checked:disabled::after {
+    @apply bg-indigo-50;
+  }
+
+  .toggle:enabled:active::after {
+    @apply w-[1.125rem];
+  }
+
+  .toggle:checked:enabled:active::after {
+    @apply w-[1.125rem] translate-x-3.5;
+  }
+
+  .toggle-large {
+    @apply w-12 h-6;
+  }
+
+  .toggle-large::after {
+    @apply m-1 w-4 h-4;
+  }
+
+  .toggle-large:checked::after {
+    @apply translate-x-6;
+  }
+
+  .toggle-large:enabled:active::after {
+    @apply w-6;
+  }
+
+  .toggle-large:checked:enabled:active::after {
+    @apply w-6 translate-x-4;
+  }
+
+  .toggle-small {
+    @apply w-6 h-3;
+  }
+
+  .toggle-small:focus {
+    /**
+     * We disable ring for .toggle-small because it is a
+     * small, inline element.
+     */
+    @apply outline-none shadow-none;
+  }
+
+  .toggle-small::after {
+    @apply w-2 h-2 m-0.5;
+  }
+
+  .toggle-small:checked::after {
+    @apply translate-x-3;
+  }
+
+  .toggle-small:enabled:active::after {
+    @apply w-[0.675rem];
+  }
+
+  .toggle-small:checked:enabled:active::after {
+    @apply w-[0.675rem] translate-x-[0.55rem];
+  }
 }
 
 /**

+ 41 - 0
client/web/src/ui/toggle.tsx

@@ -0,0 +1,41 @@
+import cx from "classnames"
+import React, { ChangeEvent } from "react"
+
+type Props = {
+  id?: string
+  className?: string
+  disabled?: boolean
+  checked: boolean
+  sizeVariant?: "small" | "medium" | "large"
+  onChange: (checked: boolean) => void
+}
+
+export default function Toggle(props: Props) {
+  const { className, id, disabled, checked, sizeVariant, onChange } = props
+
+  function handleChange(e: ChangeEvent<HTMLInputElement>) {
+    onChange(e.target.checked)
+  }
+
+  return (
+    <input
+      id={id}
+      type="checkbox"
+      className={cx(
+        "toggle",
+        {
+          "toggle-large": sizeVariant === "large",
+          "toggle-small": sizeVariant === "small",
+        },
+        className
+      )}
+      disabled={disabled}
+      checked={checked}
+      onChange={handleChange}
+    />
+  )
+}
+
+Toggle.defaultProps = {
+  sizeVariant: "medium",
+}

+ 21 - 22
client/web/web.go

@@ -539,6 +539,7 @@ type nodeData struct {
 
 	AdvertiseExitNode bool
 	AdvertiseRoutes   string
+	RunningSSHServer  bool
 
 	LicensesURL string
 
@@ -563,24 +564,25 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
 		debugMode = "login"
 	}
 	data := &nodeData{
-		ID:          st.Self.ID,
-		Status:      st.BackendState,
-		DeviceName:  strings.Split(st.Self.DNSName, ".")[0],
-		TailnetName: st.CurrentTailnet.MagicDNSSuffix,
-		DomainName:  st.CurrentTailnet.Name,
-		OS:          st.Self.OS,
-		IPNVersion:  strings.Split(st.Version, "-")[0],
-		Profile:     st.User[st.Self.UserID],
-		IsTagged:    st.Self.IsTagged(),
-		KeyExpired:  st.Self.Expired,
-		TUNMode:     st.TUN,
-		IsSynology:  distro.Get() == distro.Synology || envknob.Bool("TS_FAKE_SYNOLOGY"),
-		DSMVersion:  distro.DSMVersion(),
-		IsUnraid:    distro.Get() == distro.Unraid,
-		UnraidToken: os.Getenv("UNRAID_CSRF_TOKEN"),
-		URLPrefix:   strings.TrimSuffix(s.pathPrefix, "/"),
-		LicensesURL: licenses.LicensesURL(),
-		DebugMode:   debugMode, // TODO(sonia,will): just pass back s.mode directly?
+		ID:               st.Self.ID,
+		Status:           st.BackendState,
+		DeviceName:       strings.Split(st.Self.DNSName, ".")[0],
+		TailnetName:      st.CurrentTailnet.MagicDNSSuffix,
+		DomainName:       st.CurrentTailnet.Name,
+		OS:               st.Self.OS,
+		IPNVersion:       strings.Split(st.Version, "-")[0],
+		Profile:          st.User[st.Self.UserID],
+		IsTagged:         st.Self.IsTagged(),
+		KeyExpired:       st.Self.Expired,
+		TUNMode:          st.TUN,
+		IsSynology:       distro.Get() == distro.Synology || envknob.Bool("TS_FAKE_SYNOLOGY"),
+		DSMVersion:       distro.DSMVersion(),
+		IsUnraid:         distro.Get() == distro.Unraid,
+		UnraidToken:      os.Getenv("UNRAID_CSRF_TOKEN"),
+		RunningSSHServer: prefs.RunSSH,
+		URLPrefix:        strings.TrimSuffix(s.pathPrefix, "/"),
+		LicensesURL:      licenses.LicensesURL(),
+		DebugMode:        debugMode, // TODO(sonia,will): just pass back s.mode directly?
 	}
 	for _, ip := range st.TailscaleIPs {
 		if ip.Is4() {
@@ -800,12 +802,9 @@ func (s *Server) proxyRequestToLocalAPI(w http.ResponseWriter, r *http.Request)
 // Rather than exposing all localapi endpoints over the proxy,
 // this limits to just the ones actually used from the web
 // client frontend.
-//
-// TODO(sonia,will): Shouldn't expand this beyond the existing
-// localapi endpoints until the larger web client auth story
-// is worked out (tailscale/corp#14335).
 var localapiAllowlist = []string{
 	"/v0/logout",
+	"/v0/prefs",
 }
 
 // csrfKey returns a key that can be used for CSRF protection.