374 lines
12 KiB
TypeScript
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();
|
|
});
|
|
},
|
|
};
|
|
}
|