diff --git a/CHANGELOG.md b/CHANGELOG.md index 016a15d..edeb114 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,11 @@ -# 0.7.0-beta.2 (April 7, 2026) +# 0.7.0-beta.2 (April 9, 2026) > This is a beta release. Please report any issues you encounter. +- **Rebuilt the Browser SSH feature** + - Should now work with custom DERP ports and properly handle sessions. + - Switched to using `libghostty` for a proper, modern terminal experience (closes [#515](https://github.com/tale/headplane/issues/515)). + - Added more resilient error handling and state handling when initiating connections. - **Migrated all UI components from react-aria/react-stately to @base-ui-components/react.** - Removed `react-aria`, `react-stately`, and `tailwindcss-react-aria-components` as dependencies. - **Replaced `openid-client` with a clean-room OIDC implementation.** @@ -30,8 +34,19 @@ - Fixed agent HostInfo not refreshing periodically using `cache_ttl` (via [#477](https://github.com/tale/headplane/pull/477), closes [#427](https://github.com/tale/headplane/issues/427)). - Fixed agent working directory being wiped on restart. - Fixed a race condition where the SSE controller could be used after being closed. +- **Rewrote the WebSSH WASM module** to match Tailscale's proven `tsconnect` init sequence. + - Switched the terminal renderer from xterm.js to [restty](https://restty.dev) (Ghostty WASM). + - Bundled self-hosted JetBrains Mono Nerd Font with Nerd Fonts symbol fallback — no CDN dependency. + - Fixed SSH sessions failing with EOF: the SSH channel multiplexer was not receiving server traffic. + - Fixed terminal resize sending swapped rows/cols, causing garbled output on window resize. + - Fixed `log.Fatal()` calls in the WASM bridge killing the entire runtime on recoverable errors. + - Fixed `Close()` returning `true` on error and `false` on success. + - Fixed stale closure bug in the NodeKey tracking callback. + - Removed unnecessary `LoginDefault` and `LocalBackendStartKeyOSNeutral` control flags. + - Added cancellation support for in-flight SSH connections on close. - Fixed WebSSH dropping DERP port information on non-standard ports (e.g. `:8443`), which caused connections to fail (closes [#515](https://github.com/tale/headplane/issues/515)). - Fixed WebSSH WASM prefix paths for correct asset loading (closes [#386](https://github.com/tale/headplane/issues/386)). +- Fixed Nix WASM build applying DERP patch to wrong vendor directory. - Fixed Dockerfile WASM copy paths. - Fixed CodeMirror version mismatch override in the ACL editor. - Fixed cookie secret generation using incorrect byte length (via [#501](https://github.com/tale/headplane/pull/501)). diff --git a/app/layout/app.tsx b/app/layout/app.tsx index 07463a2..0c764ef 100644 --- a/app/layout/app.tsx +++ b/app/layout/app.tsx @@ -2,7 +2,6 @@ import { Outlet, redirect, type ShouldRevalidateFunction } from "react-router"; import { ErrorBanner } from "~/components/error-banner"; import StatusBanner from "~/components/status-banner"; -import { pruneEphemeralNodes } from "~/server/db/pruner"; import { isDataUnauthorizedError } from "~/server/headscale/api/error-client"; import { usersResource } from "~/server/headscale/live-store"; import { Capabilities } from "~/server/web/roles"; @@ -30,7 +29,7 @@ export const shouldRevalidate: ShouldRevalidateFunction = ({ return false; }; -export async function loader({ request, context, ...rest }: Route.LoaderArgs) { +export async function loader({ request, context }: Route.LoaderArgs) { try { const principal = await context.auth.require(request); @@ -53,7 +52,6 @@ export async function loader({ request, context, ...rest }: Route.LoaderArgs) { if (isHealthy) { try { await api.getApiKeys(); - await pruneEphemeralNodes({ context, request, ...rest }); } catch (error) { if (isDataUnauthorizedError(error)) { const displayName = diff --git a/app/routes.ts b/app/routes.ts index 382cea0..7490570 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -13,7 +13,7 @@ export default [ route("/logout", "routes/auth/logout.ts"), route("/oidc/callback", "routes/auth/oidc-callback.ts"), route("/oidc/start", "routes/auth/oidc-start.ts"), - route("/ssh", "routes/ssh/console.tsx"), + route("/ssh/:id", "routes/ssh/page.tsx"), // All the main logged-in routes layout("layout/app.tsx", [ diff --git a/app/routes/machines/components/machine-row.tsx b/app/routes/machines/components/machine-row.tsx index d5812b2..489deac 100644 --- a/app/routes/machines/components/machine-row.tsx +++ b/app/routes/machines/components/machine-row.tsx @@ -50,8 +50,8 @@ export default function MachineRow({ }, [magic, node.ipAddresses]); return ( - - + +

{ window.open( - `${__PREFIX__}/ssh?hostname=${node.givenName}`, + `${__PREFIX__}/ssh/${node.givenName}`, "_blank", "noopener,noreferrer,width=800,height=600", ); diff --git a/app/routes/ssh/console.tsx b/app/routes/ssh/console.tsx deleted file mode 100644 index 8198f40..0000000 --- a/app/routes/ssh/console.tsx +++ /dev/null @@ -1,278 +0,0 @@ -import { faker } from "@faker-js/faker"; -import { eq } from "drizzle-orm"; -import { Loader2 } from "lucide-react"; -import { useEffect, useState } from "react"; -import { data, type ShouldRevalidateFunction, useSubmit } from "react-router"; -import { ExternalScriptsHandle } from "remix-utils/external-scripts"; - -import { EphemeralNodeInsert, ephemeralNodes } from "~/server/db/schema"; -import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity"; -import { useLiveData } from "~/utils/live-data"; -import log from "~/utils/log"; - -import type { Route } from "./+types/console"; -import UserPrompt from "./user-prompt"; -import XTerm from "./xterm.client"; - -export const shouldRevalidate: ShouldRevalidateFunction = () => { - return false; -}; - -export async function loader({ request, context }: Route.LoaderArgs) { - const origin = new URL(request.url).origin; - const assets = [`${__PREFIX__}/wasm_exec.js`, `${__PREFIX__}/hp_ssh.wasm`]; - const missing: string[] = []; - - for (const file of assets) { - const res = await fetch(`${origin}${file}`, { method: "HEAD" }); - if (!res.ok) missing.push(file); - } - - if (missing.length > 0) { - throw data("WebSSH is not configured in this build.", 405); - } - - if (!context.agents) { - throw data("WebSSH is only available with the Headplane agent integration", 400); - } - - const principal = await context.auth.require(request); - if (principal.kind === "api_key") { - throw data("Only OAuth users are allowed to use WebSSH", 403); - } - - const apiKey = context.auth.getHeadscaleApiKey(principal); - const api = context.hsApi.getRuntimeClient(apiKey); - const users = await api.getUsers(); - - // MARK: This assumes that a user has authenticated with Headscale first - // Since the only way to enforce permissions via ACLs is to generate a - // pre-authkey which REQUIRES a user ID, meaning the user has to have - // authenticated with Headscale first. - const lookup = findHeadscaleUserBySubject(users, principal.user.subject, principal.profile.email); - - if (!lookup) { - throw data(`User with subject ${principal.user.subject} not found within Headscale`, 404); - } - - const preAuthKey = await api.createPreAuthKey( - lookup.id, - true, // ephemeral - false, // reusable - new Date(Date.now() + 60 * 1000), // expiration: 1 minute - null, // aclTags - ); - - // TODO: Enable config to enforce generate_authkeys capability - // For now, any user is capable of WebSSH connections - // const check = await context.sessions.check( - // request, - // Capabilities.generate_authkeys, - // ); - - const qp = new URL(request.url).searchParams; - const username = qp.get("username") || undefined; - const hostname = qp.get("hostname") || undefined; - if (!hostname) { - throw data("Missing required parameter: hostname", 400); - } - - if (!username) { - return { - ipnDetails: undefined, - sshDetails: { - username, - hostname, - }, - }; - } - - // We're making a request to /key?v=116 to check the CORS headers - const u = context.config.headscale.public_url ?? context.config.headscale.url; - // const res = await fetch(`${u}/key?v=116`, { - // method: 'GET', - // }); - - // const corsOrigin = res.headers.get('Access-Control-Allow-Origin'); - // const corsMethods = res.headers.get('Access-Control-Allow-Methods'); - // const corsHeaders = res.headers.get('Access-Control-Allow-Headers'); - // console.log(corsOrigin, corsMethods, corsHeaders); - - // if (!corsOrigin || !corsMethods || !corsHeaders) { - // throw data( - // 'Headscale server does not have the required CORS headers for WebSSH', - // 500, - // ); - // } - - const nodes = await api.getNodes(); - const lookupNode = nodes.find((n) => n.givenName === hostname); - if (!lookupNode) { - throw data(`Node with hostname ${hostname} not found`, 404); - } - - // Last thing is keeping track of the ephemeral node in the database - // because Headscale doesn't automatically delete ephemeral nodes??? - const [_ephemeralNode] = await context.db - .insert(ephemeralNodes) - .values({ - auth_key: preAuthKey.key, - } satisfies EphemeralNodeInsert) - .returning(); - - return { - ipnDetails: { - PreAuthKey: preAuthKey.key, - Hostname: generateHostname(username), - ControlURL: u, - }, - - sshDetails: { - username, - hostname, - }, - }; -} - -function generateHostname(username: string) { - const adjective = faker.word.adjective({ - length: { - min: 3, - max: 6, - }, - }); - - const noun = faker.word.noun({ - length: { - min: 3, - max: 6, - }, - }); - - return `ssh-${adjective}-${noun}-${username}`; -} - -export async function action({ request, context }: Route.ActionArgs) { - await context.auth.require(request); - if (!context.agents) { - throw data("WebSSH is only available with the Headplane agent integration", 400); - } - - const form = await request.formData(); - const nodeKey = form.get("node_key"); - const authKey = form.get("auth_key"); - - if (nodeKey === null || typeof nodeKey !== "string") { - throw data("Missing node_key", 400); - } - - if (authKey === null || typeof authKey !== "string") { - throw data("Missing auth_key", 400); - } - - await context.db - .update(ephemeralNodes) - .set({ - node_key: nodeKey, - }) - .where(eq(ephemeralNodes.auth_key, authKey)); - - context.agents?.triggerSync().catch((err) => { - log.debug("agent", "Background agent sync failed: %s", err); - }); -} - -export const links: Route.LinksFunction = () => [ - { - rel: "preload", - href: `${__PREFIX__}/hp_ssh.wasm`, - as: "fetch", - type: "application/wasm", - crossOrigin: "anonymous", - }, -]; - -export const handle: ExternalScriptsHandle = { - scripts: [ - { - src: `${__PREFIX__}/wasm_exec.js`, - crossOrigin: "anonymous", - preload: true, - }, - ], -}; - -export default function Page({ loaderData: { ipnDetails, sshDetails } }: Route.ComponentProps) { - const submit = useSubmit(); - const { pause } = useLiveData(); - - const [ipn, setIpn] = useState(null); - const [nodeKey, setNodeKey] = useState(null); - - useEffect(() => { - if (!ipnDetails) { - return; - } - - pause(); - const go = new Go(); // Go is defined by wasm_exec.js - WebAssembly.instantiateStreaming(fetch(`${__PREFIX__}/hp_ssh.wasm`), go.importObject).then( - (value) => { - go.run(value.instance); - const handle = TsWasmNet(ipnDetails, { - NotifyState: (state) => { - console.log("State changed:", state); - if (state === "Running") { - setIpn(handle); - } - }, - NotifyNetMap: (netmap) => { - // Only set NodeKey if it is not already set and then - // also dispatch that to the backend to track the - // ephemeral node. - // - // We open an SSE connection to the backend - // so that when the connection is closed, - // the backend can delete the ephemeral node. - if (nodeKey === null) { - setNodeKey(netmap.NodeKey); - submit( - { - node_key: netmap.NodeKey, - auth_key: ipnDetails.PreAuthKey, - }, - { method: "POST" }, - ); - } - }, - NotifyBrowseToURL: (url) => { - console.log("Browse to URL:", url); - }, - NotifyPanicRecover: (message) => { - console.error("Panic recover:", message); - }, - }); - - handle.Start(); - }, - ); - }, []); - - if (!sshDetails.username) { - return ; - } - - return ( -

- {ipn === null ? ( -
- -
- ) : ( -
- -
- )} -
- ); -} diff --git a/app/routes/ssh/errors.tsx b/app/routes/ssh/errors.tsx new file mode 100644 index 0000000..53089c5 --- /dev/null +++ b/app/routes/ssh/errors.tsx @@ -0,0 +1,77 @@ +import { AlertCircle } from "lucide-react"; + +import Card from "~/components/card"; +import Link from "~/components/link"; + +export const sshErrors = { + wasm_missing: { + title: "Browser SSH is not available", + message: "This version of Headplane was not built with browser SSH support.", + anchor: "#ssh-not-available", + }, + + agent_required: { + title: "Browser SSH requires the Headplane agent", + message: "Browser SSH is only available when the Headplane agent integration is enabled.", + anchor: "#agent-required", + }, + + oidc_required: { + title: "Browser SSH requires OIDC authentication", + message: "Browser SSH is only available when OIDC authentication is enabled.", + anchor: "#oidc-required", + }, + + node_not_found: (hostname: string) => ({ + title: "Node not found", + message: `No node found with hostname ${hostname}.`, + anchor: "#node-not-found", + }), + + user_not_linked: { + title: "User account not linked", + message: + "You'll need to link your user account to a Headscale user before you can use Browser SSH.", + anchor: "#user-not-linked", + }, +} as const; + +interface SSHErrorBoundaryProps { + title: string; + message: string; + anchor: string; +} + +export function isSSHError(error: unknown): error is SSHErrorBoundaryProps { + return ( + typeof error === "object" && + error !== null && + "title" in error && + "message" in error && + "anchor" in error && + typeof error.title === "string" && + typeof error.message === "string" && + typeof error.anchor === "string" + ); +} + +const DOCS_BASE = "https://headplane.net/features/ssh"; + +export function SSHErrorBoundary({ title, message, anchor }: SSHErrorBoundaryProps) { + return ( + +
+ {title} + +
+ + {message} +
+
+ + Headplane SSH Documentation + {" "} +
+
+ ); +} diff --git a/app/routes/ssh/ghostty.client.tsx b/app/routes/ssh/ghostty.client.tsx new file mode 100644 index 0000000..661a944 --- /dev/null +++ b/app/routes/ssh/ghostty.client.tsx @@ -0,0 +1,137 @@ +import { useEffect, useRef } from "react"; +import { Restty } from "restty"; +import type { GhosttyTheme } from "restty"; +import type { PtyTransport } from "restty/internal"; + +import type { HeadplaneSSH, TunnelSession } from "./wasm.client"; + +const FONT_BASE = `${__PREFIX__}/fonts`; + +// Ghostty's default canvas background is rgb(20,23,26) — a dark gray, not black. +// Override it so the terminal matches the page and pane container backgrounds. +const HEADPLANE_THEME: GhosttyTheme = { + colors: { + background: { r: 0, g: 0, b: 0 }, + foreground: { r: 235, g: 237, b: 242 }, + palette: [], + }, + raw: {}, +}; + +function createSSHTransport(ssh: HeadplaneSSH, ipAddress: string, username: string): PtyTransport { + let session: TunnelSession | null = null; + + return { + connect(options) { + session = ssh.openTunnel({ + ipAddress, + username, + onData: (data) => options.callbacks.onData?.(data), + onConnect: () => options.callbacks.onConnect?.(), + onDisconnect: () => { + options.callbacks.onDisconnect?.(); + session = null; + }, + }); + + if (options.cols && options.rows) { + session.resize(options.cols, options.rows); + } + }, + disconnect() { + session?.close(); + session = null; + }, + sendInput(data) { + session?.writeInput(data); + return session != null; + }, + resize(cols, rows) { + session?.resize(cols, rows); + return session != null; + }, + isConnected() { + return session != null; + }, + destroy() { + session?.close(); + session = null; + }, + }; +} + +interface GhosttyProps { + ssh: HeadplaneSSH; + ipAddress: string; + username: string; + onConnected: () => void; +} + +export default function Ghostty({ ssh, ipAddress, username, onConnected }: GhosttyProps) { + const divRef = useRef(null); + + useEffect(() => { + if (!divRef.current) return; + + const transport = createSSHTransport(ssh, ipAddress, username); + const restty = new Restty({ + root: divRef.current, + createInitialPane: true, + defaultContextMenu: false, + shortcuts: false, + searchUi: false, + paneStyles: { + inactivePaneOpacity: 1, + activePaneOpacity: 1, + }, + appOptions: { + fontSize: 20, + ligatures: true, + fontPreset: "none", + fontSources: [ + { + type: "url", + url: `${FONT_BASE}/JetBrainsMonoNLNerdFontMono-Regular.ttf`, + label: "JetBrains Mono Nerd Font", + }, + { + type: "url", + url: `${FONT_BASE}/JetBrainsMonoNLNerdFontMono-Bold.ttf`, + label: "JetBrains Mono Nerd Font Bold", + }, + { + type: "url", + url: `${FONT_BASE}/JetBrainsMonoNLNerdFontMono-Italic.ttf`, + label: "JetBrains Mono Nerd Font Italic", + }, + { + type: "url", + url: `${FONT_BASE}/JetBrainsMonoNLNerdFontMono-BoldItalic.ttf`, + label: "JetBrains Mono Nerd Font Bold Italic", + }, + { + type: "url", + url: `${FONT_BASE}/SymbolsNerdFontMono-Regular.ttf`, + label: "Symbols Nerd Font", + }, + ], + ptyTransport: transport, + callbacks: { + onPtyStatus: (status) => { + if (status === "connected") onConnected(); + }, + }, + }, + }); + + restty.applyTheme(HEADPLANE_THEME); + restty.updateSize(true); + restty.connectPty(); + + return () => { + restty.destroy(); + }; + }, [ssh, ipAddress, username]); + + return
; +} diff --git a/app/routes/ssh/hp_ssh.d.ts b/app/routes/ssh/hp_ssh.d.ts deleted file mode 100644 index 2bb73e8..0000000 --- a/app/routes/ssh/hp_ssh.d.ts +++ /dev/null @@ -1,57 +0,0 @@ -declare function TsWasmNet( - options: TsWasmNetOptions, - callbacks: TsWasmNetCallbacks, -): TsWasmNet; - -interface TsWasmNetOptions { - ControlURL: string; - PreAuthKey: string; - Hostname: string; -} - -interface TsWasmNetCallbacks { - NotifyState: (state: IPNState) => void; - NotifyNetMap: (netmap: TsWasmNetMap) => void; - NotifyBrowseToURL: (url: string) => void; - NotifyPanicRecover: (err: string) => void; -} - -interface TsWasmNetMap { - NodeKey: string; -} - -interface TsWasmNet { - Start: () => void; - OpenSSH: ( - hostname: string, - username: string, - options: XtermConfig, - ) => SSHSession; -} - -type IPNState = - | 'NoState' - | 'InUseOtherUser' - | 'NeedsLogin' - | 'NeedsMachineAuth' - | 'Stopped' - | 'Starting' - | 'Running'; - -interface XtermConfig { - rows: number; - cols: number; - timeout?: number; - - onStdout: (data: Uint8Array) => void; - onStderr: (data: Uint8Array) => void; - onStdin: (func: (input: Uint8Array) => void) => void; - - onConnect: () => void; - onDisconnect: () => void; -} - -interface SSHSession { - Close(): boolean; - Resize(rows: number, cols: number): boolean; -} diff --git a/app/routes/ssh/page.tsx b/app/routes/ssh/page.tsx new file mode 100644 index 0000000..23686c6 --- /dev/null +++ b/app/routes/ssh/page.tsx @@ -0,0 +1,242 @@ +import { faker } from "@faker-js/faker"; +import { Loader2, WifiOff } from "lucide-react"; +import { useEffect, useState } from "react"; +import { data, isRouteErrorResponse, type ShouldRevalidateFunction } from "react-router"; +import { ExternalScriptsHandle } from "remix-utils/external-scripts"; + +import Button from "~/components/button"; +import Card from "~/components/card"; +import Code from "~/components/code"; +import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity"; + +import type { Route } from "./+types/page"; +import { isSSHError, SSHErrorBoundary, sshErrors } from "./errors"; +import Ghostty from "./ghostty.client"; +import UserPrompt from "./user-prompt"; +import type { HeadplaneSSH } from "./wasm.client"; +import { loadHeadplaneWASM } from "./wasm.client"; + +const WASM_MODULE_URL = `${__PREFIX__}/hp_ssh.wasm`; +const WASM_HELPER_URL = `${__PREFIX__}/wasm_exec.js`; + +export const shouldRevalidate: ShouldRevalidateFunction = () => { + return false; +}; + +export async function loader({ request, params, context }: Route.LoaderArgs) { + const origin = new URL(request.url).origin; + const assets = [WASM_HELPER_URL, WASM_MODULE_URL]; + const missing: string[] = []; + + for (const file of assets) { + const res = await fetch(`${origin}${file}`, { method: "HEAD" }); + if (!res.ok) { + missing.push(file); + } + } + + if (missing.length > 0) { + throw data(sshErrors.wasm_missing, 405); + } + + if (context.agents == null) { + throw data(sshErrors.agent_required, 400); + } + + const principal = await context.auth.require(request); + if (principal.kind === "api_key") { + throw data(sshErrors.oidc_required, 403); + } + + const apiKey = context.auth.getHeadscaleApiKey(principal); + const api = context.hsApi.getRuntimeClient(apiKey); + + const hostname = params.id; + const username = new URL(request.url).searchParams.get("user") || undefined; + + const nodes = await api.getNodes(); + const node = nodes.find((n) => n.givenName === hostname); + if (!node) { + throw data(sshErrors.node_not_found(hostname), 404); + } + + if (!node.online) { + return { hostname, username, offline: true, node: undefined }; + } + + if (!username) { + return { hostname, username: undefined, offline: false, node: undefined }; + } + + // The user must exist within Headscale to generate a pre-auth key + const users = await api.getUsers(); + const hsUser = findHeadscaleUserBySubject(users, principal.user.subject, principal.profile.email); + + if (!hsUser) { + throw data(sshErrors.user_not_linked, 404); + } + + const preAuthKey = await api.createPreAuthKey( + hsUser.id, + true, + false, + new Date(Date.now() + 60 * 1000), // 1 minute expiry + null, + ); + + const controlURL = context.config.headscale.public_url ?? context.config.headscale.url; + return { + hostname, + username, + offline: false, + node: { + ipAddress: node.ipAddresses[0], + controlURL, + preAuthKey: preAuthKey.key, + ephemeralHostname: generateHostname(username), + }, + }; +} + +function generateHostname(username: string) { + const adjective = faker.word.adjective({ length: { min: 3, max: 6 } }); + const noun = faker.word.noun({ length: { min: 3, max: 6 } }); + return `ssh-${adjective}-${noun}-${username}`; +} + +export const links: Route.LinksFunction = () => [ + { + rel: "preload", + href: WASM_MODULE_URL, + as: "fetch", + type: "application/wasm", + crossOrigin: "anonymous", + }, +]; + +export const handle: ExternalScriptsHandle = { + scripts: [ + { + src: WASM_HELPER_URL, + crossOrigin: "anonymous", + preload: true, + }, + ], +}; + +export default function Page({ loaderData }: Route.ComponentProps) { + const { hostname, username, offline, node } = loaderData; + + if (offline) { + return ( +
+ +
+ Node Offline + +
+ + {hostname} is not currently connected to the Tailnet. + + +
+
+ ); + } + + if (!username || !node) { + return ; + } + + return ; +} + +function SSHConsole({ + hostname, + username, + node, +}: { + hostname: string; + username: string; + node: { ipAddress: string; controlURL: string; preAuthKey: string; ephemeralHostname: string }; +}) { + const [ssh, setSsh] = useState(null); + const [connected, setConnected] = useState(false); + const [status, setStatus] = useState("Starting tunnel…"); + + useEffect(() => { + let cancelled = false; + + console.log("[ssh] Loading WASM factory"); + loadHeadplaneWASM().then((create) => { + console.log("[ssh] Factory loaded, creating IPN", create); + + if (cancelled) { + return; + } + + setStatus("Joining Tailnet…"); + const instance = create({ + controlURL: node.controlURL, + preAuthKey: node.preAuthKey, + hostname: node.ephemeralHostname, + onReady: () => { + console.log("[ssh] IPN ready (Running)"); + if (!cancelled) { + setStatus(`Connecting to ${hostname}…`); + setSsh(instance); + } + }, + onError: (msg) => console.error("[ssh] IPN error:", msg), + }); + + console.log("[ssh] IPN instance created", instance); + }); + + return () => { + cancelled = true; + }; + }, [node]); + + return ( +
+ {!connected && ( +
+
+ +

{status}

+
+
+ )} + + {ssh && ( + setConnected(true)} + /> + )} +
+ ); +} + +export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { + const routeError = isRouteErrorResponse(error) ? error.data : null; + if (routeError == null || !isSSHError(routeError)) { + // Pass through further down the tree to the global error boundary + throw error; + } + + return ( +
+ +
+ ); +} diff --git a/app/routes/ssh/user-prompt.tsx b/app/routes/ssh/user-prompt.tsx index eef06a9..7d1df8d 100644 --- a/app/routes/ssh/user-prompt.tsx +++ b/app/routes/ssh/user-prompt.tsx @@ -1,17 +1,16 @@ -import { useState } from "react"; +import { Form } from "react-router"; import Button from "~/components/button"; import Card from "~/components/card"; import Code from "~/components/code"; import Input from "~/components/input"; +import Link from "~/components/link"; interface UserPromptProps { hostname: string; } export default function UserPrompt({ hostname }: UserPromptProps) { - const [username, setUsername] = useState(""); - return (
@@ -19,27 +18,47 @@ export default function UserPrompt({ hostname }: UserPromptProps) { Enter the username you want to use to connect to {hostname} {". "} - WebSSH follows the Headscale ACLs, so only permitted usernames will be able to connect. + SSH via the web follows the same ACL rules as regular SSH access in Headscale, so only + permitted usernames will work. +
+
+ See the{" "} + + troubleshooting guide + {" "} + for common errors.
- - + + +
); diff --git a/app/routes/ssh/wasm.client.ts b/app/routes/ssh/wasm.client.ts new file mode 100644 index 0000000..883c145 --- /dev/null +++ b/app/routes/ssh/wasm.client.ts @@ -0,0 +1,64 @@ +const WASM_MODULE_URL = `${__PREFIX__}/hp_ssh.wasm`; + +declare global { + type HeadplaneSSHFactory = (config: HeadplaneSSHConfig) => HeadplaneSSH; + var __hp_ssh_resolve: ((factory: HeadplaneSSHFactory) => void) | undefined; + + var Go: { + new (): { + importObject: WebAssembly.Imports; + run(instance: WebAssembly.Instance): Promise; + argv?: string[]; + env?: Record; + exit?: (code: number) => void; + }; + }; +} + +interface HeadplaneSSHConfig { + controlURL: string; + preAuthKey: string; + hostname: string; + onReady: () => void; + onError?: (message: string) => void; +} + +export interface HeadplaneSSH { + openTunnel(config: TunnelConfig): TunnelSession; +} + +interface TunnelConfig { + ipAddress: string; + username: string; + timeout?: number; + onData: (data: string) => void; + onConnect: () => void; + onDisconnect: () => void; +} + +export interface TunnelSession { + writeInput(data: string): void; + resize(cols: number, rows: number): void; + close(): void; +} + +let resolvedFactory: Promise | null = null; + +/** + * One-shot function that loads the Go WASM binary and returns the SSH factory. + * It expects the Go WASM helper to be loaded, and will error if called before. + */ +export async function loadHeadplaneWASM(): Promise { + if (!resolvedFactory) { + const go = new Go(); + const result = await WebAssembly.instantiateStreaming(fetch(WASM_MODULE_URL), go.importObject); + + resolvedFactory = new Promise((resolve) => { + globalThis.__hp_ssh_resolve = resolve; + }); + + go.run(result.instance); + } + + return resolvedFactory; +} diff --git a/app/routes/ssh/wasm_exec.d.ts b/app/routes/ssh/wasm_exec.d.ts deleted file mode 100644 index 25b56fd..0000000 --- a/app/routes/ssh/wasm_exec.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -declare class Go { - importObject: WebAssembly.Imports; - run(instance: WebAssembly.Instance): Promise; - argv?: string[]; - env?: Record; - exit?: (code: number) => void; -} diff --git a/app/routes/ssh/xterm.client.tsx b/app/routes/ssh/xterm.client.tsx deleted file mode 100644 index e2b79b3..0000000 --- a/app/routes/ssh/xterm.client.tsx +++ /dev/null @@ -1,218 +0,0 @@ -import { ClipboardAddon } from "@xterm/addon-clipboard"; -import { FitAddon } from "@xterm/addon-fit"; -import { Unicode11Addon } from "@xterm/addon-unicode11"; -import { WebLinksAddon } from "@xterm/addon-web-links"; -import * as xterm from "@xterm/xterm"; -import { Loader2 } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; - -import cn from "~/utils/cn"; -import { useLiveData } from "~/utils/live-data"; -import toast from "~/utils/toast"; - -import "@xterm/xterm/css/xterm.css"; - -interface XTermProps { - ipn: TsWasmNet; - username: string; - hostname: string; -} - -// Go's WASM -> JS crosses realms so we might have to normalize the data under -// certain conditions. This also enforces bytes instead of strings being sent. -function normU8(data: unknown) { - if (data instanceof Uint8Array) { - return data; - } - - if (data && typeof data === "object") { - const any = data as { - buffer?: ArrayBufferLike; - byteOffset?: number; - byteLength?: number; - }; - - if (any.buffer instanceof ArrayBuffer && typeof any.byteLength === "number") { - return new Uint8Array( - any.buffer.slice(any.byteOffset ?? 0, (any.byteOffset ?? 0) + any.byteLength), - ); - } - } - - throw new Error("Data is not a Uint8Array or ArrayBuffer-like object"); -} - -export default function XTerm({ ipn, username, hostname }: XTermProps) { - const { pause } = useLiveData(); - - const genRef = useRef(0); - const termRef = useRef(null); - const roRef = useRef(null); - const inputRef = useRef<(input: Uint8Array) => void>(null); - const sshRef = useRef(null); - const divRef = useRef(null); - - const [isResizing, setIsResizing] = useState(false); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - pause(); - }); - - useEffect(() => { - if (!divRef.current) { - return; - } - - const currentGen = ++genRef.current; - const term = new xterm.Terminal({ - allowProposedApi: true, - cursorBlink: true, - convertEol: true, - fontSize: 14, - }); - - const fit = new FitAddon(); - term.loadAddon(fit); - - term.loadAddon(new Unicode11Addon()); - term.loadAddon(new ClipboardAddon()); - term.loadAddon( - new WebLinksAddon((event, uri) => { - event.view?.open(uri, "_blank", "noopener noreferrer"); - }), - ); - - term.unicode.activeVersion = "11"; - termRef.current = term; - term.open(divRef.current!); - fit.fit(); - term.focus(); - - const session = ipn.OpenSSH(hostname, username, { - rows: term.rows, - cols: term.cols, - onStdout: (data) => { - if (currentGen !== genRef.current || term !== termRef.current) { - console.warn("Stale terminal instance, ignoring stdout"); - return; - } - - const text = normU8(data); - term.write(text); - }, - onStderr: (data) => { - if (currentGen !== genRef.current || term !== termRef.current) { - console.warn("Stale terminal instance, ignoring stderr"); - return; - } - - const text = normU8(data); - term.write(text); - const str = new TextDecoder().decode(text); - setError(str); - }, - onStdin: (func) => { - inputRef.current = func; - }, - onConnect: () => { - if (currentGen !== genRef.current) { - console.warn("Stale terminal instance, ignoring onConnect"); - return; - } - - setIsLoading(false); - }, - onDisconnect: () => { - if (currentGen !== genRef.current) { - console.warn("Stale terminal instance, ignoring onDisconnect"); - return; - } - - roRef.current?.disconnect(); - term.dispose(); - termRef.current = null; - inputRef.current = null; - sshRef.current = null; - - setIsLoading(false); - }, - }); - - sshRef.current = session; - const enc = new TextEncoder(); - term.onData((data) => { - if (currentGen !== genRef.current) { - console.warn("Stale terminal instance, ignoring onData"); - return; - } - - const bytes = enc.encode(data); - inputRef.current?.(bytes); - }); - - const ro = new ResizeObserver(() => { - if (currentGen !== genRef.current || term !== termRef.current) { - console.warn("Stale terminal instance, ignoring resize"); - return; - } - - setIsResizing(true); - fit.fit(); - sshRef.current?.Resize(term.cols, term.rows); - setTimeout(() => setIsResizing(false), 100); - }); - - roRef.current = ro; - ro.observe(divRef.current!); - - return () => { - ++genRef.current; - roRef.current?.disconnect(); - roRef.current = null; - - sshRef.current?.Close(); - sshRef.current = null; - - term.dispose(); - if (termRef.current === term) { - termRef.current = null; - } - - inputRef.current = null; - }; - }, [ipn, username, hostname]); - - return ( - <> - {isLoading ? ( -
- -
- ) : undefined} -
- {termRef.current && isResizing ? ( -
- {termRef.current.cols}x{termRef.current.rows} -
- ) : undefined} - {error !== null ? ( -
- Failed to connect to SSH session - {error} -
- ) : undefined} - - ); -} diff --git a/app/routes/users/components/headplane-user-row.tsx b/app/routes/users/components/headplane-user-row.tsx index c75ae5c..b3e686b 100644 --- a/app/routes/users/components/headplane-user-row.tsx +++ b/app/routes/users/components/headplane-user-row.tsx @@ -30,8 +30,8 @@ export default function HeadplaneUserRow({ const displayEmail = user.linkedHeadscaleUser?.email ?? user.email; return ( - - + +
{user.profilePicUrl ? ( {displayName} diff --git a/app/server/db/pruner.ts b/app/server/db/pruner.ts deleted file mode 100644 index 3d44c6a..0000000 --- a/app/server/db/pruner.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { eq, isNotNull } from "drizzle-orm"; - -import { nodesResource } from "~/server/headscale/live-store"; -import log from "~/utils/log"; - -import type { Route } from "../../layout/+types/app"; -import { ephemeralNodes } from "./schema"; - -export async function pruneEphemeralNodes({ context, request }: Route.LoaderArgs) { - const principal = await context.auth.require(request); - const ephemerals = await context.db - .select() - .from(ephemeralNodes) - .where(isNotNull(ephemeralNodes.node_key)); - - if (ephemerals.length === 0) { - log.debug("api", "No ephemeral nodes to prune"); - return; - } - - const apiKey = context.auth.getHeadscaleApiKey(principal); - const api = context.hsApi.getRuntimeClient(apiKey); - const nodes = await api.getNodes(); - const toPrune = nodes.filter((node) => { - if (node.online) { - return false; - } - - return ephemerals.some((ephemeral) => node.nodeKey === ephemeral.node_key); - }); - - if (toPrune.length === 0) { - log.debug("api", "No SSH nodes to prune"); - return; - } - - // Delete from the Headscale nodes list and then from the database - const promises = toPrune.map((node) => { - return async () => { - log.debug("api", `Pruning node ${node.name}`); - await api.deleteNode(node.id); - - await context.db.delete(ephemeralNodes).where(eq(ephemeralNodes.node_key, node.nodeKey)); - log.debug("api", `Node ${node.name} pruned successfully`); - }; - }); - - await Promise.all(promises.map((p) => p())); - - if (toPrune.length > 0) { - await context.hsLive.refresh(nodesResource, api); - } -} diff --git a/app/server/db/schema.ts b/app/server/db/schema.ts index ce8d5fc..452baaa 100644 --- a/app/server/db/schema.ts +++ b/app/server/db/schema.ts @@ -2,14 +2,6 @@ import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; import { HostInfo } from "~/types"; -export const ephemeralNodes = sqliteTable("ephemeral_nodes", { - auth_key: text("auth_key").primaryKey(), - node_key: text("node_key"), -}); - -export type EphemeralNode = typeof ephemeralNodes.$inferSelect; -export type EphemeralNodeInsert = typeof ephemeralNodes.$inferInsert; - export const hostInfo = sqliteTable("host_info", { host_id: text("host_id").primaryKey(), payload: text("payload", { mode: "json" }).$type(), diff --git a/app/server/hp-agent.ts b/app/server/hp-agent.ts index 2099510..fdeedc7 100644 --- a/app/server/hp-agent.ts +++ b/app/server/hp-agent.ts @@ -10,7 +10,7 @@ import { HostInfo } from "~/types"; import log from "~/utils/log"; import { HeadplaneConfig } from "./config/config-schema"; -import { ephemeralNodes, hostInfo } from "./db/schema"; +import { hostInfo } from "./db/schema"; import { RuntimeApiClient } from "./headscale/api/endpoints"; export interface AgentManager { @@ -266,6 +266,10 @@ export async function createAgentManager( } } + /** + * Prunes any offline nodes marked as ephemeral. This is due to a Headscale + * bug where ephemeral nodes wouldn't be automatically removed on disconnect. + */ async function pruneStaleHostInfo() { try { const nodes = await apiClient.getNodes(); @@ -294,23 +298,12 @@ export async function createAgentManager( async function pruneEphemeralNodes() { try { - const rows = await db.select().from(ephemeralNodes); - if (rows.length === 0) { - return; - } - const nodes = await apiClient.getNodes(); - const activeKeys = new Set(nodes.map((n) => n.nodeKey)); + const toPrune = nodes.filter((n) => n.preAuthKey?.ephemeral && !n.online); - for (const row of rows) { - if (!row.node_key) { - continue; - } - - if (!activeKeys.has(row.node_key)) { - await db.delete(ephemeralNodes).where(inArray(ephemeralNodes.auth_key, [row.auth_key])); - log.info("agent", "Pruned ephemeral SSH node %s", row.node_key); - } + for (const node of toPrune) { + await apiClient.deleteNode(node.id); + log.info("agent", "Pruned offline ephemeral node %s", node.givenName); } } catch (error) { log.debug( diff --git a/cmd/hp_ssh/hp_ssh.go b/cmd/hp_ssh/hp_ssh.go index bee7ce1..3fd3ce9 100644 --- a/cmd/hp_ssh/hp_ssh.go +++ b/cmd/hp_ssh/hp_ssh.go @@ -12,96 +12,82 @@ import ( func main() { log.Printf("Loading WASM Headplane SSH module") - js.Global().Set("TsWasmNet", js.FuncOf(func(this js.Value, args []js.Value) any { - if len(args) != 2 { - log.Fatal("Usage: TsWasmNet(config, callbacks)") + + factory := js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) != 1 { + log.Printf("Usage: create(config)") return nil } - options, err := hp_ipn.ParseTsWasmNetOptions(args[0]) + config, err := hp_ipn.ParseIPNConfig(args[0]) if err != nil { - log.Fatal("Error parsing options:", err) + log.Printf("Error parsing config: %v", err) return nil } - callbacks, err := hp_ipn.ParseTsWasmNetCallbacks(args[1]) + callbacks := hp_ipn.ParseIPNCallbacks(args[0]) + + ipn, err := hp_ipn.NewTsWasmIpn(config, callbacks) if err != nil { - log.Fatal("Error parsing callbacks:", err) + callbacks.OnError(err.Error()) return nil } - ipn, err := hp_ipn.NewTsWasmIpn(options, callbacks) - if err != nil { - log.Fatal("Error creating TsWasmIpn:", err) - return nil - } + go func() { + if err := ipn.Start(context.Background()); err != nil { + callbacks.OnError(err.Error()) + } + }() return map[string]any{ - "Start": js.FuncOf(func(this js.Value, args []js.Value) any { - ipn.Start(context.Background()) - return nil - }), - "OpenSSH": js.FuncOf(func(this js.Value, args []js.Value) any { - if len(args) != 3 { - log.Fatal("Usage: OpenSSH(host, user, options)") + "openTunnel": js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) != 1 { + log.Printf("Usage: openTunnel(config)") return nil } - hostname := args[0] - if hostname.IsNull() || hostname.IsUndefined() { - log.Fatal("Hostname must be a non-null, non-undefined string") - return nil - } - - if hostname.Type() != js.TypeString { - log.Fatal("Hostname must be a string") - return nil - } - - username := args[1] - if username.IsNull() || username.IsUndefined() { - log.Fatal("Username must be a non-null, non-undefined string") - return nil - } - - if username.Type() != js.TypeString { - log.Fatal("Username must be a string") - return nil - } - - sshOptions, err := hp_ipn.ParseSSHXtermConfig(args[2]) + tunnelConfig, err := hp_ipn.ParseTunnelConfig(args[0]) if err != nil { - log.Fatal("Error parsing SSH options:", err) + log.Printf("Error parsing tunnel config: %v", err) return nil } - session := ipn.NewSSHSession(hostname.String(), username.String(), sshOptions) + session := ipn.NewSSHSession(tunnelConfig) go session.ConnectAndRun() return map[string]any{ - "Close": js.FuncOf(func(this js.Value, args []js.Value) any { - return session.Close() != nil + "writeInput": js.FuncOf(func(this js.Value, args []js.Value) any { + if len(args) == 1 { + session.WriteInput(args[0].String()) + } + return nil }), - "Resize": js.FuncOf(func(this js.Value, args []js.Value) any { + "resize": js.FuncOf(func(this js.Value, args []js.Value) any { if len(args) != 2 { - log.Fatal("Usage: Resize(cols, rows)") return nil } + session.Resize(args[0].Int(), args[1].Int()) + return nil + }), - rows := args[0].Int() - cols := args[1].Int() - if cols <= 0 || rows <= 0 { - log.Fatal("Columns and rows must be positive integers") - return nil - } - - return session.Resize(cols, rows) == nil + "close": js.FuncOf(func(this js.Value, args []js.Value) any { + session.Close() + return nil }), } }), } - })) + }) + + resolve := js.Global().Get("__hp_ssh_resolve") + if resolve.Type() != js.TypeFunction { + log.Printf("__hp_ssh_resolve is not set, cannot initialize") + return + } + + resolve.Invoke(factory) + js.Global().Delete("__hp_ssh_resolve") log.Printf("WASM Headplane SSH module loaded successfully") <-make(chan bool) diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index c00456a..90a5a77 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -46,7 +46,7 @@ export default defineConfig({ items: [ { text: "Single Sign-On (SSO)", link: "/features/sso" }, { text: "Headplane Agent", link: "/features/agent" }, - { text: "WebSSH", link: "/features/ssh" }, + { text: "Browser SSH", link: "/features/ssh" }, ], }, { diff --git a/docs/assets/ssh-btop.png b/docs/assets/ssh-btop.png new file mode 100644 index 0000000..fa98367 Binary files /dev/null and b/docs/assets/ssh-btop.png differ diff --git a/docs/assets/ssh-fastfetch.png b/docs/assets/ssh-fastfetch.png new file mode 100644 index 0000000..268a511 Binary files /dev/null and b/docs/assets/ssh-fastfetch.png differ diff --git a/docs/assets/ssh.png b/docs/assets/ssh.png deleted file mode 100644 index 3ca28f7..0000000 Binary files a/docs/assets/ssh.png and /dev/null differ diff --git a/docs/features/ssh.md b/docs/features/ssh.md index a3ff5bf..1146896 100644 --- a/docs/features/ssh.md +++ b/docs/features/ssh.md @@ -1,73 +1,227 @@ --- -title: WebSSH +title: Browser SSH description: Open SSH sessions to your Tailnet nodes directly from the browser. --- -# WebSSH +# Browser SSH
- -
SSH access via the browser
+ +
btop running over browser SSH
-WebSSH lets you open an SSH session to any node directly from the browser. It -uses a Go-based WASM shim that creates an ephemeral Tailscale node in the -browser and connects to the target node over the Tailnet. +Browser SSH allows a user to open an SSH session to any accesible node in the +Tailnet directly from the browser. It spins up an ephemeral Tailscale node that +joins the tailnet for the duration of the SSH session. + +
+ +
fastfetch with Nerd Font icons
+
## Prerequisites - **Headscale 0.28 or newer** is required. -- Target nodes must have **Tailscale SSH** enabled. -- Users must be authenticated via **OIDC** (API key logins cannot use WebSSH). -- The **Headplane Agent** must be - [enabled and configured](/features/agent) so that ephemeral node cleanup - works correctly. -- The WASM assets (`hp_ssh.wasm` and `wasm_exec.js`) must be present in the - build. These are built automatically by `./build.sh --wasm`. +- Target nodes must have **Tailscale SSH** enabled (`tailscale up --ssh`). +- Users must be logged-in via **OIDC** (API key logins cannot use browser SSH). +- The **Headplane Agent** must be [enabled and configured](/features/agent). ## How It Works +:::tip +While we use Ghostty (via [restty](https://restty.dev)) to render the terminal, +the SSH connection is opened with a `TERM` value of `xterm-256color` for maximum +compatibility. Nerd Font glyphs are supported out of the box — the terminal +ships with a self-hosted JetBrains Mono Nerd Font. +::: + When a user opens an SSH session from the UI, the browser: -1. Loads a Go WASM binary that implements a minimal Tailscale node. -2. Authenticates to the Tailnet using an ephemeral pre-auth key generated - server-side. -3. Connects to the target node over the Tailnet using DERP relay servers. -4. Opens an SSH session and renders it in an [xterm.js](https://xtermjs.org) - terminal. +1. Loads a WASM module that runs a minimal Tailscale node using userspace + WireGuard. This node will connect to the tailnet via a pre-auth key. +2. Opens an SSH session to the target node's Tailscale IP address over the + tunnel and passes it to the browser. +3. Using [restty](https://restty.dev) (a Ghostty-based WASM terminal emulator), + the browser renders a full-featured terminal and proxies the SSH session + to it. -The ephemeral node is automatically cleaned up after the session ends. +## Reverse Proxy Configuration -## DERP Servers on Non-Standard Ports +Browser SSH requires that the browser can reach both **Headplane** and +**Headscale** directly. If either is behind a reverse proxy, the proxy must be +configured to support WebSocket connections — this is how the WASM node +communicates with DERP relay servers. -In the browser, Tailscale connects to DERP relay servers via WebSockets. If -your DERP servers run on a non-standard port (e.g. `:8443` instead of `:443`), -Headplane includes a patch to Tailscale's DERP client that preserves the port -in WebSocket URLs. This patch is applied automatically during the WASM build. +### Required Headers -::: warning -If you are building the WASM module manually (outside of `build.sh`), make sure -to apply `patches/tailscale-derp-port.patch` to the vendored Tailscale source -before compiling. See the `build_wasm()` function in `build.sh` for reference. -::: +Your reverse proxy must forward these headers for Headscale's DERP endpoint: + +| Header | Value | +| ------------------------ | --------------------------------------- | +| `Upgrade` | `websocket` | +| `Connection` | `Upgrade` | +| `Sec-WebSocket-Protocol` | forwarded as-is (Tailscale uses `derp`) | + +### CORS Headers + +Headscale must be accessible from the origin where Headplane is served. If +Headplane and Headscale are on different origins (different hosts or ports), +your reverse proxy must add CORS headers to Headscale responses: + +| Header | Value | +| ------------------------------ | ----------------------------------------------- | +| `Access-Control-Allow-Origin` | The origin of your Headplane instance | +| `Access-Control-Allow-Methods` | `GET, POST, OPTIONS` | +| `Access-Control-Allow-Headers` | `Content-Type, Upgrade, Sec-WebSocket-Protocol` | + +### Example: Caddy + +```caddyfile +# Headscale +hs.example.com { + reverse_proxy localhost:8080 + + # If Headplane is on a different origin: + header Access-Control-Allow-Origin "https://headplane.example.com" + header Access-Control-Allow-Methods "GET, POST, OPTIONS" + header Access-Control-Allow-Headers "Content-Type, Upgrade, Sec-WebSocket-Protocol" +} +``` + +Caddy handles WebSocket upgrades automatically — no extra configuration needed. + +### Example: nginx + +```nginx +# Headscale +server { + listen 443 ssl; + server_name hs.example.com; + + location / { + proxy_pass http://localhost:8080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + + # If Headplane is on a different origin: + add_header Access-Control-Allow-Origin "https://headplane.example.com" always; + add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always; + add_header Access-Control-Allow-Headers "Content-Type, Upgrade, Sec-WebSocket-Protocol" always; + } +} +``` + +### Same-Origin Setup + +If Headplane and Headscale share the same origin (e.g. a single reverse proxy +routing `/admin` to Headplane and everything else to Headscale), CORS headers +are not needed. WebSocket upgrade forwarding is still required. ## Troubleshooting -### "WebSSH is not configured in this build" +### SSH Not Available -The WASM assets are missing. Rebuild with `./build.sh --wasm` or ensure your -Docker image was built with the `--wasm` flag. +**Error:** "This version of Headplane was not built with browser SSH support." -### "Only OAuth users are allowed to use WebSSH" +The WASM assets (`hp_ssh.wasm` and `wasm_exec.js`) are missing. Rebuild with +`./build.sh --wasm` or ensure your Docker image was built with the `--wasm` +flag. -WebSSH requires OIDC authentication to generate pre-auth keys tied to a +### Agent Required + +**Error:** "Browser SSH is only available when the Headplane agent integration +is enabled." + +The Headplane Agent is not enabled. Browser SSH depends on the agent for +Tailnet connectivity and ephemeral node cleanup. See the +[Agent documentation](/features/agent) for setup instructions. + +### OIDC Required + +**Error:** "Browser SSH is only available when OIDC authentication is enabled." + +Browser SSH requires OIDC authentication to generate pre-auth keys tied to a Headscale user. API key logins do not have an associated Headscale user -identity. +identity. Log in via your configured OIDC provider instead. -### Connection hangs or fails to reach the node +### User Not Linked -- Verify that the target node has Tailscale SSH enabled. -- If using custom DERP servers on non-standard ports, ensure you are running - a build that includes the DERP port patch (any build from `build.sh` or - Docker includes it automatically). +**Error:** "You'll need to link your user account to a Headscale user before +you can use Browser SSH." + +Your OIDC account does not match any user in Headscale. You must authenticate +with Headscale at least once before using Browser SSH, so that a Headscale +user is created and linked to your OIDC identity. + +### Node Not Found + +**Error:** "No node found with hostname ..." + +The node name in the URL does not match any node registered in Headscale. The +node may have been renamed or removed. Navigate back to the machines list and +try again. + +### Node Offline + +Headplane checks whether the target node is connected to the Tailnet before +attempting an SSH session. If the node is offline, you'll see an error page +with a **Retry Connection** button. Ensure the node is running and connected +to Headscale, then retry. + +### Connection fails with EOF or hangs + +- **Check `server_url` in your Headscale config.** If Headscale runs on a + non-standard port, `server_url` must include it (e.g. + `https://hs.example.com:8443`). The embedded DERP server derives its + advertised port from this value. Do **not** put the DERP port in Headplane's + `headscale.public_url` — that setting is used for display in the UI and + changing it will break registration commands and auth key instructions. +- **Verify reverse proxy WebSocket support.** The proxy in front of Headscale + must forward `Upgrade: websocket` headers. Without this, the DERP connection + will fail immediately. +- **Check CORS if on different origins.** Open the browser console and look for + CORS errors. If Headplane and Headscale are on different origins, CORS headers + must be configured on Headscale's proxy. +- Verify that the target node has Tailscale SSH enabled (`tailscale up --ssh`). - Check the browser console for WASM errors or DERP connection failures. + +### "failed to look up local user \*" + +This error appears in the terminal when Headscale SSH ACLs use +`"users": ["*"]`, which some Tailscale versions interpret as a literal +username rather than a wildcard. To fix this, change your ACL SSH rules to +use `"autogroup:nonroot"` or explicit usernames instead: + +```jsonc +// Before (broken on some versions) +{ "action": "accept", "src": ["autogroup:member"], "dst": ["autogroup:self"], "users": ["*"] } + +// After (recommended) +{ "action": "accept", "src": ["autogroup:member"], "dst": ["autogroup:self"], "users": ["autogroup:nonroot"] } +``` + +### Terminal opens but input doesn't work + +- Ensure the target node's Tailscale SSH ACLs permit the user. The SSH + connection succeeds at the transport level but the session may be rejected + by the node's SSH policy. +- Check that the username you entered is a valid Linux user on the target + node. If the user doesn't exist, the SSH session will appear to connect + but immediately fail. + +### "SSH error: ssh: handshake failed: ssh: no common algorithm" + +The target node's SSH server doesn't support any of the algorithms offered +by the Go SSH client. This usually means the target node is running a very +old or very new version of OpenSSH with non-default algorithm configuration. +Updating Tailscale on the target node typically resolves this. + +### SSH session connects but immediately disconnects + +- The target node may not have an SSH server installed or running. Tailscale + SSH (`tailscale up --ssh`) runs its own SSH server, but if Tailscale SSH + is not enabled, the node needs a standard SSH server (e.g. `openssh-server`) + listening on port 22. +- The node may have a firewall blocking port 22 even for Tailnet connections. diff --git a/drizzle/20260408181638_parallel_synch/migration.sql b/drizzle/20260408181638_parallel_synch/migration.sql new file mode 100644 index 0000000..c1698c0 --- /dev/null +++ b/drizzle/20260408181638_parallel_synch/migration.sql @@ -0,0 +1 @@ +DROP TABLE `ephemeral_nodes`; \ No newline at end of file diff --git a/drizzle/20260408181638_parallel_synch/snapshot.json b/drizzle/20260408181638_parallel_synch/snapshot.json new file mode 100644 index 0000000..842c0f5 --- /dev/null +++ b/drizzle/20260408181638_parallel_synch/snapshot.json @@ -0,0 +1,266 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "dd3ce0c0-106f-4628-8c36-bdf9747e5f41", + "prevIds": ["b0533d83-6eb6-422f-925f-c59e4db89384"], + "ddl": [ + { + "name": "auth_sessions", + "entityType": "tables" + }, + { + "name": "host_info", + "entityType": "tables" + }, + { + "name": "users", + "entityType": "tables" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "auth_sessions" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "kind", + "entityType": "columns", + "table": "auth_sessions" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "user_id", + "entityType": "columns", + "table": "auth_sessions" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "api_key_hash", + "entityType": "columns", + "table": "auth_sessions" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "api_key_display", + "entityType": "columns", + "table": "auth_sessions" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "expires_at", + "entityType": "columns", + "table": "auth_sessions" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "auth_sessions" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "host_id", + "entityType": "columns", + "table": "host_info" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "payload", + "entityType": "columns", + "table": "host_info" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "host_info" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "id", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "sub", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "picture", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": "'member'", + "generated": null, + "name": "role", + "entityType": "columns", + "table": "users" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "headscale_user_id", + "entityType": "columns", + "table": "users" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "users" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "updated_at", + "entityType": "columns", + "table": "users" + }, + { + "type": "integer", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "last_login_at", + "entityType": "columns", + "table": "users" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": "0", + "generated": null, + "name": "caps", + "entityType": "columns", + "table": "users" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "auth_sessions_pk", + "table": "auth_sessions", + "entityType": "pks" + }, + { + "columns": ["host_id"], + "nameExplicit": false, + "name": "host_info_pk", + "table": "host_info", + "entityType": "pks" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "users_pk", + "table": "users", + "entityType": "pks" + }, + { + "columns": ["sub"], + "nameExplicit": false, + "name": "users_sub_unique", + "entityType": "uniques", + "table": "users" + }, + { + "columns": ["headscale_user_id"], + "nameExplicit": false, + "name": "users_headscale_user_id_unique", + "entityType": "uniques", + "table": "users" + } + ], + "renames": [] +} diff --git a/internal/hp_ipn/ipnserver.go b/internal/hp_ipn/ipnserver.go index d299b70..7c378d8 100644 --- a/internal/hp_ipn/ipnserver.go +++ b/internal/hp_ipn/ipnserver.go @@ -14,7 +14,6 @@ import ( "tailscale.com/ipn/ipnlocal" "tailscale.com/ipn/ipnserver" "tailscale.com/ipn/store/mem" - // "tailscale.com/net/netmon" "tailscale.com/net/netns" "tailscale.com/net/tsdial" "tailscale.com/safesocket" @@ -24,102 +23,74 @@ import ( "tailscale.com/wgengine/netstack" ) -// Represents an in-state Tailscale backend that is WASM friendly. -// The bare minimum to have userspace Wireguard networking is a dialer, -// a server, and a backend. type TsWasmIpn struct { - // The options used to initialize the TsWasmNet module. - options *TsWasmNetOptions - - // The Tailscale dialer, which is used to establish connections. - dialer *tsdial.Dialer - - // The Tailscale server, which handles incoming connections and requests. - server *ipnserver.Server - - // The Tailscale backend, which manages the local state and operations. + options *IPNConfig + dialer *tsdial.Dialer + server *ipnserver.Server backend *ipnlocal.LocalBackend } -// NewTsWasmIpn initializes a new TsWasmIpn instance with the provided options. -// This intentionally does not initialize Logtail, as it is only available in -// the Tailscale SaaS and not on self-hosted instances. -func NewTsWasmIpn(options *TsWasmNetOptions, callbacks *TsWasmNetCallbacks) (*TsWasmIpn, error) { - logf := log.Printf // TODO: Update +func NewTsWasmIpn(options *IPNConfig, callbacks *IPNCallbacks) (*TsWasmIpn, error) { + logf := log.Printf + netns.SetEnabled(false) - netns.SetEnabled(false) // netns is a separate process (not WASM friendly) - - // Base system (NewSystem() creates a bus automatically) - // We supply an in-memory store sys := tsd.NewSystem() - // bus := sys.Bus.Get() sys.Set(new(mem.Store)) dialer := &tsdial.Dialer{Logf: logf} - // netmon, err := netmon.New(bus, logf) - // if err != nil { - // return nil, err - // } - // Userspace Wireguard engine engine, err := wgengine.NewUserspaceEngine(logf, wgengine.Config{ - Dialer: dialer, - // NetMon: netmon, + Dialer: dialer, SetSubsystem: sys.Set, ControlKnobs: sys.ControlKnobs(), HealthTracker: sys.HealthTracker(), Metrics: sys.UserMetricsRegistry(), EventBus: sys.Bus.Get(), }) - if err != nil { - return nil, err + return nil, fmt.Errorf("failed to create userspace engine: %w", err) } sys.Set(engine) + tun := sys.Tun.Get() msock := sys.MagicSock.Get() dnsman := sys.DNSManager.Get() proxymap := sys.ProxyMapper() - wgstack, err := netstack.Create(logf, tun, engine, msock, dialer, dnsman, proxymap) - sys.Set(wgstack) + wgstack, err := netstack.Create(logf, tun, engine, msock, dialer, dnsman, proxymap) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to create netstack: %w", err) } - // Configure the local Netstack and Dialer + sys.Set(wgstack) + wgstack.ProcessLocalIPs = true wgstack.ProcessSubnets = true dialer.UseNetstackForIP = func(ip netip.Addr) bool { return true } - dialer.NetstackDialTCP = func(ctx context.Context, dst netip.AddrPort) (net.Conn, error) { return wgstack.DialContextTCP(ctx, dst) } - dialer.NetstackDialUDP = func(ctx context.Context, dst netip.AddrPort) (net.Conn, error) { return wgstack.DialContextUDP(ctx, dst) } - // Dummy logid for the Tailscale backend - logid := logid.PublicID{} + logID := logid.PublicID{} sys.NetstackRouter.Set(true) sys.Tun.Get().Start() - server := ipnserver.New(logf, logid, sys.NetMon.Get()) - flags := controlclient.LoginDefault | controlclient.LoginEphemeral | controlclient.LocalBackendStartKeyOSNeutral + server := ipnserver.New(logf, logID, sys.NetMon.Get()) - backend, err := ipnlocal.NewLocalBackend(logf, logid, sys, flags) + backend, err := ipnlocal.NewLocalBackend(logf, logID, sys, controlclient.LoginEphemeral) if err != nil { - return nil, err + return nil, fmt.Errorf("failed to create local backend: %w", err) } - err = wgstack.Start(backend) - if err != nil { - return nil, err + if err := wgstack.Start(backend); err != nil { + return nil, fmt.Errorf("failed to start netstack: %w", err) } server.SetLocalBackend(backend) @@ -133,26 +104,18 @@ func NewTsWasmIpn(options *TsWasmNetOptions, callbacks *TsWasmNetCallbacks) (*Ts }, nil } -// Starts the WASM backend which will connect to the Tailscale tailnet and -// register an ephemeral node viewable in the Tailscale admin console. func (t *TsWasmIpn) Start(ctx context.Context) error { - // Blank "socket" is a requirement for WASM - // This NEEDS to happen before the LocalBackend is started, listener, err := safesocket.Listen("") if err != nil { return fmt.Errorf("failed to create safesocket listener: %w", err) } - // Start the server BEFORE the LocalBackend is started go func() { - err := t.server.Run(ctx, listener) - if err != nil { - // TODO: Handle this dispatch using a chan - log.Printf("Failed to run Tailscale server: %v", err) + if err := t.server.Run(ctx, listener); err != nil { + log.Printf("Tailscale server exited: %v", err) } }() - // Start the LocalBackend err = t.backend.Start(ipn.Options{ AuthKey: t.options.PreAuthKey, UpdatePrefs: &ipn.Prefs{ @@ -163,11 +126,9 @@ func (t *TsWasmIpn) Start(ctx context.Context) error { LoggedOut: false, }, }) - if err != nil { return fmt.Errorf("failed to start Tailscale backend: %w", err) } - log.Printf("Tailscale backend started successfully with hostname: %s", t.options.Hostname) return nil } diff --git a/internal/hp_ipn/jsfunc.go b/internal/hp_ipn/jsfunc.go index 0b63185..691b903 100644 --- a/internal/hp_ipn/jsfunc.go +++ b/internal/hp_ipn/jsfunc.go @@ -3,15 +3,35 @@ package hp_ipn import ( - "errors" - "fmt" "syscall/js" "tailscale.com/ipn" - "tailscale.com/types/netmap" ) -// Maps ipn.State values to their string representations for the frontend. +type IPNCallbacks struct { + OnReady func() + OnError func(string) +} + +func ParseIPNCallbacks(obj js.Value) *IPNCallbacks { + cb := &IPNCallbacks{ + OnReady: func() {}, + OnError: func(string) {}, + } + + onReady := obj.Get("onReady") + if onReady.Type() == js.TypeFunction { + cb.OnReady = func() { onReady.Invoke() } + } + + onError := obj.Get("onError") + if onError.Type() == js.TypeFunction { + cb.OnError = func(msg string) { onError.Invoke(msg) } + } + + return cb +} + var BackendState = map[ipn.State]string{ ipn.NoState: "NoState", ipn.Stopped: "Stopped", @@ -21,86 +41,3 @@ var BackendState = map[ipn.State]string{ ipn.NeedsMachineAuth: "NeedsMachineAuth", ipn.NeedsLogin: "NeedsLogin", } - -// Represents the callbacks that the TsWasmNet module can invoke to register -// data retrieval and notifications on the frontend. -type TsWasmNetCallbacks struct { - // Changes in the backend state. - NotifyState func(ipn.State) - - // Updates to the backend's network map. - NotifyNetMap func(*netmap.NetworkMap) - - // If interactive login is required, this passes a login URL. - NotifyBrowseToURL func(string) - - // If the process panics, this function is called in go.recover. - NotifyPanicRecover func(string) -} - -// Parses a JavaScript object containing the necessary callbacks for the -// TsWasmNet module to properly interact with the frontend. -func ParseTsWasmNetCallbacks(obj js.Value) (*TsWasmNetCallbacks, error) { - if obj.IsUndefined() || obj.IsNull() { - return nil, errors.New("callbacks object is undefined or null") - } - - state, err := validateCallback("NotifyState", obj) - if err != nil { - return nil, fmt.Errorf("invalid callback NotifyState: %w", err) - } - - // TODO: This is complicated, as the NetworkMap is a complex type. - _, err = validateCallback("NotifyNetMap", obj) - if err != nil { - return nil, fmt.Errorf("invalid callback NotifyNetMap: %w", err) - } - - browseURL, err := validateCallback("NotifyBrowseToURL", obj) - if err != nil { - return nil, fmt.Errorf("invalid callback NotifyBrowseToURL: %w", err) - } - - panicRecover, err := validateCallback("NotifyPanicRecover", obj) - if err != nil { - return nil, fmt.Errorf("invalid callback NotifyPanicRecover: %w", err) - } - - return &TsWasmNetCallbacks{ - NotifyState: func(ipnState ipn.State) { - state.Invoke(BackendState[ipnState]) - }, - - NotifyNetMap: func(nm *netmap.NetworkMap) { - // We need to build a JSON representation of the NetworkMap - // For now we just pass the NodeKey since that's what we need. - jsObj := js.ValueOf(map[string]any{ - "NodeKey": nm.NodeKey.String(), - }) - - obj.Get("NotifyNetMap").Invoke(jsObj) - }, - - NotifyBrowseToURL: func(url string) { - browseURL.Invoke(url) - }, - - NotifyPanicRecover: func(msg string) { - panicRecover.Invoke(msg) - }, - }, nil -} - -// Validates the specified key is a JS function and returns it. -func validateCallback(key string, obj js.Value) (*js.Value, error) { - val := obj.Get(key) - if val.IsUndefined() || val.IsNull() { - return nil, errors.New("callback is undefined or null") - } - - if val.Type() != js.TypeFunction { - return nil, errors.New("callback is not a function") - } - - return &val, nil -} diff --git a/internal/hp_ipn/jsmap.go b/internal/hp_ipn/jsmap.go index 3800873..9914332 100644 --- a/internal/hp_ipn/jsmap.go +++ b/internal/hp_ipn/jsmap.go @@ -7,114 +7,83 @@ import ( "syscall/js" ) -// Represents the options needed to initialize the TsWasmNet module. -type TsWasmNetOptions struct { - // Tailscale control URL, e.g., "https://controlplane.tailscale.com" +type IPNConfig struct { ControlURL string - - // Pre-authentication key for the Tailnet PreAuthKey string - - // Optional hostname, autogenerated if not provided. - Hostname string + Hostname string } -// Parses the provided JS object to validate and extract the TsWasmNetOptions. -func ParseTsWasmNetOptions(obj js.Value) (*TsWasmNetOptions, error) { +func ParseIPNConfig(obj js.Value) (*IPNConfig, error) { if obj.IsUndefined() || obj.IsNull() { - return nil, errors.New("TsWasmNetOptions cannot be undefined or null") + return nil, errors.New("config cannot be undefined or null") } - cUrl := safeString("ControlURL", obj) - preAuthKey := safeString("PreAuthKey", obj) - hostname := safeString("Hostname", obj) + controlURL := safeString("controlURL", obj) + preAuthKey := safeString("preAuthKey", obj) + hostname := safeString("hostname", obj) - if cUrl == "" || preAuthKey == "" || hostname == "" { - return nil, errors.New("missing required fields in TsWasmNetOptions") + if controlURL == "" || preAuthKey == "" || hostname == "" { + return nil, errors.New("missing required fields: controlURL, preAuthKey, hostname") } - return &TsWasmNetOptions{ - ControlURL: cUrl, + return &IPNConfig{ + ControlURL: controlURL, PreAuthKey: preAuthKey, Hostname: hostname, }, nil } -// Options passed from the JS side to pass data to xterm.js. -type SSHXtermConfig struct { - Timeout int // Timeout in seconds for the PTY connection. - Rows int // Number of rows in the PTY. - Cols int // Number of columns in the PTY. - OnStdout func(data js.Value) // Fires when the PTY has output. - OnStderr func(error js.Value) // Fires when the PTY has an error. - OnStdin js.Value // Passes a function to the JS side to provide input. - OnConnect func() // Fires when the PTY is opened. - OnDisconnect func() // Fires when the PTY is closed. +type TunnelConfig struct { + IPAddress string + Username string + Timeout int + OnData func(data string) + OnConnect func() + OnDisconnect func() } -// Parses the provided JS object to validate and extract SSHXtermConfig. -func ParseSSHXtermConfig(obj js.Value) (*SSHXtermConfig, error) { +func ParseTunnelConfig(obj js.Value) (*TunnelConfig, error) { if obj.IsUndefined() || obj.IsNull() { - return nil, errors.New("SSHXtermConfig cannot be undefined or null") + return nil, errors.New("tunnel config cannot be undefined or null") + } + + ipAddress := safeString("ipAddress", obj) + username := safeString("username", obj) + if ipAddress == "" || username == "" { + return nil, errors.New("missing required fields: ipAddress, username") } timeout := safeInt("timeout", obj) - rows := safeInt("rows", obj) - cols := safeInt("cols", obj) - - if rows <= 0 || cols <= 0 { - return nil, errors.New("`rows` and `cols` must be positive integers") - } - if timeout <= 0 { - timeout = 30 // Default timeout to 30 seconds if not specified + timeout = 30 } - config := &SSHXtermConfig{ - Timeout: timeout, - Rows: rows, - Cols: cols, + config := &TunnelConfig{ + IPAddress: ipAddress, + Username: username, + Timeout: timeout, } - onStdout := obj.Get("onStdout") - if onStdout.IsUndefined() || onStdout.IsNull() || (onStdout.Type() != js.TypeFunction) { - return nil, errors.New("`onStdout` is required and must be a function") + onData := obj.Get("onData") + if onData.IsUndefined() || onData.IsNull() || onData.Type() != js.TypeFunction { + return nil, errors.New("`onData` is required and must be a function") } - - config.OnStdout = func(data js.Value) { - onStdout.Invoke(data) + config.OnData = func(data string) { + onData.Invoke(data) } - onStderr := obj.Get("onStderr") - if onStderr.IsUndefined() || onStderr.IsNull() || (onStderr.Type() != js.TypeFunction) { - return nil, errors.New("`onStderr` is required and must be a function") - } - - config.OnStderr = func(error js.Value) { - onStderr.Invoke(error) - } - - onStdin := obj.Get("onStdin") - if onStdin.IsUndefined() || onStdin.IsNull() || (onStdin.Type() != js.TypeFunction) { - return nil, errors.New("`onStdin` is required and must be a function") - } - - config.OnStdin = onStdin - onConnect := obj.Get("onConnect") - if onConnect.IsUndefined() || onConnect.IsNull() || (onConnect.Type() != js.TypeFunction) { + if onConnect.IsUndefined() || onConnect.IsNull() || onConnect.Type() != js.TypeFunction { return nil, errors.New("`onConnect` is required and must be a function") } - config.OnConnect = func() { onConnect.Invoke() } onDisconnect := obj.Get("onDisconnect") - if onDisconnect.IsUndefined() || onDisconnect.IsNull() || (onDisconnect.Type() != js.TypeFunction) { + if onDisconnect.IsUndefined() || onDisconnect.IsNull() || onDisconnect.Type() != js.TypeFunction { return nil, errors.New("`onDisconnect` is required and must be a function") } - config.OnDisconnect = func() { onDisconnect.Invoke() } @@ -122,7 +91,6 @@ func ParseSSHXtermConfig(obj js.Value) (*SSHXtermConfig, error) { return config, nil } -// Retrieves a string value from a JS object safely. func safeString(key string, obj js.Value) string { if obj.IsUndefined() || obj.IsNull() { return "" @@ -136,7 +104,6 @@ func safeString(key string, obj js.Value) string { return val.String() } -// Retrieves an integer value from a JS object safely. func safeInt(key string, obj js.Value) int { if obj.IsUndefined() || obj.IsNull() { return 0 diff --git a/internal/hp_ipn/notify.go b/internal/hp_ipn/notify.go index c236654..7121d9f 100644 --- a/internal/hp_ipn/notify.go +++ b/internal/hp_ipn/notify.go @@ -6,94 +6,36 @@ import ( "context" "fmt" "log" + "sync" "tailscale.com/ipn" "tailscale.com/ipn/ipnlocal" ) -func registerNotifyCallback(callbacks *TsWasmNetCallbacks, lb *ipnlocal.LocalBackend) { - lb.SetNotifyCallback(func(n ipn.Notify) { - // Panics should be treated with care in a JS/wasm environment. - // If a panic occurs, notify the user and either automatically reload - // or give the option to reload. +func registerNotifyCallback(callbacks *IPNCallbacks, lb *ipnlocal.LocalBackend) { + var readyOnce sync.Once + lb.SetNotifyCallback(func(n ipn.Notify) { defer func() { - rec := recover() - if rec != nil { - callbacks.NotifyPanicRecover(fmt.Sprint(rec)) + if rec := recover(); rec != nil { + callbacks.OnError(fmt.Sprint(rec)) } }() if n.State != nil { - callbacks.NotifyState(*n.State) + if *n.State == ipn.Running { + readyOnce.Do(callbacks.OnReady) + } if *n.State == ipn.NeedsLogin { - // If the state is NeedsLogin, we need to force an interactive login. go forceInteractiveLogin(lb) } } - - if n.BrowseToURL != nil { - callbacks.NotifyBrowseToURL(*n.BrowseToURL) - } - - if n.NetMap != nil { - callbacks.NotifyNetMap(n.NetMap) - } - - log.Printf("NOTIFY: %+v", n) - - // if nm := n.NetMap; nm != nil { - // jsNetMap := jsNetMap{ - // Self: jsNetMapSelfNode{ - // jsNetMapNode: jsNetMapNode{ - // Name: nm.Name, - // Addresses: mapSliceView(nm.GetAddresses(), func(a netip.Prefix) string { return a.Addr().String() }), - // NodeKey: nm.NodeKey.String(), - // MachineKey: nm.MachineKey.String(), - // }, - // MachineStatus: jsMachineStatus[nm.GetMachineStatus()], - // }, - // Peers: mapSlice(nm.Peers, func(p tailcfg.NodeView) jsNetMapPeerNode { - // name := p.Name() - // if name == "" { - // // In practice this should only happen for Hello. - // name = p.Hostinfo().Hostname() - // } - // addrs := make([]string, p.Addresses().Len()) - // for i, ap := range p.Addresses().All() { - // addrs[i] = ap.Addr().String() - // } - // return jsNetMapPeerNode{ - // jsNetMapNode: jsNetMapNode{ - // Name: name, - // Addresses: addrs, - // MachineKey: p.Machine().String(), - // NodeKey: p.Key().String(), - // }, - // Online: p.Online().Clone(), - // TailscaleSSHEnabled: p.Hostinfo().TailscaleSSHEnabled(), - // } - // }), - // LockedOut: nm.TKAEnabled && nm.SelfNode.KeySignature().Len() == 0, - // } - // if jsonNetMap, err := json.Marshal(jsNetMap); err == nil { - // jsCallbacks.Call("notifyNetMap", string(jsonNetMap)) - // } else { - // log.Printf("Could not generate JSON netmap: %v", err) - // } - // } - // if n.BrowseToURL != nil { - // jsCallbacks.Call("notifyBrowseToURL", *n.BrowseToURL) - // } }) } -// To get auth to work, even with a pre-auth key, we need to -// force an interactive login on the NeedsLogin state. func forceInteractiveLogin(lb *ipnlocal.LocalBackend) { - err := lb.StartLoginInteractive(context.Background()) - if err != nil { - fmt.Printf("Error starting interactive login: %v\n", err) + if err := lb.StartLoginInteractive(context.Background()); err != nil { + log.Printf("Error starting interactive login: %v", err) } } diff --git a/internal/hp_ipn/ssh.go b/internal/hp_ipn/ssh.go index 7fc5e74..e1caf2f 100644 --- a/internal/hp_ipn/ssh.go +++ b/internal/hp_ipn/ssh.go @@ -8,83 +8,71 @@ import ( "io" "log" "net" - "syscall/js" "time" "golang.org/x/crypto/ssh" ) -// Represents an SSH session over the Tailnet. type SSHSession struct { - // Hostname on the Tailnet. - Hostname string + IPAddress string + Username string + Config *TunnelConfig + Ipn *TsWasmIpn + Pty *ssh.Session - // Username for the SSH connection. - Username string - - // Xterm configuration for the SSH session. - TermConfig *SSHXtermConfig - - // Handle to the current IPN connection. - Ipn *TsWasmIpn - - // Handle to the current SSH session. - Pty *ssh.Session - - // Tracks resize notifications for rows. - ResizeRows int - - // Tracks resize notifications for columns. - ResizeCols int - - // Reference to our stdin handler, released on close. - stdinHandler *js.Func + stdin io.Writer + resizeCols int + resizeRows int + cancel context.CancelFunc } -// Creates a new SSH session given a hostname and username. -func (i *TsWasmIpn) NewSSHSession(hostname, username string, termConfig *SSHXtermConfig) *SSHSession { +func (i *TsWasmIpn) NewSSHSession(config *TunnelConfig) *SSHSession { return &SSHSession{ - Hostname: hostname, - Username: username, - TermConfig: termConfig, - Ipn: i, + IPAddress: config.IPAddress, + Username: config.Username, + Config: config, + Ipn: i, } } func (s *SSHSession) ConnectAndRun() { - defer s.TermConfig.OnDisconnect() + defer s.Config.OnDisconnect() - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(s.TermConfig.Timeout)*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(s.Config.Timeout)*time.Second) + s.cancel = cancel defer cancel() - // TODO: Log here - log.Printf("Attempting SSH dial to host: %s", net.JoinHostPort(s.Hostname, "22")) - conn, err := s.Ipn.dialer.UserDial(ctx, "tcp", net.JoinHostPort(s.Hostname, "22")) + conn, err := s.Ipn.dialer.UserDial(ctx, "tcp", net.JoinHostPort(s.IPAddress, "22")) if err != nil { - log.Printf("SSH dial error: %v", err) s.writeError("Dial", err) return } - defer conn.Close() + + // In Go WASM, gVisor's netstack conn.Read blocks indefinitely without + // a deadline because the single-threaded goroutine scheduler needs the + // deadline machinery to yield to the browser event loop and process + // inbound WireGuard packets. We set a deadline that covers the entire + // SSH handshake and clear it once the session is established. + conn.SetReadDeadline(time.Now().Add(30 * time.Second)) + sshConf := &ssh.ClientConfig{ User: s.Username, HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error { - // Tailscale SSH doesn't use host keys - // TODO: Log that the connection was established return nil }, } - // TODO: LOG: Starting SSH Client - sshConn, _, _, err := ssh.NewClientConn(conn, s.Hostname, sshConf) + sshConn, chans, reqs, err := ssh.NewClientConn(conn, s.IPAddress, sshConf) if err != nil { s.writeError("SSH", err) return } - defer sshConn.Close() - sshClient := ssh.NewClient(sshConn, nil, nil) + + conn.SetReadDeadline(time.Time{}) + + sshClient := ssh.NewClient(sshConn, chans, reqs) defer sshClient.Close() pty, err := sshClient.NewSession() @@ -92,30 +80,28 @@ func (s *SSHSession) ConnectAndRun() { s.writeError("SSH", err) return } - defer pty.Close() s.Pty = pty - rows := s.TermConfig.Rows - if s.ResizeRows != 0 { - rows = s.ResizeRows + rows := 24 + if s.resizeRows != 0 { + rows = s.resizeRows } - cols := s.TermConfig.Cols - if s.ResizeCols != 0 { - cols = s.ResizeCols + cols := 80 + if s.resizeCols != 0 { + cols = s.resizeCols } - err = pty.RequestPty("xterm", rows, cols, ssh.TerminalModes{ - ssh.ECHO: 1, // enable echoing - ssh.ICANON: 1, // canonical mode - ssh.ISIG: 1, // enable signals - ssh.ICRNL: 1, // map CR to NL on input - ssh.IUTF8: 1, // input is UTF-8 - ssh.TTY_OP_ISPEED: 14400, // input speed = 14.4kbaud - ssh.TTY_OP_OSPEED: 14400, // output speed = 14.4kbaud + err = pty.RequestPty("xterm-256color", rows, cols, ssh.TerminalModes{ + ssh.ECHO: 1, + ssh.ICANON: 1, + ssh.ISIG: 1, + ssh.ICRNL: 1, + ssh.IUTF8: 1, + ssh.TTY_OP_ISPEED: 14400, + ssh.TTY_OP_OSPEED: 14400, }) - if err != nil { s.writeError("SSH", err) return @@ -126,8 +112,7 @@ func (s *SSHSession) ConnectAndRun() { s.writeError("SSH", err) return } - - s.wireStdinHandler(stdin) + s.stdin = stdin stdout, err := pty.StdoutPipe() if err != nil { @@ -141,100 +126,61 @@ func (s *SSHSession) ConnectAndRun() { return } - go io.Copy(XtermPipe{s.TermConfig.OnStdout}, stdout) - go io.Copy(XtermPipe{s.TermConfig.OnStderr}, stderr) + go io.Copy(DataPipe{s.Config.OnData}, stdout) + go io.Copy(DataPipe{s.Config.OnData}, stderr) - // Create our shell err = pty.Shell() if err != nil { s.writeError("SSH", err) return } - s.TermConfig.OnConnect() - err = pty.Wait() - if err != nil { - s.writeError("SSH", err) - return + s.Config.OnConnect() + if err := pty.Wait(); err != nil { + log.Printf("SSH session ended: %v", err) } } -// Resize resizes the terminal for the SSH session. -// TODO: This does NOT work correctly from Xterm.js -func (s *SSHSession) Resize(rows, cols int) error { - // Used to handle resizes while still connecting. +func (s *SSHSession) WriteInput(data string) { + if s.stdin != nil { + s.stdin.Write([]byte(data)) + } +} + +// Resize takes cols and rows (JS convention: cols first, rows second) +// and translates to SSH's WindowChange(rows, cols) order. +func (s *SSHSession) Resize(cols, rows int) error { if s.Pty == nil { - s.ResizeRows = rows - s.ResizeCols = cols + s.resizeCols = cols + s.resizeRows = rows return nil } - return s.Pty.WindowChange(cols, rows) + return s.Pty.WindowChange(rows, cols) } -// Closes the SSH session. func (s *SSHSession) Close() error { - if s.stdinHandler != nil { - s.stdinHandler.Release() - s.stdinHandler = nil + if s.cancel != nil { + s.cancel() + s.cancel = nil } if s.Pty != nil { - err := s.Pty.Close() - if err != nil { - return err - } + return s.Pty.Close() } return nil } -// Wires up the stdin handler to pass data from JS to the SSH session. -func (s *SSHSession) wireStdinHandler(w io.Writer) { - if s.stdinHandler != nil { - s.stdinHandler.Release() - s.stdinHandler = nil - } - - cb := js.FuncOf(func(this js.Value, args []js.Value) any { - v := args[0] // This is ALWAYS a Uint8Array technically - len := v.Get("byteLength").Int() - buf := make([]byte, len) - js.CopyBytesToGo(buf, v) - - if _, err := w.Write(buf); err != nil { - s.writeError("SSH Stdin", err) - return nil - } - - // TODO: Remove debug log - log.Printf("SSH wrote %d bytes: %v (%q)", len, buf, string(buf)) - return nil - }) - - s.stdinHandler = &cb - s.TermConfig.OnStdin.Invoke(cb) -} - -// Quick easy formatter for writing errors to the terminal. func (s *SSHSession) writeError(label string, err error) { - o := fmt.Sprintf("%s error: %v\r\n", label, err) - uint8Array := js.Global().Get("Uint8Array").New(len(o)) - - js.CopyBytesToJS(uint8Array, []byte(o)) - s.TermConfig.OnStderr(uint8Array) + s.Config.OnData(fmt.Sprintf("%s error: %v\r\n", label, err)) } -// io.Writer "emulator" to pass to the ssh module. -type XtermPipe struct { - // Function to call when data is written. - Send func(data js.Value) +type DataPipe struct { + Send func(data string) } -// Write implements the io.Writer interface for XtermPipe. -func (x XtermPipe) Write(data []byte) (int, error) { - uint8Array := js.Global().Get("Uint8Array").New(len(data)) - js.CopyBytesToJS(uint8Array, data) - x.Send(uint8Array) +func (p DataPipe) Write(data []byte) (int, error) { + p.Send(string(data)) return len(data), nil } diff --git a/nix/ssh-wasm.nix b/nix/ssh-wasm.nix index 959de87..0ebfe92 100644 --- a/nix/ssh-wasm.nix +++ b/nix/ssh-wasm.nix @@ -26,7 +26,7 @@ in # Patch Tailscale's derphttp to include DERPPort in WebSocket URLs. # Without this, DERP servers on non-443 ports fail in WASM builds. if [ -f patches/tailscale-derp-port.patch ]; then - patch -d vendor -p1 < patches/tailscale-derp-port.patch + patch -d vendor/tailscale.com -p1 < patches/tailscale-derp-port.patch fi go build -mod=vendor -o hp_ssh.wasm ./cmd/hp_ssh diff --git a/package.json b/package.json index b3492af..c990a55 100644 --- a/package.json +++ b/package.json @@ -33,11 +33,6 @@ "@uiw/codemirror-theme-github": "4.25.1", "@uiw/codemirror-theme-xcode": "4.25.5", "@uiw/react-codemirror": "4.25.5", - "@xterm/addon-clipboard": "^0.2.0", - "@xterm/addon-fit": "^0.11.0", - "@xterm/addon-unicode11": "^0.9.0", - "@xterm/addon-web-links": "^0.12.0", - "@xterm/xterm": "^6.0.0", "arktype": "^2.1.29", "clsx": "^2.1.1", "drizzle-orm": "1.0.0-beta.16-c2458b2", @@ -55,6 +50,7 @@ "react-router-dom": "^7.13.1", "react-router-hono-server": "2.25.0", "remix-utils": "^9.0.1", + "restty": "^0.1.35", "tailwind-merge": "3.5.0", "ulidx": "2.4.1", "undici": "7.22.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4593822..3a14c89 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,21 +55,6 @@ importers: '@uiw/react-codemirror': specifier: 4.25.5 version: 4.25.5(@babel/runtime@7.28.6)(@codemirror/autocomplete@6.18.2(@codemirror/language@6.12.2)(@codemirror/state@6.6.0)(@codemirror/view@6.39.15)(@lezer/common@1.5.1))(@codemirror/language@6.12.2)(@codemirror/lint@6.8.2)(@codemirror/search@6.5.7)(@codemirror/state@6.6.0)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.39.15)(codemirror@6.0.1(@lezer/common@1.5.1))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@xterm/addon-clipboard': - specifier: ^0.2.0 - version: 0.2.0 - '@xterm/addon-fit': - specifier: ^0.11.0 - version: 0.11.0 - '@xterm/addon-unicode11': - specifier: ^0.9.0 - version: 0.9.0 - '@xterm/addon-web-links': - specifier: ^0.12.0 - version: 0.12.0 - '@xterm/xterm': - specifier: ^6.0.0 - version: 6.0.0 arktype: specifier: ^2.1.29 version: 2.1.29 @@ -121,6 +106,9 @@ importers: remix-utils: specifier: ^9.0.1 version: 9.0.1(@oslojs/crypto@1.0.1)(@oslojs/encoding@1.1.0)(@standard-schema/spec@1.1.0)(react-router@7.13.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + restty: + specifier: ^0.1.35 + version: 0.1.35(typescript@5.9.3) tailwind-merge: specifier: 3.5.0 version: 3.5.0 @@ -2218,21 +2206,6 @@ packages: peerDependencies: vue: ^3.5.0 - '@xterm/addon-clipboard@0.2.0': - resolution: {integrity: sha512-Dl31BCtBhLaUEECUbEiVcCLvLBbaeGYdT7NofB8OJkGTD3MWgBsaLjXvfGAD4tQNHhm6mbKyYkR7XD8kiZsdNg==} - - '@xterm/addon-fit@0.11.0': - resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==} - - '@xterm/addon-unicode11@0.9.0': - resolution: {integrity: sha512-FxDnYcyuXhNl+XSqGZL/t0U9eiNb/q3EWT5rYkQT/zuig8Gz/VagnQANKHdDWFM2lTMk9ly0EFQxxxtZUoRetw==} - - '@xterm/addon-web-links@0.12.0': - resolution: {integrity: sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==} - - '@xterm/xterm@6.0.0': - resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==} - abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -3760,6 +3733,10 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + restty@0.1.35: + resolution: {integrity: sha512-IUr4NsdYCTErKBxuAd0eu/KdiGZhADBxA0ddSPEEIxAz8np3RT1bLiL9iIH+wglmUeSmHRmJzkPN7hGJJ+E/Yw==} + engines: {bun: '>=1.2.0'} + retry@0.12.0: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} @@ -3984,6 +3961,11 @@ packages: text-decoder@1.2.3: resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==} + text-shaper@0.1.22: + resolution: {integrity: sha512-OYxqPcNEox3tlXilxZVPNub8EU/A6Lxy9vlCAe362fiGI9f3pMav497wL7cecX0CPq1yVtiBn/lii/ZZUUk9bQ==} + peerDependencies: + typescript: ^5 + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -6182,18 +6164,6 @@ snapshots: dependencies: vue: 3.5.18(typescript@5.9.3) - '@xterm/addon-clipboard@0.2.0': - dependencies: - js-base64: 3.7.8 - - '@xterm/addon-fit@0.11.0': {} - - '@xterm/addon-unicode11@0.9.0': {} - - '@xterm/addon-web-links@0.12.0': {} - - '@xterm/xterm@6.0.0': {} - abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 @@ -6957,7 +6927,8 @@ snapshots: jose@6.2.1: {} - js-base64@3.7.8: {} + js-base64@3.7.8: + optional: true js-md4@0.3.2: {} @@ -7643,6 +7614,12 @@ snapshots: resolve-pkg-maps@1.0.0: {} + restty@0.1.35(typescript@5.9.3): + dependencies: + text-shaper: 0.1.22(typescript@5.9.3) + transitivePeerDependencies: + - typescript + retry@0.12.0: {} rfc4648@1.5.4: {} @@ -7970,6 +7947,10 @@ snapshots: transitivePeerDependencies: - react-native-b4a + text-shaper@0.1.22(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + tinybench@2.9.0: {} tinyexec@1.0.2: {} diff --git a/public/fonts/JetBrainsMonoNLNerdFontMono-Bold.ttf b/public/fonts/JetBrainsMonoNLNerdFontMono-Bold.ttf new file mode 100644 index 0000000..2515d7b Binary files /dev/null and b/public/fonts/JetBrainsMonoNLNerdFontMono-Bold.ttf differ diff --git a/public/fonts/JetBrainsMonoNLNerdFontMono-BoldItalic.ttf b/public/fonts/JetBrainsMonoNLNerdFontMono-BoldItalic.ttf new file mode 100644 index 0000000..1ed2ee9 Binary files /dev/null and b/public/fonts/JetBrainsMonoNLNerdFontMono-BoldItalic.ttf differ diff --git a/public/fonts/JetBrainsMonoNLNerdFontMono-Italic.ttf b/public/fonts/JetBrainsMonoNLNerdFontMono-Italic.ttf new file mode 100644 index 0000000..c9156a0 Binary files /dev/null and b/public/fonts/JetBrainsMonoNLNerdFontMono-Italic.ttf differ diff --git a/public/fonts/JetBrainsMonoNLNerdFontMono-Regular.ttf b/public/fonts/JetBrainsMonoNLNerdFontMono-Regular.ttf new file mode 100644 index 0000000..bde439e Binary files /dev/null and b/public/fonts/JetBrainsMonoNLNerdFontMono-Regular.ttf differ diff --git a/public/fonts/SymbolsNerdFontMono-Regular.ttf b/public/fonts/SymbolsNerdFontMono-Regular.ttf new file mode 100644 index 0000000..d898eea Binary files /dev/null and b/public/fonts/SymbolsNerdFontMono-Regular.ttf differ