Files
Codeman/src/tunnel-manager.ts
T
Codeman maintainer 4d8857f72a feat(multiuser): phase 2, multi-user auth pipeline
Adds a parallel multi-user auth branch (the single-user Basic-auth path is
left byte-identical). Off unless CODEMAN_MULTIUSER/--multiuser.

- middleware/auth.ts: mode-selecting registerAuthMiddleware. New async
  multi-user hook verifies username:password against the user store (scrypt),
  mints identity-carrying cookies, decorates req.authUser, enforces a per-IP
  AND per-username failure bucket, and the mustChangePassword lockbox. The
  hook-secret loopback bypass is now a single shared helper used by both
  branches. FastifyRequest.authUser module augmentation.
- ports/auth-port.ts: AuthSessionRecord gains username/role/mustChangePassword.
- user-store.ts: verifyPassword (timing-equalized against user enumeration).
- route-helpers.ts: getAuthUser (synthetic admin fallback), canAccessOwned,
  requireAdmin, revokeUserSessions; findSessionOrFail gains an optional req for
  a NOT_FOUND owner check (dormant until phase 3 wires callers).
- routes/me-routes.ts: GET /api/me (synthetic admin in single-user) and
  POST /api/me/password (verify current, min 8, clear mustChangePassword,
  revoke other sessions).
- QR: QrTokenRecord + AuthSessionRecord carry a username; tunnel-manager
  mintUserToken / consumeTokenWithIdentity / getQrSvgForCode; /q/:code binds
  the cookie to the token's user (rejects identity-less tokens in multi-user);
  GET /api/tunnel/qr mints a per-user token. Single-user keeps the rotating token.
- server.ts: bootstrap the initial admin from CODEMAN_USERNAME/PASSWORD on first
  boot (refuse to start with no users); multi-user with >= 1 user satisfies the
  non-loopback auth requirement and the tunnel-enable guard; userFailures bucket
  disposal.
- types/api.ts: FORBIDDEN, PASSWORD_CHANGE_REQUIRED, USER_EXISTS, USER_NOT_FOUND,
  LAST_ADMIN error codes (message + status wired).
- Session.owner field + getter/setter, SessionState.owner, MuxSession.owner,
  CreateSessionOptions.owner (foundation for phase 3 ownership threading).

Tests: test/multiuser-auth.test.ts (10, live server on 3170/3171). Existing auth
suite (auth-security, qr-auth, cod54-hook-event, network-auth-policy) unchanged
and green; full test:ci sweep passes (3519 tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 03:26:21 +02:00

466 lines
15 KiB
TypeScript

/**
* @fileoverview Cloudflare Tunnel Manager
*
* Manages a cloudflared child process for remote access to Codeman.
* Spawns `cloudflared tunnel --url` as a child process and parses
* the trycloudflare.com URL from its stderr output.
*
* Follows the same lifecycle pattern as ImageWatcher/SubagentWatcher:
* extends EventEmitter, start()/stop(), emits typed events.
*
* Lifecycle states:
* IDLE → STARTING → RUNNING → (crash) → RESTARTING → STARTING → ...
* Any state → stop() → IDLE
*/
import { EventEmitter } from 'node:events';
import { spawn, type ChildProcess } from 'node:child_process';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { randomBytes } from 'node:crypto';
import {
QR_TOKEN_TTL_MS,
QR_TOKEN_GRACE_MS,
SHORT_CODE_LENGTH,
QR_RATE_LIMIT_MAX,
QR_RATE_LIMIT_WINDOW_MS,
URL_TIMEOUT_MS,
RESTART_DELAY_MS,
FORCE_KILL_MS,
} from './config/tunnel-config.js';
import { getErrorMessage } from './types.js';
// ========== Types ==========
interface TunnelStatus {
running: boolean;
url: string | null;
}
interface QrTokenRecord {
token: string; // 64 hex chars (256 bits)
shortCode: string; // 6 chars base62 (for URL path)
createdAt: number; // Date.now()
consumed: boolean; // single-use flag
/** Multi-user: the user this token logs in when redeemed (absent = rotating global token). */
username?: string;
}
/** Rejection-sampled base62 short code — no modulo bias */
function generateShortCode(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const maxUnbiased = 248; // largest multiple of 62 that fits in a byte (248 = 62 * 4)
const result: string[] = [];
while (result.length < SHORT_CODE_LENGTH) {
const [byte] = randomBytes(1);
if (byte < maxUnbiased) result.push(chars[byte % 62]);
// else: discard and re-draw (rejection sampling)
}
return result.join('');
}
/** Regex to extract the trycloudflare.com URL from cloudflared output */
const TUNNEL_URL_REGEX = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
// ========== TunnelManager Class ==========
export class TunnelManager extends EventEmitter {
private process: ChildProcess | null = null;
private url: string | null = null;
private cloudflaredPath: string | null = null;
private urlTimeoutTimer: NodeJS.Timeout | null = null;
private restartTimer: NodeJS.Timeout | null = null;
private forceKillTimer: NodeJS.Timeout | null = null;
/** True when the user explicitly requested stop — suppresses auto-restart */
private stopped = true;
private localPort = 3000;
private useHttps = false;
// ========== QR Token State ==========
/** Map-based lookup: shortCode → QrTokenRecord (hash-based, timing-safe) */
private qrTokensByCode = new Map<string, QrTokenRecord>();
private currentShortCode: string | null = null;
private rotationTimer: ReturnType<typeof setInterval> | null = null;
/** SVG cache — regenerated only on token rotation, not per request */
private cachedQrSvg: { shortCode: string; svg: string } | null = null;
/** Global rate limit counter (separate from Basic Auth rate limiting) */
private qrAttemptCount = 0;
private qrRateLimitResetTimer: ReturnType<typeof setInterval> | null = null;
/**
* Resolve cloudflared binary path.
* Checks ~/.local/bin first, then falls back to PATH.
*/
private resolveCloudflared(): string | null {
if (this.cloudflaredPath) return this.cloudflaredPath;
// Check ~/.local/bin first (common user install location)
const localBin = join(homedir(), '.local', 'bin', 'cloudflared');
if (existsSync(localBin)) {
this.cloudflaredPath = localBin;
return localBin;
}
// Check /usr/local/bin
const usrLocalBin = '/usr/local/bin/cloudflared';
if (existsSync(usrLocalBin)) {
this.cloudflaredPath = usrLocalBin;
return usrLocalBin;
}
// Fall back to PATH
this.cloudflaredPath = 'cloudflared';
return 'cloudflared';
}
/** Clear all pending timers */
private clearTimers(): void {
if (this.restartTimer) {
clearTimeout(this.restartTimer);
this.restartTimer = null;
}
if (this.urlTimeoutTimer) {
clearTimeout(this.urlTimeoutTimer);
this.urlTimeoutTimer = null;
}
if (this.forceKillTimer) {
clearTimeout(this.forceKillTimer);
this.forceKillTimer = null;
}
}
/**
* Start the cloudflared tunnel process.
*/
start(localPort: number, https: boolean): void {
if (this.process) {
return; // Already running
}
// Cancel any pending restart — we're starting fresh
this.clearTimers();
this.stopped = false;
this.localPort = localPort;
this.useHttps = https;
const binary = this.resolveCloudflared();
if (!binary) {
this.emit(
'error',
'cloudflared not found. Install from https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/'
);
return;
}
const protocol = https ? 'https' : 'http';
const args = ['tunnel', '--url', `${protocol}://localhost:${localPort}`];
if (https) {
args.push('--no-tls-verify');
}
console.log(`[TunnelManager] Starting: ${binary} ${args.join(' ')}`);
try {
this.process = spawn(binary, args, {
stdio: ['ignore', 'pipe', 'pipe'],
detached: false,
});
} catch (err) {
this.emit('error', `Failed to spawn cloudflared: ${getErrorMessage(err)}`);
return;
}
this.emit('progress', { message: 'Spawning cloudflared process...' });
// Parse stdout/stderr for the URL, then detach once found
const handleOutput = (data: Buffer) => {
const line = data.toString().trim();
if (!line) return;
// Emit progress for interesting cloudflared log lines
if (!this.url) {
if (/connector.*registered/i.test(line)) {
this.emit('progress', { message: 'Tunnel connector registered' });
} else if (/connection.*registered/i.test(line)) {
this.emit('progress', { message: 'Connection registered with Cloudflare edge' });
} else if (/route.*propagating/i.test(line) || /ingress/i.test(line)) {
this.emit('progress', { message: 'Propagating route to Cloudflare edge...' });
} else if (/Starting tunnel/i.test(line) || /initial.*connection/i.test(line)) {
this.emit('progress', { message: 'Establishing tunnel connection...' });
} else if (/Registered tunnel connection/i.test(line)) {
this.emit('progress', { message: 'Tunnel connection registered' });
}
}
const match = line.match(TUNNEL_URL_REGEX);
if (match && !this.url) {
this.url = match[0];
console.log(`[TunnelManager] Tunnel URL: ${this.url}`);
if (this.urlTimeoutTimer) {
clearTimeout(this.urlTimeoutTimer);
this.urlTimeoutTimer = null;
}
// Detach listeners — no need to parse further output
this.process?.stdout?.off('data', handleOutput);
this.process?.stderr?.off('data', handleOutput);
// Start QR token rotation when tunnel URL is acquired (only if auth enabled)
if (process.env.CODEMAN_PASSWORD) {
this.startTokenRotation();
}
this.emit('started', { url: this.url });
}
};
this.process.stdout?.on('data', handleOutput);
this.process.stderr?.on('data', handleOutput);
// Guard: both 'error' and 'exit' can fire — only handle once
let exited = false;
this.process.on('error', (err) => {
if (exited) return;
exited = true;
console.error(`[TunnelManager] Process error:`, err.message);
this.process = null;
this.url = null;
this.emit('error', `cloudflared error: ${err.message}`);
this.maybeScheduleRestart();
});
this.process.on('exit', (code, signal) => {
if (exited) return;
exited = true;
console.log(`[TunnelManager] Process exited (code=${code}, signal=${signal})`);
const wasRunning = this.url !== null;
this.process = null;
this.url = null;
if (this.forceKillTimer) {
clearTimeout(this.forceKillTimer);
this.forceKillTimer = null;
}
if (this.stopped) {
// User requested stop — clean exit
this.emit('stopped', {});
} else {
// Unexpected exit — attempt restart if the tunnel had been working
this.emit('error', `cloudflared exited unexpectedly (code=${code})`);
if (wasRunning) {
this.maybeScheduleRestart();
}
}
});
// Set URL timeout
this.urlTimeoutTimer = setTimeout(() => {
this.urlTimeoutTimer = null;
if (!this.url && this.process) {
this.emit('error', 'Timed out waiting for tunnel URL');
}
}, URL_TIMEOUT_MS);
}
/**
* Schedule an auto-restart if the user hasn't requested stop.
*/
private maybeScheduleRestart(): void {
if (this.stopped || this.restartTimer || this.process) return;
console.log(`[TunnelManager] Scheduling restart in ${RESTART_DELAY_MS}ms`);
this.restartTimer = setTimeout(() => {
this.restartTimer = null;
if (!this.stopped && !this.process) {
this.start(this.localPort, this.useHttps);
}
}, RESTART_DELAY_MS);
}
/**
* Stop the cloudflared tunnel process. Safe to call from any state.
*/
stop(): void {
this.stopped = true;
this.clearTimers();
this.stopTokenRotation();
if (this.process) {
const pid = this.process.pid;
console.log(`[TunnelManager] Stopping tunnel (PID ${pid})`);
this.process.kill('SIGTERM');
// Force kill after timeout if still alive
this.forceKillTimer = setTimeout(() => {
this.forceKillTimer = null;
try {
if (pid) process.kill(pid, 'SIGKILL');
} catch {
// Process already gone
}
}, FORCE_KILL_MS);
} else {
// No process running (maybe in restart delay) — just emit stopped
this.url = null;
this.emit('stopped', {});
}
}
// ========== QR Token Management ==========
/** Start token rotation — called after tunnel URL is acquired */
startTokenRotation(): void {
this.stopTokenRotation();
this.rotateToken();
this.rotationTimer = setInterval(() => this.rotateToken(), QR_TOKEN_TTL_MS);
this.qrRateLimitResetTimer = setInterval(() => {
this.qrAttemptCount = 0;
}, QR_RATE_LIMIT_WINDOW_MS);
}
/** Stop token rotation and clear all tokens */
stopTokenRotation(): void {
if (this.rotationTimer) {
clearInterval(this.rotationTimer);
this.rotationTimer = null;
}
if (this.qrRateLimitResetTimer) {
clearInterval(this.qrRateLimitResetTimer);
this.qrRateLimitResetTimer = null;
}
this.qrTokensByCode.clear();
this.currentShortCode = null;
this.cachedQrSvg = null;
this.qrAttemptCount = 0;
}
/** Create a new token, evict expired/consumed ones, emit rotation event */
private rotateToken(): void {
const record: QrTokenRecord = {
token: randomBytes(32).toString('hex'),
shortCode: generateShortCode(),
createdAt: Date.now(),
consumed: false,
};
// Evict expired or consumed tokens
const now = Date.now();
for (const [code, rec] of this.qrTokensByCode) {
if (now - rec.createdAt > QR_TOKEN_GRACE_MS || rec.consumed) {
this.qrTokensByCode.delete(code);
}
}
this.qrTokensByCode.set(record.shortCode, record);
this.currentShortCode = record.shortCode;
this.cachedQrSvg = null; // invalidate SVG cache
this.emit('qrTokenRotated');
}
/** Get the current (newest) token's short code for QR URL */
getCurrentShortCode(): string | undefined {
return this.currentShortCode ?? undefined;
}
/** Get cached QR SVG, regenerating only if the short code changed */
async getQrSvg(tunnelUrl: string): Promise<string> {
const code = this.currentShortCode;
if (!code) throw new Error('No QR token available');
if (this.cachedQrSvg?.shortCode === code) return this.cachedQrSvg.svg;
const QRCode = await import('qrcode');
const svg: string = await QRCode.toString(`${tunnelUrl}/q/${code}`, {
type: 'svg',
margin: 2,
width: 256,
});
this.cachedQrSvg = { shortCode: code, svg };
return svg;
}
/**
* Validate and atomically consume a token by short code.
* Map.get() is hash-based — no timing side-channel from string comparison.
*/
consumeToken(shortCode: string): boolean {
return this.consumeTokenWithIdentity(shortCode).ok;
}
/**
* Like consumeToken, but also returns the bound username for multi-user tokens
* (undefined for the rotating global token). Only the identity-less rotating
* token triggers an immediate re-rotation (desktop gets a fresh QR); per-user
* tokens are on-demand and self-expire.
*/
consumeTokenWithIdentity(shortCode: string): { ok: boolean; username?: string } {
// Global rate limit (across all IPs)
if (this.qrAttemptCount >= QR_RATE_LIMIT_MAX) return { ok: false };
this.qrAttemptCount++;
const record = this.qrTokensByCode.get(shortCode);
if (!record) return { ok: false };
if (record.consumed) return { ok: false };
const now = Date.now();
if (now - record.createdAt > QR_TOKEN_GRACE_MS) return { ok: false };
// Atomic consume (single-threaded JS = no race)
record.consumed = true;
const username = record.username;
if (!username) {
// Rotating global token — immediately rotate so desktop gets a fresh QR.
this.rotateToken();
this.emit('qrTokenRegenerated');
} else {
this.qrTokensByCode.delete(shortCode);
}
return { ok: true, username };
}
/**
* Multi-user: mint a single-use token bound to a specific user (on-demand, no
* rotation). Evicts expired/consumed tokens first. Returns the short code.
*/
mintUserToken(username: string): string {
const now = Date.now();
for (const [code, rec] of this.qrTokensByCode) {
if (now - rec.createdAt > QR_TOKEN_GRACE_MS || rec.consumed) this.qrTokensByCode.delete(code);
}
const record: QrTokenRecord = {
token: randomBytes(32).toString('hex'),
shortCode: generateShortCode(),
createdAt: Date.now(),
consumed: false,
username,
};
this.qrTokensByCode.set(record.shortCode, record);
return record.shortCode;
}
/** Render a QR SVG for an arbitrary short code (used by per-user minting). */
async getQrSvgForCode(tunnelUrl: string, code: string): Promise<string> {
const QRCode = await import('qrcode');
return QRCode.toString(`${tunnelUrl}/q/${code}`, { type: 'svg', margin: 2, width: 256 });
}
/** Force-regenerate (manual revocation via API) */
regenerateQrToken(): void {
this.qrTokensByCode.clear();
this.currentShortCode = null;
this.rotateToken();
this.emit('qrTokenRegenerated');
}
isRunning(): boolean {
return this.process !== null || this.restartTimer !== null;
}
getUrl(): string | null {
return this.url;
}
getStatus(): TunnelStatus {
return {
running: this.process !== null || this.restartTimer !== null,
url: this.url,
};
}
}