feat: rebuild xiteng.site homelab platform
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
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);
|
||||
Reference in New Issue
Block a user