diff --git a/apps/api/package.json b/apps/api/package.json index 162d008..3ff295d 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -6,7 +6,8 @@ "exports": "./src/index.ts", "scripts": { "test": "vitest run --root ../.. apps/api/src", - "typecheck": "tsc -p tsconfig.json" + "typecheck": "tsc -p tsconfig.json", + "canary": "node --experimental-strip-types src/canary-cli.ts" }, "dependencies": { "@multi-simadmin/contracts": "workspace:*", diff --git a/apps/api/src/canary-cli.ts b/apps/api/src/canary-cli.ts new file mode 100644 index 0000000..8d0f6db --- /dev/null +++ b/apps/api/src/canary-cli.ts @@ -0,0 +1,40 @@ +import { resolve } from 'node:path'; + +import { + CANARY_DEFAULT_PORT, + CANARY_DEFAULT_UPSTREAM_PORT, + createCanaryGateway, +} from './canary-gateway.ts'; + +function environmentPort(name: string, fallback: number): number { + const value = process.env[name]; + if (value === undefined || value === '') return fallback; + const port = Number(value); + if (!Number.isInteger(port)) throw new Error(`${name} must be an integer port`); + return port; +} + +const gateway = createCanaryGateway({ + distDir: resolve(process.env.CANARY_DIST_DIR ?? 'apps/web/dist'), + port: environmentPort('CANARY_PORT', CANARY_DEFAULT_PORT), + upstreamPort: environmentPort('CANARY_UPSTREAM_PORT', CANARY_DEFAULT_UPSTREAM_PORT), +}); + +let stopping = false; +async function stop(signal: string): Promise { + if (stopping) return; + stopping = true; + process.stdout.write(`Stopping canary gateway (${signal})\n`); + await gateway.stop(); +} + +process.once('SIGINT', () => void stop('SIGINT')); +process.once('SIGTERM', () => void stop('SIGTERM')); + +try { + await gateway.start(); + process.stdout.write(`Canary gateway listening at ${gateway.origin}\n`); +} catch (error) { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; +} diff --git a/apps/api/src/canary-gateway.test.ts b/apps/api/src/canary-gateway.test.ts new file mode 100644 index 0000000..1c967b4 --- /dev/null +++ b/apps/api/src/canary-gateway.test.ts @@ -0,0 +1,286 @@ +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { createServer, request as httpRequest, type Server } from 'node:http'; +import { connect } from 'node:net'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { + CANARY_DEFAULT_HOST, + CANARY_DEFAULT_PORT, + createCanaryGateway, + type CanaryGateway, +} from './canary-gateway.js'; + +const gateways: CanaryGateway[] = []; +const servers: Server[] = []; +const directories: string[] = []; + +async function listen(server: Server): Promise { + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Expected TCP server address'); + return address.port; +} + +async function fixtureDist(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'canary-gateway-')); + directories.push(directory); + await mkdir(join(directory, 'assets')); + await writeFile(join(directory, 'index.html'), '
canary
'); + await writeFile(join(directory, 'assets', 'app.a1b2c3d4.js'), 'export const canary = true;'); + await writeFile(join(directory, 'plain.css'), 'body { color: green; }'); + return directory; +} + +async function rawRequest( + origin: string, + path: string, + headers: Record = {}, +): Promise<{ status: number; headers: import('node:http').IncomingHttpHeaders; body: string }> { + const target = new URL(origin); + return await new Promise((resolve, reject) => { + const request = httpRequest( + { host: target.hostname, port: target.port, path, headers }, + (response) => { + const chunks: Buffer[] = []; + response.on('data', (chunk: Buffer) => chunks.push(chunk)); + response.on('end', () => + resolve({ + status: response.statusCode ?? 0, + headers: response.headers, + body: Buffer.concat(chunks).toString(), + }), + ); + }, + ); + request.once('error', reject); + request.end(); + }); +} + +async function rawSocketRequest(origin: string, request: string): Promise { + const target = new URL(origin); + return await new Promise((resolve, reject) => { + const socket = connect(Number(target.port), target.hostname); + const chunks: Buffer[] = []; + socket.once('connect', () => socket.write(request)); + socket.on('data', (chunk: Buffer) => chunks.push(chunk)); + socket.once('end', () => resolve(Buffer.concat(chunks).toString())); + socket.once('error', reject); + }); +} + +async function startGateway(distDir: string, upstreamPort: number): Promise { + const gateway = createCanaryGateway({ distDir, port: 0, upstreamPort }); + gateways.push(gateway); + await gateway.start(); + return gateway; +} + +afterEach(async () => { + await Promise.all(gateways.splice(0).map((gateway) => gateway.stop())); + await Promise.all( + servers + .splice(0) + .map((server) => new Promise((resolve) => server.close(() => resolve()))), + ); + await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true }))); +}); + +describe('same-origin canary gateway', () => { + it('uses the isolated loopback canary defaults and rejects reserved ports', () => { + expect(CANARY_DEFAULT_HOST).toBe('127.0.0.1'); + expect(CANARY_DEFAULT_PORT).toBe(8789); + expect(() => createCanaryGateway({ distDir: '/tmp/dist', port: 8788 })).toThrow(/reserved/i); + expect(() => createCanaryGateway({ distDir: '/tmp/dist', port: 8790 })).toThrow(/reserved/i); + }); + + it('accepts only numeric loopback bind and upstream hosts', () => { + for (const host of ['127.0.0.1', '127.1.2.3', '::1']) { + expect(() => createCanaryGateway({ distDir: '/tmp/dist', host })).not.toThrow(); + expect(() => createCanaryGateway({ distDir: '/tmp/dist', upstreamHost: host })).not.toThrow(); + } + for (const host of [ + 'localhost', + 'example.test', + '0.0.0.0', + '10.0.0.1', + '::', + '::ffff:127.0.0.1', + ]) { + expect(() => createCanaryGateway({ distDir: '/tmp/dist', host })).toThrow(/loopback/i); + expect(() => createCanaryGateway({ distDir: '/tmp/dist', upstreamHost: host })).toThrow( + /loopback/i, + ); + } + }); + + it('rejects missing, malformed, and foreign authority before serving or proxying', async () => { + const upstream = createServer((_request, response) => response.end('upstream reached')); + servers.push(upstream); + const gateway = await startGateway(await fixtureDist(), await listen(upstream)); + const authority = new URL(gateway.origin).host; + + const valid = await rawSocketRequest( + gateway.origin, + `GET / HTTP/1.1\r\nHost: ${authority}\r\nConnection: close\r\n\r\n`, + ); + expect(valid).toMatch(/^HTTP\/1\.1 200 /); + + for (const request of [ + 'GET / HTTP/1.0\r\nConnection: close\r\n\r\n', + 'GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n', + 'GET /api/v1/instances HTTP/1.1\r\nHost: 127.0.0.1:1\r\nConnection: close\r\n\r\n', + `GET / HTTP/1.1\r\nHost: ${authority}\r\nHost: foreign.test\r\nConnection: close\r\n\r\n`, + ]) { + const response = await rawSocketRequest(gateway.origin, request); + expect(response).toMatch(/^HTTP\/1\.1 400 /); + expect(response).not.toContain('canary'); + expect(response).not.toContain('upstream reached'); + } + }); + + it('rejects absolute-form and network-path request targets', async () => { + const gateway = await startGateway(await fixtureDist(), 1); + const authority = new URL(gateway.origin).host; + for (const target of [ + `http://${authority}/`, + '//foreign.test/', + `http://${authority}/healthz`, + ]) { + const response = await rawSocketRequest( + gateway.origin, + `GET ${target} HTTP/1.1\r\nHost: ${authority}\r\nConnection: close\r\n\r\n`, + ); + expect(response).toMatch(/^HTTP\/1\.1 400 /); + expect(response).not.toContain('canary'); + } + }); + + it('serves assets with MIME/cache policy and falls back only for browser routes', async () => { + const gateway = await startGateway(await fixtureDist(), 1); + const origin = gateway.origin; + + const asset = await fetch(`${origin}/assets/app.a1b2c3d4.js`); + expect(asset.status).toBe(200); + expect(asset.headers.get('content-type')).toMatch(/^text\/javascript/); + expect(asset.headers.get('cache-control')).toBe('public, max-age=31536000, immutable'); + + const plain = await fetch(`${origin}/plain.css`); + expect(plain.headers.get('content-type')).toMatch(/^text\/css/); + expect(plain.headers.get('cache-control')).toBe('no-cache'); + + const route = await fetch(`${origin}/instances/device-1`); + expect(route.status).toBe(200); + expect(route.headers.get('content-type')).toMatch(/^text\/html/); + expect(await route.text()).toContain('canary'); + + const unknownApi = await fetch(`${origin}/api/not-real`); + expect(unknownApi.status).toBe(404); + expect(await unknownApi.text()).not.toContain('canary'); + }); + + it('blocks encoded and plain path traversal rather than serving files outside dist', async () => { + const dist = await fixtureDist(); + await writeFile(join(dist, '..', 'secret.txt'), 'secret'); + const gateway = await startGateway(dist, 1); + + for (const path of [ + '/../secret.txt', + '/%2e%2e/secret.txt', + '/%2e%2e%2fsecret.txt', + '/assets/%2e%2e%2findex.html', + ]) { + const response = await rawRequest(gateway.origin, path); + expect([400, 404]).toContain(response.status); + expect(response.body).not.toContain('secret'); + } + }); + + it('proxies only approved API and health paths while preserving browser origin semantics', async () => { + const seen: Array<{ + url?: string; + host?: string; + origin?: string; + connection?: string; + custom?: string; + }> = []; + const upstream = createServer((request, response) => { + const observation: (typeof seen)[number] = {}; + if (request.url !== undefined) observation.url = request.url; + if (request.headers.host !== undefined) observation.host = request.headers.host; + if (request.headers.origin !== undefined) observation.origin = request.headers.origin; + if (request.headers.connection !== undefined) + observation.connection = request.headers.connection; + const custom = request.headers['x-remove-me']; + if (typeof custom === 'string') observation.custom = custom; + seen.push(observation); + response.setHeader('connection', 'x-upstream-hop'); + response.setHeader('x-upstream-hop', 'removed'); + response.setHeader('x-upstream', 'yes'); + response.end(JSON.stringify({ path: request.url })); + }); + servers.push(upstream); + const upstreamPort = await listen(upstream); + const gateway = await startGateway(await fixtureDist(), upstreamPort); + const browserOrigin = gateway.origin; + + for (const path of ['/api/v1/instances?active=true', '/healthz', '/readyz']) { + const response = await rawRequest(browserOrigin, path, { + origin: browserOrigin, + connection: 'x-remove-me', + 'x-remove-me': 'private', + }); + expect(response.status).toBe(200); + expect(response.headers['x-upstream']).toBe('yes'); + expect(response.headers['x-upstream-hop']).toBeUndefined(); + } + + expect(seen.map(({ url }) => url)).toEqual([ + '/api/v1/instances?active=true', + '/healthz', + '/readyz', + ]); + expect( + seen.every( + ({ host, origin }) => host === new URL(browserOrigin).host && origin === browserOrigin, + ), + ).toBe(true); + expect(seen.every(({ custom }) => custom === undefined)).toBe(true); + }); + + it('streams SSE chunks without waiting for the upstream response to finish', async () => { + const upstream = createServer((_request, response) => { + response.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' }); + response.write('data: first\n\n'); + setTimeout(() => response.end('data: second\n\n'), 150); + }); + servers.push(upstream); + const gateway = await startGateway(await fixtureDist(), await listen(upstream)); + + const response = await fetch(`${gateway.origin}/api/v1/events`); + const reader = response.body?.getReader(); + const first = await reader?.read(); + expect(new TextDecoder().decode(first?.value)).toContain('data: first'); + expect(first?.done).toBe(false); + await reader?.cancel(); + }); + + it('has reversible, idempotent start/stop lifecycle', async () => { + const gateway = createCanaryGateway({ distDir: await fixtureDist(), port: 0, upstreamPort: 1 }); + gateways.push(gateway); + expect(gateway.running).toBe(false); + await gateway.start(); + expect(gateway.running).toBe(true); + const origin = gateway.origin; + await gateway.stop(); + await gateway.stop(); + expect(gateway.running).toBe(false); + await gateway.start(); + expect(gateway.origin).not.toBe(origin); + }); +}); diff --git a/apps/api/src/canary-gateway.ts b/apps/api/src/canary-gateway.ts new file mode 100644 index 0000000..6cf59f7 --- /dev/null +++ b/apps/api/src/canary-gateway.ts @@ -0,0 +1,293 @@ +import { createReadStream } from 'node:fs'; +import { realpath, stat } from 'node:fs/promises'; +import { + createServer, + request as httpRequest, + type IncomingHttpHeaders, + type IncomingMessage, + type Server, + type ServerResponse, +} from 'node:http'; +import { extname, isAbsolute, relative, resolve, sep } from 'node:path'; +import { pipeline } from 'node:stream/promises'; + +export const CANARY_DEFAULT_HOST = '127.0.0.1'; +export const CANARY_DEFAULT_PORT = 8789; +export const CANARY_DEFAULT_UPSTREAM_HOST = '127.0.0.1'; +export const CANARY_DEFAULT_UPSTREAM_PORT = 8790; + +const RESERVED_PORTS = new Set([8788, 8790]); +const BASE_HOP_BY_HOP_HEADERS = new Set([ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', +]); +const MIME_TYPES: Readonly> = { + '.css': 'text/css; charset=utf-8', + '.gif': 'image/gif', + '.html': 'text/html; charset=utf-8', + '.ico': 'image/x-icon', + '.jpeg': 'image/jpeg', + '.jpg': 'image/jpeg', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.map': 'application/json; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.txt': 'text/plain; charset=utf-8', + '.webp': 'image/webp', + '.woff': 'font/woff', + '.woff2': 'font/woff2', +}; + +function isNumericLoopback(host: string): boolean { + if (host === '::1') return true; + const octets = host.split('.'); + if (octets.length !== 4 || octets.some((octet) => !/^\d{1,3}$/.test(octet))) return false; + const numbers = octets.map(Number); + return numbers[0] === 127 && numbers.every((octet) => octet >= 0 && octet <= 255); +} + +export interface CanaryGatewayOptions { + readonly distDir: string; + readonly host?: string; + readonly port?: number; + readonly upstreamHost?: string; + readonly upstreamPort?: number; +} + +export interface CanaryGateway { + readonly running: boolean; + readonly origin: string; + start(): Promise; + stop(): Promise; +} + +function connectionTokens(headers: IncomingHttpHeaders): Set { + const tokens = new Set(BASE_HOP_BY_HOP_HEADERS); + const connection = headers.connection; + if (connection) { + for (const token of connection.split(',')) tokens.add(token.trim().toLowerCase()); + } + return tokens; +} + +function withoutHopByHop(headers: IncomingHttpHeaders): IncomingHttpHeaders { + const blocked = connectionTokens(headers); + return Object.fromEntries( + Object.entries(headers).filter( + ([name, value]) => value !== undefined && !blocked.has(name.toLowerCase()), + ), + ); +} + +function isProxyPath(pathname: string): boolean { + return pathname === '/healthz' || pathname === '/readyz' || pathname.startsWith('/api/v1/'); +} + +function isHashedAsset(pathname: string): boolean { + return /(?:^|[.-])[a-f0-9]{8,}(?:[.-]|$)/i.test(pathname); +} + +function isWithin(root: string, candidate: string): boolean { + const child = relative(root, candidate); + return child === '' || (!child.startsWith(`..${sep}`) && child !== '..' && !isAbsolute(child)); +} + +function sendText(response: ServerResponse, status: number, text: string): void { + response.writeHead(status, { + 'content-type': 'text/plain; charset=utf-8', + 'content-length': Buffer.byteLength(text), + 'cache-control': 'no-store', + }); + response.end(text); +} + +async function proxyRequest( + incoming: IncomingMessage, + response: ServerResponse, + upstreamHost: string, + upstreamPort: number, +): Promise { + await new Promise((resolvePromise) => { + const upstream = httpRequest( + { + host: upstreamHost, + port: upstreamPort, + method: incoming.method, + path: incoming.url, + headers: withoutHopByHop(incoming.headers), + }, + (upstreamResponse) => { + response.writeHead( + upstreamResponse.statusCode ?? 502, + upstreamResponse.statusMessage, + withoutHopByHop(upstreamResponse.headers), + ); + pipeline(upstreamResponse, response) + .then(resolvePromise) + .catch(() => { + response.destroy(); + resolvePromise(); + }); + }, + ); + upstream.once('error', () => { + if (!response.headersSent) sendText(response, 502, 'Bad Gateway'); + else response.destroy(); + resolvePromise(); + }); + incoming.once('aborted', () => upstream.destroy()); + incoming.pipe(upstream); + }); +} + +async function serveFile( + request: IncomingMessage, + response: ServerResponse, + distRoot: string, + pathname: string, +): Promise { + const candidate = resolve(distRoot, `.${pathname}`); + if (!isWithin(distRoot, candidate)) return false; + + let canonical: string; + try { + canonical = await realpath(candidate); + if (!isWithin(distRoot, canonical) || !(await stat(canonical)).isFile()) return false; + } catch { + return false; + } + + const headers = { + 'content-type': MIME_TYPES[extname(canonical).toLowerCase()] ?? 'application/octet-stream', + 'cache-control': isHashedAsset(pathname) ? 'public, max-age=31536000, immutable' : 'no-cache', + 'x-content-type-options': 'nosniff', + }; + response.writeHead(200, headers); + if (request.method === 'HEAD') response.end(); + else await pipeline(createReadStream(canonical), response); + return true; +} + +export function createCanaryGateway(options: CanaryGatewayOptions): CanaryGateway { + const host = options.host ?? CANARY_DEFAULT_HOST; + const port = options.port ?? CANARY_DEFAULT_PORT; + const upstreamHost = options.upstreamHost ?? CANARY_DEFAULT_UPSTREAM_HOST; + const upstreamPort = options.upstreamPort ?? CANARY_DEFAULT_UPSTREAM_PORT; + if (!isNumericLoopback(host)) { + throw new Error('Canary gateway must bind to a loopback host'); + } + if (!isNumericLoopback(upstreamHost)) { + throw new Error('Canary upstream must use a loopback host'); + } + if (!Number.isInteger(port) || port < 0 || port > 65_535) throw new Error('Invalid canary port'); + if (RESERVED_PORTS.has(port)) + throw new Error(`Port ${port} is reserved and cannot run the canary`); + if (!options.distDir) throw new Error('distDir is required'); + + let server: Server | undefined; + let activeOrigin = ''; + + async function handle(request: IncomingMessage, response: ServerResponse): Promise { + if (!request.url || !request.method) return sendText(response, 400, 'Bad Request'); + if (!request.url.startsWith('/') || request.url.startsWith('//')) { + return sendText(response, 400, 'Bad Request'); + } + const expectedAuthority = activeOrigin ? new URL(activeOrigin).host : ''; + const hostHeaders = request.headersDistinct.host; + if (hostHeaders?.length !== 1 || hostHeaders[0] !== expectedAuthority) { + return sendText(response, 400, 'Bad Request'); + } + const rawPathname = request.url.split('?', 1)[0] ?? ''; + let unnormalizedPathname: string; + try { + unnormalizedPathname = decodeURIComponent(rawPathname); + } catch { + return sendText(response, 400, 'Bad Request'); + } + if ( + unnormalizedPathname.includes('\0') || + unnormalizedPathname.split('/').includes('..') || + unnormalizedPathname.includes('\\') + ) { + return sendText(response, 400, 'Bad Request'); + } + + let parsed: URL; + try { + parsed = new URL(request.url, 'http://canary.invalid'); + } catch { + return sendText(response, 400, 'Bad Request'); + } + + let pathname: string; + try { + pathname = decodeURIComponent(parsed.pathname); + } catch { + return sendText(response, 400, 'Bad Request'); + } + + if (isProxyPath(pathname)) { + await proxyRequest(request, response, upstreamHost, upstreamPort); + return; + } + if (pathname.startsWith('/api')) return sendText(response, 404, 'Not Found'); + if (request.method !== 'GET' && request.method !== 'HEAD') { + return sendText(response, 405, 'Method Not Allowed'); + } + + const distRoot = await realpath(resolve(options.distDir)).catch(() => resolve(options.distDir)); + if (pathname !== '/' && (await serveFile(request, response, distRoot, pathname))) return; + if (!(await serveFile(request, response, distRoot, '/index.html'))) { + sendText(response, 404, 'Not Found'); + } + } + + return { + get running() { + return server?.listening ?? false; + }, + get origin() { + if (!activeOrigin) throw new Error('Canary gateway is not running'); + return activeOrigin; + }, + async start() { + if (server?.listening) return; + const nextServer = createServer((request, response) => { + void handle(request, response).catch(() => { + if (!response.headersSent) sendText(response, 500, 'Internal Server Error'); + else response.destroy(); + }); + }); + server = nextServer; + await new Promise((resolvePromise, reject) => { + nextServer.once('error', reject); + nextServer.listen(port, host, resolvePromise); + }).catch((error: unknown) => { + server = undefined; + throw error; + }); + const address = nextServer.address(); + if (!address || typeof address === 'string') throw new Error('Expected TCP server address'); + const originHost = address.family === 'IPv6' ? `[${address.address}]` : address.address; + activeOrigin = `http://${originHost}:${address.port}`; + }, + async stop() { + const activeServer = server; + server = undefined; + activeOrigin = ''; + if (!activeServer?.listening) return; + await new Promise((resolvePromise, reject) => { + activeServer.close((error) => (error ? reject(error) : resolvePromise())); + activeServer.closeAllConnections(); + }); + }, + }; +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 5434d03..202bdf5 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -8,6 +8,14 @@ export { export type { BuildAppOptions, ListenEnvironment, ListenOptions, ReadinessResult } from './app.js'; export { startApi } from './start.js'; export type { StartApiOptions } from './start.js'; +export { + CANARY_DEFAULT_HOST, + CANARY_DEFAULT_PORT, + CANARY_DEFAULT_UPSTREAM_HOST, + CANARY_DEFAULT_UPSTREAM_PORT, + createCanaryGateway, +} from './canary-gateway.js'; +export type { CanaryGateway, CanaryGatewayOptions } from './canary-gateway.js'; export { backupDatabase, restoreDatabase } from './infrastructure/database/backup.js'; export { openDatabase } from './infrastructure/database/database.js'; export { diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index 3dff0dc..acedda5 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -4,5 +4,6 @@ "rootDir": "src", "types": ["node"] }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts"], + "exclude": ["src/canary-cli.ts"] }