feat(map): add cache-first Cesium gateway and AMD egress
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
import { createServer } from "node:http";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { connect } from "node:net";
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
|
||||
const allowedHosts = new Set([
|
||||
"api.cesium.com",
|
||||
"assets.ion.cesium.com",
|
||||
"dev.virtualearth.net",
|
||||
"ecn.t0.tiles.virtualearth.net",
|
||||
"ecn.t1.tiles.virtualearth.net",
|
||||
"ecn.t2.tiles.virtualearth.net",
|
||||
"ecn.t3.tiles.virtualearth.net",
|
||||
]);
|
||||
const config = await readConfig();
|
||||
|
||||
const server = createServer((request, response) => {
|
||||
if (!isAuthorized(request.headers["proxy-authorization"])) return writeJson(response, 401, { ok: false, error: "connector_unauthorized" }, { "proxy-authenticate": "Bearer" });
|
||||
if (request.method === "GET" && request.url === "/healthz") return writeJson(response, 200, { ok: true, service: "dc-amd-connector", mode: "restricted-connect", allowedHosts: allowedHosts.size });
|
||||
return writeJson(response, 405, { ok: false, error: "connect_only" });
|
||||
});
|
||||
|
||||
server.on("connect", (request, clientSocket, head) => {
|
||||
if (!isAuthorized(request.headers["proxy-authorization"])) return rejectTunnel(clientSocket, 407, "Proxy Authentication Required", { "Proxy-Authenticate": "Bearer" });
|
||||
|
||||
let target;
|
||||
try {
|
||||
target = parseConnectTarget(request.url || "");
|
||||
} catch (error) {
|
||||
log("warn", "connect_rejected", { reason: error.message });
|
||||
return rejectTunnel(clientSocket, 403, "Forbidden");
|
||||
}
|
||||
|
||||
const upstream = connect({ host: target.host, port: target.port });
|
||||
let settled = false;
|
||||
const timeout = setTimeout(() => upstream.destroy(new Error("upstream_connect_timeout")), config.connectTimeoutMs);
|
||||
clientSocket.setTimeout(config.idleTimeoutMs, () => clientSocket.destroy());
|
||||
upstream.setTimeout(config.idleTimeoutMs, () => upstream.destroy());
|
||||
|
||||
upstream.once("connect", () => {
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
clientSocket.write("HTTP/1.1 200 Connection Established\r\nProxy-Agent: dc-amd-connector\r\n\r\n");
|
||||
if (head?.length) upstream.write(head);
|
||||
clientSocket.pipe(upstream);
|
||||
upstream.pipe(clientSocket);
|
||||
log("info", "connect_established", { host: target.host, port: target.port });
|
||||
});
|
||||
upstream.once("error", (error) => {
|
||||
clearTimeout(timeout);
|
||||
if (!settled) {
|
||||
log("warn", "connect_failed", { host: target.host, port: target.port, reason: sanitizeError(error) });
|
||||
rejectTunnel(clientSocket, 502, "Bad Gateway");
|
||||
}
|
||||
});
|
||||
clientSocket.once("error", () => upstream.destroy());
|
||||
clientSocket.once("close", () => upstream.destroy());
|
||||
upstream.once("close", () => clientSocket.destroy());
|
||||
});
|
||||
|
||||
server.on("clientError", (_error, socket) => rejectTunnel(socket, 400, "Bad Request"));
|
||||
server.listen(config.port, "0.0.0.0", () => log("info", "connector_started", { port: config.port, allowedHosts: allowedHosts.size }));
|
||||
|
||||
for (const signal of ["SIGINT", "SIGTERM"]) process.on(signal, () => server.close(() => process.exit(0)));
|
||||
|
||||
async function readConfig() {
|
||||
const port = parsePort(process.env.PORT, 8791);
|
||||
const tokenFile = String(process.env.AMD_CONNECTOR_ACCESS_TOKEN_FILE || "/run/dc-amd-secrets/connector-access").trim();
|
||||
const token = String(await readFile(tokenFile, "utf8")).trim();
|
||||
if (!/^[A-Za-z0-9_-]{48,256}$/.test(token)) throw new Error("connector_access_token_invalid");
|
||||
return {
|
||||
port,
|
||||
token: Buffer.from(token, "utf8"),
|
||||
connectTimeoutMs: parseDuration(process.env.AMD_CONNECTOR_CONNECT_TIMEOUT_SECONDS, 20),
|
||||
idleTimeoutMs: parseDuration(process.env.AMD_CONNECTOR_IDLE_TIMEOUT_SECONDS, 90),
|
||||
};
|
||||
}
|
||||
|
||||
function parsePort(raw, fallback) {
|
||||
const value = Number(String(raw || fallback).trim());
|
||||
if (!Number.isInteger(value) || value < 1024 || value > 65535) throw new Error("connector_port_invalid");
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseDuration(raw, fallbackSeconds) {
|
||||
const seconds = Number(String(raw || fallbackSeconds).trim());
|
||||
if (!Number.isInteger(seconds) || seconds < 1 || seconds > 600) throw new Error("connector_timeout_invalid");
|
||||
return seconds * 1000;
|
||||
}
|
||||
|
||||
function isAuthorized(rawHeader) {
|
||||
const match = /^Bearer\s+([A-Za-z0-9_-]{48,256})$/i.exec(String(rawHeader || "").trim());
|
||||
if (!match) return false;
|
||||
const candidate = Buffer.from(match[1], "utf8");
|
||||
return candidate.length === config.token.length && timingSafeEqual(candidate, config.token);
|
||||
}
|
||||
|
||||
function parseConnectTarget(raw) {
|
||||
const match = /^([A-Za-z0-9.-]{1,253}):(443)$/.exec(String(raw || "").trim());
|
||||
if (!match) throw new Error("connect_target_invalid");
|
||||
const host = match[1].toLowerCase();
|
||||
if (!allowedHosts.has(host)) throw new Error("connect_target_not_allowed");
|
||||
return { host, port: Number(match[2]) };
|
||||
}
|
||||
|
||||
function rejectTunnel(socket, status, message, headers = {}) {
|
||||
if (!socket || socket.destroyed) return;
|
||||
const extra = Object.entries(headers).map(([name, value]) => `${name}: ${value}\r\n`).join("");
|
||||
socket.end(`HTTP/1.1 ${status} ${message}\r\n${extra}Connection: close\r\nContent-Length: 0\r\n\r\n`);
|
||||
}
|
||||
|
||||
function writeJson(response, status, body, headers = {}) {
|
||||
const payload = JSON.stringify(body);
|
||||
response.writeHead(status, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-length": Buffer.byteLength(payload),
|
||||
"cache-control": "no-store",
|
||||
...headers,
|
||||
});
|
||||
response.end(payload);
|
||||
}
|
||||
|
||||
function sanitizeError(error) {
|
||||
const message = String(error?.message || "connector_error");
|
||||
return message.replace(/[^A-Za-z0-9_.:-]/g, "_").slice(0, 120);
|
||||
}
|
||||
|
||||
function log(level, event, fields = {}) {
|
||||
console.log(JSON.stringify({ ts: new Date().toISOString(), level, event, ...fields }));
|
||||
}
|
||||
Reference in New Issue
Block a user