client/web: add Tailscale SSH view

Updates tailscale/corp#14335

Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
This commit is contained in:
Sonia Appasamy 2023-11-13 14:54:24 -05:00 committed by Sonia Appasamy
parent 103c00a175
commit c9bfb7c683
9 changed files with 290 additions and 31 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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];
}
}
/**

View File

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

View File

@ -539,6 +539,7 @@ type nodeData struct {
AdvertiseExitNode bool
AdvertiseRoutes string
RunningSSHServer bool
LicensesURL string
@ -578,6 +579,7 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
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?
@ -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.