Files
Bjorn Blomberg 94259b3456
All checks were successful
build-and-push / build (push) Successful in 18s
release-client / build-release (push) Successful in 48s
auth: OIDC/lokal inloggning + device-flow för klienter, sessioner per användare
Ersätter den delade AGENT_HELM_TOKEN med en riktig auth-modell:

- Webben loggar in med lokala konton ELLER OIDC (Authentik/Keycloak/…), valt
  per server. Inloggning ger en HMAC-signerad cookie; WS autentiseras via cookien.
- Klienten (daemon) kopplas via device-flödet (RFC 8628-likt): `agent-helm connect`
  skriver ut en URL, du loggar in + godkänner i webben, och får ett klient-token
  som identifierar din användare. Token sparas i ~/.config/agent-helm/.
- Sessioner är per användare (web ser bara sina egna; admin ser alla). Flera
  klient-instanser = flera parallella sessioner; --new tvingar ny, annars resume.
- Identitet är utbytbar (lokal/OIDC) men device-flödet + sessionerna är alltid
  agent-helms egna → funkar för vem som helst som hostar detta, med eller utan IdP.
- Bootstrap-admin via env (engångs) eller first-run-setup i UI:t. Klient-creds
  kan listas/återkallas; minimal admin-UI (skapa användare, konfigurera OIDC).

Nya filer: server/store.ts (JSON-store, scrypt, cookies), server/oidc.ts
(auth-code+PKCE), web/lib/api.ts. Verifierat end-to-end lokalt (login → device
→ approve → daemon-WS → web ser sessionen; web utan cookie nekas).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U68YyHsxU91ecsen84WQV4
2026-06-29 01:21:29 +02:00

692 lines
25 KiB
TypeScript

/**
* agent-helm control-plane.
*
* Auth (nytt): ingen delad token längre.
* - Webben loggar in (lokala konton ELLER OIDC) och får en signerad cookie.
* WS-anslutningen autentiseras via cookien på handskaket.
* - Klienten (daemon) skaffar ett klient-token via device-flödet (RFC 8628-likt):
* skriver ut en URL, du loggar in i webben och godkänner. Tokenet identifierar
* *vilken användare* klienten tillhör. Sessioner filtreras per användare.
*
* Identitet (lokal/OIDC) är utbytbar; device-flödet + sessionerna är alltid
* agent-helms egna -> funkar för vem som helst som hostar detta, med eller utan IdP.
*
* Behåller: WS-mux, headless-xterm-scrollback per session, statisk web-serving.
*/
import http from "node:http";
import type { IncomingMessage, ServerResponse } from "node:http";
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import { WebSocketServer, WebSocket } from "ws";
import XtermHeadless from "@xterm/headless";
import type { Terminal as TerminalType } from "@xterm/headless";
import XtermSerialize from "@xterm/addon-serialize";
import type { SerializeAddon as SerializeAddonType } from "@xterm/addon-serialize";
import type {
AuthMode,
DaemonHello,
DaemonMessage,
DeviceCodeResponse,
DeviceInfoResponse,
DeviceTokenResponse,
MeResponse,
PublicConfig,
ServerToWeb,
SessionMeta,
WebMessage,
} from "@agent-helm/shared";
import { parseMessage } from "@agent-helm/shared";
import { Store, type User } from "./store";
import { buildAuthUrl, exchangeCode, pkce } from "./oidc";
const { Terminal } = XtermHeadless;
const { SerializeAddon } = XtermSerialize;
const PORT = Number(process.env.PORT ?? 8787);
const DATA_DIR = process.env.DATA_DIR || "./data";
fs.mkdirSync(DATA_DIR, { recursive: true });
const PUBLIC_URL = (process.env.AGENT_HELM_PUBLIC_URL || "").replace(/\/$/, "");
const COOKIE_SECURE = process.env.COOKIE_SECURE
? process.env.COOKIE_SECURE === "true"
: PUBLIC_URL.startsWith("https");
const SESSION_TTL = 7 * 24 * 3600; // 7 dagar
const DEVICE_TTL = 600; // 10 min
const POLL_INTERVAL = 5; // s
const store = new Store(DATA_DIR, process.env.AGENT_HELM_SECRET || undefined);
/* ------------------------- bootstrap från miljövariabler ------------------- */
const ENV_AUTH = process.env.AGENT_HELM_AUTH as AuthMode | undefined;
if (ENV_AUTH === "local" || ENV_AUTH === "oidc") store.setAuthMode(ENV_AUTH);
if (process.env.AGENT_HELM_OIDC_ISSUER && process.env.AGENT_HELM_OIDC_CLIENT_ID) {
store.setOidc({
issuer: process.env.AGENT_HELM_OIDC_ISSUER,
clientId: process.env.AGENT_HELM_OIDC_CLIENT_ID,
clientSecret: process.env.AGENT_HELM_OIDC_CLIENT_SECRET || "",
label: process.env.AGENT_HELM_OIDC_LABEL || "OIDC",
allowedGroup: process.env.AGENT_HELM_OIDC_GROUP || undefined,
});
}
const ADMIN_USER = process.env.AGENT_HELM_ADMIN_USER;
const ADMIN_PASS = process.env.AGENT_HELM_ADMIN_PASSWORD;
if (!store.setupComplete && ADMIN_USER && ADMIN_PASS) {
store.createLocalUser(ADMIN_USER, ADMIN_PASS, ADMIN_USER, true);
console.log(`[server] admin '${ADMIN_USER}' seedat från env — ta bort AGENT_HELM_ADMIN_* efter inloggning.`);
}
if (!store.setupComplete) {
console.log(
"[server] inget admin-konto än. Sätt AGENT_HELM_ADMIN_USER/PASSWORD i miljön och starta om, " +
"eller öppna web-UI:t och kör first-run-setupen (lokalt läge).",
);
}
/* -------------------------------- sessioner -------------------------------- */
interface Session {
meta: SessionMeta;
userId: string;
daemon: WebSocket | null;
term: TerminalType;
serializer: SerializeAddonType;
subscribers: Set<WebSocket>;
}
type Role =
| { kind: "pending" }
| { kind: "daemon"; sessionId: string }
| { kind: "web"; userId: string; isAdmin: boolean };
const sessions = new Map<string, Session>();
const webClients = new Set<WebSocket>();
const roles = new WeakMap<WebSocket, Role>();
const pendingCookieUser = new WeakMap<WebSocket, User | null>();
function sessionListForRole(role: { userId: string; isAdmin: boolean }): SessionMeta[] {
return [...sessions.values()]
.filter((s) => role.isAdmin || s.userId === role.userId)
.map((s) => s.meta);
}
function sendWeb(ws: WebSocket, msg: ServerToWeb): void {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
}
function broadcastSessions(): void {
for (const ws of webClients) {
const role = roles.get(ws);
if (role?.kind !== "web") continue;
sendWeb(ws, { type: "server:sessions", sessions: sessionListForRole(role) });
}
}
function closeWithError(ws: WebSocket, message: string): void {
sendWeb(ws, { type: "server:error", message });
ws.close();
}
function ownsSession(role: Role, s: Session): boolean {
return role.kind === "web" && (role.isAdmin || s.userId === role.userId);
}
/* --------------------------------- device-flöde ---------------------------- */
interface DeviceReq {
deviceCode: string;
userCode: string;
clientName: string;
status: "pending" | "approved" | "denied";
userId?: string;
expiresAt: number;
lastPollAt: number;
issued?: { token: string; sessionId: string; clientName: string; user: string };
}
const devicesByCode = new Map<string, DeviceReq>();
const devicesByUserCode = new Map<string, DeviceReq>();
const USERCODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // utan I/O/0/1
function genUserCode(): string {
const bytes = crypto.randomBytes(8);
let s = "";
for (let i = 0; i < 8; i++) s += USERCODE_ALPHABET[bytes[i] % USERCODE_ALPHABET.length];
return `${s.slice(0, 4)}-${s.slice(4)}`;
}
function newDevice(clientName: string): DeviceReq {
const dev: DeviceReq = {
deviceCode: crypto.randomBytes(32).toString("base64url"),
userCode: genUserCode(),
clientName,
status: "pending",
expiresAt: Date.now() + DEVICE_TTL * 1000,
lastPollAt: 0,
};
devicesByCode.set(dev.deviceCode, dev);
devicesByUserCode.set(dev.userCode, dev);
return dev;
}
setInterval(() => {
const now = Date.now();
for (const [k, d] of devicesByCode) if (d.expiresAt < now) devicesByCode.delete(k);
for (const [k, d] of devicesByUserCode) if (d.expiresAt < now) devicesByUserCode.delete(k);
}, 60_000).unref();
/* --------------------------------- OIDC-state ------------------------------ */
const oidcStates = new Map<string, { verifier: string; next: string; at: number }>();
setInterval(() => {
const now = Date.now();
for (const [k, s] of oidcStates) if (now - s.at > 600_000) oidcStates.delete(k);
}, 60_000).unref();
/* ------------------------------- HTTP-helpers ------------------------------ */
function parseCookies(req: IncomingMessage): Record<string, string> {
const out: Record<string, string> = {};
const raw = req.headers.cookie;
if (!raw) return out;
for (const part of raw.split(";")) {
const i = part.indexOf("=");
if (i > 0) out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim());
}
return out;
}
function readJson(req: IncomingMessage): Promise<any> {
return new Promise((resolve) => {
let body = "";
req.on("data", (c) => {
body += c;
if (body.length > 1_000_000) req.destroy();
});
req.on("end", () => {
try {
resolve(body ? JSON.parse(body) : {});
} catch {
resolve({});
}
});
req.on("error", () => resolve({}));
});
}
function sendJson(res: ServerResponse, status: number, obj: unknown): void {
const buf = Buffer.from(JSON.stringify(obj));
res.writeHead(status, { "content-type": "application/json", "content-length": buf.length });
res.end(buf);
}
function setSessionCookie(res: ServerResponse, token: string): void {
res.setHeader(
"Set-Cookie",
`helm_session=${token}; HttpOnly; Path=/; SameSite=Lax; Max-Age=${SESSION_TTL}${COOKIE_SECURE ? "; Secure" : ""}`,
);
}
function clearSessionCookie(res: ServerResponse): void {
res.setHeader("Set-Cookie", `helm_session=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0${COOKIE_SECURE ? "; Secure" : ""}`);
}
function currentUser(req: IncomingMessage): User | null {
return store.verifySession(parseCookies(req)["helm_session"]);
}
function publicBase(req: IncomingMessage): string {
if (PUBLIC_URL) return PUBLIC_URL;
const proto = ((req.headers["x-forwarded-proto"] as string) || "http").split(",")[0];
const host = (req.headers["x-forwarded-host"] as string) || req.headers.host || `localhost:${PORT}`;
return `${proto}://${host}`;
}
function me(u: User): MeResponse {
return { userId: u.id, username: u.username, displayName: u.displayName, isAdmin: u.isAdmin };
}
/* --------------------------------- API-router ------------------------------ */
async function handleApi(req: IncomingMessage, res: ServerResponse, p: string, qs: URLSearchParams): Promise<boolean> {
const method = req.method ?? "GET";
if (p === "/api/config" && method === "GET") {
const cfg: PublicConfig = {
authMode: store.authMode,
oidcLabel: store.oidc?.label,
setupComplete: store.setupComplete,
};
sendJson(res, 200, cfg);
return true;
}
if (p === "/api/setup" && method === "POST") {
if (store.setupComplete) return sendJson(res, 409, { error: "redan konfigurerad" }), true;
const b = await readJson(req);
if (!b.username || !b.password) return sendJson(res, 400, { error: "username + password krävs" }), true;
const u = store.createLocalUser(b.username, b.password, b.displayName || b.username, true);
setSessionCookie(res, store.signSession(u.id, SESSION_TTL));
sendJson(res, 200, me(u));
return true;
}
if (p === "/api/login" && method === "POST") {
if (store.authMode !== "local") return sendJson(res, 400, { error: "servern använder OIDC" }), true;
const b = await readJson(req);
const u = store.verifyLocalLogin(b.username ?? "", b.password ?? "");
if (!u) return sendJson(res, 401, { error: "fel användarnamn eller lösenord" }), true;
setSessionCookie(res, store.signSession(u.id, SESSION_TTL));
sendJson(res, 200, me(u));
return true;
}
if (p === "/api/logout" && method === "POST") {
clearSessionCookie(res);
sendJson(res, 200, { ok: true });
return true;
}
if (p === "/api/me" && method === "GET") {
const u = currentUser(req);
if (!u) return sendJson(res, 401, { error: "ej inloggad" }), true;
sendJson(res, 200, me(u));
return true;
}
/* ---- OIDC ---- */
if (p === "/auth/oidc/login" && method === "GET") {
const oidc = store.oidc;
if (store.authMode !== "oidc" || !oidc) return sendJson(res, 400, { error: "OIDC ej konfigurerat" }), true;
const { verifier, challenge } = pkce();
const state = crypto.randomBytes(16).toString("hex");
oidcStates.set(state, { verifier, next: qs.get("next") || "/", at: Date.now() });
const authUrl = await buildAuthUrl(oidc, `${publicBase(req)}/auth/oidc/callback`, state, challenge);
res.writeHead(302, { Location: authUrl });
res.end();
return true;
}
if (p === "/auth/oidc/callback" && method === "GET") {
const oidc = store.oidc;
const st = oidcStates.get(qs.get("state") || "");
oidcStates.delete(qs.get("state") || "");
const code = qs.get("code") || "";
if (!oidc || !st || !code) {
res.writeHead(400);
res.end("ogiltig OIDC-callback");
return true;
}
try {
const claims = await exchangeCode(oidc, `${publicBase(req)}/auth/oidc/callback`, code, st.verifier);
if (oidc.allowedGroup && !claims.groups.includes(oidc.allowedGroup)) {
res.writeHead(403);
res.end(`Åtkomst nekad: saknar grupp '${oidc.allowedGroup}'.`);
return true;
}
const u = store.upsertOidcUser(claims.sub, claims.username, claims.displayName, true);
setSessionCookie(res, store.signSession(u.id, SESSION_TTL));
res.writeHead(302, { Location: st.next || "/" });
res.end();
} catch (e) {
res.writeHead(500);
res.end(`OIDC-fel: ${(e as Error).message}`);
}
return true;
}
/* ---- device-flöde ---- */
if (p === "/api/device/code" && method === "POST") {
const b = await readJson(req);
const dev = newDevice((b.clientName || "klient").toString().slice(0, 60));
const base = PUBLIC_URL || publicBase(req);
const resp: DeviceCodeResponse = {
deviceCode: dev.deviceCode,
userCode: dev.userCode,
verificationUri: `${base}/device`,
verificationUriComplete: `${base}/device?code=${dev.userCode}`,
interval: POLL_INTERVAL,
expiresIn: DEVICE_TTL,
};
sendJson(res, 200, resp);
return true;
}
if (p === "/api/device/info" && method === "GET") {
const u = currentUser(req);
if (!u) return sendJson(res, 401, { error: "ej inloggad" }), true;
const dev = devicesByUserCode.get((qs.get("user_code") || "").toUpperCase());
if (!dev || dev.expiresAt < Date.now()) return sendJson(res, 404, { error: "okänd eller utgången kod" }), true;
const info: DeviceInfoResponse = { clientName: dev.clientName, status: dev.status };
sendJson(res, 200, info);
return true;
}
if (p === "/api/device/approve" && method === "POST") {
const u = currentUser(req);
if (!u) return sendJson(res, 401, { error: "ej inloggad" }), true;
const b = await readJson(req);
const dev = devicesByUserCode.get((b.userCode || "").toUpperCase());
if (!dev || dev.expiresAt < Date.now()) return sendJson(res, 404, { error: "okänd eller utgången kod" }), true;
if (b.decision === "deny") {
dev.status = "denied";
} else {
dev.status = "approved";
dev.userId = u.id;
}
sendJson(res, 200, { ok: true });
return true;
}
if (p === "/api/device/token" && method === "POST") {
const b = await readJson(req);
const dev = devicesByCode.get(b.deviceCode || "");
if (!dev || dev.expiresAt < Date.now()) return sendJson(res, 200, { status: "expired" } as DeviceTokenResponse), true;
const now = Date.now();
if (now - dev.lastPollAt < (POLL_INTERVAL - 1) * 1000) {
dev.lastPollAt = now;
return sendJson(res, 200, { status: "slow_down" } as DeviceTokenResponse), true;
}
dev.lastPollAt = now;
if (dev.status === "denied") return sendJson(res, 200, { status: "denied" } as DeviceTokenResponse), true;
if (dev.status !== "approved" || !dev.userId) return sendJson(res, 200, { status: "pending" } as DeviceTokenResponse), true;
if (!dev.issued) {
const owner = store.userById(dev.userId);
const { token } = store.issueClient(dev.userId, dev.clientName);
dev.issued = { token, sessionId: crypto.randomUUID(), clientName: dev.clientName, user: owner?.displayName ?? "?" };
}
sendJson(res, 200, { status: "ready", ...dev.issued } as DeviceTokenResponse);
return true;
}
/* ---- inloggad användares egna klient-creds ---- */
if (p === "/api/clients" && method === "GET") {
const u = currentUser(req);
if (!u) return sendJson(res, 401, { error: "ej inloggad" }), true;
sendJson(res, 200, { clients: store.listClients(u.id) });
return true;
}
if (p.startsWith("/api/clients/") && method === "DELETE") {
const u = currentUser(req);
if (!u) return sendJson(res, 401, { error: "ej inloggad" }), true;
sendJson(res, 200, { ok: store.revokeClient(p.split("/").pop()!, u.id) });
return true;
}
/* ---- admin ---- */
if (p.startsWith("/api/admin/")) {
const u = currentUser(req);
if (!u || !u.isAdmin) return sendJson(res, 403, { error: "admin krävs" }), true;
if (p === "/api/admin/users" && method === "GET") return sendJson(res, 200, { users: store.listUsers() }), true;
if (p === "/api/admin/users" && method === "POST") {
const b = await readJson(req);
try {
const nu = store.createLocalUser(b.username, b.password, b.displayName || b.username, !!b.isAdmin);
return sendJson(res, 200, me(nu)), true;
} catch (e) {
return sendJson(res, 400, { error: (e as Error).message }), true;
}
}
if (p.startsWith("/api/admin/users/") && method === "DELETE") {
store.deleteUser(p.split("/").pop()!);
return sendJson(res, 200, { ok: true }), true;
}
if (p === "/api/admin/oidc" && method === "POST") {
const b = await readJson(req);
store.setOidc({
issuer: b.issuer,
clientId: b.clientId,
clientSecret: b.clientSecret,
label: b.label || "OIDC",
allowedGroup: b.allowedGroup || undefined,
});
return sendJson(res, 200, { ok: true }), true;
}
if (p === "/api/admin/authmode" && method === "POST") {
const b = await readJson(req);
if (b.mode === "local" || b.mode === "oidc") store.setAuthMode(b.mode);
return sendJson(res, 200, { ok: true }), true;
}
if (p === "/api/admin/clients" && method === "GET") return sendJson(res, 200, { clients: store.listClients() }), true;
return sendJson(res, 404, { error: "okänd admin-endpoint" }), true;
}
return false;
}
/* -------------------------------- WS-routing ------------------------------- */
function registerDaemon(ws: WebSocket, hello: DaemonHello, userId: string, userDisplay: string): void {
roles.set(ws, { kind: "daemon", sessionId: hello.sessionId });
const existing = sessions.get(hello.sessionId);
if (existing) {
existing.daemon = ws;
existing.userId = userId;
existing.meta.status = "running";
existing.meta.user = userDisplay;
existing.meta.title = hello.clientName;
} else {
const term = new Terminal({ cols: hello.cols, rows: hello.rows, allowProposedApi: true });
const serializer = new SerializeAddon();
term.loadAddon(serializer);
sessions.set(hello.sessionId, {
meta: {
sessionId: hello.sessionId,
title: hello.clientName,
status: "running",
cols: hello.cols,
rows: hello.rows,
user: userDisplay,
},
userId,
daemon: ws,
term,
serializer,
subscribers: new Set(),
});
}
console.log(`[server] daemon registrerad: ${hello.sessionId} (${hello.clientName}) ägare=${userDisplay}`);
broadcastSessions();
}
function handleHello(ws: WebSocket, text: string): void {
const msg = parseMessage<DaemonMessage | WebMessage>(text);
if (!msg) return closeWithError(ws, "ogiltigt meddelande");
if (msg.type === "daemon:hello") {
const cred = store.clientByToken(msg.token);
if (!cred) return closeWithError(ws, "ogiltig klient-token");
store.touchClient(cred.id);
const owner = store.userById(cred.userId);
return registerDaemon(ws, msg, cred.userId, owner?.displayName ?? cred.userId);
}
if (msg.type === "web:hello") {
const user = pendingCookieUser.get(ws);
if (!user) return closeWithError(ws, "ej inloggad");
const role: Role = { kind: "web", userId: user.id, isAdmin: user.isAdmin };
roles.set(ws, role);
webClients.add(ws);
sendWeb(ws, { type: "server:welcome" });
sendWeb(ws, { type: "server:sessions", sessions: sessionListForRole(role) });
return;
}
return closeWithError(ws, "förväntade hello");
}
function handleDaemonMessage(sessionId: string, text: string): void {
const msg = parseMessage<DaemonMessage>(text);
if (!msg) return;
const session = sessions.get(sessionId);
if (!session) return;
if (msg.type === "daemon:output") {
session.term.write(msg.data);
const out: ServerToWeb = { type: "server:output", sessionId, data: msg.data };
for (const sub of session.subscribers) sendWeb(sub, out);
} else if (msg.type === "daemon:exit") {
session.meta.status = "exited";
session.meta.exitCode = msg.code;
for (const sub of session.subscribers) sendWeb(sub, { type: "server:exit", sessionId, code: msg.code });
broadcastSessions();
} else if (msg.type === "daemon:approval") {
const out: ServerToWeb = {
type: "server:approval",
sessionId,
approvalId: msg.approvalId,
toolName: msg.toolName,
toolInput: msg.toolInput,
};
for (const sub of session.subscribers) sendWeb(sub, out);
}
}
function handleWebMessage(ws: WebSocket, role: Role, text: string): void {
const msg = parseMessage<WebMessage>(text);
if (!msg || role.kind !== "web") return;
if (msg.type === "web:subscribe") {
const session = sessions.get(msg.sessionId);
if (!session || !ownsSession(role, session)) return sendWeb(ws, { type: "server:error", message: "okänd session" });
session.subscribers.add(ws);
sendWeb(ws, {
type: "server:snapshot",
sessionId: msg.sessionId,
data: session.serializer.serialize(),
meta: session.meta,
});
} else if (msg.type === "web:input") {
const session = sessions.get(msg.sessionId);
if (!session || !ownsSession(role, session)) return;
if (session.daemon?.readyState === WebSocket.OPEN) {
session.daemon.send(JSON.stringify({ type: "server:input", data: msg.data }));
}
} else if (msg.type === "web:approval-decision") {
const session = sessions.get(msg.sessionId);
if (!session || !ownsSession(role, session)) return;
if (session.daemon?.readyState === WebSocket.OPEN) {
session.daemon.send(
JSON.stringify({
type: "server:approval-decision",
approvalId: msg.approvalId,
decision: msg.decision,
reason: msg.reason,
}),
);
}
const resolved: ServerToWeb = {
type: "server:approval-resolved",
sessionId: msg.sessionId,
approvalId: msg.approvalId,
decision: msg.decision,
};
for (const sub of session.subscribers) sendWeb(sub, resolved);
}
}
function handleClose(ws: WebSocket): void {
const role = roles.get(ws);
roles.delete(ws);
pendingCookieUser.delete(ws);
if (!role) return;
if (role.kind === "web") {
webClients.delete(ws);
for (const s of sessions.values()) s.subscribers.delete(ws);
} else if (role.kind === "daemon") {
const session = sessions.get(role.sessionId);
if (session && session.daemon === ws) {
session.daemon = null;
console.log(`[server] daemon för ${role.sessionId} kopplade ner`);
}
}
}
/* ------------------------------ statisk web-UI ----------------------------- */
const WEB_DIST = process.env.WEB_DIST ?? "";
const MIME: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "text/javascript",
".mjs": "text/javascript",
".css": "text/css",
".json": "application/json",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".png": "image/png",
".webmanifest": "application/manifest+json",
".woff2": "font/woff2",
".map": "application/json",
};
function serveStatic(req: IncomingMessage, res: ServerResponse): boolean {
if (!WEB_DIST) return false;
const urlPath = decodeURIComponent((req.url ?? "/").split("?")[0]);
const indexHtml = path.join(WEB_DIST, "index.html");
let filePath = path.normalize(path.join(WEB_DIST, urlPath));
if (!filePath.startsWith(WEB_DIST) || urlPath === "/" || !path.extname(filePath)) filePath = indexHtml;
fs.readFile(filePath, (err, buf) => {
if (err) {
fs.readFile(indexHtml, (err2, idx) => {
if (err2) {
res.writeHead(404);
res.end("not found");
} else {
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
res.end(idx);
}
});
return;
}
res.writeHead(200, { "content-type": MIME[path.extname(filePath)] ?? "application/octet-stream" });
res.end(buf);
});
return true;
}
/* ---------------------------------- server --------------------------------- */
const server = http.createServer((req, res) => {
const u = new URL(req.url ?? "/", "http://localhost");
if (u.pathname === "/health") {
return sendJson(res, 200, { ok: true, sessions: sessions.size });
}
if (u.pathname.startsWith("/api/") || u.pathname.startsWith("/auth/")) {
handleApi(req, res, u.pathname, u.searchParams)
.then((handled) => {
if (!handled) {
res.writeHead(404);
res.end();
}
})
.catch((e) => {
console.error("[server] api-fel:", e);
if (!res.headersSent) sendJson(res, 500, { error: "internt fel" });
});
return;
}
if (serveStatic(req, res)) return;
res.writeHead(404);
res.end();
});
const wss = new WebSocketServer({ server });
wss.on("connection", (ws, req) => {
roles.set(ws, { kind: "pending" });
pendingCookieUser.set(ws, store.verifySession(parseCookies(req)["helm_session"]));
ws.on("message", (raw) => {
const role = roles.get(ws);
if (!role) return;
const text = raw.toString();
if (role.kind === "pending") handleHello(ws, text);
else if (role.kind === "daemon") handleDaemonMessage(role.sessionId, text);
else handleWebMessage(ws, role, text);
});
ws.on("close", () => handleClose(ws));
ws.on("error", () => {});
});
server.listen(PORT, () => {
console.log(`[server] lyssnar på :${PORT} (auth=${store.authMode}, publicUrl=${PUBLIC_URL || "(auto)"})`);
if (WEB_DIST) console.log(`[server] serverar web-UI från ${WEB_DIST}`);
console.log(`[server] data-dir: ${DATA_DIR}`);
});