offscale is a plugin
headscale leaves the platform. 45 files move to plugins/offscale/ and the
platform stops knowing it exists.
api/router.ts the thin auth-gated proxy, now at /api/offscale
sidecar/ 18 files, the whole headscale contract and its admin keys
db/ schema + queries, offscale_servers
web/ 26 files as panels and a layout — no screen, per the rule
removed from the platform: the hono mount, the `headscale` capability, the
App.tsx route pair, the screen and its barrel, the AppRegistry spread, the
officerdev re-exports, the dock tile, the page-title rule, and both database
barrels. tsgo is clean and nothing references it.
the imports tell the story of what the plugin↔host API actually is. the sidecar
takes @@/sidecar/protocol, @@/sidecar/connect, @@/data-path and
@@/officer-url.mjs; the queries take officerdb/db and officerdb/crypto; the
schema takes officerdb/auth/schema for the one reference a plugin may make; the
web half takes useClient, copyToClipboard, WorkspaceView and TerminalView from
the officerdev barrel. all of it resolves because a plugin lives inside the repo
— no publishing, no version negotiation.
AND IT FOUND A REAL BUG IN THE INSTALLER. createSidecarProxy learns its port
from a one-shot `<name>:server` event and subscribes when the plugin's router is
first imported — at mount. install started the sidecar BEFORE mounting, so the
announcement fired into a void: process online, routes mounted, every request
answering `503 sidecar not available` until something forced a reconnect. it
would have hit every plugin with an http sidecar. `example` never caught it
because it has no listener to announce.
install and enable now mount before starting; disable still unmounts before
stopping. neither direction leaves a mounted route in front of a sidecar that
cannot be reached.
verified live: /api/offscale/_officer/servers answers {"servers":[]}, /offscale
and /offscale/nodes serve, the old /api/headscale is 404, the offscale
capability is registered from the manifest, and officer-offscale is online.
757 pass, same 10 pre-existing failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
import { badRequest, methodNotAllowed, readJson, type OfficerContext } from './routes';
|
||||
|
||||
// SSH console support — the escape hatch for when the Headscale API cannot answer.
|
||||
//
|
||||
// Officer never handles a password, a key or a port here. The console runs `ssh <host>` in the owner's own
|
||||
// shell, so it authenticates with whatever `~/.ssh` already knows; the only thing stored is where to point it.
|
||||
// That is why this file has no credential handling at all, and why it must never grow any: the moment Officer
|
||||
// starts holding a private key or a password, this stops being "run the command you would have run yourself".
|
||||
//
|
||||
// The host string is typed into an interactive shell, so it is validated to a conservative charset rather than
|
||||
// quoted. Quoting would let a plausible-looking value survive to the shell and be someone else's problem;
|
||||
// rejecting it says which character is wrong while the form is still open.
|
||||
|
||||
/** `user@` plus a hostname or IP. Deliberately no spaces, no flags, no shell metacharacters. */
|
||||
const SSH_HOST_RE = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*)?(?:@[A-Za-z0-9](?:[A-Za-z0-9._:-]*)?)?$/;
|
||||
|
||||
/**
|
||||
* Validate a console target. Returns the trimmed host, null when the field was blank (meaning "no console"),
|
||||
* or an error Response.
|
||||
*/
|
||||
export function normalizeSshHost(raw: unknown): string | null | Response {
|
||||
if (raw === null) return null;
|
||||
if (typeof raw !== 'string') return badRequest('sshHost must be a string');
|
||||
const host = raw.trim();
|
||||
if (!host) return null;
|
||||
if (host.length > 255) return badRequest('sshHost is too long');
|
||||
if (!SSH_HOST_RE.test(host)) {
|
||||
return badRequest('sshHost must be a plain host, IP or user@host — no ports, flags or spaces');
|
||||
}
|
||||
return host;
|
||||
}
|
||||
|
||||
type SshProbe = { ok: boolean; error?: string; ms: number };
|
||||
|
||||
/**
|
||||
* Prove the machine is reachable with the keys already on this box, without opening a session.
|
||||
*
|
||||
* `BatchMode=yes` is what makes this a test rather than a hang: ssh fails instead of prompting for a password
|
||||
* or a passphrase, which is exactly the outcome the owner needs to see. `accept-new` records an unknown host
|
||||
* key here rather than leaving the console to open on an interactive "are you sure" prompt the first time —
|
||||
* it still refuses a CHANGED key, which is the check worth keeping.
|
||||
*/
|
||||
export async function probeSsh(host: string): Promise<SshProbe> {
|
||||
const started = Date.now();
|
||||
const proc = Bun.spawn(
|
||||
['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', '-o', 'StrictHostKeyChecking=accept-new', host, 'true'],
|
||||
{ stdout: 'ignore', stderr: 'pipe' },
|
||||
);
|
||||
|
||||
// ConnectTimeout only bounds the TCP connect; a server that accepts and then stalls would hang forever.
|
||||
const timer = setTimeout(() => proc.kill(), 15_000);
|
||||
let stderr = '';
|
||||
try {
|
||||
[stderr] = await Promise.all([new Response(proc.stderr).text(), proc.exited]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
const ms = Date.now() - started;
|
||||
if (proc.exitCode === 0) return { ok: true, ms };
|
||||
|
||||
// ssh's own first line is the useful one ("Permission denied", "Connection timed out"); the rest is noise.
|
||||
const first = stderr
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line && !line.startsWith('Warning: Permanently added'));
|
||||
return { ok: false, error: first || `ssh exited ${proc.exitCode ?? 'on a signal'}`, ms };
|
||||
}
|
||||
|
||||
/**
|
||||
* `POST /_officer/ssh-test {host}`. Takes the host in the body rather than a server id on purpose: the form
|
||||
* needs to test a value the owner has typed but not yet saved, which is the case where a typo is still cheap.
|
||||
*/
|
||||
export async function handleSshTestRoute(ctx: OfficerContext, rest: string[]): Promise<Response> {
|
||||
if (rest.length > 0) return badRequest('unexpected path');
|
||||
if (ctx.req.method !== 'POST') return methodNotAllowed();
|
||||
|
||||
const body = await readJson(ctx.req);
|
||||
if (!body) return badRequest('expected a JSON body');
|
||||
|
||||
const host = normalizeSshHost(body.host);
|
||||
if (host instanceof Response) return host;
|
||||
if (!host) return badRequest('host is required');
|
||||
|
||||
return Response.json(await probeSsh(host));
|
||||
}
|
||||
Reference in New Issue
Block a user