/** * 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; } type Role = | { kind: "pending" } | { kind: "daemon"; sessionId: string } | { kind: "web"; userId: string; isAdmin: boolean }; const sessions = new Map(); const webClients = new Set(); const roles = new WeakMap(); const pendingCookieUser = new WeakMap(); 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(); const devicesByUserCode = new Map(); 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(); 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 { const out: Record = {}; 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 { 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 { 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(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(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(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 = { ".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}`); });