Files
homelab/ai-gateway/server.mjs
T

184 lines
6.1 KiB
JavaScript

import {timingSafeEqual} from "node:crypto";
import {readFileSync} from "node:fs";
import http from "node:http";
import {loadBuiltinProviders, normalizeProvider} from "./providers.mjs";
import {loadKeyFile, Vault} from "./vault.mjs";
const port = Number.parseInt(process.env.PORT || "8093", 10);
const databasePath = process.env.DATABASE_PATH || "/data/vault.db";
const masterKeyFile = process.env.MASTER_KEY_FILE || "/run/secrets/vault_master_key";
const serviceTokenFile = process.env.SERVICE_TOKEN_FILE || "/run/secrets/portal_gateway_hmac";
const providerCatalogFile = process.env.PROVIDER_CATALOG_FILE || "/app/providers.json";
const adminUsername = process.env.ADMIN_USERNAME || "liooil";
const serviceToken = readFileSync(serviceTokenFile, "utf8").trim();
const vault = new Vault({
databasePath,
masterKey: loadKeyFile(masterKeyFile),
builtinProviders: loadBuiltinProviders(providerCatalogFile)
});
const securityHeaders = {
"Cache-Control": "no-store",
"Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY"
};
function sendJson(response, statusCode, payload) {
const body = JSON.stringify(payload);
response.writeHead(statusCode, {
...securityHeaders,
"Content-Type": "application/json; charset=utf-8",
"Content-Length": Buffer.byteLength(body)
});
response.end(body);
}
async function readBody(request) {
const chunks = [];
let size = 0;
for await (const chunk of request) {
size += chunk.length;
if (size > 1048576) {
const error = new Error("Request body too large");
error.statusCode = 413;
throw error;
}
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
function parseJson(body) {
if (!body.length) return {};
try {
return JSON.parse(body.toString("utf8"));
} catch {
const error = new Error("Invalid JSON body");
error.statusCode = 400;
throw error;
}
}
function safeEqual(left, right) {
const a = Buffer.from(left || "");
const b = Buffer.from(right || "");
return a.length === b.length && a.length > 0 && timingSafeEqual(a, b);
}
function authenticateService(request) {
const authorization = request.headers.authorization || "";
const token = authorization.startsWith("Bearer ") ? authorization.slice(7).trim() : "";
if (!safeEqual(token, serviceToken)) return null;
const issuer = String(request.headers["x-key-vault-actor-issuer"] || "").trim();
const sub = String(request.headers["x-key-vault-actor-sub"] || "").trim();
const username = String(request.headers["x-key-vault-actor-username"] || "").trim();
if (!issuer || !sub || !username) return null;
return {issuer, sub, username, admin: username === adminUsername};
}
const server = http.createServer(async (request, response) => {
try {
if (!request.url) {
sendJson(response, 400, {error: "Bad request"});
return;
}
const url = new URL(request.url, "http://localhost");
const pathname = url.pathname;
if (request.method === "GET" && pathname === "/healthz") {
sendJson(response, 200, {status: "ok", vault: "sealed-at-rest"});
return;
}
const actor = authenticateService(request);
if (!actor) {
sendJson(response, 401, {error: "Trusted service authentication required"});
return;
}
const body = ["POST", "PUT", "PATCH"].includes(request.method || "")
? await readBody(request)
: Buffer.alloc(0);
if (request.method === "GET" && pathname === "/v1/session") {
sendJson(response, 200, {actor});
return;
}
if (request.method === "GET" && pathname === "/v1/providers") {
sendJson(response, 200, {providers: vault.listProviders(actor)});
return;
}
if (request.method === "POST" && pathname === "/v1/providers") {
const provider = normalizeProvider(parseJson(body), {builtin: false});
sendJson(response, 200, {provider: vault.saveCustomProvider(actor, provider)});
return;
}
const providerDelete = pathname.match(/^\/v1\/providers\/([a-z0-9._-]+)\/delete$/);
if (request.method === "POST" && providerDelete) {
sendJson(response, 200, vault.deleteCustomProvider(actor, providerDelete[1]));
return;
}
if (request.method === "GET" && pathname === "/v1/credentials") {
sendJson(response, 200, {
credentials: vault.listCredentials(actor, {all: actor.admin && url.searchParams.get("scope") === "all"})
});
return;
}
if (request.method === "POST" && pathname === "/v1/credentials") {
sendJson(response, 201, {credential: vault.createCredential(actor, parseJson(body))});
return;
}
const credentialAction = pathname.match(/^\/v1\/credentials\/([0-9a-f-]+)\/(replace|verify|delete)$/);
if (request.method === "POST" && credentialAction) {
const [, id, action] = credentialAction;
if (action === "replace") {
sendJson(response, 200, {credential: vault.replaceCredential(actor, id, parseJson(body))});
} else if (action === "verify") {
sendJson(response, 200, vault.verifyCredential(actor, id));
} else {
sendJson(response, 200, vault.deleteCredential(actor, id));
}
return;
}
if (request.method === "POST" && pathname === "/v1/resolve") {
sendJson(response, 200, vault.resolve(actor, parseJson(body)));
return;
}
if (request.method === "GET" && pathname === "/v1/audit") {
sendJson(response, 200, {
events: vault.listAudit(actor, {
all: actor.admin && url.searchParams.get("scope") === "all",
limit: url.searchParams.get("limit")
})
});
return;
}
sendJson(response, 404, {error: "Not found"});
} catch (error) {
console.error("Key Vault request failed", error.message);
sendJson(response, error.statusCode || 400, {error: error.message || "Request failed"});
}
});
server.listen(port, "0.0.0.0", () => {
console.log(`key-vault listening on :${port}`);
});
function shutdown() {
server.close(() => {
vault.close();
process.exit(0);
});
}
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);