feat: cleanup removal of old ssh plexer and logic

we also have added the necessary logic to auto prune ephemeral nodes because
headscale doesn't seem to automatically remove them. this change made use of a database
which is now stored in the persistent headplane directory.
This commit is contained in:
Aarnav Tale 2025-06-20 00:14:00 -04:00
parent bf1d75a27a
commit b18147fa82
No known key found for this signature in database
24 changed files with 963 additions and 547 deletions

View File

@ -1,3 +1,8 @@
### 0.6.1 (Next)
- **Headplane now supports connecting to machines via SSH in the web browser.**
- This is an experimental feature and requires the `integration.agent` section to be set up in the config file.
- This is built on top of a Go binary that runs in WebAssembly, using Xterm.js for the terminal interface.
### 0.6.0 (May 25, 2025)
- Headplane 0.6.0 now requires **Headscale 0.26.0** or newer.
- Breaking API changes with routes and pre auth keys are now supported (closes [#204](https://github.com/tale/headplane/issues/204)).

View File

@ -1,25 +1,18 @@
import { XCircleFillIcon } from '@primer/octicons-react';
import { ServerCrash } from 'lucide-react';
import {
type LoaderFunctionArgs,
isRouteErrorResponse,
redirect,
useRouteError,
} from 'react-router';
import { Outlet, useLoaderData } from 'react-router';
import Card from '~/components/Card';
import { ErrorPopup, getErrorMessage } from '~/components/Error';
import { type LoaderFunctionArgs, Outlet, redirect } from 'react-router';
import { ErrorPopup } from '~/components/Error';
import type { LoadContext } from '~/server';
import { pruneEphemeralNodes } from '~/server/db/pruner';
import ResponseError from '~/server/headscale/api-error';
import cn from '~/utils/cn';
import log from '~/utils/log';
export async function loader({
request,
context,
...rest
}: LoaderFunctionArgs<LoadContext>) {
const healthy = await context.client.healthcheck();
const session = await context.sessions.auth(request);
await pruneEphemeralNodes({ context, request, ...rest });
// We shouldn't session invalidate if Headscale is down
// TODO: Notify in the logs or the UI that OIDC auth key is wrong if enabled

View File

@ -66,8 +66,8 @@ export default function Page() {
The ACL policy mode is most likely set to <Code>file</Code> in your
Headscale configuration. This means that the ACL file cannot be edited
through the web interface. In order to resolve this, you'll need to
set <Code>policy.mode</Code> to <Code>database</Code> in your Headscale
configuration.
set <Code>policy.mode</Code> to <Code>database</Code> in your
Headscale configuration.
</Notice>
) : undefined}
<h1 className="text-2xl font-medium mb-4">Access Control List (ACL)</h1>

View File

@ -2,17 +2,22 @@ import { faker } from '@faker-js/faker';
import { Loader2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import {
ActionFunctionArgs,
LoaderFunctionArgs,
ShouldRevalidateFunction,
data,
useLoaderData,
useSubmit,
} from 'react-router';
import { useEventSource } from 'remix-utils/sse/react';
import { LoadContext } from '~/server';
import { PreAuthKey, User } from '~/types';
import { Machine, PreAuthKey, User } from '~/types';
import { useLiveData } from '~/utils/live-data';
import XTerm from './xterm.client';
import { eq } from 'drizzle-orm';
import wasm from '~/hp_ssh.wasm?url';
import { EphemeralNodeInsert, ephemeralNodes } from '~/server/db/schema';
import '~/wasm_exec';
export const shouldRevalidate: ShouldRevalidateFunction = () => {
@ -54,12 +59,14 @@ export async function loader({
}
return subject === user.subject;
});
if (!lookup) {
throw data(
`User with subject ${user.subject} not found within Headscale`,
404,
);
}
const { preAuthKey } = await context.client.post<{ preAuthKey: PreAuthKey }>(
'v1/preauthkey',
session.get('api_key')!,
@ -67,30 +74,71 @@ export async function loader({
user: lookup.id,
reusable: false,
ephemeral: true,
expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(), // 1 hour
expiration: new Date(Date.now() + 60 * 1000).toISOString(), // 1 minute
},
);
// 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 (!username || !hostname) {
throw data('Missing required parameters: username, hostname', 400);
}
// TODO: Verify the Host headers to ensure CORS friendly
// TODO: Check if the URL actually resolves correctly
// TODO: Keep track of hostname since ephemeral nodes are broken atm
// 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 context.client.get<{ nodes: Machine[] }>(
'v1/node',
session.get('api_key')!,
);
// node.name is the hostname, given_name is the set name
const lookupNode = nodes.find((n) => n.name === 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();
console.log('Created ephemeral node:', ephemeralNode);
return {
ipnDetails: {
PreAuthKey: preAuthKey.key,
ControlURL:
context.config.headscale.public_url ?? context.config.headscale.url,
Hostname: generateHostname(username),
ssh: {
ControlURL: u,
},
sshDetails: {
username,
hostname,
},
@ -115,11 +163,46 @@ function generateHostname(username: string) {
return `ssh-${adjective}-${noun}-${username}`;
}
export async function action({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
if (!context.agents?.agentID()) {
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));
}
export default function Page() {
const submit = useSubmit();
const { pause } = useLiveData();
const [ipn, setIpn] = useState<TsWasmNet | null>(null);
const { PreAuthKey, ControlURL, Hostname, ssh } =
useLoaderData<typeof loader>();
const [nodeKey, setNodeKey] = useState<string | null>(null);
const { ipnDetails, sshDetails } = useLoaderData<typeof loader>();
const ping = useEventSource('/ssh/ping', { event: 'ping' });
useEffect(() => {
pause();
@ -127,13 +210,7 @@ export default function Page() {
WebAssembly.instantiateStreaming(fetch(wasm), go.importObject).then(
(value) => {
go.run(value.instance);
const handle = TsWasmNet(
{
PreAuthKey,
ControlURL,
Hostname,
},
{
const handle = TsWasmNet(ipnDetails, {
NotifyState: (state) => {
console.log('State changed:', state);
if (state === 'Running') {
@ -141,7 +218,23 @@ export default function Page() {
}
},
NotifyNetMap: (netmap) => {
console.log('NetMap updated:', 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);
@ -149,8 +242,7 @@ export default function Page() {
NotifyPanicRecover: (message) => {
console.error('Panic recover:', message);
},
},
);
});
handle.Start();
},
@ -165,7 +257,11 @@ export default function Page() {
</div>
) : (
<div className="flex flex-col h-screen">
<XTerm ipn={ipn} username={ssh.username} hostname={ssh.hostname} />
<XTerm
ipn={ipn}
username={sshDetails.username}
hostname={sshDetails.hostname}
/>
</div>
)}
</div>

View File

@ -11,11 +11,15 @@ interface TsWasmNetOptions {
interface TsWasmNetCallbacks {
NotifyState: (state: IPNState) => void;
NotifyNetMap: (netMapJson: string) => void;
NotifyNetMap: (netmap: TsWasmNetMap) => void;
NotifyBrowseToURL: (url: string) => void;
NotifyPanicRecover: (err: string) => void;
}
interface TsWasmNetMap {
NodeKey: string;
}
interface TsWasmNet {
Start: () => void;
OpenSSH: (

View File

@ -1,89 +0,0 @@
import type { Writable } from 'node:stream';
import { encode } from 'cborg';
import { WSContext } from 'hono/ws';
import { ChannelType } from './encoder';
export interface Command {
op: string;
payload: unknown;
}
export interface SSHConnectCommand extends Command {
op: 'ssh_conn';
payload: {
sessionId: string;
username: string;
hostname: string;
port: number;
};
}
export interface SSHCloseCommand extends Command {
op: 'ssh_term';
payload: {
sessionId: string;
};
}
export interface SSHResizeCommand extends Command {
op: 'ssh_resize';
payload: {
sessionId: string;
width: number;
height: number;
};
}
export interface SSHDataCommand extends Command {
op: 'ssh_data';
payload: {
sessionId: string;
data: Uint8Array;
};
}
type AgentCommand = SSHConnectCommand | SSHCloseCommand | SSHResizeCommand;
export async function dispatchCommand(
dispatcher: Writable,
command: AgentCommand,
) {
return new Promise<void>((resolve, reject) => {
const encodedCommand = Buffer.concat([encode(command), Buffer.from('\n')]);
dispatcher.write(encodedCommand, (err) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}
export interface SSHConnectData extends Command {
op: 'ssh_conn_successful';
payload: {
sessionId: string;
};
}
export interface SSHConnectFailedData extends Command {
op: 'ssh_conn_failed';
payload: {
reason: string;
};
}
export interface SSHFrameData extends Command {
op: 'ssh_frame';
payload: {
channel: ChannelType;
frame: Buffer;
};
}
type WebData = SSHConnectData | SSHConnectFailedData | SSHFrameData;
export function dispatchWeb<T>(dispatcher: WSContext<T>, data: WebData) {
return dispatcher.send(encode(data));
}

View File

@ -1,91 +0,0 @@
// Refer to agent/internal/sshutil/encoder.go for more details
// This is the Node.js implementation of the SSH encoder
import log from '~/utils/log';
const MAGIC = 'HPLS';
const VERSION = 1;
// 0 -> Stdin
// 1 -> Stdout
// 2 -> Stderr
export type ChannelType = 0 | 1 | 2;
interface SSHFrame {
sessionId: string;
channel: ChannelType;
payload: Buffer;
}
export async function encodeSSHFrame(frame: SSHFrame) {
const sid = Buffer.from(frame.sessionId, 'utf8');
if (sid.length > 255) {
log.error('agent', 'SSH session ID too long: %s', frame.sessionId);
return;
}
// Size can only hold 4 bytes
if (frame.payload.length > 0xffffffff) {
log.error('agent', 'SSH payload too large: %d bytes', frame.payload.length);
return;
}
const frameSize =
4 + // Magic
1 + // Version
1 + // Channel Type
(1 + sid.length) + // Session ID length + SID
(4 + frame.payload.length); // Payload length + Payload
const buf = Buffer.alloc(frameSize);
buf.write(MAGIC, 0, 'utf8');
buf.writeUInt8(VERSION, 4);
buf.writeUInt8(frame.channel, 5);
buf.writeUInt8(sid.length, 6);
const offset = 7 + sid.length;
sid.copy(buf, 7);
buf.writeUInt32BE(frame.payload.length, offset);
frame.payload.copy(buf, offset + 4);
return buf;
}
export function decodeSSHFrame(data: Buffer) {
if (data.length < 5) {
log.error('agent', 'SSH frame too short: %d bytes', data.length);
return;
}
const magic = data.toString('utf8', 0, 4);
const version = data.readUInt8(4);
if (magic !== MAGIC || version !== VERSION) {
log.error('agent', 'Invalid SSH frame magic or version');
return;
}
const channel = data.readUInt8(5) as ChannelType;
const sidLength = data.readUInt8(6);
if (data.length < 7 + sidLength + 4) {
log.error('agent', 'SSH frame too short for session ID and payload');
return;
}
const sessionId = data.toString('utf8', 7, 7 + sidLength);
const payloadLength = data.readUInt32BE(7 + sidLength);
if (data.length < 7 + sidLength + 4 + payloadLength) {
log.error('agent', 'SSH frame too short for payload');
return;
}
const payload = data.subarray(
7 + sidLength + 4,
7 + sidLength + 4 + payloadLength,
);
return {
sessionId,
channel,
payload,
};
}

View File

@ -1,255 +0,0 @@
import { ChildProcess } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import type { Readable, Writable } from 'node:stream';
import { decode } from 'cborg';
import { Context } from 'hono';
import { WSContext, WSEvents } from 'hono/ws';
import log from '~/utils/log';
import {
Command,
SSHDataCommand,
SSHResizeCommand,
dispatchCommand,
dispatchWeb,
} from './dispatcher';
import { decodeSSHFrame, encodeSSHFrame } from './encoder';
interface SSHConnection {
username: string;
hostname: string;
port: number;
}
interface SSHSession {
connectionDetails: SSHConnection;
connected: boolean;
sessionId: string;
ws: WSContext;
}
interface FrameDecodeSuccess {
id: string;
data: Buffer;
}
interface FrameDecodeFailure {
id: undefined;
data: undefined;
}
export function createSSHMultiplexer(proc: ChildProcess): SSHMultiplexer {
const control = proc.stdin;
const sshInput = proc.stdio[3];
const sshOutput = proc.stdio[4];
if (!control || !sshInput || !sshOutput) {
throw new Error('Invalid SSH multiplexer process: missing stdio streams');
}
return new SSHMultiplexer(
control,
sshInput as Writable,
sshOutput as Readable,
);
}
export class SSHMultiplexer {
private connections: Map<string, SSHSession>;
private control: Writable;
private sshInput: Writable;
private sshOutput: Readable;
constructor(control: Writable, sshInput: Writable, sshOutput: Readable) {
this.connections = new Map();
this.control = control;
this.sshInput = sshInput;
this.sshOutput = sshOutput;
this.configureStdout();
}
// TODO: Determine if we want to allow multiple connections for the same
// target or attempt to reuse the existing connection (sounds stupid)
private async connect(conn: SSHConnection, ws: WSContext<string>) {
const sessionId = randomUUID();
const session: SSHSession = {
connectionDetails: conn,
connected: true,
sessionId,
ws,
};
log.debug('agent', 'Dispatching SSH connection for %s', sessionId);
await dispatchCommand(this.control, {
op: 'ssh_conn',
payload: {
sessionId,
...conn,
},
});
this.connections.set(sessionId, session);
return sessionId;
}
websocketHandler(c: Context): WSEvents<string> {
return {
onOpen: async (_, ws) => {
const { username, hostname, port } = c.req.query();
if (!username || !hostname || !port) {
ws.close(1008, 'Missing connection parameters');
return;
}
const conn: SSHConnection = {
username,
hostname,
port: Number.parseInt(port, 10),
};
try {
const sessionId = await this.connect(conn, ws);
ws.raw = sessionId;
dispatchWeb(ws, {
op: 'ssh_conn_successful',
payload: { sessionId },
});
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error';
dispatchWeb(ws, {
op: 'ssh_conn_failed',
payload: {
reason: errorMessage,
},
});
ws.close(1011, 'Connection failed');
}
},
onMessage: async (event, ws) => {
const sessionId = ws.raw;
if (!sessionId || !this.connections.has(sessionId)) {
ws.close(1008, 'Invalid session ID');
return;
}
const session = this.connections.get(sessionId);
if (!session || !session.connected) {
ws.close(1008, 'Session not connected');
return;
}
const wsData = Buffer.isBuffer(event.data)
? event.data
: typeof event.data === 'string'
? Buffer.from(event.data, 'utf8')
: event.data instanceof Blob
? Buffer.from(await event.data.arrayBuffer())
: Buffer.from(event.data);
const obj = decode(wsData) as Command;
if (obj.op === 'ssh_data') {
const data = obj as SSHDataCommand;
if (data.payload.sessionId !== sessionId) {
log.warn(
'agent',
'Received data for mismatched SSH session %s',
data.payload.sessionId,
);
return;
}
const encodedFrame = await encodeSSHFrame({
sessionId,
channel: 0, // stdin
payload: Buffer.from(data.payload.data),
});
this.sshInput.write(encodedFrame);
}
if (obj.op === 'ssh_resize') {
const resize = obj as SSHResizeCommand;
if (resize.payload.sessionId !== sessionId) {
log.warn(
'agent',
'Received resize for mismatched SSH session %s',
resize.payload.sessionId,
);
return;
}
await dispatchCommand(this.control, resize);
}
},
onClose: async (_, ws) => {
const sessionId = ws.raw;
if (sessionId && this.connections.has(sessionId)) {
const session = this.connections.get(sessionId);
if (session) {
await dispatchCommand(this.control, {
op: 'ssh_term',
payload: {
sessionId,
},
});
session.connected = false;
this.connections.delete(sessionId);
}
}
},
onError: async (event, ws) => {
const sessionId = ws.raw;
if (sessionId && this.connections.has(sessionId)) {
const session = this.connections.get(sessionId);
if (session) {
await dispatchCommand(this.control, {
op: 'ssh_term',
payload: {
sessionId,
},
});
session.connected = false;
this.connections.delete(sessionId);
}
}
log.error('agent', 'SSH WebSocket Error with %s', sessionId);
log.debug('agent', 'Error details: %o', event);
},
};
}
private configureStdout() {
this.sshOutput.on('data', (bytes) => {
const frame = decodeSSHFrame(bytes);
if (!frame) {
return;
}
const session = this.connections.get(frame.sessionId);
if (!session || !session.connected) {
log.warn(
'agent',
'Received data for invalid SSH session %s',
frame.sessionId,
);
return;
}
dispatchWeb(session.ws, {
op: 'ssh_frame',
payload: {
channel: frame.channel,
frame: frame.payload,
},
});
});
}
}

View File

@ -19,6 +19,7 @@ const stringToBool = type('string | boolean').pipe((v) => {
const serverConfig = type({
host: 'string.ip',
port: type('string | number.integer').pipe((v) => Number(v)),
data_path: 'string = "/var/lib/headplane/"',
cookie_secret: '32 <= string <= 32',
cookie_secure: stringToBool,
});

26
app/server/db/client.ts Normal file
View File

@ -0,0 +1,26 @@
import { mkdir } from 'node:fs/promises';
import { dirname } from 'node:path';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
import log from '~/utils/log';
export async function createDbClient(path: string) {
try {
await mkdir(dirname(path), { recursive: true });
} catch (error) {
log.error(
'server',
'Failed to create directory for database at %s: %s',
path,
error instanceof Error ? error.message : String(error),
);
throw new Error(`Could not create directory for database at ${path}`);
}
const db = drizzle(path);
migrate(db, {
migrationsFolder: './drizzle',
});
return db;
}

58
app/server/db/pruner.ts Normal file
View File

@ -0,0 +1,58 @@
import { eq, isNotNull } from 'drizzle-orm';
import { LoaderFunctionArgs } from 'react-router';
import { Machine } from '~/types';
import log from '~/utils/log';
import { LoadContext } from '..';
import { ephemeralNodes } from './schema';
export async function pruneEphemeralNodes({
context,
request,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(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 { nodes } = await context.client.get<{ nodes: Machine[] }>(
'v1/node',
session.get('api_key')!,
);
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.info('api', `Pruning node ${node.name}`);
await context.client.delete(
`v1/node/${node.id}`,
session.get('api_key')!,
);
await context.db
.delete(ephemeralNodes)
.where(eq(ephemeralNodes.node_key, node.nodeKey));
log.info('api', `Node ${node.name} pruned successfully`);
};
});
await Promise.all(promises.map((p) => p()));
}

9
app/server/db/schema.ts Normal file
View File

@ -0,0 +1,9 @@
import { sqliteTable, text } from 'drizzle-orm/sqlite-core';
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;

View File

@ -1,10 +1,11 @@
import { join } from 'node:path';
import { env, versions } from 'node:process';
import type { WSEvents } from 'hono/ws';
import { createHonoServer } from 'react-router-hono-server/node';
import log from '~/utils/log';
import { configureConfig, configureLogger, envVariables } from './config/env';
import { loadIntegration } from './config/integration';
import { loadConfig } from './config/loader';
import { createDbClient } from './db/client';
import { createApiClient } from './headscale/api-client';
import { loadHeadscaleConfig } from './headscale/config-loader';
import { loadAgentSocket } from './web/agent';
@ -33,6 +34,8 @@ const agentManager = await loadAgentSocket(
config.headscale.url,
);
const db = await createDbClient(join(config.server.data_path, 'hp_persist.db'));
// We also use this file to load anything needed by the react router code.
// These are usually per-request things that we need access to, like the
// helper that can issue and revoke cookies.
@ -64,6 +67,7 @@ const appLoadContext = {
agents: agentManager,
integration: await loadIntegration(config.integration),
oidc: config.oidc ? await createOidcClient(config.oidc) : undefined,
db,
};
declare module 'react-router' {
@ -87,36 +91,4 @@ export default createHonoServer({
listeningListener(info) {
log.info('server', 'Running on %s:%s', info.address, info.port);
},
useWebSocket: true,
configure: (app, { upgradeWebSocket }) => {
if (agentManager === undefined) {
return;
}
app.get(
'/_ssh_plexer',
upgradeWebSocket((c) => {
// MARK: This is a limitation of the hono NPM module we use
const wsHandler = agentManager.multiplexer?.websocketHandler(
c,
) as WSEvents<unknown>;
return {
onOpen: wsHandler
? wsHandler.onOpen
: (_, ws) => ws.close(1000, 'Multiplexer not available'),
onClose: wsHandler
? wsHandler.onClose
: (_, ws) => ws.close(1000, 'Multiplexer not available'),
onMessage: wsHandler
? wsHandler.onMessage
: (_, ws) => ws.close(1000, 'Multiplexer not available'),
onError: wsHandler
? wsHandler.onError
: (_, ws) => ws.close(1000, 'Multiplexer error'),
};
}),
);
},
});

View File

@ -10,12 +10,10 @@ import {
} from 'node:fs/promises';
import { exit } from 'node:process';
import { createInterface } from 'node:readline';
import { Readable, Writable } from 'node:stream';
import { setTimeout } from 'node:timers/promises';
import { type } from 'arktype';
import { HostInfo } from '~/types';
import log from '~/utils/log';
import { SSHMultiplexer, createSSHMultiplexer } from '../agent/ssh';
import type { HeadplaneConfig } from '../config/schema';
interface LogResponse {
@ -115,7 +113,6 @@ class AgentManager {
>;
private spawnProcess: ChildProcess | null;
multiplexer: SSHMultiplexer | null;
private agentId: string | null;
constructor(
@ -127,7 +124,6 @@ class AgentManager {
this.config = config;
this.headscaleUrl = headscaleUrl;
this.spawnProcess = null;
this.multiplexer = null;
this.agentId = null;
this.startAgent();
@ -214,14 +210,6 @@ class AgentManager {
return;
}
const sshInput = this.spawnProcess.stdio[3];
const sshOutput = this.spawnProcess.stdio[4];
if (sshInput && sshOutput) {
log.info('agent', 'Using SSH multiplexer manager');
this.multiplexer = createSSHMultiplexer(this.spawnProcess);
}
const rlStdout = createInterface({
input: this.spawnProcess.stdout,
crlfDelay: Number.POSITIVE_INFINITY,

View File

@ -7,7 +7,7 @@
},
"files": {
"ignoreUnknown": false,
"ignore": []
"ignore": ["app/wasm_exec.js"]
},
"formatter": {
"enabled": true,

View File

@ -12,6 +12,13 @@ server:
# (I recommend this is true in production)
cookie_secure: true
# The path to persist Headplane specific data. All data going forward
# is stored in this directory, including the internal database and
# any cache related files.
#
# Data formats prior to 0.6.1 will automatically be migrated.
data_path: "/var/lib/headplane"
# Headscale specific settings to allow Headplane to talk
# to Headscale and access deep integration features
headscale:

8
drizzle.config.ts Normal file
View File

@ -0,0 +1,8 @@
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
dialect: 'sqlite',
schema: './app/server/db/schema.ts',
dbCredentials: {
url: 'file:test/hp_persist.db',
},
});

View File

@ -0,0 +1,4 @@
CREATE TABLE `ephemeral_nodes` (
`auth_key` text PRIMARY KEY NOT NULL,
`node_key` text
);

View File

@ -0,0 +1,42 @@
{
"version": "6",
"dialect": "sqlite",
"id": "ab03ffcd-9aa5-4b4b-9f38-322acc6899a3",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"ephemeral_nodes": {
"name": "ephemeral_nodes",
"columns": {
"auth_key": {
"name": "auth_key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"node_key": {
"name": "node_key",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1750355487927,
"tag": "0000_spicy_bloodscream",
"breakpoints": true
}
]
}

View File

@ -72,7 +72,13 @@ func ParseTsWasmNetCallbacks(obj js.Value) (*TsWasmNetCallbacks, error) {
},
NotifyNetMap: func(nm *netmap.NetworkMap) {
// TODO: This is complicated
// 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) {

View File

@ -37,6 +37,10 @@ func registerNotifyCallback(callbacks *TsWasmNetCallbacks, lb *ipnlocal.LocalBac
callbacks.NotifyBrowseToURL(*n.BrowseToURL)
}
if n.NetMap != nil {
callbacks.NotifyNetMap(n.NetMap)
}
log.Printf("NOTIFY: %+v", n)
// if nm := n.NetMap; nm != nil {

View File

@ -36,9 +36,10 @@
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"arktype": "^2.1.20",
"cborg": "^4.2.11",
"better-sqlite3": "^11.10.0",
"clsx": "^2.1.1",
"dotenv": "^16.5.0",
"drizzle-orm": "^0.44.2",
"isbot": "^5.1.28",
"lucide-react": "^0.511.0",
"mime": "^4.0.7",
@ -62,8 +63,9 @@
"@biomejs/biome": "^1.9.4",
"@react-router/dev": "^7.6.1",
"@tailwindcss/vite": "^4.1.8",
"@types/better-sqlite3": "^7.6.13",
"@types/websocket": "^1.0.10",
"hono": "^4.7.10",
"drizzle-kit": "^0.31.1",
"lefthook": "^1.11.13",
"postcss": "^8.5.4",
"react-router-dom": "^7.6.1",
@ -84,6 +86,11 @@
"patchedDependencies": {
"react-router-hono-server": "patches/react-router-hono-server.patch"
},
"onlyBuiltDependencies": ["@biomejs/biome", "esbuild", "lefthook"]
"onlyBuiltDependencies": [
"@biomejs/biome",
"better-sqlite3",
"esbuild",
"lefthook"
]
}
}

632
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff