Files
homelab/site/server.mjs
T

395 lines
14 KiB
JavaScript

import {createAuthentikAdmin} from "./authentik.mjs";
import {createReadStream, readFileSync} from "node:fs";
import {stat} from "node:fs/promises";
import http from "node:http";
const port = Number.parseInt(process.env.PORT || "8080", 10);
const registryUrl = process.env.REGISTRY_URL || "http://xiteng-site-registry:8091/components";
const registryAdminUrl = process.env.REGISTRY_ADMIN_URL || "http://xiteng-site-registry:8091/admin";
const metricsUrl = process.env.METRICS_URL || "http://xiteng-site-metrics:8092/metrics";
const keyVaultUrl = process.env.KEY_VAULT_URL || "http://ai-gateway:8093";
const providerTestUrl = process.env.PROVIDER_TEST_URL || "http://xiteng-chat:3000/api/provider-test";
const keyVaultTokenFile = process.env.KEY_VAULT_TOKEN_FILE || "/run/secrets/portal_gateway_hmac";
const adminUsername = process.env.ADMIN_USERNAME || "liooil";
const authentikIssuer = process.env.AUTHENTIK_ISSUER || "https://auth.xiteng.site";
const authentikUrl = process.env.AUTHENTIK_URL || "https://auth.xiteng.site";
const authentikTokenFile = process.env.AUTHENTIK_TOKEN_FILE || "/run/authentik-secrets/portal_api_token";
const identityAuditPath = process.env.IDENTITY_AUDIT_PATH || "/data/identity-audit.jsonl";
const staticFiles = new Map([
["/", {path: "/app/index.html", type: "text/html; charset=utf-8"}],
["/index.html", {path: "/app/index.html", type: "text/html; charset=utf-8"}],
["/styles.css", {path: "/app/styles.css", type: "text/css; charset=utf-8"}],
["/app.js", {path: "/app/app.js", type: "text/javascript; charset=utf-8"}],
["/sw.js", {path: "/app/sw.js", type: "text/javascript; charset=utf-8", cacheControl: "no-cache"}],
["/manifest.webmanifest", {path: "/app/manifest.webmanifest", type: "application/manifest+json; charset=utf-8"}],
["/favicon.svg", {path: "/app/favicon.svg", type: "image/svg+xml"}],
["/favicon.ico", {path: "/app/favicon.ico", type: "image/x-icon"}],
["/icons/favicon-32.png", {path: "/app/icons/favicon-32.png", type: "image/png"}],
["/icons/apple-touch-icon.png", {path: "/app/icons/apple-touch-icon.png", type: "image/png"}],
["/icons/icon-192.png", {path: "/app/icons/icon-192.png", type: "image/png"}],
["/icons/icon-512.png", {path: "/app/icons/icon-512.png", type: "image/png"}],
["/icons/icon-maskable-512.png", {path: "/app/icons/icon-maskable-512.png", type: "image/png"}],
["/icons/services/chat.svg", {path: "/app/icons/services/chat.svg", type: "image/svg+xml"}],
["/admin", {path: "/app/admin.html", type: "text/html; charset=utf-8"}],
["/admin/", {path: "/app/admin.html", type: "text/html; charset=utf-8"}],
["/admin.js", {path: "/app/admin.js", type: "text/javascript; charset=utf-8"}],
["/account", {path: "/app/account.html", type: "text/html; charset=utf-8"}],
["/account/", {path: "/app/account.html", type: "text/html; charset=utf-8"}],
["/account.js", {path: "/app/account.js", type: "text/javascript; charset=utf-8"}]
]);
const securityHeaders = {
"Content-Security-Policy": "default-src 'self'; connect-src 'self'; img-src 'self' data: https://www.gravatar.com https://seccdn.libravatar.org https://cdn.jsdelivr.net https://cdn.simpleicons.org; style-src 'self'; script-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'",
"Referrer-Policy": "strict-origin-when-cross-origin",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY"
};
function sendJson(response, statusCode, payload, extraHeaders = {}) {
const body = JSON.stringify(payload);
response.writeHead(statusCode, {
...securityHeaders,
"Cache-Control": "no-store",
"Content-Type": "application/json; charset=utf-8",
"Content-Length": Buffer.byteLength(body),
...extraHeaders
});
response.end(body);
}
function readOptionalSecret(path) {
try {
return readFileSync(path, "utf8").trim();
} catch {
return null;
}
}
const keyVaultToken = readOptionalSecret(keyVaultTokenFile);
const authentikAdmin = createAuthentikAdmin({
baseUrl: authentikUrl,
token: readOptionalSecret(authentikTokenFile),
auditPath: identityAuditPath,
adminUsername
});
async function readBody(request, maximum = 65536) {
const chunks = [];
let size = 0;
for await (const chunk of request) {
size += chunk.length;
if (size > maximum) {
const error = new Error("Request body too large");
error.statusCode = 413;
throw error;
}
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
function parseJson(body) {
try {
return body.length ? JSON.parse(body.toString("utf8")) : {};
} catch {
const error = new Error("Invalid JSON body");
error.statusCode = 400;
throw error;
}
}
async function proxyJson(request, response, upstreamUrl, unavailableMessage, headers = {}, timeoutMs = 5000) {
try {
const method = request.method === "POST" ? "POST" : "GET";
const body = method === "POST" ? await readBody(request) : undefined;
const upstream = await fetch(upstreamUrl, {
method,
body: body?.length ? body : undefined,
headers: {
"Accept": "application/json",
...(body?.length ? {"Content-Type": "application/json"} : {}),
...headers
},
signal: AbortSignal.timeout(timeoutMs)
});
const upstreamBody = await upstream.text();
response.writeHead(upstream.status, {
...securityHeaders,
"Cache-Control": "no-store",
"Content-Type": "application/json; charset=utf-8",
"Content-Length": Buffer.byteLength(upstreamBody)
});
response.end(upstreamBody);
} catch (error) {
console.error(unavailableMessage, error.message);
sendJson(response, 503, {error: unavailableMessage});
}
}
async function serveStatic(request, response, file) {
try {
const metadata = await stat(file.path);
response.writeHead(200, {
...securityHeaders,
"Cache-Control": file.cacheControl || "no-cache",
"Content-Type": file.type,
"Content-Length": metadata.size
});
if (request.method === "HEAD") {
response.end();
return;
}
createReadStream(file.path).pipe(response);
} catch {
sendJson(response, 404, {error: "Not found"});
}
}
function header(request, name) {
const value = request.headers[name];
return typeof value === "string" ? value.trim() : "";
}
function identityFromRequest(request) {
const username = header(request, "x-authentik-username");
return {
issuer: authentikIssuer,
sub: header(request, "x-authentik-uid") || username,
username,
email: header(request, "x-authentik-email"),
admin: username === adminUsername,
provider: "authentik"
};
}
function isAuthenticated(request) {
return header(request, "x-portal-authenticated") === "1" && Boolean(identityFromRequest(request).username);
}
function validOrigin(request) {
return request.headers.origin === "https://xiteng.site";
}
function requireMutationOrigin(request, response) {
if (!validOrigin(request)) {
sendJson(response, 403, {error: "Invalid request origin"});
return false;
}
return true;
}
async function proxyKeyVault(request, response, upstreamPath) {
if (!keyVaultToken) {
sendJson(response, 503, {error: "Key Vault is not initialized"});
return;
}
try {
const body = request.method === "POST" ? await readBody(request, 1048576) : Buffer.alloc(0);
const actor = identityFromRequest(request);
const upstream = await fetch(new URL(upstreamPath, keyVaultUrl), {
method: request.method,
headers: {
"Accept": "application/json",
"Authorization": `Bearer ${keyVaultToken}`,
...(body.length ? {"Content-Type": "application/json"} : {}),
"X-Key-Vault-Actor-Issuer": actor.issuer,
"X-Key-Vault-Actor-Sub": actor.sub,
"X-Key-Vault-Actor-Username": actor.username
},
body: body.length ? body : undefined,
signal: AbortSignal.timeout(10000)
});
const upstreamBody = await upstream.text();
response.writeHead(upstream.status, {
...securityHeaders,
"Cache-Control": "no-store",
"Content-Type": "application/json; charset=utf-8",
"Content-Length": Buffer.byteLength(upstreamBody)
});
response.end(upstreamBody);
} catch (error) {
console.error("Key Vault unavailable", error.message);
sendJson(response, 503, {error: "Key Vault unavailable"});
}
}
function vaultUpstreamPath(pathname, admin, searchParams) {
const prefix = admin ? "/api/admin/vault" : "/api/account/vault";
const suffix = pathname.slice(prefix.length);
if (suffix === "/providers") return "/v1/providers";
if (/^\/providers\/[a-z0-9._-]+\/delete$/.test(suffix)) return `/v1${suffix}`;
if (suffix === "/credentials") {
return `/v1/credentials${admin && searchParams.get("scope") !== "own" ? "?scope=all" : ""}`;
}
if (suffix === "/audit") return `/v1/audit${admin ? "?scope=all&limit=200" : "?limit=100"}`;
if (/^\/credentials\/[0-9a-f-]+\/(replace|verify|delete)$/.test(suffix)) return `/v1${suffix}`;
return null;
}
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"});
return;
}
if (["GET", "HEAD"].includes(request.method || "") && pathname === "/api/components") {
await proxyJson(request, response, registryUrl, "Component registry unavailable");
return;
}
if (["GET", "HEAD"].includes(request.method || "") && pathname === "/api/metrics") {
await proxyJson(request, response, metricsUrl, "Host metrics unavailable");
return;
}
const isAdminPath = pathname === "/admin"
|| pathname === "/admin/"
|| pathname === "/admin.js"
|| pathname.startsWith("/api/admin/");
const isAccountPath = pathname === "/account"
|| pathname === "/account/"
|| pathname === "/account.js"
|| pathname.startsWith("/api/account/");
if ((isAdminPath || isAccountPath) && !isAuthenticated(request)) {
sendJson(response, 403, {error: "Authenticated user required"});
return;
}
if (isAdminPath && !identityFromRequest(request).admin) {
sendJson(response, 403, {error: `Administrator ${adminUsername} required`});
return;
}
if (request.method === "GET" && ["/api/admin/session", "/api/account/session"].includes(pathname)) {
sendJson(response, 200, {identity: identityFromRequest(request), administrator: adminUsername});
return;
}
if (pathname === "/api/admin/identity" && request.method === "GET") {
sendJson(response, 200, await authentikAdmin.summary(url.searchParams.get("search") || ""));
return;
}
if (pathname.startsWith("/api/admin/identity/") && request.method === "POST") {
if (!requireMutationOrigin(request, response)) {
return;
}
const result = await authentikAdmin.mutate(
pathname,
parseJson(await readBody(request)),
identityFromRequest(request).username
);
sendJson(response, 200, {result});
return;
}
if (pathname === "/api/account/identity" && request.method === "GET") {
sendJson(response, 200, await authentikAdmin.accountSummary(identityFromRequest(request).username));
return;
}
if (pathname === "/api/account/provider-test" && request.method === "POST") {
if (!requireMutationOrigin(request, response)) return;
const identity = identityFromRequest(request);
await proxyJson(request, response, providerTestUrl, "Provider connectivity test unavailable", {
"X-Authentik-Username": identity.username,
"X-Authentik-Uid": identity.sub,
"X-Authentik-Email": identity.email
}, 20000);
return;
}
if (pathname.startsWith("/api/account/") && request.method === "POST" && !pathname.startsWith("/api/account/vault")) {
if (!requireMutationOrigin(request, response)) return;
const result = await authentikAdmin.accountMutate(
pathname,
parseJson(await readBody(request)),
identityFromRequest(request).username
);
sendJson(response, 200, {result});
return;
}
if (pathname.startsWith("/api/admin/vault") || pathname.startsWith("/api/account/vault")) {
const admin = pathname.startsWith("/api/admin/vault");
const upstreamPath = vaultUpstreamPath(pathname, admin, url.searchParams);
if (!upstreamPath) {
sendJson(response, 404, {error: "Vault action not found"});
return;
}
if (request.method === "POST" && !requireMutationOrigin(request, response)) {
return;
}
if (!["GET", "POST"].includes(request.method || "")) {
sendJson(response, 405, {error: "Method not allowed"});
return;
}
await proxyKeyVault(request, response, upstreamPath);
return;
}
if (pathname === "/api/admin/components" && request.method === "GET") {
await proxyJson(
request,
response,
`${registryAdminUrl}/components`,
"Registry administration unavailable",
{"X-Portal-Admin": "1"}
);
return;
}
const adminAction = pathname.match(/^\/api\/admin\/components\/([a-z0-9][a-z0-9-]*)\/(archive|restore|pause|resume|purge)$/);
if (adminAction && request.method === "POST") {
if (!requireMutationOrigin(request, response)) {
return;
}
await proxyJson(
request,
response,
`${registryAdminUrl}/components/${adminAction[1]}/${adminAction[2]}`,
"Registry administration unavailable",
{"X-Portal-Admin": "1"}
);
return;
}
const file = staticFiles.get(pathname);
if (file && ["GET", "HEAD"].includes(request.method || "")) {
await serveStatic(request, response, file);
return;
}
if (!["GET", "HEAD", "POST"].includes(request.method || "")) {
sendJson(response, 405, {error: "Method not allowed"}, {"Allow": "GET, HEAD, POST"});
return;
}
sendJson(response, 404, {error: "Not found"});
} catch (error) {
console.error("Portal request failed", error.message);
sendJson(response, error.statusCode || 500, {error: error.message || "Request failed"});
}
});
server.listen(port, "0.0.0.0", () => {
console.log(`xiteng.site listening on :${port}; administrator=${adminUsername}`);
});