feat(runtime): add isolated same-origin canary gateway
This commit is contained in:
@@ -6,7 +6,8 @@
|
|||||||
"exports": "./src/index.ts",
|
"exports": "./src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "vitest run --root ../.. apps/api/src",
|
"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": {
|
"dependencies": {
|
||||||
"@multi-simadmin/contracts": "workspace:*",
|
"@multi-simadmin/contracts": "workspace:*",
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<number> {
|
||||||
|
await new Promise<void>((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<string> {
|
||||||
|
const directory = await mkdtemp(join(tmpdir(), 'canary-gateway-'));
|
||||||
|
directories.push(directory);
|
||||||
|
await mkdir(join(directory, 'assets'));
|
||||||
|
await writeFile(join(directory, 'index.html'), '<!doctype html><main>canary</main>');
|
||||||
|
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<string, string> = {},
|
||||||
|
): 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<string> {
|
||||||
|
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<CanaryGateway> {
|
||||||
|
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<void>((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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<Record<string, string>> = {
|
||||||
|
'.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<void>;
|
||||||
|
stop(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectionTokens(headers: IncomingHttpHeaders): Set<string> {
|
||||||
|
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<void> {
|
||||||
|
await new Promise<void>((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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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<void>((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<void>((resolvePromise, reject) => {
|
||||||
|
activeServer.close((error) => (error ? reject(error) : resolvePromise()));
|
||||||
|
activeServer.closeAllConnections();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -8,6 +8,14 @@ export {
|
|||||||
export type { BuildAppOptions, ListenEnvironment, ListenOptions, ReadinessResult } from './app.js';
|
export type { BuildAppOptions, ListenEnvironment, ListenOptions, ReadinessResult } from './app.js';
|
||||||
export { startApi } from './start.js';
|
export { startApi } from './start.js';
|
||||||
export type { StartApiOptions } 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 { backupDatabase, restoreDatabase } from './infrastructure/database/backup.js';
|
||||||
export { openDatabase } from './infrastructure/database/database.js';
|
export { openDatabase } from './infrastructure/database/database.js';
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -4,5 +4,6 @@
|
|||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"types": ["node"]
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"]
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["src/canary-cli.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user