Files

93 lines
3.5 KiB
JavaScript

/**
* hermes-gateway — minimaler Bearer-Token Reverse-Proxy vor hermes-proxy.
*
* WARUM: hermes-proxy selbst hat KEINE Authentifizierung — genau wie ARIAs
* Proxy (siehe /root/ARIA-AGENT/docker-compose.yml, Service "proxy"). Bei
* ARIA ist das ok, weil der Proxy NIE einen Netzwerk-Port bekommt, nur
* Docker-intern von aria-brain erreichbar ist.
*
* Hermes Agent laeuft aber auf einer ANDEREN Maschine (Stefans Wunsch) und
* muss den Proxy ueber Netzwerk erreichen -> der Port muss also offen sein.
* Ohne Auth koennte dann JEDER der den Port erreicht (falsches Netz-Segment,
* offenes WLAN, Portscan) Stefans Claude-Max-Subscription fremdnutzen —
* die eigentliche "Auth" ist ja nur die eingeloggte Claude-CLI-Session im
* Volume, kein Provider-seitiger API-Key-Check.
*
* Dieses Gateway sitzt davor: prueft den Authorization-Header gegen ein
* Shared Secret (ENV TOKEN), leitet nur bei Match an hermes-proxy weiter.
* Alles andere -> 401, kein Request geht durch.
*
* Praktischer Nebeneffekt: OpenAI-kompatible Clients (Hermes Agent
* eingeschlossen) schicken ihren konfigurierten `api_key` sowieso schon als
* "Authorization: Bearer <api_key>"-Header. In Hermes' config.yaml also
* einfach `api_key: "<TOKEN>"` (= derselbe Wert wie hier in TOKEN) setzen —
* kein Extra-Code auf Hermes-Seite noetig.
*/
const http = require("http");
const crypto = require("crypto");
const TOKEN = process.env.TOKEN;
const UPSTREAM_HOST = process.env.UPSTREAM_HOST || "hermes-proxy";
const UPSTREAM_PORT = parseInt(process.env.UPSTREAM_PORT || "3456", 10);
const LISTEN_PORT = parseInt(process.env.LISTEN_PORT || "8080", 10);
if (!TOKEN || TOKEN.length < 16) {
console.error(
"FATAL: TOKEN env fehlt oder ist zu kurz (< 16 Zeichen). " +
"Ohne ordentliches Shared Secret startet das Gateway bewusst nicht " +
"— sonst haengt hier ein offener Claude-Proxy im Netz."
);
process.exit(1);
}
function safeEqual(a, b) {
const ab = Buffer.from(a, "utf8");
const bb = Buffer.from(b, "utf8");
if (ab.length !== bb.length) return false;
return crypto.timingSafeEqual(ab, bb);
}
function unauthorized(res) {
res.writeHead(401, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: { message: "unauthorized", type: "invalid_api_key" } }));
}
const server = http.createServer((req, res) => {
const auth = req.headers["authorization"] || "";
const expected = `Bearer ${TOKEN}`;
if (!safeEqual(auth, expected)) {
console.log(`[hermes-gateway] 401 ${req.method} ${req.url} von ${req.socket.remoteAddress}`);
return unauthorized(res);
}
const proxyReq = http.request(
{
hostname: UPSTREAM_HOST,
port: UPSTREAM_PORT,
path: req.url,
method: req.method,
headers: req.headers,
},
(proxyRes) => {
res.writeHead(proxyRes.statusCode, proxyRes.headers);
proxyRes.pipe(res);
}
);
proxyReq.on("error", (err) => {
console.error(`[hermes-gateway] upstream error: ${err.message}`);
if (!res.headersSent) {
res.writeHead(502, { "Content-Type": "application/json" });
}
res.end(JSON.stringify({ error: { message: "bad_gateway", detail: String(err.message) } }));
});
req.pipe(proxyReq);
});
server.listen(LISTEN_PORT, "0.0.0.0", () => {
console.log(`[hermes-gateway] listening on 0.0.0.0:${LISTEN_PORT} -> http://${UPSTREAM_HOST}:${UPSTREAM_PORT}`);
});