131 lines
5.4 KiB
JavaScript
131 lines
5.4 KiB
JavaScript
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 }));
|
|
}
|