feat(runtime): add isolated same-origin canary gateway

This commit is contained in:
chick
2026-07-18 04:54:21 +08:00
parent cc8eb0f54b
commit 9451e1ee42
6 changed files with 631 additions and 2 deletions
+293
View File
@@ -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();
});
},
};
}