mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 07:48:14 +00:00
feat: rebuild browser ssh from the ground up
This commit is contained in:
+16
-1
@@ -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)).
|
||||
|
||||
+1
-3
@@ -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 =
|
||||
|
||||
+1
-1
@@ -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", [
|
||||
|
||||
@@ -50,8 +50,8 @@ export default function MachineRow({
|
||||
}, [magic, node.ipAddresses]);
|
||||
|
||||
return (
|
||||
<tr className="group hover:bg-mist-50 dark:hover:bg-mist-950" key={node.id}>
|
||||
<td className="py-2 pl-0.5 focus-within:ring-3">
|
||||
<tr className="group hover:bg-mist-100 dark:hover:bg-mist-800" key={node.id}>
|
||||
<td className="py-2 pl-2 focus-within:ring-3">
|
||||
<Link className={cn("group/link h-full focus:outline-hidden")} to={`/machines/${node.id}`}>
|
||||
<p
|
||||
className={cn(
|
||||
|
||||
@@ -107,7 +107,7 @@ export default function MachineMenu({
|
||||
// in a new WINDOW since href can only
|
||||
// do a new TAB.
|
||||
window.open(
|
||||
`${__PREFIX__}/ssh?hostname=${node.givenName}`,
|
||||
`${__PREFIX__}/ssh/${node.givenName}`,
|
||||
"_blank",
|
||||
"noopener,noreferrer,width=800,height=600",
|
||||
);
|
||||
@@ -127,7 +127,7 @@ export default function MachineMenu({
|
||||
variant="light"
|
||||
onClick={() => {
|
||||
window.open(
|
||||
`${__PREFIX__}/ssh?hostname=${node.givenName}`,
|
||||
`${__PREFIX__}/ssh/${node.givenName}`,
|
||||
"_blank",
|
||||
"noopener,noreferrer,width=800,height=600",
|
||||
);
|
||||
|
||||
@@ -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 <url>/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<TsWasmNet | null>(null);
|
||||
const [nodeKey, setNodeKey] = useState<string | null>(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 <UserPrompt hostname={sshDetails.hostname} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen bg-mist-900">
|
||||
{ipn === null ? (
|
||||
<div className="mx-auto flex h-screen items-center justify-center">
|
||||
<Loader2 className="size-10 animate-spin text-mist-50" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-screen flex-col">
|
||||
<XTerm hostname={sshDetails.hostname} ipn={ipn} username={sshDetails.username} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<Card className="w-screen" variant="flat">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Card.Title>{title}</Card.Title>
|
||||
<AlertCircle className="mb-2 h-6 w-6 text-red-500" />
|
||||
</div>
|
||||
<Card.Text>
|
||||
{message}
|
||||
<br />
|
||||
<br />
|
||||
<Link to={`${DOCS_BASE}${anchor}`} external styled>
|
||||
Headplane SSH Documentation
|
||||
</Link>{" "}
|
||||
</Card.Text>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLDivElement>(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 <div className="min-h-0 min-w-0 flex-1 overflow-hidden bg-black" ref={divRef} />;
|
||||
}
|
||||
Vendored
-57
@@ -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;
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-black">
|
||||
<Card className="w-screen" variant="flat">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Card.Title>Node Offline</Card.Title>
|
||||
<WifiOff className="mb-2 h-6 w-6 text-red-500" />
|
||||
</div>
|
||||
<Card.Text>
|
||||
<Code>{hostname}</Code> is not currently connected to the Tailnet.
|
||||
</Card.Text>
|
||||
<Button className="mt-8 w-full" onClick={() => window.location.reload()}>
|
||||
Retry Connection
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!username || !node) {
|
||||
return <UserPrompt hostname={hostname} />;
|
||||
}
|
||||
|
||||
return <SSHConsole hostname={hostname} username={username} node={node} />;
|
||||
}
|
||||
|
||||
function SSHConsole({
|
||||
hostname,
|
||||
username,
|
||||
node,
|
||||
}: {
|
||||
hostname: string;
|
||||
username: string;
|
||||
node: { ipAddress: string; controlURL: string; preAuthKey: string; ephemeralHostname: string };
|
||||
}) {
|
||||
const [ssh, setSsh] = useState<HeadplaneSSH | null>(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 (
|
||||
<div className="fixed inset-0 flex flex-col bg-black">
|
||||
{!connected && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="size-8 animate-spin text-mist-200" />
|
||||
<p className="text-sm text-mist-400">{status}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ssh && (
|
||||
<Ghostty
|
||||
ssh={ssh}
|
||||
username={username}
|
||||
ipAddress={node.ipAddress}
|
||||
onConnected={() => setConnected(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex h-screen w-screen items-center justify-center">
|
||||
<SSHErrorBoundary
|
||||
title={routeError.title}
|
||||
message={routeError.message}
|
||||
anchor={routeError.anchor}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<Card>
|
||||
@@ -19,27 +18,47 @@ export default function UserPrompt({ hostname }: UserPromptProps) {
|
||||
<Card.Text className="mb-4">
|
||||
Enter the username you want to use to connect to <Code>{hostname}</Code>
|
||||
{". "}
|
||||
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.
|
||||
<br />
|
||||
<br />
|
||||
See the{" "}
|
||||
<Link external styled to="https://headplane.net/features/ssh#troubleshooting">
|
||||
troubleshooting guide
|
||||
</Link>{" "}
|
||||
for common errors.
|
||||
</Card.Text>
|
||||
<Input
|
||||
labelHidden
|
||||
type="text"
|
||||
label="Username"
|
||||
placeholder="Username"
|
||||
className="mb-2"
|
||||
onChange={setUsername}
|
||||
/>
|
||||
<Button
|
||||
variant="heavy"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
// We can't use the navigate hook here as we need to do a
|
||||
// full page reload to ensure the SSH connection is established
|
||||
window.location.href = `${__PREFIX__}/ssh?hostname=${hostname}&username=${username}`;
|
||||
<Form
|
||||
method="GET"
|
||||
onSubmit={(e) => {
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const username = formData.get("user");
|
||||
if (!username) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// We have to do a full navigation, since the page needs a full
|
||||
// reload to initialize the SSH connection due to us disabling the
|
||||
// revalidator.
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("user", username.toString());
|
||||
window.location.assign(url.toString());
|
||||
}}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
<Input
|
||||
labelHidden
|
||||
type="text"
|
||||
label="Username"
|
||||
name="user"
|
||||
placeholder="Username"
|
||||
className="mb-2"
|
||||
required
|
||||
/>
|
||||
<Button type="submit" variant="heavy" className="w-full">
|
||||
Connect
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<void>;
|
||||
argv?: string[];
|
||||
env?: Record<string, string>;
|
||||
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<HeadplaneSSHFactory> | 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<HeadplaneSSHFactory> {
|
||||
if (!resolvedFactory) {
|
||||
const go = new Go();
|
||||
const result = await WebAssembly.instantiateStreaming(fetch(WASM_MODULE_URL), go.importObject);
|
||||
|
||||
resolvedFactory = new Promise<HeadplaneSSHFactory>((resolve) => {
|
||||
globalThis.__hp_ssh_resolve = resolve;
|
||||
});
|
||||
|
||||
go.run(result.instance);
|
||||
}
|
||||
|
||||
return resolvedFactory;
|
||||
}
|
||||
Vendored
-7
@@ -1,7 +0,0 @@
|
||||
declare class Go {
|
||||
importObject: WebAssembly.Imports;
|
||||
run(instance: WebAssembly.Instance): Promise<void>;
|
||||
argv?: string[];
|
||||
env?: Record<string, string>;
|
||||
exit?: (code: number) => void;
|
||||
}
|
||||
@@ -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<xterm.Terminal>(null);
|
||||
const roRef = useRef<ResizeObserver>(null);
|
||||
const inputRef = useRef<(input: Uint8Array) => void>(null);
|
||||
const sshRef = useRef<SSHSession>(null);
|
||||
const divRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 ? (
|
||||
<div className="absolute z-50 mx-auto flex h-screen w-screen items-center justify-center">
|
||||
<Loader2 className="size-10 animate-spin text-mist-50" />
|
||||
</div>
|
||||
) : undefined}
|
||||
<div className={cn("w-full h-full", isLoading ? "opacity-0" : "opacity-100")} ref={divRef} />
|
||||
{termRef.current && isResizing ? (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2",
|
||||
"px-4 py-2 bg-mist-800 text-white rounded-full shadow z-50",
|
||||
)}
|
||||
>
|
||||
{termRef.current.cols}x{termRef.current.rows}
|
||||
</div>
|
||||
) : undefined}
|
||||
{error !== null ? (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-center",
|
||||
"px-4 py-2 bg-mist-800 text-white rounded-full shadow z-50",
|
||||
)}
|
||||
>
|
||||
Failed to connect to SSH session
|
||||
{error}
|
||||
</div>
|
||||
) : undefined}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -30,8 +30,8 @@ export default function HeadplaneUserRow({
|
||||
const displayEmail = user.linkedHeadscaleUser?.email ?? user.email;
|
||||
|
||||
return (
|
||||
<tr className="group hover:bg-mist-50 dark:hover:bg-mist-950" key={user.id}>
|
||||
<td className="py-2 pl-0.5">
|
||||
<tr className="group hover:bg-mist-100 dark:hover:bg-mist-800" key={user.id}>
|
||||
<td className="py-2 pl-2">
|
||||
<div className="flex items-center">
|
||||
{user.profilePicUrl ? (
|
||||
<img alt={displayName} className="h-10 w-10 rounded-full" src={user.profilePicUrl} />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<HostInfo>(),
|
||||
|
||||
+9
-16
@@ -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(
|
||||
|
||||
+43
-57
@@ -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)
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 337 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 147 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 596 KiB |
+196
-42
@@ -1,73 +1,227 @@
|
||||
---
|
||||
title: WebSSH
|
||||
title: Browser SSH
|
||||
description: Open SSH sessions to your Tailnet nodes directly from the browser.
|
||||
---
|
||||
|
||||
# WebSSH
|
||||
# Browser SSH
|
||||
|
||||
<figure>
|
||||
<img src="../assets/ssh.png" />
|
||||
<figcaption>SSH access via the browser</figcaption>
|
||||
<img src="../assets/ssh-btop.png" style="width: 100%;" />
|
||||
<figcaption><code>btop</code> running over browser SSH</figcaption>
|
||||
</figure>
|
||||
|
||||
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.
|
||||
|
||||
<figure>
|
||||
<img src="../assets/ssh-fastfetch.png" style="width: 100%;" />
|
||||
<figcaption><code>fastfetch</code> with Nerd Font icons</figcaption>
|
||||
</figure>
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE `ephemeral_nodes`;
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
+24
-87
@@ -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
|
||||
}
|
||||
|
||||
+38
-71
@@ -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
|
||||
|
||||
+11
-69
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+72
-126
@@ -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
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-5
@@ -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",
|
||||
|
||||
Generated
+24
-43
@@ -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: {}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user