395 lines
14 KiB
JavaScript
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}`);
|
|
});
|