P1 spine: monorepo, shared WS protocol, server daemon
- npm workspaces (shared / server / web), TS strict, project refs - @arboretum/shared: multiplexed WS protocol (JSON control + binary output frames: 1B type + u32le channel), flow-control constants (ACK 256K, HIGH 384K, LOW 128K, lagging 2M), REST types - git-arboretum server: Fastify 5 + node:sqlite (single native dep: node-pty prebuilt), token auth (sha256 at rest, HMAC cookie, global login rate limit + backoff), strict Origin check on /api and /ws, PtyManager (2MiB ring with monotonic offset, resync replay = reset + 256KiB tail, pause/resume only when ALL interactive clients exceed HIGH, observers never throttle, lagging clients resync), WS gateway (attach/stdin/resize/ack, heartbeat 30s), SIGTERM→SIGKILL 5s grace - CLI: arboretum [--port 7317] [--bind 127.0.0.1] — non-loopback bind requires an explicit safety flag - Smoke-tested: login/401/403-origin/spawn bash/kill/grace-SIGKILL all green
This commit is contained in:
91
packages/server/src/app.ts
Normal file
91
packages/server/src/app.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import Fastify, { type FastifyInstance, type FastifyRequest } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import fastifyWebsocket from '@fastify/websocket';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { Config } from './config.js';
|
||||
import type { Db } from './db/index.js';
|
||||
import { AuthService, LoginRateLimiter, type AuthContext } from './auth/service.js';
|
||||
import { PtyManager } from './core/pty-manager.js';
|
||||
import { registerAuthRoutes } from './routes/auth.js';
|
||||
import { registerSessionRoutes } from './routes/sessions.js';
|
||||
import { registerWsGateway } from './ws/gateway.js';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
authContext: AuthContext | null;
|
||||
}
|
||||
interface FastifyContextConfig {
|
||||
public?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
export interface AppBundle {
|
||||
app: FastifyInstance;
|
||||
auth: AuthService;
|
||||
manager: PtyManager;
|
||||
}
|
||||
|
||||
export function buildApp(config: Config, db: Db, serverVersion: string): AppBundle {
|
||||
const app = Fastify({ logger: { level: process.env.ARBORETUM_LOG ?? 'info' } });
|
||||
const auth = new AuthService(db);
|
||||
const limiter = new LoginRateLimiter();
|
||||
const manager = new PtyManager(db);
|
||||
|
||||
void app.register(fastifyCookie);
|
||||
void app.register(fastifyWebsocket, {
|
||||
options: { maxPayload: 1024 * 1024 },
|
||||
});
|
||||
|
||||
const allowedOrigins = new Set<string>([
|
||||
`http://127.0.0.1:${config.port}`,
|
||||
`http://localhost:${config.port}`,
|
||||
...config.allowedOrigins,
|
||||
]);
|
||||
|
||||
const authenticate = (req: FastifyRequest): AuthContext | null => {
|
||||
const bearer = req.headers.authorization;
|
||||
if (bearer?.startsWith('Bearer ')) {
|
||||
const ctx = auth.verifyRawToken(bearer.slice(7));
|
||||
if (ctx) return ctx;
|
||||
}
|
||||
return auth.verifyCookie(req.cookies[auth.cookieName]);
|
||||
};
|
||||
|
||||
// Garde globale : auth sur tout /api/** et /ws ; check Origin strict quand l'en-tête est présent
|
||||
// (anti cross-site WebSocket hijacking — le cookie SameSite=Strict ne suffit pas pour les upgrades).
|
||||
app.addHook('preValidation', async (req, reply) => {
|
||||
const isApi = req.url.startsWith('/api/');
|
||||
const isWs = req.url.startsWith('/ws');
|
||||
if (!isApi && !isWs) return; // statique : public (la SPA gère son écran de login)
|
||||
const origin = req.headers.origin;
|
||||
if (origin && !allowedOrigins.has(origin)) {
|
||||
return reply.status(403).send({ error: { code: 'BAD_ORIGIN', message: `Origin not allowed: ${origin}` } });
|
||||
}
|
||||
req.authContext = authenticate(req);
|
||||
if (req.routeOptions.config.public) return;
|
||||
if (!req.authContext) {
|
||||
return reply.status(401).send({ error: { code: 'UNAUTHORIZED', message: 'Authentication required' } });
|
||||
}
|
||||
});
|
||||
|
||||
registerAuthRoutes(app, auth, limiter, serverVersion);
|
||||
registerSessionRoutes(app, manager);
|
||||
registerWsGateway(app, manager, serverVersion);
|
||||
|
||||
// SPA buildée embarquée dans le paquet npm (public/) — absente en dev (vite dev sert le front)
|
||||
const publicDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'public');
|
||||
if (existsSync(publicDir)) {
|
||||
void app.register(fastifyStatic, { root: publicDir, wildcard: false });
|
||||
app.setNotFoundHandler((req, reply) => {
|
||||
if (req.url.startsWith('/api/') || req.url.startsWith('/ws')) {
|
||||
return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'Route not found' } });
|
||||
}
|
||||
return reply.sendFile('index.html');
|
||||
});
|
||||
}
|
||||
|
||||
return { app, auth, manager };
|
||||
}
|
||||
124
packages/server/src/auth/service.ts
Normal file
124
packages/server/src/auth/service.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
||||
import { type Db, getSetting, setSetting } from '../db/index.js';
|
||||
|
||||
const COOKIE_NAME = 'arb_session';
|
||||
const COOKIE_TTL_MS = 30 * 24 * 3600 * 1000;
|
||||
|
||||
export interface AuthContext {
|
||||
tokenId: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export class AuthService {
|
||||
private readonly secret: Buffer;
|
||||
|
||||
constructor(private readonly db: Db) {
|
||||
let secretHex = getSetting(db, 'server_secret');
|
||||
if (!secretHex) {
|
||||
secretHex = randomBytes(32).toString('hex');
|
||||
setSetting(db, 'server_secret', secretHex);
|
||||
}
|
||||
this.secret = Buffer.from(secretHex, 'hex');
|
||||
}
|
||||
|
||||
get cookieName(): string {
|
||||
return COOKIE_NAME;
|
||||
}
|
||||
|
||||
/** Crée le token initial au premier démarrage et renvoie sa valeur en clair (affichée une seule fois). */
|
||||
ensureBootstrapToken(): string | null {
|
||||
const row = this.db
|
||||
.prepare('SELECT COUNT(*) AS n FROM auth_tokens WHERE revoked_at IS NULL')
|
||||
.get() as { n: number };
|
||||
if (row.n > 0) return null;
|
||||
return this.createToken('initial');
|
||||
}
|
||||
|
||||
createToken(label: string): string {
|
||||
const raw = `arb_${randomBytes(24).toString('hex')}`;
|
||||
this.db
|
||||
.prepare('INSERT INTO auth_tokens (id, label, token_hash, created_at) VALUES (?, ?, ?, ?)')
|
||||
.run(randomUUID(), label, sha256(raw), new Date().toISOString());
|
||||
return raw;
|
||||
}
|
||||
|
||||
verifyRawToken(raw: string): AuthContext | null {
|
||||
if (typeof raw !== 'string' || raw.length < 8 || raw.length > 128) return null;
|
||||
const row = this.db
|
||||
.prepare('SELECT id, label, token_hash FROM auth_tokens WHERE revoked_at IS NULL AND token_hash = ?')
|
||||
.get(sha256(raw)) as { id: string; label: string; token_hash: string } | undefined;
|
||||
if (!row) return null;
|
||||
// double vérification en temps constant (le lookup par hash est déjà non-oracle, ceinture+bretelles)
|
||||
if (!timingSafeEqual(Buffer.from(row.token_hash), Buffer.from(sha256(raw)))) return null;
|
||||
this.db.prepare('UPDATE auth_tokens SET last_used_at = ? WHERE id = ?').run(new Date().toISOString(), row.id);
|
||||
return { tokenId: row.id, label: row.label };
|
||||
}
|
||||
|
||||
issueCookie(ctx: AuthContext): string {
|
||||
const expires = Date.now() + COOKIE_TTL_MS;
|
||||
const payload = `${ctx.tokenId}.${expires}`;
|
||||
return `${payload}.${this.sign(payload)}`;
|
||||
}
|
||||
|
||||
verifyCookie(value: string | undefined): AuthContext | null {
|
||||
if (!value) return null;
|
||||
const lastDot = value.lastIndexOf('.');
|
||||
if (lastDot <= 0) return null;
|
||||
const payload = value.slice(0, lastDot);
|
||||
const sig = value.slice(lastDot + 1);
|
||||
const expected = this.sign(payload);
|
||||
if (sig.length !== expected.length || !timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null;
|
||||
const [tokenId, expiresStr] = payload.split('.');
|
||||
if (!tokenId || !expiresStr || Number(expiresStr) < Date.now()) return null;
|
||||
const row = this.db
|
||||
.prepare('SELECT id, label FROM auth_tokens WHERE id = ? AND revoked_at IS NULL')
|
||||
.get(tokenId) as { id: string; label: string } | undefined;
|
||||
return row ? { tokenId: row.id, label: row.label } : null;
|
||||
}
|
||||
|
||||
private sign(payload: string): string {
|
||||
return createHmac('sha256', this.secret).update(payload).digest('hex');
|
||||
}
|
||||
}
|
||||
|
||||
function sha256(s: string): string {
|
||||
return createHash('sha256').update(s).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limit GLOBAL du login (pas par IP : derrière Tailscale Serve toutes les
|
||||
* requêtes arrivent de 127.0.0.1) + backoff exponentiel sur échecs consécutifs.
|
||||
*/
|
||||
export class LoginRateLimiter {
|
||||
private attempts: number[] = [];
|
||||
private consecutiveFailures = 0;
|
||||
private lockedUntil = 0;
|
||||
|
||||
constructor(
|
||||
private readonly maxPerMinute = 5,
|
||||
private readonly now: () => number = Date.now,
|
||||
) {}
|
||||
|
||||
/** null = autorisé ; sinon délai d'attente en ms */
|
||||
check(): number | null {
|
||||
const t = this.now();
|
||||
if (t < this.lockedUntil) return this.lockedUntil - t;
|
||||
this.attempts = this.attempts.filter((a) => t - a < 60_000);
|
||||
if (this.attempts.length >= this.maxPerMinute) return 60_000 - (t - (this.attempts[0] ?? t));
|
||||
this.attempts.push(t);
|
||||
return null;
|
||||
}
|
||||
|
||||
recordFailure(): void {
|
||||
this.consecutiveFailures += 1;
|
||||
if (this.consecutiveFailures >= 3) {
|
||||
const backoff = Math.min(2 ** (this.consecutiveFailures - 3) * 30_000, 30 * 60_000);
|
||||
this.lockedUntil = this.now() + backoff;
|
||||
}
|
||||
}
|
||||
|
||||
recordSuccess(): void {
|
||||
this.consecutiveFailures = 0;
|
||||
this.lockedUntil = 0;
|
||||
}
|
||||
}
|
||||
50
packages/server/src/config.ts
Normal file
50
packages/server/src/config.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { parseArgs } from 'node:util';
|
||||
import { join } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
|
||||
export interface Config {
|
||||
port: number;
|
||||
bind: string;
|
||||
dbPath: string;
|
||||
dataDir: string;
|
||||
/** origins supplémentaires autorisées (ex. https://machine.tailnet.ts.net) */
|
||||
allowedOrigins: string[];
|
||||
printToken: boolean;
|
||||
}
|
||||
|
||||
export function loadConfig(argv = process.argv.slice(2)): Config {
|
||||
const { values } = parseArgs({
|
||||
args: argv,
|
||||
options: {
|
||||
port: { type: 'string', default: '7317' },
|
||||
bind: { type: 'string', default: '127.0.0.1' },
|
||||
db: { type: 'string' },
|
||||
'allow-origin': { type: 'string', multiple: true },
|
||||
'print-token': { type: 'boolean', default: false },
|
||||
'i-know-this-exposes-a-terminal': { type: 'boolean', default: false },
|
||||
},
|
||||
strict: true,
|
||||
});
|
||||
|
||||
const bind = values.bind ?? '127.0.0.1';
|
||||
const loopback = bind === '127.0.0.1' || bind === '::1' || bind === 'localhost';
|
||||
if (!loopback && !values['i-know-this-exposes-a-terminal']) {
|
||||
throw new Error(
|
||||
`Refusing to bind to ${bind}: an Arboretum server is remote code execution by design.\n` +
|
||||
`Use Tailscale Serve against the default 127.0.0.1 bind (recommended), or pass\n` +
|
||||
`--i-know-this-exposes-a-terminal if you really know what you are doing.`,
|
||||
);
|
||||
}
|
||||
|
||||
const dataDir = join(process.env.XDG_DATA_HOME ?? join(homedir(), '.local', 'share'), 'arboretum');
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
return {
|
||||
port: Number(values.port),
|
||||
bind,
|
||||
dbPath: values.db ?? join(dataDir, 'arboretum.db'),
|
||||
dataDir,
|
||||
allowedOrigins: values['allow-origin'] ?? [],
|
||||
printToken: values['print-token'] ?? false,
|
||||
};
|
||||
}
|
||||
34
packages/server/src/core/claude-launcher.ts
Normal file
34
packages/server/src/core/claude-launcher.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
export interface SpawnSpec {
|
||||
file: string;
|
||||
args: string[];
|
||||
env: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
let cachedClaudeBin: string | null = null;
|
||||
|
||||
export function resolveClaudeBin(): string {
|
||||
if (cachedClaudeBin) return cachedClaudeBin;
|
||||
try {
|
||||
cachedClaudeBin = execFileSync('which', ['claude'], { encoding: 'utf8' }).trim();
|
||||
} catch {
|
||||
throw new Error(
|
||||
'Claude Code CLI not found in PATH. Install it first: https://code.claude.com/docs/en/quickstart',
|
||||
);
|
||||
}
|
||||
return cachedClaudeBin;
|
||||
}
|
||||
|
||||
/** Module volontairement abstrait : le plan B « BYO API key / Agent SDK » se brancherait ici. */
|
||||
export function buildSpawnSpec(command: 'claude' | 'bash'): SpawnSpec {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
TERM: 'xterm-256color',
|
||||
COLORTERM: 'truecolor',
|
||||
};
|
||||
if (command === 'bash') {
|
||||
return { file: 'bash', args: ['--norc'], env };
|
||||
}
|
||||
return { file: resolveClaudeBin(), args: [], env };
|
||||
}
|
||||
271
packages/server/src/core/pty-manager.ts
Normal file
271
packages/server/src/core/pty-manager.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { existsSync, statSync } from 'node:fs';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import pty from '@homebridge/node-pty-prebuilt-multiarch';
|
||||
import { FLOW, REPLAY_TAIL_BYTES, type SessionSummary } from '@arboretum/shared';
|
||||
import { RingBuffer } from './ring-buffer.js';
|
||||
import { buildSpawnSpec } from './claude-launcher.js';
|
||||
import type { Db } from '../db/index.js';
|
||||
|
||||
const RING_CAPACITY = 2 * 1024 * 1024;
|
||||
const KILL_GRACE_MS = 5000;
|
||||
|
||||
/** Lien entre un client WS attaché et une session. La gateway fournit les callbacks d'envoi. */
|
||||
export interface ClientBinding {
|
||||
channel: number;
|
||||
mode: 'interactive' | 'observer';
|
||||
controlling: boolean;
|
||||
sentBytes: number;
|
||||
ackedBytes: number;
|
||||
lagging: boolean;
|
||||
sendOutput: (payload: Buffer) => void;
|
||||
sendResync: (payload: Buffer) => void;
|
||||
onDetached: (reason: 'session_exit' | 'replaced') => void;
|
||||
onControlChanged: (controlling: boolean) => void;
|
||||
}
|
||||
|
||||
interface ManagedSession {
|
||||
id: string;
|
||||
cwd: string;
|
||||
command: 'claude' | 'bash';
|
||||
title: string | null;
|
||||
createdAt: string;
|
||||
proc: pty.IPty;
|
||||
ring: RingBuffer;
|
||||
clients: Set<ClientBinding>;
|
||||
paused: boolean;
|
||||
exited: { exitCode: number | null; signal: number | null } | null;
|
||||
killTimer: NodeJS.Timeout | null;
|
||||
}
|
||||
|
||||
export interface PtyManagerEvents {
|
||||
session_update: [SessionSummary];
|
||||
session_exit: [{ sessionId: string; exitCode: number | null; signal: number | null }];
|
||||
}
|
||||
|
||||
export class PtyManager extends EventEmitter<PtyManagerEvents> {
|
||||
private readonly live = new Map<string, ManagedSession>();
|
||||
|
||||
constructor(private readonly db: Db) {
|
||||
super();
|
||||
}
|
||||
|
||||
spawn(opts: { cwd: string; command?: 'claude' | 'bash' }): SessionSummary {
|
||||
const cwd = opts.cwd;
|
||||
if (!existsSync(cwd) || !statSync(cwd).isDirectory()) {
|
||||
throw Object.assign(new Error(`Not a directory: ${cwd}`), { statusCode: 400 });
|
||||
}
|
||||
const command = opts.command ?? 'claude';
|
||||
const spec = buildSpawnSpec(command);
|
||||
const id = randomUUID();
|
||||
const proc = pty.spawn(spec.file, spec.args, {
|
||||
name: 'xterm-256color',
|
||||
cols: 120,
|
||||
rows: 32,
|
||||
cwd,
|
||||
env: spec.env as { [key: string]: string },
|
||||
});
|
||||
const session: ManagedSession = {
|
||||
id,
|
||||
cwd,
|
||||
command,
|
||||
title: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
proc,
|
||||
ring: new RingBuffer(RING_CAPACITY),
|
||||
clients: new Set(),
|
||||
paused: false,
|
||||
exited: null,
|
||||
killTimer: null,
|
||||
};
|
||||
this.live.set(id, session);
|
||||
this.db
|
||||
.prepare('INSERT INTO sessions (id, cwd, command, created_at) VALUES (?, ?, ?, ?)')
|
||||
.run(id, cwd, command, session.createdAt);
|
||||
|
||||
proc.onData((data) => this.handleOutput(session, Buffer.from(data, 'utf8')));
|
||||
proc.onExit(({ exitCode, signal }) => this.handleExit(session, exitCode, signal ?? null));
|
||||
|
||||
const summary = this.summarize(session);
|
||||
this.emit('session_update', summary);
|
||||
return summary;
|
||||
}
|
||||
|
||||
list(): SessionSummary[] {
|
||||
const liveSummaries = [...this.live.values()].map((s) => this.summarize(s));
|
||||
const liveIds = new Set(this.live.keys());
|
||||
const rows = this.db
|
||||
.prepare('SELECT id, cwd, command, title, created_at, ended_at, exit_code FROM sessions ORDER BY created_at DESC LIMIT 100')
|
||||
.all() as Array<{ id: string; cwd: string; command: string; title: string | null; created_at: string; ended_at: string | null; exit_code: number | null }>;
|
||||
const historical: SessionSummary[] = rows
|
||||
.filter((r) => !liveIds.has(r.id))
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
cwd: r.cwd,
|
||||
command: r.command,
|
||||
title: r.title,
|
||||
status: 'exited',
|
||||
live: false,
|
||||
createdAt: r.created_at,
|
||||
endedAt: r.ended_at,
|
||||
exitCode: r.exit_code,
|
||||
clients: 0,
|
||||
}));
|
||||
return [...liveSummaries, ...historical];
|
||||
}
|
||||
|
||||
get(id: string): SessionSummary | null {
|
||||
const s = this.live.get(id);
|
||||
return s ? this.summarize(s) : null;
|
||||
}
|
||||
|
||||
kill(id: string): boolean {
|
||||
const s = this.live.get(id);
|
||||
if (!s || s.exited) return false;
|
||||
try {
|
||||
process.kill(s.proc.pid, 'SIGTERM');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
s.killTimer ??= setTimeout(() => {
|
||||
if (!s.exited) {
|
||||
try {
|
||||
process.kill(s.proc.pid, 'SIGKILL');
|
||||
} catch {
|
||||
/* déjà mort */
|
||||
}
|
||||
}
|
||||
}, KILL_GRACE_MS);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Arrêt du daemon : SIGTERM à toutes les sessions (le CLI nettoie son registre sur SIGTERM — spike S1). */
|
||||
shutdown(): void {
|
||||
for (const id of this.live.keys()) this.kill(id);
|
||||
}
|
||||
|
||||
// ---- attach / detach / io ----
|
||||
|
||||
attach(sessionId: string, binding: ClientBinding, cols: number, rows: number): { ok: true; controlling: boolean } | { ok: false; code: 'NOT_FOUND' | 'SESSION_EXITED' } {
|
||||
const s = this.live.get(sessionId);
|
||||
if (!s) return { ok: false, code: 'NOT_FOUND' };
|
||||
if (s.exited) return { ok: false, code: 'SESSION_EXITED' };
|
||||
const hasController = [...s.clients].some((c) => c.controlling);
|
||||
binding.controlling = binding.mode === 'interactive' && !hasController;
|
||||
s.clients.add(binding);
|
||||
if (binding.controlling) s.proc.resize(cols, rows);
|
||||
// Replay : reset terminal + queue du ring (l'écran TUI courant se reconstitue)
|
||||
binding.sendResync(s.ring.tail(REPLAY_TAIL_BYTES));
|
||||
binding.sentBytes = 0;
|
||||
binding.ackedBytes = 0;
|
||||
return { ok: true, controlling: binding.controlling };
|
||||
}
|
||||
|
||||
detach(sessionId: string, binding: ClientBinding): void {
|
||||
const s = this.live.get(sessionId);
|
||||
if (!s) return;
|
||||
s.clients.delete(binding);
|
||||
if (binding.controlling) {
|
||||
const next = [...s.clients].find((c) => c.mode === 'interactive');
|
||||
if (next) {
|
||||
next.controlling = true;
|
||||
next.onControlChanged(true);
|
||||
}
|
||||
}
|
||||
this.updateFlowControl(s);
|
||||
}
|
||||
|
||||
write(sessionId: string, binding: ClientBinding, data: string): 'ok' | 'not_controlling' | 'gone' {
|
||||
const s = this.live.get(sessionId);
|
||||
if (!s || s.exited) return 'gone';
|
||||
// Outil mono-utilisateur : tous les interactifs peuvent écrire ; les observers jamais.
|
||||
if (binding.mode !== 'interactive') return 'not_controlling';
|
||||
s.proc.write(data);
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
resize(sessionId: string, binding: ClientBinding, cols: number, rows: number): void {
|
||||
const s = this.live.get(sessionId);
|
||||
if (!s || s.exited || !binding.controlling) return;
|
||||
s.proc.resize(cols, rows);
|
||||
}
|
||||
|
||||
ack(sessionId: string, binding: ClientBinding, bytes: number): void {
|
||||
const s = this.live.get(sessionId);
|
||||
if (!s) return;
|
||||
binding.ackedBytes = Math.max(binding.ackedBytes, bytes);
|
||||
if (binding.lagging && binding.sentBytes - binding.ackedBytes < FLOW.LOW_WATERMARK) {
|
||||
binding.lagging = false;
|
||||
binding.sendResync(s.ring.tail(REPLAY_TAIL_BYTES));
|
||||
binding.sentBytes = 0;
|
||||
binding.ackedBytes = 0;
|
||||
}
|
||||
this.updateFlowControl(s);
|
||||
}
|
||||
|
||||
// ---- interne ----
|
||||
|
||||
private handleOutput(s: ManagedSession, chunk: Buffer): void {
|
||||
s.ring.write(chunk);
|
||||
for (const c of s.clients) {
|
||||
if (c.lagging) continue;
|
||||
c.sendOutput(chunk);
|
||||
c.sentBytes += chunk.length;
|
||||
if (c.sentBytes - c.ackedBytes > FLOW.LAGGING_BYTES) c.lagging = true;
|
||||
}
|
||||
this.updateFlowControl(s);
|
||||
}
|
||||
|
||||
/**
|
||||
* pause() seulement quand TOUS les clients interactifs non-lagging dépassent HIGH ;
|
||||
* resume() quand le min repasse sous LOW. Les observers ne freinent jamais le PTY.
|
||||
*/
|
||||
private updateFlowControl(s: ManagedSession): void {
|
||||
if (s.exited) return;
|
||||
const interactive = [...s.clients].filter((c) => c.mode === 'interactive' && !c.lagging);
|
||||
if (interactive.length === 0) {
|
||||
if (s.paused) {
|
||||
s.proc.resume();
|
||||
s.paused = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const outstandings = interactive.map((c) => c.sentBytes - c.ackedBytes);
|
||||
const min = Math.min(...outstandings);
|
||||
if (!s.paused && outstandings.every((o) => o > FLOW.HIGH_WATERMARK)) {
|
||||
s.proc.pause();
|
||||
s.paused = true;
|
||||
} else if (s.paused && min < FLOW.LOW_WATERMARK) {
|
||||
s.proc.resume();
|
||||
s.paused = false;
|
||||
}
|
||||
}
|
||||
|
||||
private handleExit(s: ManagedSession, exitCode: number | null, signal: number | null): void {
|
||||
s.exited = { exitCode, signal };
|
||||
if (s.killTimer) clearTimeout(s.killTimer);
|
||||
this.db
|
||||
.prepare('UPDATE sessions SET ended_at = ?, exit_code = ? WHERE id = ?')
|
||||
.run(new Date().toISOString(), exitCode, s.id);
|
||||
for (const c of s.clients) c.onDetached('session_exit');
|
||||
s.clients.clear();
|
||||
this.live.delete(s.id);
|
||||
this.emit('session_exit', { sessionId: s.id, exitCode, signal });
|
||||
this.emit('session_update', { ...this.summarize(s), live: false, status: 'exited' });
|
||||
}
|
||||
|
||||
private summarize(s: ManagedSession): SessionSummary {
|
||||
return {
|
||||
id: s.id,
|
||||
cwd: s.cwd,
|
||||
command: s.command,
|
||||
title: s.title,
|
||||
status: s.exited ? 'exited' : 'running',
|
||||
live: !s.exited,
|
||||
createdAt: s.createdAt,
|
||||
endedAt: null,
|
||||
exitCode: s.exited?.exitCode ?? null,
|
||||
clients: s.clients.size,
|
||||
};
|
||||
}
|
||||
}
|
||||
67
packages/server/src/core/ring-buffer.ts
Normal file
67
packages/server/src/core/ring-buffer.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Ring buffer binaire à offset monotone.
|
||||
* `totalOffset` compte tous les octets jamais écrits ; le ring ne garde que la
|
||||
* fenêtre récente. Un lecteur qui demande un offset sorti de la fenêtre reçoit
|
||||
* null et doit resync (reset terminal + queue du ring).
|
||||
*/
|
||||
export class RingBuffer {
|
||||
private buf: Buffer;
|
||||
private length = 0; // octets valides dans la fenêtre
|
||||
private total = 0; // offset monotone (Number : sûr jusqu'à 2^53)
|
||||
|
||||
constructor(readonly capacity: number) {
|
||||
this.buf = Buffer.alloc(capacity);
|
||||
}
|
||||
|
||||
get totalOffset(): number {
|
||||
return this.total;
|
||||
}
|
||||
|
||||
get windowStart(): number {
|
||||
return this.total - this.length;
|
||||
}
|
||||
|
||||
write(chunk: Buffer): void {
|
||||
if (chunk.length >= this.capacity) {
|
||||
chunk.copy(this.buf, 0, chunk.length - this.capacity);
|
||||
this.length = this.capacity;
|
||||
} else {
|
||||
const writePos = this.total % this.capacity;
|
||||
const tailSpace = this.capacity - writePos;
|
||||
if (chunk.length <= tailSpace) {
|
||||
chunk.copy(this.buf, writePos);
|
||||
} else {
|
||||
chunk.copy(this.buf, writePos, 0, tailSpace);
|
||||
chunk.copy(this.buf, 0, tailSpace);
|
||||
}
|
||||
this.length = Math.min(this.capacity, this.length + chunk.length);
|
||||
}
|
||||
this.total += chunk.length;
|
||||
}
|
||||
|
||||
/** Octets depuis `offset` (inclus), ou null si l'offset est sorti de la fenêtre. */
|
||||
readFrom(offset: number): Buffer | null {
|
||||
if (offset > this.total) return null;
|
||||
if (offset === this.total) return Buffer.alloc(0);
|
||||
if (offset < this.windowStart) return null;
|
||||
const count = this.total - offset;
|
||||
return this.tail(count);
|
||||
}
|
||||
|
||||
/** Les `count` derniers octets (bornés à la fenêtre), dans l'ordre. */
|
||||
tail(count: number): Buffer {
|
||||
const n = Math.min(count, this.length);
|
||||
const out = Buffer.alloc(n);
|
||||
const end = this.total % this.capacity;
|
||||
const start = (end - n + this.capacity * 2) % this.capacity;
|
||||
if (n === 0) return out;
|
||||
if (start < end || end === 0) {
|
||||
this.buf.copy(out, 0, start, start + n);
|
||||
} else {
|
||||
const firstPart = this.capacity - start;
|
||||
this.buf.copy(out, 0, start, this.capacity);
|
||||
this.buf.copy(out, firstPart, 0, end);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
68
packages/server/src/db/index.ts
Normal file
68
packages/server/src/db/index.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
const MIGRATIONS: Array<{ id: number; sql: string }> = [
|
||||
{
|
||||
id: 1,
|
||||
sql: `
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE auth_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
label TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL,
|
||||
last_used_at TEXT,
|
||||
revoked_at TEXT
|
||||
);
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
cwd TEXT NOT NULL,
|
||||
command TEXT NOT NULL,
|
||||
title TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
ended_at TEXT,
|
||||
exit_code INTEGER
|
||||
);
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
export type Db = DatabaseSync;
|
||||
|
||||
export function openDb(path: string): Db {
|
||||
const db = new DatabaseSync(path);
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
migrate(db);
|
||||
return db;
|
||||
}
|
||||
|
||||
function migrate(db: DatabaseSync): void {
|
||||
db.exec('CREATE TABLE IF NOT EXISTS schema_migrations (id INTEGER PRIMARY KEY, applied_at TEXT NOT NULL)');
|
||||
const applied = new Set(
|
||||
(db.prepare('SELECT id FROM schema_migrations').all() as Array<{ id: number }>).map((r) => r.id),
|
||||
);
|
||||
for (const m of MIGRATIONS) {
|
||||
if (applied.has(m.id)) continue;
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
db.exec(m.sql);
|
||||
db.prepare('INSERT INTO schema_migrations (id, applied_at) VALUES (?, ?)').run(m.id, new Date().toISOString());
|
||||
db.exec('COMMIT');
|
||||
} catch (err) {
|
||||
db.exec('ROLLBACK');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getSetting(db: Db, key: string): string | null {
|
||||
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as { value: string } | undefined;
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
export function setSetting(db: Db, key: string, value: string): void {
|
||||
db.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value').run(key, value);
|
||||
}
|
||||
51
packages/server/src/index.ts
Normal file
51
packages/server/src/index.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env node
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loadConfig } from './config.js';
|
||||
import { openDb } from './db/index.js';
|
||||
import { buildApp } from './app.js';
|
||||
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'),
|
||||
) as { version: string };
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const config = loadConfig();
|
||||
const db = openDb(config.dbPath);
|
||||
const { app, auth, manager } = buildApp(config, db, pkg.version);
|
||||
|
||||
const bootstrapToken = auth.ensureBootstrapToken();
|
||||
await app.listen({ port: config.port, host: config.bind });
|
||||
|
||||
const url = `http://${config.bind === '0.0.0.0' ? '127.0.0.1' : config.bind}:${config.port}`;
|
||||
app.log.info(`Arboretum v${pkg.version} — ${url}`);
|
||||
if (bootstrapToken) {
|
||||
// Affiché une seule fois : le hash seul est stocké.
|
||||
console.log('\n┌──────────────────────────────────────────────────────────────────┐');
|
||||
console.log('│ First start — your access token (shown once, store it safely): │');
|
||||
console.log('└──────────────────────────────────────────────────────────────────┘');
|
||||
console.log(`\n ${bootstrapToken}\n`);
|
||||
console.log(` Login at: ${url}/\n`);
|
||||
} else if (config.printToken) {
|
||||
console.log('Tokens are stored hashed and cannot be re-printed. Create a new one from Settings (or reset the db).');
|
||||
}
|
||||
|
||||
let shuttingDown = false;
|
||||
const shutdown = (signal: string): void => {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
app.log.info(`${signal} received — draining sessions then exiting`);
|
||||
manager.shutdown();
|
||||
setTimeout(() => {
|
||||
void app.close().then(() => process.exit(0));
|
||||
}, 1000).unref();
|
||||
};
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err instanceof Error ? err.message : err);
|
||||
process.exit(1);
|
||||
});
|
||||
42
packages/server/src/routes/auth.ts
Normal file
42
packages/server/src/routes/auth.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { LoginRequest, LoginResponse, MeResponse } from '@arboretum/shared';
|
||||
import type { AuthService, LoginRateLimiter } from '../auth/service.js';
|
||||
|
||||
export function registerAuthRoutes(
|
||||
app: FastifyInstance,
|
||||
auth: AuthService,
|
||||
limiter: LoginRateLimiter,
|
||||
serverVersion: string,
|
||||
): void {
|
||||
app.post('/api/v1/auth/login', { config: { public: true } }, async (req, reply) => {
|
||||
const wait = limiter.check();
|
||||
if (wait !== null) {
|
||||
return reply.status(429).send({ error: { code: 'RATE_LIMITED', message: `Retry in ${Math.ceil(wait / 1000)}s` } });
|
||||
}
|
||||
const body = req.body as Partial<LoginRequest> | null;
|
||||
const ctx = typeof body?.token === 'string' ? auth.verifyRawToken(body.token) : null;
|
||||
if (!ctx) {
|
||||
limiter.recordFailure();
|
||||
return reply.status(401).send({ error: { code: 'BAD_TOKEN', message: 'Invalid token' } });
|
||||
}
|
||||
limiter.recordSuccess();
|
||||
void reply.setCookie(auth.cookieName, auth.issueCookie(ctx), {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: 30 * 24 * 3600,
|
||||
});
|
||||
const res: LoginResponse = { ok: true, label: ctx.label };
|
||||
return reply.send(res);
|
||||
});
|
||||
|
||||
app.get('/api/v1/auth/me', async (req, reply) => {
|
||||
const res: MeResponse = { ok: true, tokenLabel: req.authContext?.label ?? 'unknown', serverVersion };
|
||||
return reply.send(res);
|
||||
});
|
||||
|
||||
app.post('/api/v1/auth/logout', async (_req, reply) => {
|
||||
void reply.clearCookie(auth.cookieName, { path: '/' });
|
||||
return reply.send({ ok: true });
|
||||
});
|
||||
}
|
||||
35
packages/server/src/routes/sessions.ts
Normal file
35
packages/server/src/routes/sessions.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { CreateSessionRequest, SessionResponse, SessionsListResponse } from '@arboretum/shared';
|
||||
import type { PtyManager } from '../core/pty-manager.js';
|
||||
|
||||
export function registerSessionRoutes(app: FastifyInstance, manager: PtyManager): void {
|
||||
app.get('/api/v1/sessions', async (): Promise<SessionsListResponse> => {
|
||||
return { sessions: manager.list() };
|
||||
});
|
||||
|
||||
app.post('/api/v1/sessions', async (req, reply) => {
|
||||
const body = req.body as Partial<CreateSessionRequest> | null;
|
||||
if (!body || typeof body.cwd !== 'string' || !body.cwd.startsWith('/')) {
|
||||
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'cwd (absolute path) is required' } });
|
||||
}
|
||||
if (body.command !== undefined && body.command !== 'claude' && body.command !== 'bash') {
|
||||
return reply.status(400).send({ error: { code: 'BAD_REQUEST', message: 'command must be claude or bash' } });
|
||||
}
|
||||
try {
|
||||
const session = manager.spawn({ cwd: body.cwd, ...(body.command ? { command: body.command } : {}) });
|
||||
const res: SessionResponse = { session };
|
||||
return reply.status(201).send(res);
|
||||
} catch (err) {
|
||||
const statusCode = (err as { statusCode?: number }).statusCode ?? 500;
|
||||
return reply.status(statusCode).send({ error: { code: 'SPAWN_FAILED', message: (err as Error).message } });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/v1/sessions/:id', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
if (!manager.kill(id)) {
|
||||
return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'No live session with this id' } });
|
||||
}
|
||||
return reply.send({ ok: true });
|
||||
});
|
||||
}
|
||||
157
packages/server/src/ws/gateway.ts
Normal file
157
packages/server/src/ws/gateway.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { WebSocket } from 'ws';
|
||||
import {
|
||||
BINARY_FRAME,
|
||||
PROTOCOL_VERSION,
|
||||
encodeBinaryFrame,
|
||||
parseClientMessage,
|
||||
type ServerMessage,
|
||||
type SessionSummary,
|
||||
} from '@arboretum/shared';
|
||||
import type { ClientBinding, PtyManager } from '../core/pty-manager.js';
|
||||
|
||||
const HEARTBEAT_MS = 30_000;
|
||||
|
||||
interface ChannelState {
|
||||
sessionId: string;
|
||||
binding: ClientBinding;
|
||||
}
|
||||
|
||||
export function registerWsGateway(app: FastifyInstance, manager: PtyManager, serverVersion: string): void {
|
||||
app.get('/ws', { websocket: true }, (socket: WebSocket, req) => {
|
||||
// L'auth + le check Origin ont eu lieu dans le preValidation global (app.ts).
|
||||
const channels = new Map<number, ChannelState>();
|
||||
let nextChannel = 1;
|
||||
let helloDone = false;
|
||||
let subscribedSessions = false;
|
||||
let alive = true;
|
||||
|
||||
const send = (msg: ServerMessage): void => {
|
||||
if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(msg));
|
||||
};
|
||||
const sendBinary = (type: number, channel: number, payload: Buffer): void => {
|
||||
if (socket.readyState === socket.OPEN) socket.send(encodeBinaryFrame(type, channel, payload));
|
||||
};
|
||||
|
||||
const onSessionUpdate = (session: SessionSummary): void => {
|
||||
if (subscribedSessions) send({ type: 'session_update', session });
|
||||
};
|
||||
const onSessionExit = (e: { sessionId: string; exitCode: number | null; signal: number | null }): void => {
|
||||
if (subscribedSessions) send({ type: 'session_exit', ...e });
|
||||
};
|
||||
manager.on('session_update', onSessionUpdate);
|
||||
manager.on('session_exit', onSessionExit);
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
if (!alive) {
|
||||
socket.terminate();
|
||||
return;
|
||||
}
|
||||
alive = false;
|
||||
socket.ping();
|
||||
}, HEARTBEAT_MS);
|
||||
socket.on('pong', () => {
|
||||
alive = true;
|
||||
});
|
||||
|
||||
socket.on('message', (raw, isBinary) => {
|
||||
if (isBinary) return; // P1 : aucune frame binaire attendue côté client
|
||||
const msg = parseClientMessage(String(raw));
|
||||
if (!msg) {
|
||||
send({ type: 'error', code: 'BAD_MESSAGE', message: 'Unparseable or invalid message' });
|
||||
return;
|
||||
}
|
||||
if (!helloDone && msg.type !== 'hello') {
|
||||
send({ type: 'error', code: 'BAD_PROTOCOL', message: 'Expected hello first' });
|
||||
return;
|
||||
}
|
||||
switch (msg.type) {
|
||||
case 'hello': {
|
||||
if (msg.protocol !== PROTOCOL_VERSION) {
|
||||
send({ type: 'error', code: 'BAD_PROTOCOL', message: `Server speaks protocol ${PROTOCOL_VERSION}` });
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
helloDone = true;
|
||||
send({ type: 'hello_ok', protocol: PROTOCOL_VERSION, serverVersion });
|
||||
return;
|
||||
}
|
||||
case 'sub': {
|
||||
subscribedSessions = msg.topics.includes('sessions');
|
||||
return;
|
||||
}
|
||||
case 'attach': {
|
||||
const channel = nextChannel++;
|
||||
const binding: ClientBinding = {
|
||||
channel,
|
||||
mode: msg.mode,
|
||||
controlling: false,
|
||||
sentBytes: 0,
|
||||
ackedBytes: 0,
|
||||
lagging: false,
|
||||
sendOutput: (payload) => sendBinary(BINARY_FRAME.OUTPUT, channel, payload),
|
||||
sendResync: (payload) => sendBinary(BINARY_FRAME.RESYNC, channel, payload),
|
||||
onDetached: (reason) => {
|
||||
channels.delete(channel);
|
||||
send({ type: 'detached', channel, reason });
|
||||
},
|
||||
onControlChanged: (controlling) => send({ type: 'control_changed', channel, controlling }),
|
||||
};
|
||||
const res = manager.attach(msg.sessionId, binding, msg.cols, msg.rows);
|
||||
if (!res.ok) {
|
||||
send({ type: 'error', code: res.code, message: `Cannot attach: ${res.code}` });
|
||||
return;
|
||||
}
|
||||
channels.set(channel, { sessionId: msg.sessionId, binding });
|
||||
send({ type: 'attached', channel, sessionId: msg.sessionId, mode: msg.mode, controlling: res.controlling });
|
||||
return;
|
||||
}
|
||||
case 'detach': {
|
||||
const st = channels.get(msg.channel);
|
||||
if (!st) return;
|
||||
channels.delete(msg.channel);
|
||||
manager.detach(st.sessionId, st.binding);
|
||||
send({ type: 'detached', channel: msg.channel, reason: 'client' });
|
||||
return;
|
||||
}
|
||||
case 'stdin': {
|
||||
const st = channels.get(msg.channel);
|
||||
if (!st) {
|
||||
send({ type: 'error', code: 'NOT_ATTACHED', message: 'Unknown channel', channel: msg.channel });
|
||||
return;
|
||||
}
|
||||
const r = manager.write(st.sessionId, st.binding, msg.data);
|
||||
if (r === 'not_controlling') {
|
||||
send({ type: 'error', code: 'NOT_CONTROLLING', message: 'Observer mode is read-only', channel: msg.channel });
|
||||
} else if (r === 'gone') {
|
||||
send({ type: 'error', code: 'SESSION_EXITED', message: 'Session has exited', channel: msg.channel });
|
||||
}
|
||||
return;
|
||||
}
|
||||
case 'resize': {
|
||||
const st = channels.get(msg.channel);
|
||||
if (st) manager.resize(st.sessionId, st.binding, msg.cols, msg.rows);
|
||||
return;
|
||||
}
|
||||
case 'ack': {
|
||||
const st = channels.get(msg.channel);
|
||||
if (st) manager.ack(st.sessionId, st.binding, msg.bytes);
|
||||
return;
|
||||
}
|
||||
case 'ping':
|
||||
send({ type: 'pong' });
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('close', () => {
|
||||
clearInterval(heartbeat);
|
||||
manager.off('session_update', onSessionUpdate);
|
||||
manager.off('session_exit', onSessionExit);
|
||||
for (const [, st] of channels) manager.detach(st.sessionId, st.binding);
|
||||
channels.clear();
|
||||
});
|
||||
|
||||
void req;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user