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}`); });