Files
multi-simadmin/apps/api/src/canary-gateway.ts
T

374 lines
12 KiB
TypeScript

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';
import { GATEWAY_AUTH_HEADER } from './runtime-config.js';
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]);
export const PRODUCTION_GATEWAY_PORT = 8788;
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 gatewayToken?: string;
readonly distDir: string;
readonly host?: string;
readonly allowedHosts?: readonly string[];
readonly port?: number;
readonly upstreamHost?: string;
readonly upstreamPort?: number;
/** Set only after the production runtime validates its explicit acknowledgement. */
readonly ownsProductionPort?: boolean;
}
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 trustedUpstreamHeaders(
headers: IncomingHttpHeaders,
gatewayToken: string | undefined,
upstreamHost: string,
upstreamPort: number,
): IncomingHttpHeaders {
const sanitized = withoutHopByHop(headers);
sanitized.host = `${upstreamHost}:${upstreamPort}`;
if (sanitized.origin !== undefined) sanitized.origin = `http://${upstreamHost}:${upstreamPort}`;
delete sanitized[GATEWAY_AUTH_HEADER];
if (gatewayToken !== undefined) sanitized[GATEWAY_AUTH_HEADER] = gatewayToken;
return sanitized;
}
function isAllowedBrowserOrigin(origin: string, allowedAuthorities: ReadonlySet<string>): boolean {
try {
const parsed = new URL(origin);
return (
parsed.protocol === 'http:' &&
parsed.username === '' &&
parsed.password === '' &&
parsed.pathname === '/' &&
parsed.search === '' &&
parsed.hash === '' &&
allowedAuthorities.has(parsed.host.toLowerCase())
);
} catch {
return false;
}
}
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,
gatewayToken: string | undefined,
): Promise<void> {
await new Promise<void>((resolvePromise) => {
const upstream = httpRequest(
{
host: upstreamHost,
port: upstreamPort,
method: incoming.method,
path: incoming.url,
headers: trustedUpstreamHeaders(incoming.headers, gatewayToken, upstreamHost, upstreamPort),
},
(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) && !(options.ownsProductionPort && host === '0.0.0.0')) {
throw new Error('Canary gateway must bind to loopback; production may explicitly bind 0.0.0.0');
}
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) && !(port === PRODUCTION_GATEWAY_PORT && options.ownsProductionPort))
throw new Error(`Port ${port} is reserved and cannot run the canary`);
if (!options.distDir) throw new Error('distDir is required');
const allowedAuthorities = new Set(
options.allowedHosts?.map((allowedHost) => {
const ipv4Octets = /^\d+(?:\.\d+){3}$/u.test(allowedHost)
? allowedHost.split('.').map(Number)
: undefined;
if (
allowedHost === '' ||
allowedHost.includes(':') ||
(ipv4Octets !== undefined &&
(ipv4Octets.some((octet) => octet > 255) || ipv4Octets.every((octet) => octet === 0))) ||
!/^(?:[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?|\d{1,3}(?:\.\d{1,3}){3})$/iu.test(allowedHost)
)
throw new Error('Invalid allowed gateway host');
return `${allowedHost.toLowerCase()}:${port}`;
}),
);
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 acceptedAuthorities =
host === '0.0.0.0' ? allowedAuthorities : new Set([expectedAuthority, ...allowedAuthorities]);
const hostHeaders = request.headersDistinct.host;
const requestAuthority = hostHeaders?.[0]?.toLowerCase();
if (
hostHeaders?.length !== 1 ||
requestAuthority === undefined ||
!acceptedAuthorities.has(requestAuthority)
) {
return sendText(response, 400, 'Bad Request');
}
const incomingOrigin = request.headers.origin;
if (
incomingOrigin !== undefined &&
!isAllowedBrowserOrigin(incomingOrigin, acceptedAuthorities)
) {
return sendText(response, 403, 'Forbidden');
}
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, options.gatewayToken);
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');
}
// Browsers request this implicitly. Avoid a noisy console 404 without routing
// this file-like path through the SPA fallback.
if (pathname === '/favicon.ico') {
response.writeHead(204, { 'cache-control': 'no-cache' });
response.end();
return;
}
const distRoot = await realpath(resolve(options.distDir)).catch(() => resolve(options.distDir));
if (pathname !== '/' && (await serveFile(request, response, distRoot, pathname))) return;
if (
pathname.startsWith('/assets/') ||
extname(pathname) !== '' ||
!(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();
});
},
};
}