feat(runtime): add isolated same-origin canary gateway
This commit is contained in:
@@ -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();
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user