mirror of
https://github.com/tailscale/tailscale.git
synced 2025-02-18 02:48:40 +00:00
client/web: add Tailscale SSH view
Updates tailscale/corp#14335 Signed-off-by: Sonia Appasamy <sonia@tailscale.com>
This commit is contained in:
parent
103c00a175
commit
c9bfb7c683
@ -10,7 +10,7 @@ let unraidCsrfToken: string | undefined // required for unraid POST requests (#8
|
|||||||
// (i.e. provide `/data` rather than `api/data`).
|
// (i.e. provide `/data` rather than `api/data`).
|
||||||
export function apiFetch(
|
export function apiFetch(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
method: "GET" | "POST",
|
method: "GET" | "POST" | "PATCH",
|
||||||
body?: any,
|
body?: any,
|
||||||
params?: Record<string, string>
|
params?: Record<string, string>
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
|
@ -5,6 +5,7 @@ import DeviceDetailsView from "src/components/views/device-details-view"
|
|||||||
import HomeView from "src/components/views/home-view"
|
import HomeView from "src/components/views/home-view"
|
||||||
import LegacyClientView from "src/components/views/legacy-client-view"
|
import LegacyClientView from "src/components/views/legacy-client-view"
|
||||||
import LoginClientView from "src/components/views/login-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 useAuth, { AuthResponse } from "src/hooks/auth"
|
||||||
import useNodeData, { NodeData } from "src/hooks/node-data"
|
import useNodeData, { NodeData } from "src/hooks/node-data"
|
||||||
import { ReactComponent as TailscaleIcon } from "src/icons/tailscale-icon.svg"
|
import { ReactComponent as TailscaleIcon } from "src/icons/tailscale-icon.svg"
|
||||||
@ -31,7 +32,7 @@ function WebClient({
|
|||||||
auth: AuthResponse
|
auth: AuthResponse
|
||||||
newSession: () => Promise<void>
|
newSession: () => Promise<void>
|
||||||
}) {
|
}) {
|
||||||
const { data, refreshData, updateNode } = useNodeData()
|
const { data, refreshData, updateNode, updatePrefs } = useNodeData()
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshData()
|
refreshData()
|
||||||
}, [auth, refreshData])
|
}, [auth, refreshData])
|
||||||
@ -72,7 +73,13 @@ function WebClient({
|
|||||||
<DeviceDetailsView readonly={!auth.canManageNode} node={data} />
|
<DeviceDetailsView readonly={!auth.canManageNode} node={data} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/subnets">{/* TODO */}Subnet router</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 path="/serve">{/* TODO */}Share local content</Route>
|
||||||
<Route>
|
<Route>
|
||||||
<h2 className="mt-8">Page not found</h2>
|
<h2 className="mt-8">Page not found</h2>
|
||||||
|
@ -15,7 +15,7 @@ export default function DeviceDetailsView({
|
|||||||
const [, setLocation] = useLocation()
|
const [, setLocation] = useLocation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<>
|
||||||
<h1 className="mb-10">Device details</h1>
|
<h1 className="mb-10">Device details</h1>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="card">
|
<div className="card">
|
||||||
@ -123,6 +123,6 @@ export default function DeviceDetailsView({
|
|||||||
in the admin console.
|
in the admin console.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -57,6 +57,14 @@ export default function HomeView({
|
|||||||
className="mb-3"
|
className="mb-3"
|
||||||
title="Tailscale SSH server"
|
title="Tailscale SSH server"
|
||||||
body="Run a Tailscale SSH server on this device and allow other devices in your tailnet to SSH into it."
|
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
|
<SettingsCard
|
||||||
link="/serve"
|
link="/serve"
|
||||||
@ -71,11 +79,16 @@ function SettingsCard({
|
|||||||
title,
|
title,
|
||||||
link,
|
link,
|
||||||
body,
|
body,
|
||||||
|
badge,
|
||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
link: string
|
link: string
|
||||||
body: string
|
body: string
|
||||||
|
badge?: {
|
||||||
|
text: string
|
||||||
|
icon?: JSX.Element
|
||||||
|
}
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@ -87,9 +100,19 @@ function SettingsCard({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-neutral-800 font-medium leading-tight mb-2">
|
<div className="flex gap-2">
|
||||||
{title}
|
<p className="text-neutral-800 font-medium leading-tight mb-2">
|
||||||
</p>
|
{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>
|
<p className="text-neutral-500 text-sm leading-tight">{body}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
51
client/web/src/components/views/ssh-view.tsx
Normal file
51
client/web/src/components/views/ssh-view.tsx
Normal 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 →
|
||||||
|
</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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
@ -25,6 +25,7 @@ export type NodeData = {
|
|||||||
TailnetName: string
|
TailnetName: string
|
||||||
IsTagged: boolean
|
IsTagged: boolean
|
||||||
Tags: string[]
|
Tags: string[]
|
||||||
|
RunningSSHServer: boolean
|
||||||
|
|
||||||
DebugMode: "" | "login" | "full" // empty when not running in any debug mode
|
DebugMode: "" | "login" | "full" // empty when not running in any debug mode
|
||||||
}
|
}
|
||||||
@ -50,6 +51,11 @@ export type NodeUpdate = {
|
|||||||
ForceLogout?: boolean
|
ForceLogout?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PrefsUpdate = {
|
||||||
|
RunSSHSet?: boolean
|
||||||
|
RunSSH?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
// useNodeData returns basic data about the current node.
|
// useNodeData returns basic data about the current node.
|
||||||
export default function useNodeData() {
|
export default function useNodeData() {
|
||||||
const [data, setData] = useState<NodeData>()
|
const [data, setData] = useState<NodeData>()
|
||||||
@ -108,6 +114,7 @@ export default function useNodeData() {
|
|||||||
refreshData()
|
refreshData()
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
setIsPosting(false)
|
||||||
alert("Failed operation: " + err.message)
|
alert("Failed operation: " + err.message)
|
||||||
throw err
|
throw err
|
||||||
})
|
})
|
||||||
@ -115,6 +122,36 @@ export default function useNodeData() {
|
|||||||
[data]
|
[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(
|
useEffect(
|
||||||
() => {
|
() => {
|
||||||
// Initial data load.
|
// Initial data load.
|
||||||
@ -134,5 +171,5 @@ export default function useNodeData() {
|
|||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
return { data, refreshData, updateNode, isPosting }
|
return { data, refreshData, updateNode, updatePrefs, isPosting }
|
||||||
}
|
}
|
||||||
|
@ -31,6 +31,107 @@
|
|||||||
.card td:last-child {
|
.card td:last-child {
|
||||||
@apply text-neutral-800 text-sm leading-tight;
|
@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
client/web/src/ui/toggle.tsx
Normal file
41
client/web/src/ui/toggle.tsx
Normal 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",
|
||||||
|
}
|
@ -539,6 +539,7 @@ type nodeData struct {
|
|||||||
|
|
||||||
AdvertiseExitNode bool
|
AdvertiseExitNode bool
|
||||||
AdvertiseRoutes string
|
AdvertiseRoutes string
|
||||||
|
RunningSSHServer bool
|
||||||
|
|
||||||
LicensesURL string
|
LicensesURL string
|
||||||
|
|
||||||
@ -563,24 +564,25 @@ func (s *Server) serveGetNodeData(w http.ResponseWriter, r *http.Request) {
|
|||||||
debugMode = "login"
|
debugMode = "login"
|
||||||
}
|
}
|
||||||
data := &nodeData{
|
data := &nodeData{
|
||||||
ID: st.Self.ID,
|
ID: st.Self.ID,
|
||||||
Status: st.BackendState,
|
Status: st.BackendState,
|
||||||
DeviceName: strings.Split(st.Self.DNSName, ".")[0],
|
DeviceName: strings.Split(st.Self.DNSName, ".")[0],
|
||||||
TailnetName: st.CurrentTailnet.MagicDNSSuffix,
|
TailnetName: st.CurrentTailnet.MagicDNSSuffix,
|
||||||
DomainName: st.CurrentTailnet.Name,
|
DomainName: st.CurrentTailnet.Name,
|
||||||
OS: st.Self.OS,
|
OS: st.Self.OS,
|
||||||
IPNVersion: strings.Split(st.Version, "-")[0],
|
IPNVersion: strings.Split(st.Version, "-")[0],
|
||||||
Profile: st.User[st.Self.UserID],
|
Profile: st.User[st.Self.UserID],
|
||||||
IsTagged: st.Self.IsTagged(),
|
IsTagged: st.Self.IsTagged(),
|
||||||
KeyExpired: st.Self.Expired,
|
KeyExpired: st.Self.Expired,
|
||||||
TUNMode: st.TUN,
|
TUNMode: st.TUN,
|
||||||
IsSynology: distro.Get() == distro.Synology || envknob.Bool("TS_FAKE_SYNOLOGY"),
|
IsSynology: distro.Get() == distro.Synology || envknob.Bool("TS_FAKE_SYNOLOGY"),
|
||||||
DSMVersion: distro.DSMVersion(),
|
DSMVersion: distro.DSMVersion(),
|
||||||
IsUnraid: distro.Get() == distro.Unraid,
|
IsUnraid: distro.Get() == distro.Unraid,
|
||||||
UnraidToken: os.Getenv("UNRAID_CSRF_TOKEN"),
|
UnraidToken: os.Getenv("UNRAID_CSRF_TOKEN"),
|
||||||
URLPrefix: strings.TrimSuffix(s.pathPrefix, "/"),
|
RunningSSHServer: prefs.RunSSH,
|
||||||
LicensesURL: licenses.LicensesURL(),
|
URLPrefix: strings.TrimSuffix(s.pathPrefix, "/"),
|
||||||
DebugMode: debugMode, // TODO(sonia,will): just pass back s.mode directly?
|
LicensesURL: licenses.LicensesURL(),
|
||||||
|
DebugMode: debugMode, // TODO(sonia,will): just pass back s.mode directly?
|
||||||
}
|
}
|
||||||
for _, ip := range st.TailscaleIPs {
|
for _, ip := range st.TailscaleIPs {
|
||||||
if ip.Is4() {
|
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,
|
// Rather than exposing all localapi endpoints over the proxy,
|
||||||
// this limits to just the ones actually used from the web
|
// this limits to just the ones actually used from the web
|
||||||
// client frontend.
|
// 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{
|
var localapiAllowlist = []string{
|
||||||
"/v0/logout",
|
"/v0/logout",
|
||||||
|
"/v0/prefs",
|
||||||
}
|
}
|
||||||
|
|
||||||
// csrfKey returns a key that can be used for CSRF protection.
|
// csrfKey returns a key that can be used for CSRF protection.
|
||||||
|
Loading…
x
Reference in New Issue
Block a user