v1.10: booterm container — xterm.js + tmux + node-pty
This commit is contained in:
11
apps/booterm/src/auth.ts
Normal file
11
apps/booterm/src/auth.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
|
||||
// Mirrors the boocode pattern: there is no app-layer auth — Authelia handles
|
||||
// it at the reverse proxy (CLAUDE.md). All broker.publishUser calls use
|
||||
// 'default' as the user key. We accept Remote-User when present (set by the
|
||||
// proxy in prod) and fall back to 'default' on direct Tailscale access.
|
||||
export function getUser(req: FastifyRequest): string {
|
||||
const header = req.headers['remote-user'];
|
||||
if (typeof header === 'string' && header.length > 0) return header;
|
||||
return 'default';
|
||||
}
|
||||
26
apps/booterm/src/config.ts
Normal file
26
apps/booterm/src/config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const ConfigSchema = z.object({
|
||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||
PORT: z.coerce.number().int().positive().default(3000),
|
||||
HOST: z.string().default('0.0.0.0'),
|
||||
DATABASE_URL: z.string().url(),
|
||||
LOG_LEVEL: z.string().default('info'),
|
||||
TMUX_CONF_PATH: z.string().default('/etc/booterm/tmux.conf'),
|
||||
});
|
||||
|
||||
export type Config = z.infer<typeof ConfigSchema>;
|
||||
|
||||
let cached: Config | null = null;
|
||||
|
||||
export function loadConfig(): Config {
|
||||
if (cached) return cached;
|
||||
const parsed = ConfigSchema.safeParse(process.env);
|
||||
if (!parsed.success) {
|
||||
console.error('Invalid environment configuration:');
|
||||
console.error(parsed.error.flatten().fieldErrors);
|
||||
process.exit(1);
|
||||
}
|
||||
cached = parsed.data;
|
||||
return cached;
|
||||
}
|
||||
46
apps/booterm/src/db.ts
Normal file
46
apps/booterm/src/db.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import pg from 'pg';
|
||||
|
||||
const { Pool } = pg;
|
||||
|
||||
let pool: pg.Pool | null = null;
|
||||
|
||||
export function getPool(databaseUrl: string): pg.Pool {
|
||||
if (pool) return pool;
|
||||
pool = new Pool({ connectionString: databaseUrl, max: 5, idleTimeoutMillis: 30_000 });
|
||||
return pool;
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
id: string;
|
||||
project_id: string;
|
||||
project_path: string;
|
||||
}
|
||||
|
||||
export async function getSessionInfo(sessionId: string): Promise<SessionInfo | null> {
|
||||
if (!pool) throw new Error('db pool not initialized');
|
||||
const res = await pool.query<SessionInfo>(
|
||||
`SELECT s.id, s.project_id, p.path AS project_path
|
||||
FROM sessions s
|
||||
JOIN projects p ON p.id = s.project_id
|
||||
WHERE s.id = $1`,
|
||||
[sessionId],
|
||||
);
|
||||
return res.rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function pingDb(): Promise<boolean> {
|
||||
if (!pool) return false;
|
||||
try {
|
||||
await pool.query('SELECT 1');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeDb(): Promise<void> {
|
||||
if (pool) {
|
||||
await pool.end();
|
||||
pool = null;
|
||||
}
|
||||
}
|
||||
60
apps/booterm/src/index.ts
Normal file
60
apps/booterm/src/index.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import Fastify from 'fastify';
|
||||
import fastifyWebsocket from '@fastify/websocket';
|
||||
import { loadConfig } from './config.js';
|
||||
import { getPool, closeDb } from './db.js';
|
||||
import { registerHealthRoutes } from './routes/health.js';
|
||||
import { registerTerminalRoutes } from './routes/terminals.js';
|
||||
import { registerWsAttachRoute } from './ws/attach.js';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const config = loadConfig();
|
||||
|
||||
const app = Fastify({
|
||||
logger: { level: config.LOG_LEVEL },
|
||||
});
|
||||
|
||||
app.removeContentTypeParser(['application/json']);
|
||||
app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => {
|
||||
const str = (body as string) ?? '';
|
||||
if (str.trim().length === 0) {
|
||||
done(null, {});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
done(null, JSON.parse(str));
|
||||
} catch (err) {
|
||||
done(err as Error, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
getPool(config.DATABASE_URL);
|
||||
|
||||
await app.register(fastifyWebsocket);
|
||||
|
||||
registerHealthRoutes(app);
|
||||
registerTerminalRoutes(app, config.TMUX_CONF_PATH);
|
||||
registerWsAttachRoute(app, config.TMUX_CONF_PATH);
|
||||
|
||||
const shutdown = async (signal: string) => {
|
||||
app.log.info(`received ${signal}, shutting down`);
|
||||
try {
|
||||
await app.close();
|
||||
await closeDb();
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
app.log.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
process.on('SIGINT', () => void shutdown('SIGINT'));
|
||||
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
||||
|
||||
await app.listen({ port: config.PORT, host: config.HOST });
|
||||
app.log.info(`booterm listening on http://${config.HOST}:${config.PORT}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Fatal startup error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
102
apps/booterm/src/pty/manager.ts
Normal file
102
apps/booterm/src/pty/manager.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
|
||||
// UUIDs already match [0-9a-f-]; allow uppercase and longer just in case.
|
||||
const ID_RE = /^[a-zA-Z0-9_-]{1,64}$/;
|
||||
|
||||
export function sanitizeId(raw: string): string | null {
|
||||
if (!ID_RE.test(raw)) return null;
|
||||
return raw.toLowerCase();
|
||||
}
|
||||
|
||||
export function tmuxSessionName(sessionId: string): string {
|
||||
return `bc-${sessionId}`;
|
||||
}
|
||||
|
||||
export function tmuxWindowName(paneId: string): string {
|
||||
return `term-${paneId}`;
|
||||
}
|
||||
|
||||
interface CmdResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number;
|
||||
}
|
||||
|
||||
// Wrap child_process.spawn with shell:false so each argv element is passed
|
||||
// as a separate argument — no shell interpolation, no injection surface.
|
||||
function runTmux(tmuxConfPath: string, args: string[]): Promise<CmdResult> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn('tmux', ['-f', tmuxConfPath, ...args], { shell: false });
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8'); });
|
||||
child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8'); });
|
||||
child.on('error', (err) => {
|
||||
resolve({ stdout, stderr: stderr + String(err), code: 1 });
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
resolve({ stdout, stderr, code: code ?? 0 });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function hasSession(tmuxConfPath: string, sessionName: string): Promise<boolean> {
|
||||
const res = await runTmux(tmuxConfPath, ['has-session', '-t', `=${sessionName}`]);
|
||||
return res.code === 0;
|
||||
}
|
||||
|
||||
export async function listWindows(tmuxConfPath: string, sessionName: string): Promise<string[]> {
|
||||
const res = await runTmux(tmuxConfPath, ['list-windows', '-t', sessionName, '-F', '#{window_name}']);
|
||||
if (res.code !== 0) return [];
|
||||
return res.stdout.trim().split('\n').filter(Boolean);
|
||||
}
|
||||
|
||||
export async function killWindow(
|
||||
tmuxConfPath: string,
|
||||
sessionName: string,
|
||||
windowName: string,
|
||||
): Promise<boolean> {
|
||||
const res = await runTmux(tmuxConfPath, ['kill-window', '-t', `${sessionName}:${windowName}`]);
|
||||
return res.code === 0;
|
||||
}
|
||||
|
||||
// Idempotent. Creates the tmux session if it doesn't exist, then ensures the
|
||||
// named window is present. The session's initial window is created with the
|
||||
// target name (via `-n`) so we don't need a separate rename step.
|
||||
export async function ensureWindow(
|
||||
tmuxConfPath: string,
|
||||
sessionName: string,
|
||||
windowName: string,
|
||||
projectRoot: string,
|
||||
log: FastifyBaseLogger,
|
||||
): Promise<void> {
|
||||
if (!(await hasSession(tmuxConfPath, sessionName))) {
|
||||
log.info({ sessionName, windowName, projectRoot }, 'creating tmux session');
|
||||
const res = await runTmux(tmuxConfPath, [
|
||||
'new-session', '-d',
|
||||
'-s', sessionName,
|
||||
'-n', windowName,
|
||||
'-c', projectRoot,
|
||||
]);
|
||||
if (res.code !== 0) {
|
||||
log.error({ res }, 'tmux new-session failed');
|
||||
throw new Error(`tmux new-session failed: ${res.stderr}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const windows = await listWindows(tmuxConfPath, sessionName);
|
||||
if (windows.includes(windowName)) return;
|
||||
|
||||
const res = await runTmux(tmuxConfPath, [
|
||||
'new-window',
|
||||
'-t', sessionName,
|
||||
'-n', windowName,
|
||||
'-c', projectRoot,
|
||||
]);
|
||||
if (res.code !== 0) {
|
||||
log.error({ res }, 'tmux new-window failed');
|
||||
throw new Error(`tmux new-window failed: ${res.stderr}`);
|
||||
}
|
||||
}
|
||||
41
apps/booterm/src/pty/pty.ts
Normal file
41
apps/booterm/src/pty/pty.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import * as pty from 'node-pty';
|
||||
import type { IPty } from 'node-pty';
|
||||
|
||||
export interface AttachPtyOptions {
|
||||
sessionName: string;
|
||||
windowName: string;
|
||||
projectRoot: string;
|
||||
cols: number;
|
||||
rows: number;
|
||||
tmuxConfPath: string;
|
||||
}
|
||||
|
||||
function cleanEnv(): { [key: string]: string } {
|
||||
const out: { [key: string]: string } = {};
|
||||
for (const [k, v] of Object.entries(process.env)) {
|
||||
if (typeof v === 'string') out[k] = v;
|
||||
}
|
||||
out['TERM'] = 'screen-256color';
|
||||
return out;
|
||||
}
|
||||
|
||||
// Spawns a tmux client attached to the given session+window. `-d` detaches any
|
||||
// other client so a browser refresh takes over the same window without
|
||||
// duplicate input. tmux server (and the window) persists across PTY exits.
|
||||
export function attachPty(opts: AttachPtyOptions): IPty {
|
||||
return pty.spawn(
|
||||
'tmux',
|
||||
[
|
||||
'-f', opts.tmuxConfPath,
|
||||
'attach-session', '-d',
|
||||
'-t', `${opts.sessionName}:${opts.windowName}`,
|
||||
],
|
||||
{
|
||||
name: 'xterm-256color',
|
||||
cols: opts.cols,
|
||||
rows: opts.rows,
|
||||
cwd: opts.projectRoot,
|
||||
env: cleanEnv(),
|
||||
},
|
||||
);
|
||||
}
|
||||
9
apps/booterm/src/routes/health.ts
Normal file
9
apps/booterm/src/routes/health.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { pingDb } from '../db.js';
|
||||
|
||||
export function registerHealthRoutes(app: FastifyInstance): void {
|
||||
app.get('/api/term/health', async () => {
|
||||
const dbOk = await pingDb();
|
||||
return { ok: true, db: dbOk };
|
||||
});
|
||||
}
|
||||
88
apps/booterm/src/routes/terminals.ts
Normal file
88
apps/booterm/src/routes/terminals.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { getSessionInfo } from '../db.js';
|
||||
import {
|
||||
sanitizeId,
|
||||
tmuxSessionName,
|
||||
tmuxWindowName,
|
||||
ensureWindow,
|
||||
killWindow,
|
||||
hasSession,
|
||||
listWindows,
|
||||
} from '../pty/manager.js';
|
||||
import { resizePane } from '../ws/attach.js';
|
||||
|
||||
const ParamsSchema = z.object({ sid: z.string(), pid: z.string() });
|
||||
const ResizeBodySchema = z.object({
|
||||
cols: z.coerce.number().int().min(1).max(2000),
|
||||
rows: z.coerce.number().int().min(1).max(2000),
|
||||
});
|
||||
|
||||
export function registerTerminalRoutes(app: FastifyInstance, tmuxConfPath: string): void {
|
||||
app.post<{ Params: { sid: string; pid: string } }>(
|
||||
'/api/term/sessions/:sid/panes/:pid/start',
|
||||
async (req, reply) => {
|
||||
const p = ParamsSchema.safeParse(req.params);
|
||||
if (!p.success) return reply.code(400).send({ error: 'bad_params' });
|
||||
const sid = sanitizeId(p.data.sid);
|
||||
const pid = sanitizeId(p.data.pid);
|
||||
if (!sid || !pid) return reply.code(400).send({ error: 'bad_id_format' });
|
||||
|
||||
const session = await getSessionInfo(sid);
|
||||
if (!session) return reply.code(404).send({ error: 'unknown_session' });
|
||||
|
||||
const sessionName = tmuxSessionName(sid);
|
||||
const windowName = tmuxWindowName(pid);
|
||||
|
||||
try {
|
||||
await ensureWindow(tmuxConfPath, sessionName, windowName, session.project_path, req.log);
|
||||
} catch (err) {
|
||||
req.log.error({ err }, 'ensureWindow failed');
|
||||
return reply.code(500).send({ error: 'tmux_failed' });
|
||||
}
|
||||
return reply.code(200).send({ tmux_window: windowName });
|
||||
},
|
||||
);
|
||||
|
||||
app.post<{ Params: { sid: string; pid: string }; Body: { cols: number; rows: number } }>(
|
||||
'/api/term/sessions/:sid/panes/:pid/resize',
|
||||
async (req, reply) => {
|
||||
const p = ParamsSchema.safeParse(req.params);
|
||||
if (!p.success) return reply.code(400).send({ error: 'bad_params' });
|
||||
const b = ResizeBodySchema.safeParse(req.body);
|
||||
if (!b.success) return reply.code(400).send({ error: 'bad_body' });
|
||||
const sid = sanitizeId(p.data.sid);
|
||||
const pid = sanitizeId(p.data.pid);
|
||||
if (!sid || !pid) return reply.code(400).send({ error: 'bad_id_format' });
|
||||
|
||||
const ok = resizePane(pid, b.data.cols, b.data.rows);
|
||||
if (!ok) return reply.code(404).send({ error: 'no_active_pty' });
|
||||
return reply.code(200).send({ ok: true });
|
||||
},
|
||||
);
|
||||
|
||||
app.post<{ Params: { sid: string; pid: string } }>(
|
||||
'/api/term/sessions/:sid/panes/:pid/kill',
|
||||
async (req, reply) => {
|
||||
const p = ParamsSchema.safeParse(req.params);
|
||||
if (!p.success) return reply.code(400).send({ error: 'bad_params' });
|
||||
const sid = sanitizeId(p.data.sid);
|
||||
const pid = sanitizeId(p.data.pid);
|
||||
if (!sid || !pid) return reply.code(400).send({ error: 'bad_id_format' });
|
||||
|
||||
const sessionName = tmuxSessionName(sid);
|
||||
const windowName = tmuxWindowName(pid);
|
||||
|
||||
if (!(await hasSession(tmuxConfPath, sessionName))) {
|
||||
return reply.code(404).send({ error: 'unknown_session' });
|
||||
}
|
||||
const windows = await listWindows(tmuxConfPath, sessionName);
|
||||
if (!windows.includes(windowName)) {
|
||||
return reply.code(404).send({ error: 'unknown_pane' });
|
||||
}
|
||||
const killed = await killWindow(tmuxConfPath, sessionName, windowName);
|
||||
if (!killed) return reply.code(500).send({ error: 'tmux_kill_failed' });
|
||||
return reply.code(200).send({ ok: true });
|
||||
},
|
||||
);
|
||||
}
|
||||
128
apps/booterm/src/ws/attach.ts
Normal file
128
apps/booterm/src/ws/attach.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { IPty } from 'node-pty';
|
||||
import { getSessionInfo } from '../db.js';
|
||||
import { sanitizeId, tmuxSessionName, tmuxWindowName, ensureWindow } from '../pty/manager.js';
|
||||
import { attachPty } from '../pty/pty.js';
|
||||
import { getUser } from '../auth.js';
|
||||
|
||||
// Registry of currently-attached PTYs keyed by paneId. Used by the resize REST
|
||||
// route to find the active node-pty handle so it can call pty.resize(cols, rows).
|
||||
const active = new Map<string, IPty>();
|
||||
|
||||
export function resizePane(paneId: string, cols: number, rows: number): boolean {
|
||||
const handle = active.get(paneId);
|
||||
if (!handle) return false;
|
||||
try {
|
||||
handle.resize(cols, rows);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function registerWsAttachRoute(app: FastifyInstance, tmuxConfPath: string): void {
|
||||
app.get<{
|
||||
Params: { sid: string; pid: string };
|
||||
Querystring: { cols?: string; rows?: string };
|
||||
}>(
|
||||
'/ws/term/sessions/:sid/panes/:pid',
|
||||
{ websocket: true },
|
||||
async (socket, req) => {
|
||||
const sid = sanitizeId(req.params.sid);
|
||||
const pid = sanitizeId(req.params.pid);
|
||||
if (!sid || !pid) {
|
||||
socket.close(1008, 'bad_id_format');
|
||||
return;
|
||||
}
|
||||
|
||||
const user = getUser(req);
|
||||
req.log.info({ user, sid, pid }, 'ws attach');
|
||||
|
||||
const session = await getSessionInfo(sid);
|
||||
if (!session) {
|
||||
socket.close(1008, 'unknown_session');
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionName = tmuxSessionName(sid);
|
||||
const windowName = tmuxWindowName(pid);
|
||||
try {
|
||||
await ensureWindow(tmuxConfPath, sessionName, windowName, session.project_path, req.log);
|
||||
} catch (err) {
|
||||
req.log.error({ err }, 'ensureWindow failed in WS handler');
|
||||
socket.close(1011, 'tmux_failed');
|
||||
return;
|
||||
}
|
||||
|
||||
const cols = parseInt(req.query.cols ?? '', 10) || 80;
|
||||
const rows = parseInt(req.query.rows ?? '', 10) || 24;
|
||||
|
||||
let handle: IPty;
|
||||
try {
|
||||
handle = attachPty({
|
||||
sessionName,
|
||||
windowName,
|
||||
projectRoot: session.project_path,
|
||||
cols,
|
||||
rows,
|
||||
tmuxConfPath,
|
||||
});
|
||||
} catch (err) {
|
||||
req.log.error({ err }, 'attachPty failed');
|
||||
socket.close(1011, 'pty_spawn_failed');
|
||||
return;
|
||||
}
|
||||
|
||||
active.set(pid, handle);
|
||||
|
||||
const onData = (data: string) => {
|
||||
if (socket.readyState !== socket.OPEN) return;
|
||||
try {
|
||||
socket.send(Buffer.from(data, 'utf8'), { binary: true });
|
||||
} catch (err) {
|
||||
req.log.warn({ err }, 'ws send failed');
|
||||
}
|
||||
};
|
||||
handle.onData(onData);
|
||||
|
||||
socket.on('message', (data: Buffer | string) => {
|
||||
try {
|
||||
if (typeof data === 'string') {
|
||||
handle.write(data);
|
||||
} else {
|
||||
handle.write(data.toString('utf8'));
|
||||
}
|
||||
} catch (err) {
|
||||
req.log.warn({ err }, 'pty write failed');
|
||||
}
|
||||
});
|
||||
|
||||
handle.onExit(({ exitCode }) => {
|
||||
try {
|
||||
if (socket.readyState === socket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: 'exit', code: exitCode }));
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
socket.close(1000);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (active.get(pid) === handle) active.delete(pid);
|
||||
});
|
||||
|
||||
// WS close kills the local PTY (the tmux client). The tmux server and
|
||||
// window persist so a refresh resumes with full scrollback.
|
||||
socket.on('close', () => {
|
||||
if (active.get(pid) === handle) active.delete(pid);
|
||||
try {
|
||||
handle.kill();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user