mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Follow-up to #200 and #201, which gate the welcome buttons and the run-mode
dropdown on whether the CLI is actually installed. Four corrections:
1. #200 also DELETED the Cloudflare Tunnel welcome button and the QR widget
outright. Its rationale is right (offering a tunnel where cloudflared is not
installed is a bad default) but the conclusion overshoots: the welcome QR is
the whole scan-to-connect-from-your-phone flow, and deleting it left a large
block of live tunnel code in settings-ui.js driving elements that no longer
existed. Both are restored and the button is gated on cloudflared, which is
what the stated rationale actually asks for. New cloudflared-resolver.ts
mirrors the CLI resolvers, and TunnelManager now shares its search path so
the button and the spawn can never disagree about where cloudflared lives.
2. Antigravity was missing from the run-mode gating, the one run mode LEAST
likely to be installed. It slipped past because #201 predates it. Covered
now, plus a static test that fails if a sixth mode reaches the dropdown
without being gated, so the next one cannot slip the same way.
3. The per-surface fetches are replaced by the injected availability object
already used for the Codex settings tab, so the codebase has one mechanism
rather than two. The status routes buy nothing as a gating source: every
resolver memoizes its PATH probe server-side, so a fetch is exactly as stale
as an injected value while costing a round trip every time the dropdown opens
and leaving the welcome buttons to flicker in after paint. The routes
themselves stay, including the /api/claude/status that #200 adds.
4. Unknown availability now reads as AVAILABLE for run buttons. Both PRs hid the
button on a failed fetch, so a blip left a working install with nothing to
click; a genuinely missing CLI only ever produced an error toast. The Codex
settings TAB keeps the opposite default, since hiding it costs nothing.
The dropdown query is also scoped to the menu: `.run-mode-option` is the class
the saved-dashboard and history rows use too, and a document-wide querySelector
would have found whichever came first in the DOM.
Fixes a latent environment-sensitivity in 816d900 while here: the index-title
test asserted the template was untouched apart from the title, which held only
on a machine with no codex installed.
Verified end-to-end against a real server on an isolated instance+socket, with
Playwright: gemini/codex hidden and claude/opencode/antigravity/shell shown,
matching this host, tunnel button back, Codex settings tab still hidden, no
console errors. Full test:ci sweep green (3902 tests).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
451 lines
15 KiB
TypeScript
451 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 { randomBytes } from 'node:crypto';
|
|
import { resolveCloudflaredPath } from './utils/cloudflared-resolver.js';
|
|
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;
|
|
|
|
// Shared with the welcome-screen availability check, so the button and the
|
|
// spawn can never disagree about where cloudflared lives.
|
|
this.cloudflaredPath = resolveCloudflaredPath() ?? 'cloudflared';
|
|
return this.cloudflaredPath;
|
|
}
|
|
|
|
/** 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,
|
|
};
|
|
}
|
|
}
|