Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions src/web/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,14 @@ export class WebServer extends EventEmitter {
private store = getStore();
private tabLayouts!: TabLayoutService;
private port: number;

/**
* The port the server is actually listening on once `start()` has resolved — the
* OS-assigned one for `new WebServer(0, …)` — and the constructor's port before that.
*/
get boundPort(): number {
return this.port;
}
private host: string;
private https: boolean;
/** Reverse-proxy sub-path prefix (normalized: '' for root, or '/foo'). */
Expand Down Expand Up @@ -746,7 +754,11 @@ export class WebServer extends EventEmitter {
saveRespawnConfig: this.saveRespawnConfig.bind(this),
// ConfigPort
store: this.store,
port: this.port,
// A getter, not a snapshot: this context is built in setupRoutes(), BEFORE
// listen() resolves an ephemeral `port: 0`, and the tunnel start reads it later.
get port() {
return self.port;
},
https: this.https,
testMode: this.testMode,
serverStartTime: this.serverStartTime,
Expand Down Expand Up @@ -1154,7 +1166,9 @@ export class WebServer extends EventEmitter {
// due times for any persisted jobs, then expose it to its routes.
this.cronService = new CronService(ctx);
this.cronService.init();
registerCronRoutes(this.app, { ...ctx, cron: this.cronService });
// Only what CronPort declares: a spread of ctx would copy `port` by value (the
// pre-listen 0 of an ephemeral bind) into an object nothing keeps in sync.
registerCronRoutes(this.app, { cron: this.cronService });

registerWsRoutes(this.app, ctx, () => this.getHostPolicy());
registerVoiceRoutes(this.app, ctx, () => this.getHostPolicy());
Expand Down Expand Up @@ -2909,6 +2923,12 @@ export class WebServer extends EventEmitter {
}

await this.app.listen({ port: this.port, host: this.host });
// A `port: 0` bind gets its number from the OS. Everything below and every later
// reader (the banner, CODEMAN_API_URL, the docker bridge listener, the
// unauthenticated-bind warnings, the route context's getter) must see that number,
// not the 0 that was asked for.
const address = this.app.server.address();
if (address !== null && typeof address === 'object') this.port = address.port;
const protocol = this.https ? 'https' : 'http';
const displayHost = this.host === '0.0.0.0' ? 'localhost' : this.host;
// The only startup banner: `codeman web` used to print its own copy of this
Expand Down
31 changes: 13 additions & 18 deletions test/auth-security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,14 @@
* 6. Logout endpoint invalidates session
* 7. Settings schema rejects unknown fields
*
* Port: 3160 (auth tests), 3161 (loopback no-auth tests), 3162 (network override tests)
* Port: ephemeral (`new WebServer(0, …)`, read back through `boundPort`)
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
import { WebServer } from '../src/web/server.js';
import { TmuxManager } from '../src/tmux-manager.js';
import { SettingsUpdateSchema } from '../src/web/schemas.js';
import { getHookSecret, HOOK_SECRET_HEADER } from '../src/config/hook-secret.js';

const AUTH_PORT = 3160;
const NOAUTH_PORT = 3161;
const NETWORK_OVERRIDE_PORT = 3162;
const AUTH_RATE_LIMIT_PORT = 3220;
const NOAUTH_NETWORK_PORT = 3221;
const TEST_USER = 'admin';
const TEST_PASS = 'test-password-12345';

Expand All @@ -30,12 +25,12 @@ function basicAuthHeader(user: string, pass: string): string {
return 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64');
}

async function startAuthServer(port: number): Promise<{ server: WebServer; baseUrl: string }> {
async function startAuthServer(): Promise<{ server: WebServer; baseUrl: string }> {
process.env.CODEMAN_PASSWORD = TEST_PASS;
process.env.CODEMAN_USERNAME = TEST_USER;
const server = new WebServer(port, false, true);
const server = new WebServer(0, false, true);
await server.start();
return { server, baseUrl: `http://localhost:${port}` };
return { server, baseUrl: `http://localhost:${server.boundPort}` };
}

async function getSessionCookie(baseUrl: string): Promise<string> {
Expand Down Expand Up @@ -66,9 +61,9 @@ describe('Auth Security', () => {
beforeAll(async () => {
process.env.CODEMAN_PASSWORD = TEST_PASS;
process.env.CODEMAN_USERNAME = TEST_USER;
server = new WebServer(AUTH_PORT, false, true);
server = new WebServer(0, false, true);
await server.start();
baseUrl = `http://localhost:${AUTH_PORT}`;
baseUrl = `http://localhost:${server.boundPort}`;
});

afterAll(async () => {
Expand Down Expand Up @@ -194,7 +189,7 @@ describe('Auth Security', () => {
let rateBaseUrl: string;

beforeEach(async () => {
({ server: rateServer, baseUrl: rateBaseUrl } = await startAuthServer(AUTH_RATE_LIMIT_PORT));
({ server: rateServer, baseUrl: rateBaseUrl } = await startAuthServer());
});

afterEach(async () => {
Expand Down Expand Up @@ -347,7 +342,7 @@ describe('No-Auth Server Startup Policy', () => {
delete process.env.CODEMAN_PASSWORD;
delete process.env.CODEMAN_USERNAME;
delete process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK;
server = new WebServer(NOAUTH_PORT, false, true, '127.0.0.1');
server = new WebServer(0, false, true, '127.0.0.1');
await server.start();
});

Expand All @@ -359,7 +354,7 @@ describe('No-Auth Server Startup Policy', () => {
});

it('allows loopback requests without auth when no password is configured', async () => {
const res = await fetch(`http://localhost:${NOAUTH_PORT}/api/status`);
const res = await fetch(`http://localhost:${server.boundPort}/api/status`);
expect(res.status).toBe(200);
});

Expand All @@ -368,10 +363,10 @@ describe('No-Auth Server Startup Policy', () => {
// bind without a password no longer refuses to start — it starts and warns,
// pointing at how to secure it. See docs/security-architecture.md.
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const networkServer = new WebServer(NOAUTH_NETWORK_PORT, false, true, '0.0.0.0');
const networkServer = new WebServer(0, false, true, '0.0.0.0');

await expect(networkServer.start()).resolves.toBeUndefined();
const res = await fetch(`http://localhost:${NOAUTH_NETWORK_PORT}/api/status`);
const res = await fetch(`http://localhost:${networkServer.boundPort}/api/status`);
expect(res.status).toBe(200);

const warned = warnSpy.mock.calls.flat().join('\n');
Expand All @@ -393,10 +388,10 @@ describe('No-Auth Server Startup Policy', () => {
});

it('allows non-loopback startup with the explicit unauthenticated-network override', async () => {
const networkServer = new WebServer(NETWORK_OVERRIDE_PORT, false, true, '0.0.0.0', undefined, true);
const networkServer = new WebServer(0, false, true, '0.0.0.0', undefined, true);

await networkServer.start();
const res = await fetch(`http://localhost:${NETWORK_OVERRIDE_PORT}/api/status`);
const res = await fetch(`http://localhost:${networkServer.boundPort}/api/status`);
expect(res.status).toBe(200);
await networkServer.stop();
});
Expand Down
17 changes: 7 additions & 10 deletions test/cod54-hook-event-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
* - tunnel NOT running + good secret → not 401 (allowed)
* - rate limiting: rapid unauthorized hook POSTs eventually 429
*
* Port: 3230 (tunnel-running), 3231 (tunnel-down), 3232 (rate-limit)
* Port: ephemeral (`new WebServer(0, …)`, read back through `boundPort`)
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { WebServer } from '../src/web/server.js';
Expand All @@ -28,9 +28,6 @@ import { TunnelManager } from '../src/tunnel-manager.js';
import { getHookSecret, HOOK_SECRET_HEADER } from '../src/config/hook-secret.js';
import { AUTH_FAILURE_MAX } from '../src/config/auth-config.js';

const TUNNEL_UP_PORT = 3230;
const TUNNEL_DOWN_PORT = 3231;
const RATE_LIMIT_PORT = 3232;
const TEST_USER = 'admin';
const TEST_PASS = 'cod54-test-password';

Expand Down Expand Up @@ -58,9 +55,9 @@ describe('COD-54 hook-event auth — tunnel running requires secret', () => {
process.env.CODEMAN_USERNAME = TEST_USER;
// Force the middleware's tunnel check to report "running".
isRunningSpy = vi.spyOn(TunnelManager.prototype, 'isRunning').mockReturnValue(true);
server = new WebServer(TUNNEL_UP_PORT, false, true);
server = new WebServer(0, false, true);
await server.start();
baseUrl = `http://localhost:${TUNNEL_UP_PORT}`;
baseUrl = `http://localhost:${server.boundPort}`;
});

afterAll(async () => {
Expand Down Expand Up @@ -97,9 +94,9 @@ describe('COD-91 hook-event auth — tunnel down ALSO requires the secret', () =
process.env.CODEMAN_USERNAME = TEST_USER;
// Tunnel NOT running — loopback-only normal prod case.
isRunningSpy = vi.spyOn(TunnelManager.prototype, 'isRunning').mockReturnValue(false);
server = new WebServer(TUNNEL_DOWN_PORT, false, true);
server = new WebServer(0, false, true);
await server.start();
baseUrl = `http://localhost:${TUNNEL_DOWN_PORT}`;
baseUrl = `http://localhost:${server.boundPort}`;
});

afterAll(async () => {
Expand Down Expand Up @@ -130,9 +127,9 @@ describe('COD-54 hook-event auth — rate limiting', () => {
process.env.CODEMAN_USERNAME = TEST_USER;
// Tunnel running so unauthorized (no-secret) hook POSTs are rejected and counted.
isRunningSpy = vi.spyOn(TunnelManager.prototype, 'isRunning').mockReturnValue(true);
server = new WebServer(RATE_LIMIT_PORT, false, true);
server = new WebServer(0, false, true);
await server.start();
baseUrl = `http://localhost:${RATE_LIMIT_PORT}`;
baseUrl = `http://localhost:${server.boundPort}`;
});

afterAll(async () => {
Expand Down
15 changes: 6 additions & 9 deletions test/multiuser-auth.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
/**
* @fileoverview Phase 2 multi-user auth integration tests (live server, port 3170+).
* @fileoverview Phase 2 multi-user auth integration tests (live server, ephemeral port).
*
* Verifies the multi-user auth branch end to end: per-user Basic verify, cookie
* identity, wrong-password / disabled-user rejection, the mustChangePassword
* lockbox + self-service change, per-account rate limiting, and QR identity binding
* (tunnel-manager unit level). Single-user auth is covered by auth-security.test.ts.
*
* Ports: 3170 (multi-user server), 3171 (rate-limit server).
* Ports: ephemeral (`new WebServer(0, …)`, read back through `boundPort`).
*/

import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
Expand All @@ -21,9 +21,6 @@ import { AUTH_FAILURE_MAX } from '../src/config/auth-config.js';

vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);

const PORT = 3170;
const RATE_PORT = 3171;

function basic(user: string, pass: string): string {
return 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64');
}
Expand Down Expand Up @@ -68,7 +65,7 @@ beforeAll(async () => {
const { updateUser } = await import('../src/user-store.js');
await updateUser('carol', { disabled: true });

server = new WebServer(PORT, false, true);
server = new WebServer(0, false, true);
await server.start();
});

Expand All @@ -84,7 +81,7 @@ afterAll(async () => {
await fs.rm(spacesDir, { recursive: true, force: true }).catch(() => {});
});

const url = (p: string) => `http://localhost:${PORT}${p}`;
const url = (p: string) => `http://localhost:${server.boundPort}${p}`;

describe('multi-user auth', () => {
it('rejects unauthenticated requests', async () => {
Expand Down Expand Up @@ -161,9 +158,9 @@ describe('multi-user auth', () => {
});

it('verify-first: a correct password is never rate-limited and self-heals failures (#17)', async () => {
rateServer = new WebServer(RATE_PORT, false, true);
rateServer = new WebServer(0, false, true);
await rateServer.start();
const rurl = (p: string) => `http://localhost:${RATE_PORT}${p}`;
const rurl = (p: string) => `http://localhost:${rateServer.boundPort}${p}`;

// Nine wrong passwords (one below the cap) are each rejected 401 — not throttled yet.
for (let i = 0; i < AUTH_FAILURE_MAX - 1; i++) {
Expand Down
12 changes: 5 additions & 7 deletions test/qr-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,12 @@
* 12. QR auth bypass in auth middleware
* 13. GET /api/tunnel/qr SVG endpoint (auth/no-auth, caching, errors)
*
* Port: 3162 (qr-auth tests), 3163 (qr-svg endpoint tests)
* Port: ephemeral (`new WebServer(0, …)`, read back through `boundPort`)
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { TunnelManager } from '../src/tunnel-manager.js';
import { WebServer } from '../src/web/server.js';

const QR_AUTH_PORT = 3162;
const QR_SVG_PORT = 3163;
const TEST_PASS = 'qr-test-pass-xyz';
const TEST_USER = 'admin';

Expand Down Expand Up @@ -312,9 +310,9 @@ describe('QR Auth Integration', () => {
beforeAll(async () => {
process.env.CODEMAN_PASSWORD = TEST_PASS;
process.env.CODEMAN_USERNAME = TEST_USER;
server = new WebServer(QR_AUTH_PORT, false, true);
server = new WebServer(0, false, true);
await server.start();
baseUrl = `http://localhost:${QR_AUTH_PORT}`;
baseUrl = `http://localhost:${server.boundPort}`;
});

afterAll(async () => {
Expand Down Expand Up @@ -608,9 +606,9 @@ describe('QR SVG Endpoint (GET /api/tunnel/qr)', () => {
beforeAll(async () => {
process.env.CODEMAN_PASSWORD = TEST_PASS;
process.env.CODEMAN_USERNAME = TEST_USER;
server = new WebServer(QR_SVG_PORT, false, true);
server = new WebServer(0, false, true);
await server.start();
baseUrl = `http://localhost:${QR_SVG_PORT}`;
baseUrl = `http://localhost:${server.boundPort}`;
});

afterAll(async () => {
Expand Down
14 changes: 9 additions & 5 deletions test/routes/voice-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
* - the socket refuses exactly what the status endpoint calls unavailable,
* - a cross-site upgrade cannot open a stream on the operator's subscription.
*
* Port: 3230 (routes), 3231 (mock upstream)
* Port: ephemeral (`port: 0` for the routes and the mock upstream)
*/

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
Expand All @@ -20,6 +20,7 @@ import fastifyWebsocket from '@fastify/websocket';
import WebSocket, { WebSocketServer } from 'ws';
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import type { AddressInfo } from 'node:net';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { registerVoiceRoutes, _resetVoiceStreamCountForTesting } from '../../src/web/routes/voice-routes.js';
import { MAX_CONCURRENT_STREAMS } from '../../src/config/voice.js';
Expand Down Expand Up @@ -47,8 +48,9 @@ function removeCredentials(): void {
rmSync(join(testHome(), '.claude', '.credentials.json'), { force: true });
}

const PORT = 3230;
const UPSTREAM_PORT = 3231;
/** Both assigned by the OS on every listen (`port: 0`); see beforeEach. */
let PORT = 0;
let UPSTREAM_PORT = 0;
const TOKEN = 'sk-ant-oat01-voice-route-test';

/** State captured by the mock upstream, so tests can assert what Codeman sent. */
Expand Down Expand Up @@ -118,7 +120,7 @@ describe('voice-routes', () => {
voiceEnabled = true;
capture = { headers: {}, url: '', binaryFrames: [], textFrames: [], socket: null };

upstream = new WebSocketServer({ port: UPSTREAM_PORT, host: '127.0.0.1' });
upstream = new WebSocketServer({ port: 0, host: '127.0.0.1' });
upstream.on('connection', (socket, req) => {
capture.headers = req.headers;
capture.url = req.url ?? '';
Expand All @@ -129,6 +131,7 @@ describe('voice-routes', () => {
});
});
await new Promise<void>((resolve) => upstream.once('listening', resolve));
UPSTREAM_PORT = (upstream.address() as AddressInfo).port;
process.env.CODEMAN_VOICE_STREAM_BASE = `ws://127.0.0.1:${UPSTREAM_PORT}`;

writeCredentials(Date.now() + 3_600_000);
Expand All @@ -138,7 +141,8 @@ describe('voice-routes', () => {
ctx = createMockRouteContext();
ctx.getClaudeVoiceEnabled = (async () => voiceEnabled) as typeof ctx.getClaudeVoiceEnabled;
registerVoiceRoutes(app, ctx as never, () => ({ bindHost: '127.0.0.1', allowedHosts: [], tunnelHost: null }));
await app.listen({ port: PORT, host: '127.0.0.1' });
await app.listen({ port: 0, host: '127.0.0.1' });
PORT = (app.server.address() as AddressInfo).port;
});

afterEach(async () => {
Expand Down
9 changes: 6 additions & 3 deletions test/routes/ws-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,20 @@
*
* @dependency test/mocks/mock-route-context.ts (createMockRouteContext)
* @dependency src/web/routes/ws-routes.ts (registerWsRoutes)
* Port: 3170 (ws-routes tests)
* Port: ephemeral (`listen({ port: 0 })`)
*/

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import type { AddressInfo } from 'node:net';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyWebsocket from '@fastify/websocket';
import WebSocket from 'ws';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { registerWsRoutes } from '../../src/web/routes/ws-routes.js';
import { MAX_INPUT_LENGTH } from '../../src/config/terminal-limits.js';

const PORT = 3170;
/** Assigned by the OS on every listen (`port: 0`); see beforeEach. */
let PORT = 0;

/** Helper: open a WebSocket connection and wait for it to reach OPEN state. */
function connectWs(path: string, timeoutMs = 5000): Promise<WebSocket> {
Expand Down Expand Up @@ -86,7 +88,8 @@ describe('ws-routes', () => {
ctx = createMockRouteContext({ sessionId: 'ws-test-session' });
registerWsRoutes(app, ctx as never, () => ({ bindHost: '127.0.0.1', allowedHosts: [], tunnelHost: null }));

await app.listen({ port: PORT, host: '127.0.0.1' });
await app.listen({ port: 0, host: '127.0.0.1' });
PORT = (app.server.address() as AddressInfo).port;
});

afterEach(async () => {
Expand Down
Loading
Loading