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.
|
> 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.**
|
- **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.
|
- Removed `react-aria`, `react-stately`, and `tailwindcss-react-aria-components` as dependencies.
|
||||||
- **Replaced `openid-client` with a clean-room OIDC implementation.**
|
- **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 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 agent working directory being wiped on restart.
|
||||||
- Fixed a race condition where the SSE controller could be used after being closed.
|
- 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 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 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 Dockerfile WASM copy paths.
|
||||||
- Fixed CodeMirror version mismatch override in the ACL editor.
|
- 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)).
|
- 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 { ErrorBanner } from "~/components/error-banner";
|
||||||
import StatusBanner from "~/components/status-banner";
|
import StatusBanner from "~/components/status-banner";
|
||||||
import { pruneEphemeralNodes } from "~/server/db/pruner";
|
|
||||||
import { isDataUnauthorizedError } from "~/server/headscale/api/error-client";
|
import { isDataUnauthorizedError } from "~/server/headscale/api/error-client";
|
||||||
import { usersResource } from "~/server/headscale/live-store";
|
import { usersResource } from "~/server/headscale/live-store";
|
||||||
import { Capabilities } from "~/server/web/roles";
|
import { Capabilities } from "~/server/web/roles";
|
||||||
@@ -30,7 +29,7 @@ export const shouldRevalidate: ShouldRevalidateFunction = ({
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function loader({ request, context, ...rest }: Route.LoaderArgs) {
|
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||||
try {
|
try {
|
||||||
const principal = await context.auth.require(request);
|
const principal = await context.auth.require(request);
|
||||||
|
|
||||||
@@ -53,7 +52,6 @@ export async function loader({ request, context, ...rest }: Route.LoaderArgs) {
|
|||||||
if (isHealthy) {
|
if (isHealthy) {
|
||||||
try {
|
try {
|
||||||
await api.getApiKeys();
|
await api.getApiKeys();
|
||||||
await pruneEphemeralNodes({ context, request, ...rest });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isDataUnauthorizedError(error)) {
|
if (isDataUnauthorizedError(error)) {
|
||||||
const displayName =
|
const displayName =
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@ export default [
|
|||||||
route("/logout", "routes/auth/logout.ts"),
|
route("/logout", "routes/auth/logout.ts"),
|
||||||
route("/oidc/callback", "routes/auth/oidc-callback.ts"),
|
route("/oidc/callback", "routes/auth/oidc-callback.ts"),
|
||||||
route("/oidc/start", "routes/auth/oidc-start.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
|
// All the main logged-in routes
|
||||||
layout("layout/app.tsx", [
|
layout("layout/app.tsx", [
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ export default function MachineRow({
|
|||||||
}, [magic, node.ipAddresses]);
|
}, [magic, node.ipAddresses]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className="group hover:bg-mist-50 dark:hover:bg-mist-950" key={node.id}>
|
<tr className="group hover:bg-mist-100 dark:hover:bg-mist-800" key={node.id}>
|
||||||
<td className="py-2 pl-0.5 focus-within:ring-3">
|
<td className="py-2 pl-2 focus-within:ring-3">
|
||||||
<Link className={cn("group/link h-full focus:outline-hidden")} to={`/machines/${node.id}`}>
|
<Link className={cn("group/link h-full focus:outline-hidden")} to={`/machines/${node.id}`}>
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export default function MachineMenu({
|
|||||||
// in a new WINDOW since href can only
|
// in a new WINDOW since href can only
|
||||||
// do a new TAB.
|
// do a new TAB.
|
||||||
window.open(
|
window.open(
|
||||||
`${__PREFIX__}/ssh?hostname=${node.givenName}`,
|
`${__PREFIX__}/ssh/${node.givenName}`,
|
||||||
"_blank",
|
"_blank",
|
||||||
"noopener,noreferrer,width=800,height=600",
|
"noopener,noreferrer,width=800,height=600",
|
||||||
);
|
);
|
||||||
@@ -127,7 +127,7 @@ export default function MachineMenu({
|
|||||||
variant="light"
|
variant="light"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
window.open(
|
window.open(
|
||||||
`${__PREFIX__}/ssh?hostname=${node.givenName}`,
|
`${__PREFIX__}/ssh/${node.givenName}`,
|
||||||
"_blank",
|
"_blank",
|
||||||
"noopener,noreferrer,width=800,height=600",
|
"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 Button from "~/components/button";
|
||||||
import Card from "~/components/card";
|
import Card from "~/components/card";
|
||||||
import Code from "~/components/code";
|
import Code from "~/components/code";
|
||||||
import Input from "~/components/input";
|
import Input from "~/components/input";
|
||||||
|
import Link from "~/components/link";
|
||||||
|
|
||||||
interface UserPromptProps {
|
interface UserPromptProps {
|
||||||
hostname: string;
|
hostname: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UserPrompt({ hostname }: UserPromptProps) {
|
export default function UserPrompt({ hostname }: UserPromptProps) {
|
||||||
const [username, setUsername] = useState("");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen items-center justify-center">
|
<div className="flex h-screen items-center justify-center">
|
||||||
<Card>
|
<Card>
|
||||||
@@ -19,27 +18,47 @@ export default function UserPrompt({ hostname }: UserPromptProps) {
|
|||||||
<Card.Text className="mb-4">
|
<Card.Text className="mb-4">
|
||||||
Enter the username you want to use to connect to <Code>{hostname}</Code>
|
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>
|
</Card.Text>
|
||||||
<Input
|
<Form
|
||||||
labelHidden
|
method="GET"
|
||||||
type="text"
|
onSubmit={(e) => {
|
||||||
label="Username"
|
const formData = new FormData(e.currentTarget);
|
||||||
placeholder="Username"
|
const username = formData.get("user");
|
||||||
className="mb-2"
|
if (!username) {
|
||||||
onChange={setUsername}
|
e.preventDefault();
|
||||||
/>
|
return;
|
||||||
<Button
|
}
|
||||||
variant="heavy"
|
|
||||||
className="w-full"
|
// We have to do a full navigation, since the page needs a full
|
||||||
onClick={() => {
|
// reload to initialize the SSH connection due to us disabling the
|
||||||
// We can't use the navigate hook here as we need to do a
|
// revalidator.
|
||||||
// full page reload to ensure the SSH connection is established
|
const url = new URL(window.location.href);
|
||||||
window.location.href = `${__PREFIX__}/ssh?hostname=${hostname}&username=${username}`;
|
url.searchParams.set("user", username.toString());
|
||||||
|
window.location.assign(url.toString());
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Connect
|
<Input
|
||||||
</Button>
|
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>
|
</Card>
|
||||||
</div>
|
</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;
|
const displayEmail = user.linkedHeadscaleUser?.email ?? user.email;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className="group hover:bg-mist-50 dark:hover:bg-mist-950" key={user.id}>
|
<tr className="group hover:bg-mist-100 dark:hover:bg-mist-800" key={user.id}>
|
||||||
<td className="py-2 pl-0.5">
|
<td className="py-2 pl-2">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{user.profilePicUrl ? (
|
{user.profilePicUrl ? (
|
||||||
<img alt={displayName} className="h-10 w-10 rounded-full" src={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";
|
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", {
|
export const hostInfo = sqliteTable("host_info", {
|
||||||
host_id: text("host_id").primaryKey(),
|
host_id: text("host_id").primaryKey(),
|
||||||
payload: text("payload", { mode: "json" }).$type<HostInfo>(),
|
payload: text("payload", { mode: "json" }).$type<HostInfo>(),
|
||||||
|
|||||||
+9
-16
@@ -10,7 +10,7 @@ import { HostInfo } from "~/types";
|
|||||||
import log from "~/utils/log";
|
import log from "~/utils/log";
|
||||||
|
|
||||||
import { HeadplaneConfig } from "./config/config-schema";
|
import { HeadplaneConfig } from "./config/config-schema";
|
||||||
import { ephemeralNodes, hostInfo } from "./db/schema";
|
import { hostInfo } from "./db/schema";
|
||||||
import { RuntimeApiClient } from "./headscale/api/endpoints";
|
import { RuntimeApiClient } from "./headscale/api/endpoints";
|
||||||
|
|
||||||
export interface AgentManager {
|
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() {
|
async function pruneStaleHostInfo() {
|
||||||
try {
|
try {
|
||||||
const nodes = await apiClient.getNodes();
|
const nodes = await apiClient.getNodes();
|
||||||
@@ -294,23 +298,12 @@ export async function createAgentManager(
|
|||||||
|
|
||||||
async function pruneEphemeralNodes() {
|
async function pruneEphemeralNodes() {
|
||||||
try {
|
try {
|
||||||
const rows = await db.select().from(ephemeralNodes);
|
|
||||||
if (rows.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nodes = await apiClient.getNodes();
|
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) {
|
for (const node of toPrune) {
|
||||||
if (!row.node_key) {
|
await apiClient.deleteNode(node.id);
|
||||||
continue;
|
log.info("agent", "Pruned offline ephemeral node %s", node.givenName);
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.debug(
|
log.debug(
|
||||||
|
|||||||
+43
-57
@@ -12,96 +12,82 @@ import (
|
|||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
log.Printf("Loading WASM Headplane SSH module")
|
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 {
|
factory := js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||||
log.Fatal("Usage: TsWasmNet(config, callbacks)")
|
if len(args) != 1 {
|
||||||
|
log.Printf("Usage: create(config)")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
options, err := hp_ipn.ParseTsWasmNetOptions(args[0])
|
config, err := hp_ipn.ParseIPNConfig(args[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Error parsing options:", err)
|
log.Printf("Error parsing config: %v", err)
|
||||||
return nil
|
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 {
|
if err != nil {
|
||||||
log.Fatal("Error parsing callbacks:", err)
|
callbacks.OnError(err.Error())
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
ipn, err := hp_ipn.NewTsWasmIpn(options, callbacks)
|
go func() {
|
||||||
if err != nil {
|
if err := ipn.Start(context.Background()); err != nil {
|
||||||
log.Fatal("Error creating TsWasmIpn:", err)
|
callbacks.OnError(err.Error())
|
||||||
return nil
|
}
|
||||||
}
|
}()
|
||||||
|
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"Start": js.FuncOf(func(this js.Value, args []js.Value) any {
|
"openTunnel": js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||||
ipn.Start(context.Background())
|
if len(args) != 1 {
|
||||||
return nil
|
log.Printf("Usage: openTunnel(config)")
|
||||||
}),
|
|
||||||
"OpenSSH": js.FuncOf(func(this js.Value, args []js.Value) any {
|
|
||||||
if len(args) != 3 {
|
|
||||||
log.Fatal("Usage: OpenSSH(host, user, options)")
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
hostname := args[0]
|
tunnelConfig, err := hp_ipn.ParseTunnelConfig(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])
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("Error parsing SSH options:", err)
|
log.Printf("Error parsing tunnel config: %v", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
session := ipn.NewSSHSession(hostname.String(), username.String(), sshOptions)
|
session := ipn.NewSSHSession(tunnelConfig)
|
||||||
go session.ConnectAndRun()
|
go session.ConnectAndRun()
|
||||||
|
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"Close": js.FuncOf(func(this js.Value, args []js.Value) any {
|
"writeInput": js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||||
return session.Close() != nil
|
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 {
|
if len(args) != 2 {
|
||||||
log.Fatal("Usage: Resize(cols, rows)")
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
session.Resize(args[0].Int(), args[1].Int())
|
||||||
|
return nil
|
||||||
|
}),
|
||||||
|
|
||||||
rows := args[0].Int()
|
"close": js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||||
cols := args[1].Int()
|
session.Close()
|
||||||
if cols <= 0 || rows <= 0 {
|
return nil
|
||||||
log.Fatal("Columns and rows must be positive integers")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return session.Resize(cols, rows) == 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")
|
log.Printf("WASM Headplane SSH module loaded successfully")
|
||||||
<-make(chan bool)
|
<-make(chan bool)
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export default defineConfig({
|
|||||||
items: [
|
items: [
|
||||||
{ text: "Single Sign-On (SSO)", link: "/features/sso" },
|
{ text: "Single Sign-On (SSO)", link: "/features/sso" },
|
||||||
{ text: "Headplane Agent", link: "/features/agent" },
|
{ 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.
|
description: Open SSH sessions to your Tailnet nodes directly from the browser.
|
||||||
---
|
---
|
||||||
|
|
||||||
# WebSSH
|
# Browser SSH
|
||||||
|
|
||||||
<figure>
|
<figure>
|
||||||
<img src="../assets/ssh.png" />
|
<img src="../assets/ssh-btop.png" style="width: 100%;" />
|
||||||
<figcaption>SSH access via the browser</figcaption>
|
<figcaption><code>btop</code> running over browser SSH</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
|
|
||||||
WebSSH lets you open an SSH session to any node directly from the browser. It
|
Browser SSH allows a user to open an SSH session to any accesible node in the
|
||||||
uses a Go-based WASM shim that creates an ephemeral Tailscale node in the
|
Tailnet directly from the browser. It spins up an ephemeral Tailscale node that
|
||||||
browser and connects to the target node over the Tailnet.
|
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
|
## Prerequisites
|
||||||
|
|
||||||
- **Headscale 0.28 or newer** is required.
|
- **Headscale 0.28 or newer** is required.
|
||||||
- Target nodes must have **Tailscale SSH** enabled.
|
- Target nodes must have **Tailscale SSH** enabled (`tailscale up --ssh`).
|
||||||
- Users must be authenticated via **OIDC** (API key logins cannot use WebSSH).
|
- Users must be logged-in via **OIDC** (API key logins cannot use browser SSH).
|
||||||
- The **Headplane Agent** must be
|
- The **Headplane Agent** must be [enabled and configured](/features/agent).
|
||||||
[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`.
|
|
||||||
|
|
||||||
## How It Works
|
## 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:
|
When a user opens an SSH session from the UI, the browser:
|
||||||
|
|
||||||
1. Loads a Go WASM binary that implements a minimal Tailscale node.
|
1. Loads a WASM module that runs a minimal Tailscale node using userspace
|
||||||
2. Authenticates to the Tailnet using an ephemeral pre-auth key generated
|
WireGuard. This node will connect to the tailnet via a pre-auth key.
|
||||||
server-side.
|
2. Opens an SSH session to the target node's Tailscale IP address over the
|
||||||
3. Connects to the target node over the Tailnet using DERP relay servers.
|
tunnel and passes it to the browser.
|
||||||
4. Opens an SSH session and renders it in an [xterm.js](https://xtermjs.org)
|
3. Using [restty](https://restty.dev) (a Ghostty-based WASM terminal emulator),
|
||||||
terminal.
|
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
|
### Required Headers
|
||||||
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.
|
|
||||||
|
|
||||||
::: warning
|
Your reverse proxy must forward these headers for Headscale's DERP endpoint:
|
||||||
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
|
| Header | Value |
|
||||||
before compiling. See the `build_wasm()` function in `build.sh` for reference.
|
| ------------------------ | --------------------------------------- |
|
||||||
:::
|
| `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
|
## Troubleshooting
|
||||||
|
|
||||||
### "WebSSH is not configured in this build"
|
### SSH Not Available
|
||||||
|
|
||||||
The WASM assets are missing. Rebuild with `./build.sh --wasm` or ensure your
|
**Error:** "This version of Headplane was not built with browser SSH support."
|
||||||
Docker image was built with the `--wasm` flag.
|
|
||||||
|
|
||||||
### "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
|
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.
|
**Error:** "You'll need to link your user account to a Headscale user before
|
||||||
- If using custom DERP servers on non-standard ports, ensure you are running
|
you can use Browser SSH."
|
||||||
a build that includes the DERP port patch (any build from `build.sh` or
|
|
||||||
Docker includes it automatically).
|
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.
|
- 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/ipnlocal"
|
||||||
"tailscale.com/ipn/ipnserver"
|
"tailscale.com/ipn/ipnserver"
|
||||||
"tailscale.com/ipn/store/mem"
|
"tailscale.com/ipn/store/mem"
|
||||||
// "tailscale.com/net/netmon"
|
|
||||||
"tailscale.com/net/netns"
|
"tailscale.com/net/netns"
|
||||||
"tailscale.com/net/tsdial"
|
"tailscale.com/net/tsdial"
|
||||||
"tailscale.com/safesocket"
|
"tailscale.com/safesocket"
|
||||||
@@ -24,102 +23,74 @@ import (
|
|||||||
"tailscale.com/wgengine/netstack"
|
"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 {
|
type TsWasmIpn struct {
|
||||||
// The options used to initialize the TsWasmNet module.
|
options *IPNConfig
|
||||||
options *TsWasmNetOptions
|
dialer *tsdial.Dialer
|
||||||
|
server *ipnserver.Server
|
||||||
// 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.
|
|
||||||
backend *ipnlocal.LocalBackend
|
backend *ipnlocal.LocalBackend
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTsWasmIpn initializes a new TsWasmIpn instance with the provided options.
|
func NewTsWasmIpn(options *IPNConfig, callbacks *IPNCallbacks) (*TsWasmIpn, error) {
|
||||||
// This intentionally does not initialize Logtail, as it is only available in
|
logf := log.Printf
|
||||||
// the Tailscale SaaS and not on self-hosted instances.
|
netns.SetEnabled(false)
|
||||||
func NewTsWasmIpn(options *TsWasmNetOptions, callbacks *TsWasmNetCallbacks) (*TsWasmIpn, error) {
|
|
||||||
logf := log.Printf // TODO: Update
|
|
||||||
|
|
||||||
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()
|
sys := tsd.NewSystem()
|
||||||
// bus := sys.Bus.Get()
|
|
||||||
sys.Set(new(mem.Store))
|
sys.Set(new(mem.Store))
|
||||||
|
|
||||||
dialer := &tsdial.Dialer{Logf: logf}
|
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{
|
engine, err := wgengine.NewUserspaceEngine(logf, wgengine.Config{
|
||||||
Dialer: dialer,
|
Dialer: dialer,
|
||||||
// NetMon: netmon,
|
|
||||||
SetSubsystem: sys.Set,
|
SetSubsystem: sys.Set,
|
||||||
ControlKnobs: sys.ControlKnobs(),
|
ControlKnobs: sys.ControlKnobs(),
|
||||||
HealthTracker: sys.HealthTracker(),
|
HealthTracker: sys.HealthTracker(),
|
||||||
Metrics: sys.UserMetricsRegistry(),
|
Metrics: sys.UserMetricsRegistry(),
|
||||||
EventBus: sys.Bus.Get(),
|
EventBus: sys.Bus.Get(),
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("failed to create userspace engine: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
sys.Set(engine)
|
sys.Set(engine)
|
||||||
|
|
||||||
tun := sys.Tun.Get()
|
tun := sys.Tun.Get()
|
||||||
msock := sys.MagicSock.Get()
|
msock := sys.MagicSock.Get()
|
||||||
dnsman := sys.DNSManager.Get()
|
dnsman := sys.DNSManager.Get()
|
||||||
proxymap := sys.ProxyMapper()
|
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 {
|
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.ProcessLocalIPs = true
|
||||||
wgstack.ProcessSubnets = true
|
wgstack.ProcessSubnets = true
|
||||||
|
|
||||||
dialer.UseNetstackForIP = func(ip netip.Addr) bool {
|
dialer.UseNetstackForIP = func(ip netip.Addr) bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
dialer.NetstackDialTCP = func(ctx context.Context, dst netip.AddrPort) (net.Conn, error) {
|
dialer.NetstackDialTCP = func(ctx context.Context, dst netip.AddrPort) (net.Conn, error) {
|
||||||
return wgstack.DialContextTCP(ctx, dst)
|
return wgstack.DialContextTCP(ctx, dst)
|
||||||
}
|
}
|
||||||
|
|
||||||
dialer.NetstackDialUDP = func(ctx context.Context, dst netip.AddrPort) (net.Conn, error) {
|
dialer.NetstackDialUDP = func(ctx context.Context, dst netip.AddrPort) (net.Conn, error) {
|
||||||
return wgstack.DialContextUDP(ctx, dst)
|
return wgstack.DialContextUDP(ctx, dst)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dummy logid for the Tailscale backend
|
logID := logid.PublicID{}
|
||||||
logid := logid.PublicID{}
|
|
||||||
sys.NetstackRouter.Set(true)
|
sys.NetstackRouter.Set(true)
|
||||||
sys.Tun.Get().Start()
|
sys.Tun.Get().Start()
|
||||||
|
|
||||||
server := ipnserver.New(logf, logid, sys.NetMon.Get())
|
server := ipnserver.New(logf, logID, sys.NetMon.Get())
|
||||||
flags := controlclient.LoginDefault | controlclient.LoginEphemeral | controlclient.LocalBackendStartKeyOSNeutral
|
|
||||||
|
|
||||||
backend, err := ipnlocal.NewLocalBackend(logf, logid, sys, flags)
|
backend, err := ipnlocal.NewLocalBackend(logf, logID, sys, controlclient.LoginEphemeral)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("failed to create local backend: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = wgstack.Start(backend)
|
if err := wgstack.Start(backend); err != nil {
|
||||||
if err != nil {
|
return nil, fmt.Errorf("failed to start netstack: %w", err)
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
server.SetLocalBackend(backend)
|
server.SetLocalBackend(backend)
|
||||||
@@ -133,26 +104,18 @@ func NewTsWasmIpn(options *TsWasmNetOptions, callbacks *TsWasmNetCallbacks) (*Ts
|
|||||||
}, nil
|
}, 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 {
|
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("")
|
listener, err := safesocket.Listen("")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create safesocket listener: %w", err)
|
return fmt.Errorf("failed to create safesocket listener: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start the server BEFORE the LocalBackend is started
|
|
||||||
go func() {
|
go func() {
|
||||||
err := t.server.Run(ctx, listener)
|
if err := t.server.Run(ctx, listener); err != nil {
|
||||||
if err != nil {
|
log.Printf("Tailscale server exited: %v", err)
|
||||||
// TODO: Handle this dispatch using a chan
|
|
||||||
log.Printf("Failed to run Tailscale server: %v", err)
|
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Start the LocalBackend
|
|
||||||
err = t.backend.Start(ipn.Options{
|
err = t.backend.Start(ipn.Options{
|
||||||
AuthKey: t.options.PreAuthKey,
|
AuthKey: t.options.PreAuthKey,
|
||||||
UpdatePrefs: &ipn.Prefs{
|
UpdatePrefs: &ipn.Prefs{
|
||||||
@@ -163,11 +126,9 @@ func (t *TsWasmIpn) Start(ctx context.Context) error {
|
|||||||
LoggedOut: false,
|
LoggedOut: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to start Tailscale backend: %w", err)
|
return fmt.Errorf("failed to start Tailscale backend: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Tailscale backend started successfully with hostname: %s", t.options.Hostname)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-87
@@ -3,15 +3,35 @@
|
|||||||
package hp_ipn
|
package hp_ipn
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"syscall/js"
|
"syscall/js"
|
||||||
|
|
||||||
"tailscale.com/ipn"
|
"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{
|
var BackendState = map[ipn.State]string{
|
||||||
ipn.NoState: "NoState",
|
ipn.NoState: "NoState",
|
||||||
ipn.Stopped: "Stopped",
|
ipn.Stopped: "Stopped",
|
||||||
@@ -21,86 +41,3 @@ var BackendState = map[ipn.State]string{
|
|||||||
ipn.NeedsMachineAuth: "NeedsMachineAuth",
|
ipn.NeedsMachineAuth: "NeedsMachineAuth",
|
||||||
ipn.NeedsLogin: "NeedsLogin",
|
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"
|
"syscall/js"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Represents the options needed to initialize the TsWasmNet module.
|
type IPNConfig struct {
|
||||||
type TsWasmNetOptions struct {
|
|
||||||
// Tailscale control URL, e.g., "https://controlplane.tailscale.com"
|
|
||||||
ControlURL string
|
ControlURL string
|
||||||
|
|
||||||
// Pre-authentication key for the Tailnet
|
|
||||||
PreAuthKey string
|
PreAuthKey string
|
||||||
|
Hostname string
|
||||||
// Optional hostname, autogenerated if not provided.
|
|
||||||
Hostname string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parses the provided JS object to validate and extract the TsWasmNetOptions.
|
func ParseIPNConfig(obj js.Value) (*IPNConfig, error) {
|
||||||
func ParseTsWasmNetOptions(obj js.Value) (*TsWasmNetOptions, error) {
|
|
||||||
if obj.IsUndefined() || obj.IsNull() {
|
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)
|
controlURL := safeString("controlURL", obj)
|
||||||
preAuthKey := safeString("PreAuthKey", obj)
|
preAuthKey := safeString("preAuthKey", obj)
|
||||||
hostname := safeString("Hostname", obj)
|
hostname := safeString("hostname", obj)
|
||||||
|
|
||||||
if cUrl == "" || preAuthKey == "" || hostname == "" {
|
if controlURL == "" || preAuthKey == "" || hostname == "" {
|
||||||
return nil, errors.New("missing required fields in TsWasmNetOptions")
|
return nil, errors.New("missing required fields: controlURL, preAuthKey, hostname")
|
||||||
}
|
}
|
||||||
|
|
||||||
return &TsWasmNetOptions{
|
return &IPNConfig{
|
||||||
ControlURL: cUrl,
|
ControlURL: controlURL,
|
||||||
PreAuthKey: preAuthKey,
|
PreAuthKey: preAuthKey,
|
||||||
Hostname: hostname,
|
Hostname: hostname,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Options passed from the JS side to pass data to xterm.js.
|
type TunnelConfig struct {
|
||||||
type SSHXtermConfig struct {
|
IPAddress string
|
||||||
Timeout int // Timeout in seconds for the PTY connection.
|
Username string
|
||||||
Rows int // Number of rows in the PTY.
|
Timeout int
|
||||||
Cols int // Number of columns in the PTY.
|
OnData func(data string)
|
||||||
OnStdout func(data js.Value) // Fires when the PTY has output.
|
OnConnect func()
|
||||||
OnStderr func(error js.Value) // Fires when the PTY has an error.
|
OnDisconnect func()
|
||||||
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.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parses the provided JS object to validate and extract SSHXtermConfig.
|
func ParseTunnelConfig(obj js.Value) (*TunnelConfig, error) {
|
||||||
func ParseSSHXtermConfig(obj js.Value) (*SSHXtermConfig, error) {
|
|
||||||
if obj.IsUndefined() || obj.IsNull() {
|
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)
|
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 {
|
if timeout <= 0 {
|
||||||
timeout = 30 // Default timeout to 30 seconds if not specified
|
timeout = 30
|
||||||
}
|
}
|
||||||
|
|
||||||
config := &SSHXtermConfig{
|
config := &TunnelConfig{
|
||||||
Timeout: timeout,
|
IPAddress: ipAddress,
|
||||||
Rows: rows,
|
Username: username,
|
||||||
Cols: cols,
|
Timeout: timeout,
|
||||||
}
|
}
|
||||||
|
|
||||||
onStdout := obj.Get("onStdout")
|
onData := obj.Get("onData")
|
||||||
if onStdout.IsUndefined() || onStdout.IsNull() || (onStdout.Type() != js.TypeFunction) {
|
if onData.IsUndefined() || onData.IsNull() || onData.Type() != js.TypeFunction {
|
||||||
return nil, errors.New("`onStdout` is required and must be a function")
|
return nil, errors.New("`onData` is required and must be a function")
|
||||||
}
|
}
|
||||||
|
config.OnData = func(data string) {
|
||||||
config.OnStdout = func(data js.Value) {
|
onData.Invoke(data)
|
||||||
onStdout.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")
|
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")
|
return nil, errors.New("`onConnect` is required and must be a function")
|
||||||
}
|
}
|
||||||
|
|
||||||
config.OnConnect = func() {
|
config.OnConnect = func() {
|
||||||
onConnect.Invoke()
|
onConnect.Invoke()
|
||||||
}
|
}
|
||||||
|
|
||||||
onDisconnect := obj.Get("onDisconnect")
|
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")
|
return nil, errors.New("`onDisconnect` is required and must be a function")
|
||||||
}
|
}
|
||||||
|
|
||||||
config.OnDisconnect = func() {
|
config.OnDisconnect = func() {
|
||||||
onDisconnect.Invoke()
|
onDisconnect.Invoke()
|
||||||
}
|
}
|
||||||
@@ -122,7 +91,6 @@ func ParseSSHXtermConfig(obj js.Value) (*SSHXtermConfig, error) {
|
|||||||
return config, nil
|
return config, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retrieves a string value from a JS object safely.
|
|
||||||
func safeString(key string, obj js.Value) string {
|
func safeString(key string, obj js.Value) string {
|
||||||
if obj.IsUndefined() || obj.IsNull() {
|
if obj.IsUndefined() || obj.IsNull() {
|
||||||
return ""
|
return ""
|
||||||
@@ -136,7 +104,6 @@ func safeString(key string, obj js.Value) string {
|
|||||||
return val.String()
|
return val.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retrieves an integer value from a JS object safely.
|
|
||||||
func safeInt(key string, obj js.Value) int {
|
func safeInt(key string, obj js.Value) int {
|
||||||
if obj.IsUndefined() || obj.IsNull() {
|
if obj.IsUndefined() || obj.IsNull() {
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
+11
-69
@@ -6,94 +6,36 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"tailscale.com/ipn"
|
"tailscale.com/ipn"
|
||||||
"tailscale.com/ipn/ipnlocal"
|
"tailscale.com/ipn/ipnlocal"
|
||||||
)
|
)
|
||||||
|
|
||||||
func registerNotifyCallback(callbacks *TsWasmNetCallbacks, lb *ipnlocal.LocalBackend) {
|
func registerNotifyCallback(callbacks *IPNCallbacks, lb *ipnlocal.LocalBackend) {
|
||||||
lb.SetNotifyCallback(func(n ipn.Notify) {
|
var readyOnce sync.Once
|
||||||
// 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.
|
|
||||||
|
|
||||||
|
lb.SetNotifyCallback(func(n ipn.Notify) {
|
||||||
defer func() {
|
defer func() {
|
||||||
rec := recover()
|
if rec := recover(); rec != nil {
|
||||||
if rec != nil {
|
callbacks.OnError(fmt.Sprint(rec))
|
||||||
callbacks.NotifyPanicRecover(fmt.Sprint(rec))
|
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if n.State != nil {
|
if n.State != nil {
|
||||||
callbacks.NotifyState(*n.State)
|
if *n.State == ipn.Running {
|
||||||
|
readyOnce.Do(callbacks.OnReady)
|
||||||
|
}
|
||||||
|
|
||||||
if *n.State == ipn.NeedsLogin {
|
if *n.State == ipn.NeedsLogin {
|
||||||
// If the state is NeedsLogin, we need to force an interactive login.
|
|
||||||
go forceInteractiveLogin(lb)
|
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) {
|
func forceInteractiveLogin(lb *ipnlocal.LocalBackend) {
|
||||||
err := lb.StartLoginInteractive(context.Background())
|
if err := lb.StartLoginInteractive(context.Background()); err != nil {
|
||||||
if err != nil {
|
log.Printf("Error starting interactive login: %v", err)
|
||||||
fmt.Printf("Error starting interactive login: %v\n", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+72
-126
@@ -8,83 +8,71 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"syscall/js"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Represents an SSH session over the Tailnet.
|
|
||||||
type SSHSession struct {
|
type SSHSession struct {
|
||||||
// Hostname on the Tailnet.
|
IPAddress string
|
||||||
Hostname string
|
Username string
|
||||||
|
Config *TunnelConfig
|
||||||
|
Ipn *TsWasmIpn
|
||||||
|
Pty *ssh.Session
|
||||||
|
|
||||||
// Username for the SSH connection.
|
stdin io.Writer
|
||||||
Username string
|
resizeCols int
|
||||||
|
resizeRows int
|
||||||
// Xterm configuration for the SSH session.
|
cancel context.CancelFunc
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Creates a new SSH session given a hostname and username.
|
func (i *TsWasmIpn) NewSSHSession(config *TunnelConfig) *SSHSession {
|
||||||
func (i *TsWasmIpn) NewSSHSession(hostname, username string, termConfig *SSHXtermConfig) *SSHSession {
|
|
||||||
return &SSHSession{
|
return &SSHSession{
|
||||||
Hostname: hostname,
|
IPAddress: config.IPAddress,
|
||||||
Username: username,
|
Username: config.Username,
|
||||||
TermConfig: termConfig,
|
Config: config,
|
||||||
Ipn: i,
|
Ipn: i,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SSHSession) ConnectAndRun() {
|
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()
|
defer cancel()
|
||||||
|
|
||||||
// TODO: Log here
|
conn, err := s.Ipn.dialer.UserDial(ctx, "tcp", net.JoinHostPort(s.IPAddress, "22"))
|
||||||
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"))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("SSH dial error: %v", err)
|
|
||||||
s.writeError("Dial", err)
|
s.writeError("Dial", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
defer conn.Close()
|
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{
|
sshConf := &ssh.ClientConfig{
|
||||||
User: s.Username,
|
User: s.Username,
|
||||||
HostKeyCallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error {
|
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
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: LOG: Starting SSH Client
|
sshConn, chans, reqs, err := ssh.NewClientConn(conn, s.IPAddress, sshConf)
|
||||||
sshConn, _, _, err := ssh.NewClientConn(conn, s.Hostname, sshConf)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.writeError("SSH", err)
|
s.writeError("SSH", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
defer sshConn.Close()
|
defer sshConn.Close()
|
||||||
sshClient := ssh.NewClient(sshConn, nil, nil)
|
|
||||||
|
conn.SetReadDeadline(time.Time{})
|
||||||
|
|
||||||
|
sshClient := ssh.NewClient(sshConn, chans, reqs)
|
||||||
defer sshClient.Close()
|
defer sshClient.Close()
|
||||||
|
|
||||||
pty, err := sshClient.NewSession()
|
pty, err := sshClient.NewSession()
|
||||||
@@ -92,30 +80,28 @@ func (s *SSHSession) ConnectAndRun() {
|
|||||||
s.writeError("SSH", err)
|
s.writeError("SSH", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
defer pty.Close()
|
defer pty.Close()
|
||||||
s.Pty = pty
|
s.Pty = pty
|
||||||
|
|
||||||
rows := s.TermConfig.Rows
|
rows := 24
|
||||||
if s.ResizeRows != 0 {
|
if s.resizeRows != 0 {
|
||||||
rows = s.ResizeRows
|
rows = s.resizeRows
|
||||||
}
|
}
|
||||||
|
|
||||||
cols := s.TermConfig.Cols
|
cols := 80
|
||||||
if s.ResizeCols != 0 {
|
if s.resizeCols != 0 {
|
||||||
cols = s.ResizeCols
|
cols = s.resizeCols
|
||||||
}
|
}
|
||||||
|
|
||||||
err = pty.RequestPty("xterm", rows, cols, ssh.TerminalModes{
|
err = pty.RequestPty("xterm-256color", rows, cols, ssh.TerminalModes{
|
||||||
ssh.ECHO: 1, // enable echoing
|
ssh.ECHO: 1,
|
||||||
ssh.ICANON: 1, // canonical mode
|
ssh.ICANON: 1,
|
||||||
ssh.ISIG: 1, // enable signals
|
ssh.ISIG: 1,
|
||||||
ssh.ICRNL: 1, // map CR to NL on input
|
ssh.ICRNL: 1,
|
||||||
ssh.IUTF8: 1, // input is UTF-8
|
ssh.IUTF8: 1,
|
||||||
ssh.TTY_OP_ISPEED: 14400, // input speed = 14.4kbaud
|
ssh.TTY_OP_ISPEED: 14400,
|
||||||
ssh.TTY_OP_OSPEED: 14400, // output speed = 14.4kbaud
|
ssh.TTY_OP_OSPEED: 14400,
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.writeError("SSH", err)
|
s.writeError("SSH", err)
|
||||||
return
|
return
|
||||||
@@ -126,8 +112,7 @@ func (s *SSHSession) ConnectAndRun() {
|
|||||||
s.writeError("SSH", err)
|
s.writeError("SSH", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.stdin = stdin
|
||||||
s.wireStdinHandler(stdin)
|
|
||||||
|
|
||||||
stdout, err := pty.StdoutPipe()
|
stdout, err := pty.StdoutPipe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -141,100 +126,61 @@ func (s *SSHSession) ConnectAndRun() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
go io.Copy(XtermPipe{s.TermConfig.OnStdout}, stdout)
|
go io.Copy(DataPipe{s.Config.OnData}, stdout)
|
||||||
go io.Copy(XtermPipe{s.TermConfig.OnStderr}, stderr)
|
go io.Copy(DataPipe{s.Config.OnData}, stderr)
|
||||||
|
|
||||||
// Create our shell
|
|
||||||
err = pty.Shell()
|
err = pty.Shell()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.writeError("SSH", err)
|
s.writeError("SSH", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
s.TermConfig.OnConnect()
|
s.Config.OnConnect()
|
||||||
err = pty.Wait()
|
if err := pty.Wait(); err != nil {
|
||||||
if err != nil {
|
log.Printf("SSH session ended: %v", err)
|
||||||
s.writeError("SSH", err)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resize resizes the terminal for the SSH session.
|
func (s *SSHSession) WriteInput(data string) {
|
||||||
// TODO: This does NOT work correctly from Xterm.js
|
if s.stdin != nil {
|
||||||
func (s *SSHSession) Resize(rows, cols int) error {
|
s.stdin.Write([]byte(data))
|
||||||
// Used to handle resizes while still connecting.
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
if s.Pty == nil {
|
||||||
s.ResizeRows = rows
|
s.resizeCols = cols
|
||||||
s.ResizeCols = cols
|
s.resizeRows = rows
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.Pty.WindowChange(cols, rows)
|
return s.Pty.WindowChange(rows, cols)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Closes the SSH session.
|
|
||||||
func (s *SSHSession) Close() error {
|
func (s *SSHSession) Close() error {
|
||||||
if s.stdinHandler != nil {
|
if s.cancel != nil {
|
||||||
s.stdinHandler.Release()
|
s.cancel()
|
||||||
s.stdinHandler = nil
|
s.cancel = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.Pty != nil {
|
if s.Pty != nil {
|
||||||
err := s.Pty.Close()
|
return s.Pty.Close()
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
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) {
|
func (s *SSHSession) writeError(label string, err error) {
|
||||||
o := fmt.Sprintf("%s error: %v\r\n", label, err)
|
s.Config.OnData(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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// io.Writer "emulator" to pass to the ssh module.
|
type DataPipe struct {
|
||||||
type XtermPipe struct {
|
Send func(data string)
|
||||||
// Function to call when data is written.
|
|
||||||
Send func(data js.Value)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write implements the io.Writer interface for XtermPipe.
|
func (p DataPipe) Write(data []byte) (int, error) {
|
||||||
func (x XtermPipe) Write(data []byte) (int, error) {
|
p.Send(string(data))
|
||||||
uint8Array := js.Global().Get("Uint8Array").New(len(data))
|
|
||||||
js.CopyBytesToJS(uint8Array, data)
|
|
||||||
x.Send(uint8Array)
|
|
||||||
return len(data), nil
|
return len(data), nil
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -26,7 +26,7 @@ in
|
|||||||
# Patch Tailscale's derphttp to include DERPPort in WebSocket URLs.
|
# Patch Tailscale's derphttp to include DERPPort in WebSocket URLs.
|
||||||
# Without this, DERP servers on non-443 ports fail in WASM builds.
|
# Without this, DERP servers on non-443 ports fail in WASM builds.
|
||||||
if [ -f patches/tailscale-derp-port.patch ]; then
|
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
|
fi
|
||||||
|
|
||||||
go build -mod=vendor -o hp_ssh.wasm ./cmd/hp_ssh
|
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-github": "4.25.1",
|
||||||
"@uiw/codemirror-theme-xcode": "4.25.5",
|
"@uiw/codemirror-theme-xcode": "4.25.5",
|
||||||
"@uiw/react-codemirror": "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",
|
"arktype": "^2.1.29",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"drizzle-orm": "1.0.0-beta.16-c2458b2",
|
"drizzle-orm": "1.0.0-beta.16-c2458b2",
|
||||||
@@ -55,6 +50,7 @@
|
|||||||
"react-router-dom": "^7.13.1",
|
"react-router-dom": "^7.13.1",
|
||||||
"react-router-hono-server": "2.25.0",
|
"react-router-hono-server": "2.25.0",
|
||||||
"remix-utils": "^9.0.1",
|
"remix-utils": "^9.0.1",
|
||||||
|
"restty": "^0.1.35",
|
||||||
"tailwind-merge": "3.5.0",
|
"tailwind-merge": "3.5.0",
|
||||||
"ulidx": "2.4.1",
|
"ulidx": "2.4.1",
|
||||||
"undici": "7.22.0",
|
"undici": "7.22.0",
|
||||||
|
|||||||
Generated
+24
-43
@@ -55,21 +55,6 @@ importers:
|
|||||||
'@uiw/react-codemirror':
|
'@uiw/react-codemirror':
|
||||||
specifier: 4.25.5
|
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)
|
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:
|
arktype:
|
||||||
specifier: ^2.1.29
|
specifier: ^2.1.29
|
||||||
version: 2.1.29
|
version: 2.1.29
|
||||||
@@ -121,6 +106,9 @@ importers:
|
|||||||
remix-utils:
|
remix-utils:
|
||||||
specifier: ^9.0.1
|
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)
|
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:
|
tailwind-merge:
|
||||||
specifier: 3.5.0
|
specifier: 3.5.0
|
||||||
version: 3.5.0
|
version: 3.5.0
|
||||||
@@ -2218,21 +2206,6 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
vue: ^3.5.0
|
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:
|
abort-controller@3.0.0:
|
||||||
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
|
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
|
||||||
engines: {node: '>=6.5'}
|
engines: {node: '>=6.5'}
|
||||||
@@ -3760,6 +3733,10 @@ packages:
|
|||||||
resolve-pkg-maps@1.0.0:
|
resolve-pkg-maps@1.0.0:
|
||||||
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
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:
|
retry@0.12.0:
|
||||||
resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==}
|
resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==}
|
||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
@@ -3984,6 +3961,11 @@ packages:
|
|||||||
text-decoder@1.2.3:
|
text-decoder@1.2.3:
|
||||||
resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==}
|
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:
|
tinybench@2.9.0:
|
||||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||||
|
|
||||||
@@ -6182,18 +6164,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
vue: 3.5.18(typescript@5.9.3)
|
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:
|
abort-controller@3.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
event-target-shim: 5.0.1
|
event-target-shim: 5.0.1
|
||||||
@@ -6957,7 +6927,8 @@ snapshots:
|
|||||||
|
|
||||||
jose@6.2.1: {}
|
jose@6.2.1: {}
|
||||||
|
|
||||||
js-base64@3.7.8: {}
|
js-base64@3.7.8:
|
||||||
|
optional: true
|
||||||
|
|
||||||
js-md4@0.3.2: {}
|
js-md4@0.3.2: {}
|
||||||
|
|
||||||
@@ -7643,6 +7614,12 @@ snapshots:
|
|||||||
|
|
||||||
resolve-pkg-maps@1.0.0: {}
|
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: {}
|
retry@0.12.0: {}
|
||||||
|
|
||||||
rfc4648@1.5.4: {}
|
rfc4648@1.5.4: {}
|
||||||
@@ -7970,6 +7947,10 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- react-native-b4a
|
- react-native-b4a
|
||||||
|
|
||||||
|
text-shaper@0.1.22(typescript@5.9.3):
|
||||||
|
dependencies:
|
||||||
|
typescript: 5.9.3
|
||||||
|
|
||||||
tinybench@2.9.0: {}
|
tinybench@2.9.0: {}
|
||||||
|
|
||||||
tinyexec@1.0.2: {}
|
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