| #!/usr/bin/env node |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { createServer } from "node:http"; |
| import process from "node:process"; |
| import { pathToFileURL } from "node:url"; |
|
|
| import { |
| createProxyHandlers, |
| createRouter, |
| isBenignSocketError, |
| isServerInfoRequest, |
| matchesPathPrefix, |
| proxyServerInfoRequest, |
| } from "./proxy-utils.mjs"; |
|
|
| |
| |
| |
|
|
| function parseArgs() { |
| const args = process.argv.slice(2); |
| const config = { |
| port: 8000, |
| routes: {}, |
| defaultBackend: null, |
| noReferrerPrefixes: [], |
| runtimeServicesInfo: null, |
| }; |
|
|
| for (let i = 0; i < args.length; i++) { |
| switch (args[i]) { |
| case "-p": |
| case "--port": |
| config.port = parseInt(args[++i], 10); |
| break; |
| case "-r": |
| case "--route": |
| |
| const [path, url] = args[++i].split("="); |
| config.routes[path] = url; |
| break; |
| case "-d": |
| case "--default": |
| config.defaultBackend = args[++i]; |
| break; |
| case "--no-referrer-prefix": { |
| const prefix = args[++i]; |
| if (!prefix || !prefix.startsWith("/")) { |
| throw new Error( |
| `--no-referrer-prefix value must start with '/': ${prefix ?? "(empty)"}`, |
| ); |
| } |
| config.noReferrerPrefixes.push(prefix); |
| break; |
| } |
| case "--runtime-services-info": |
| config.runtimeServicesInfo = args[++i] || null; |
| break; |
| case "-h": |
| case "--help": |
| showHelp(); |
| process.exit(0); |
| } |
| } |
|
|
| return config; |
| } |
|
|
| function showHelp() { |
| console.log(` |
| Standalone Ingress / Reverse Proxy |
| |
| Routes HTTP requests to multiple backends based on URL path prefix. |
| |
| USAGE: |
| node scripts/ingress.mjs [options] |
| |
| OPTIONS: |
| -p, --port <port> Port to listen on (default: 8000) |
| -r, --route <path=url> Add a route (can be repeated) |
| -d, --default <url> Default backend for unmatched routes |
| --no-referrer-prefix <p> Send "Referrer-Policy: no-referrer" on proxied |
| responses under <p>. For upstreams whose URL |
| carries a credential in the query string. |
| --runtime-services-info Runtime services JSON for /server_info |
| -h, --help Show this help |
| |
| ENVIRONMENT VARIABLES: |
| INGRESS_PORT Port to listen on |
| INGRESS_ROUTES JSON object: {"path": "url", ...} |
| INGRESS_DEFAULT Default backend URL |
| INGRESS_RUNTIME_SERVICES_INFO |
| Runtime services JSON for /server_info |
| |
| EXAMPLES: |
| # Basic setup with agent server and automation |
| node scripts/ingress.mjs \\ |
| --port 8000 \\ |
| --route "/api/automation=http://localhost:18001" \\ |
| --route "/api=http://localhost:18000" \\ |
| --route "/sockets=http://localhost:18000" \\ |
| --default "http://localhost:3001" |
| |
| # Using environment variables |
| INGRESS_PORT=8000 \\ |
| INGRESS_ROUTES='{"/ api/automation":"http://localhost:18001","/api":"http://localhost:18000"}' \\ |
| INGRESS_DEFAULT="http://localhost:3001" \\ |
| node scripts/ingress.mjs |
| |
| ROUTE MATCHING: |
| Routes are sorted by path length (longest first), so more specific |
| routes like /api/automation will match before /api. |
| `); |
| } |
|
|
| function buildConfig(args, env = process.env) { |
| let routes = { ...args.routes }; |
|
|
| |
| if (env.INGRESS_ROUTES) { |
| try { |
| const envRoutes = JSON.parse(env.INGRESS_ROUTES); |
| routes = { ...routes, ...envRoutes }; |
| } catch (e) { |
| console.error("Failed to parse INGRESS_ROUTES:", e.message); |
| } |
| } |
|
|
| return { |
| port: args.port || parseInt(env.INGRESS_PORT, 10) || 8000, |
| routes, |
| defaultBackend: args.defaultBackend || env.INGRESS_DEFAULT || null, |
| noReferrerPrefixes: args.noReferrerPrefixes ?? [], |
| runtimeServicesInfo: |
| args.runtimeServicesInfo || env.INGRESS_RUNTIME_SERVICES_INFO || null, |
| }; |
| } |
|
|
| |
| |
| |
|
|
| export function startIngress(config) { |
| const route = createRouter(config.routes, config.defaultBackend); |
| const proxy = createProxyHandlers({ label: `ingress:${config.port}` }); |
| const uninstallDiagnostics = proxy.installDiagnostics(); |
|
|
| const noReferrerPrefixes = config.noReferrerPrefixes ?? []; |
|
|
| const server = createServer((req, res) => { |
| const url = req.url ?? "/"; |
| const backend = route(url); |
|
|
| if (!backend) { |
| res.writeHead(503); |
| res.end("No backend configured for this route"); |
| return; |
| } |
|
|
| |
| |
| |
| if (noReferrerPrefixes.some((prefix) => matchesPathPrefix(url, prefix))) { |
| res.setHeader("Referrer-Policy", "no-referrer"); |
| } |
|
|
| if ( |
| config.runtimeServicesInfo && |
| isServerInfoRequest(req) && |
| (req.method === "GET" || req.method === "HEAD") |
| ) { |
| proxyServerInfoRequest(req, res, backend, config.runtimeServicesInfo); |
| return; |
| } |
|
|
| proxy.proxyHttp(req, res, backend); |
| }); |
|
|
| |
| server.on("upgrade", (req, socket, head) => { |
| const backend = route(req.url ?? "/"); |
|
|
| if (!backend) { |
| socket.destroy(); |
| return; |
| } |
|
|
| proxy.proxyWebSocket(req, socket, head, backend); |
| }); |
|
|
| |
| |
| server.on("clientError", (err, socket) => { |
| if (!isBenignSocketError(err)) { |
| console.error("Client error:", err.message); |
| } |
| if (socket.writable) { |
| socket.end("HTTP/1.1 400 Bad Request\r\n\r\n"); |
| } else { |
| socket.destroy(); |
| } |
| }); |
| server.on("close", uninstallDiagnostics); |
|
|
| server.listen(config.port, () => { |
| console.log(""); |
| console.log( |
| "βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ", |
| ); |
| console.log( |
| "β Ingress Proxy β", |
| ); |
| console.log( |
| "β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ£", |
| ); |
| console.log( |
| `β Listening on: http://localhost:${config.port}/`.padEnd(66) + "β", |
| ); |
| console.log( |
| "β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ£", |
| ); |
| console.log( |
| "β Routes: β", |
| ); |
|
|
| const sortedRoutes = Object.entries(config.routes).sort( |
| ([a], [b]) => b.length - a.length, |
| ); |
| for (const [path, backend] of sortedRoutes) { |
| const line = ` ${path} β ${backend}`; |
| console.log(`β ${line.padEnd(61)}β`); |
| } |
|
|
| if (config.defaultBackend) { |
| const line = ` * (default) β ${config.defaultBackend}`; |
| console.log(`β ${line.padEnd(61)}β`); |
| } |
|
|
| console.log( |
| "β β", |
| ); |
| console.log( |
| "βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ", |
| ); |
| console.log(""); |
| }); |
|
|
| return server; |
| } |
|
|
| |
| |
| |
|
|
| const isMainModule = |
| process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; |
|
|
| if (isMainModule) { |
| const args = parseArgs(); |
| const config = buildConfig(args); |
|
|
| if (Object.keys(config.routes).length === 0 && !config.defaultBackend) { |
| console.error( |
| "Error: No routes configured. Use --route or --default options.", |
| ); |
| console.error("Run with --help for usage information."); |
| process.exit(1); |
| } |
|
|
| startIngress(config); |
|
|
| |
| process.on("SIGINT", () => { |
| console.log("\nShutting down..."); |
| process.exit(0); |
| }); |
|
|
| process.on("SIGTERM", () => { |
| console.log("\nShutting down..."); |
| process.exit(0); |
| }); |
| } |
|
|