auth: OIDC/lokal inloggning + device-flow för klienter, sessioner per användare
All checks were successful
build-and-push / build (push) Successful in 18s
release-client / build-release (push) Successful in 48s

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
This commit is contained in:
2026-06-29 01:21:29 +02:00
parent 98c4d13360
commit 94259b3456
12 changed files with 1704 additions and 403 deletions

View File

@@ -1,23 +1,43 @@
# Delad token som BÅDE daemon och frontend måste skicka för att få ansluta till servern.
# Byt till något hemligt — den här skyddar fjärrstyrning av en agent som kan köra shell.
AGENT_HELM_TOKEN=change-me
# ============================================================================
# agent-helm — exempel-miljö. Kopiera till .env (dev) eller sätt i Dockge/compose.
# Ingen delad token längre: webben loggar in (lokalt konto eller OIDC) och
# klienten kopplas via device-flödet.
# ============================================================================
# --- server ---
PORT=8787
DATA_DIR=./data # store.json (konton, klient-creds) lagras här
AGENT_HELM_PUBLIC_URL=https://helm.example.com # publik URL (device-länken + Secure-cookie)
# COOKIE_SECURE=true # default: true om PUBLIC_URL är https
# AGENT_HELM_SECRET=<lång slump> # HMAC-nyckel för cookies; genereras+sparas om ej satt
# --- daemon ---
SERVER_URL=ws://localhost:8787
# Kommandot som körs i pty:n. Default: gemini. Sätt t.ex. AGENT_CMD=bash för att testa pipelinen.
AGENT_CMD=gemini
# Identitetsläge: local (egna konton) eller oidc (Authentik/Keycloak/…)
AGENT_HELM_AUTH=local
# Bootstrap-admin (engångs): seedar det första kontot om inget admin finns.
# Ta bort efter första inloggning — servern kör sen från sin egen databas.
# Alternativt: lämna tomt och kör first-run-setupen i web-UI:t (lokalt läge).
AGENT_HELM_ADMIN_USER=admin
AGENT_HELM_ADMIN_PASSWORD=byt-mig-vid-forsta-start
# --- OIDC (endast om AGENT_HELM_AUTH=oidc; kan även sättas i admin-UI:t) ---
# AGENT_HELM_OIDC_ISSUER=https://authentik.brasse-pc.eu/application/o/agent-helm/
# AGENT_HELM_OIDC_CLIENT_ID=<client_id>
# AGENT_HELM_OIDC_CLIENT_SECRET=<client_secret>
# AGENT_HELM_OIDC_LABEL=Logga in med Authentik
# AGENT_HELM_OIDC_GROUP=agent-helm # valfri grupp-gate (kräver claim "groups")
# Registrera redirect-URI i providern: <PUBLIC_URL>/auth/oidc/callback
# --- daemon (klienten) ---
# Anges oftast på CLI:n: agent-helm connect --server https://helm.example.com
# SERVER_URL=https://helm.example.com
AGENT_CMD=gemini # kommandot i pty:n; sätt bash för att testa pipelinen
# AGENT_ARGS=
# AGENT_CWD=
# AGENT_TITLE=
# AGENT_TITLE= # klient-/sessionsnamn (annars "cmd @ hostname")
# AGENT_HELM_CONFIG_DIR=~/.config/agent-helm # var klient-token sparas
# --- hook (valfritt: strukturerade verktygsgodkännanden via BeforeTool) ---
# AGENT_HELM_HOOK_PORT=8788 # lokal 127.0.0.1-endpoint som `agent-helm hook` anropar
# AGENT_HELM_HOOK_DEFAULT=allow # beslut när ingen UI är ansluten (allow|deny)
# AGENT_HELM_HOOK_TIMEOUT=120000 # ms att vänta på beslut innan default används
# --- frontend (Vite läser bara VITE_*-prefix) ---
VITE_SERVER_WS=ws://localhost:8787
VITE_AGENT_HELM_TOKEN=change-me
# AGENT_HELM_HOOK_PORT=8788
# AGENT_HELM_HOOK_DEFAULT=allow # beslut när ingen UI är ansluten (allow|deny)
# AGENT_HELM_HOOK_TIMEOUT=120000

View File

@@ -155,8 +155,14 @@ konfigurerade, och att en trasig hook aldrig låser en session.
## Åtkomst, auth & notiser (Pi5)
- **Auth:** ingen delad token. **Webben** loggar in med **lokala konton** *eller*
**OIDC** (Authentik/Keycloak/…) — väljs per server. **Klienten** kopplas via
**device-flödet**: den skriver ut en URL, du loggar in och godkänner i webben, och
får då ett klient-token som identifierar din användare. Sessioner är per användare;
klienter kan återkallas individuellt. Första admin seedas via
`AGENT_HELM_ADMIN_USER/PASSWORD` (engångs) eller en first-run-setup i UI:t.
- **Åtkomst:** primärt via **Tailscale** (inget exponerat publikt). Alternativt via din
NPM-reverse-proxy på t.ex. `agent.brasse-pc.eu` med auth framför.
NPM-reverse-proxy på t.ex. `agent.brasse-pc.eu` (TLS terminerar i NPM → Secure-cookie).
- **Notiser:** self-hostad **ntfy** på Pi5 pushar till mobilen när en session väntar på
input. Servern postar till ett ntfy-topic; PWA:n / ntfy-appen tar emot.
- **Registry:** server- och daemon-images byggs och pushas till `192.168.0.19:5000`.
@@ -189,17 +195,21 @@ pnpm install \
--virtual-store-dir /home/brasse/.cache/agent-helm/vstore
# (På vanlig Linux-fs räcker: pnpm install)
# Konfig: kopiera och sätt en token
cp .env.example .env # sätt AGENT_HELM_TOKEN + VITE_AGENT_HELM_TOKEN
# Konfig: kopiera och sätt bootstrap-admin (lokalt läge)
cp .env.example .env # sätt AGENT_HELM_ADMIN_USER/PASSWORD (engångs)
# Kör de tre delarna i var sin terminal:
pnpm dev:server # control-plane på :8787
pnpm dev:daemon # kör `gemini` i en pty, ansluter till servern
pnpm dev:web # Vite-dev-server — öppna länken i webbläsaren
pnpm dev:web # Vite-dev-server (proxar /api + /ws → :8787)
# I en tredje terminal: logga in klienten + kör agenten
pnpm --filter @agent-helm/daemon start -- connect --server http://localhost:5173
```
Logga in i web-UI:t med samma token, välj sessionen och styr den. Sätt
`AGENT_CMD=bash` för daemonen om du vill testa pipelinen utan Gemini-auth.
1. Öppna Vite-länken, logga in som admin (eller kör first-run-setupen).
2. Daemonen skriver ut en URL — öppna den, godkänn klienten.
3. Daemonen ansluter och dyker upp i din session-lista. Styr den i webben.
Sätt `AGENT_CMD=bash` för daemonen om du vill testa pipelinen utan Gemini-auth.
## Status

View File

@@ -3,8 +3,13 @@
# Uppdatera till senaste: Dockge -> denna stack -> "Update" (pull + recreate).
#
# Lägg i /srv/dockge-staks/agent-helm/ och starta i Dockge.
# Sätt en stark token i stackens .env (Dockge: env-flik):
# AGENT_HELM_TOKEN=<lång-slumpad-hemlighet>
# Sätt hemligheter i stackens .env (Dockge: env-flik) — INTE i denna fil:
# AGENT_HELM_ADMIN_PASSWORD=<starkt-lösen, engångs vid första start>
# AGENT_HELM_SECRET=<lång slump, cookie-HMAC>
# (om OIDC) AGENT_HELM_OIDC_CLIENT_SECRET=<…>
#
# Auth: ingen delad token. Webben loggar in (lokalt konto eller OIDC); klienten
# kopplas via device-flödet. Ta bort AGENT_HELM_ADMIN_* efter första inloggning.
x-dockge:
urls:
- https://rcai.brasse-pc.eu
@@ -17,9 +22,20 @@ services:
expose:
- "8787" # NPM binder publikt; ingen host-port behövs
environment:
AGENT_HELM_TOKEN: ${AGENT_HELM_TOKEN}
PORT: "8787"
DATA_DIR: /app/data
AGENT_HELM_PUBLIC_URL: https://rcai.brasse-pc.eu
AGENT_HELM_AUTH: ${AGENT_HELM_AUTH:-local} # local | oidc
AGENT_HELM_SECRET: ${AGENT_HELM_SECRET:-} # cookie-HMAC (sätts i .env)
# Bootstrap-admin (engångs): seedar första kontot. Ta bort efter inloggning.
AGENT_HELM_ADMIN_USER: ${AGENT_HELM_ADMIN_USER:-}
AGENT_HELM_ADMIN_PASSWORD: ${AGENT_HELM_ADMIN_PASSWORD:-}
# OIDC (om AGENT_HELM_AUTH=oidc) — kan även sättas i admin-UI:t
AGENT_HELM_OIDC_ISSUER: ${AGENT_HELM_OIDC_ISSUER:-}
AGENT_HELM_OIDC_CLIENT_ID: ${AGENT_HELM_OIDC_CLIENT_ID:-}
AGENT_HELM_OIDC_CLIENT_SECRET: ${AGENT_HELM_OIDC_CLIENT_SECRET:-}
AGENT_HELM_OIDC_LABEL: ${AGENT_HELM_OIDC_LABEL:-Logga in med Authentik}
AGENT_HELM_OIDC_GROUP: ${AGENT_HELM_OIDC_GROUP:-}
volumes:
# Host-mountad config/data enligt övriga appars konvention (/srv/docker/<stack>).
# Containern kör som uid 1000 (node) = brasse på host, så filer blir brasse-ägda.

View File

@@ -1,36 +1,313 @@
/**
* agent-helm daemon ("appen") + hook-läge.
* agent-helm daemon ("klienten") + hook-läge.
*
* Normalläge: spawnar en agent (default gemini) i en pty, strömmar pty över WS till
* servern och skriver tillbaka input. Kör dessutom en LOKAL http-endpoint (endast
* 127.0.0.1) som BeforeTool-hooken anropar för att få ett godkännande-beslut från UI:t.
* Underkommandon:
* agent-helm connect [--server URL] [--name N] [--new] [--login]
* Loggar in mot servern via device-flödet första gången (skriver ut en
* URL att öppna + godkänna i webben), sparar ett klient-token lokalt, och
* kör sedan agenten i en pty och strömmar den över WS. Nästa start är tyst.
* --new -> starta en ny parallell session istället för att återuppta.
* --login -> tvinga ny inloggning (t.ex. byt användare/server).
* agent-helm logout -> radera sparat klient-token.
* agent-helm whoami -> visa sparad inloggning.
* agent-helm hook -> internt: anropas av agentens BeforeTool-hook.
*
* Hook-läge (`agent-helm hook`): läser verktygsanropet på stdin, frågar den körande
* daemonen via den lokala endpointen, och skriver beslutet på stdout i Geminis format.
* Samma binär gör båda jobben -> inga extra beroenden på målet.
*
* Persistens (att daemonen själv överlever) är ett yttre lager: tmux eller en
* Docker-container med restart-policy. Hanteras inte här.
* Klient-token + senaste session sparas i ~/.config/agent-helm/credentials.json.
*/
import { spawn, type IPty } from "@homebridge/node-pty-prebuilt-multiarch";
import { WebSocket } from "ws";
import http from "node:http";
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import type { DaemonMessage, ServerToDaemon } from "@agent-helm/shared";
import { randomUUID } from "node:crypto";
import type { DaemonMessage, DeviceCodeResponse, DeviceTokenResponse } from "@agent-helm/shared";
import { parseMessage } from "@agent-helm/shared";
type Decision = { decision: "allow" | "deny"; reason?: string };
const HOOK_PORT = Number(process.env.AGENT_HELM_HOOK_PORT ?? 8788);
const AGENT_CMD = process.env.AGENT_CMD ?? "gemini";
if (process.argv[2] === "hook") {
void runHookMode();
} else {
runDaemon();
const cmd = process.argv[2];
if (cmd === "hook") void runHookMode();
else if (cmd === "logout") logout();
else if (cmd === "whoami") whoami();
else void runConnect();
/* ------------------------------ creds-hantering ---------------------------- */
interface Creds {
server: string;
token: string;
clientName: string;
user: string;
lastSessionId?: string;
}
/* --------------------------------- hook-läge -------------------------------- */
const CONFIG_DIR = process.env.AGENT_HELM_CONFIG_DIR || path.join(os.homedir(), ".config", "agent-helm");
const CRED_FILE = path.join(CONFIG_DIR, "credentials.json");
function loadCreds(): Creds | null {
try {
return JSON.parse(fs.readFileSync(CRED_FILE, "utf8")) as Creds;
} catch {
return null;
}
}
function saveCreds(c: Creds): void {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
fs.writeFileSync(CRED_FILE, JSON.stringify(c, null, 2), { mode: 0o600 });
}
function logout(): void {
try {
fs.unlinkSync(CRED_FILE);
console.log("Utloggad — klient-token raderat.");
} catch {
console.log("Inget sparat token.");
}
process.exit(0);
}
function whoami(): void {
const c = loadCreds();
if (!c) {
console.log("Ej inloggad.");
} else {
console.log(`Inloggad som ${c.user} mot ${c.server} (klient: ${c.clientName})`);
}
process.exit(0);
}
/* ------------------------------ CLI-argument ------------------------------- */
function flag(name: string): string | undefined {
const i = process.argv.indexOf(name);
return i >= 0 ? process.argv[i + 1] : undefined;
}
function has(name: string): boolean {
return process.argv.includes(name);
}
function deriveUrls(input: string): { http: string; ws: string } {
const u = input.replace(/\/$/, "");
if (u.startsWith("ws://")) return { ws: u, http: "http://" + u.slice(5) };
if (u.startsWith("wss://")) return { ws: u, http: "https://" + u.slice(6) };
if (u.startsWith("http://")) return { http: u, ws: "ws://" + u.slice(7) };
if (u.startsWith("https://")) return { http: u, ws: "wss://" + u.slice(8) };
return { http: "https://" + u, ws: "wss://" + u }; // bara host angiven -> anta TLS
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
/* -------------------------------- device-flöde ----------------------------- */
async function deviceFlow(
httpBase: string,
clientName: string,
): Promise<{ token: string; sessionId: string; clientName: string; user: string }> {
const codeRes = await fetch(`${httpBase}/api/device/code`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ clientName }),
});
if (!codeRes.ok) throw new Error(`device/code misslyckades (HTTP ${codeRes.status})`);
const code = (await codeRes.json()) as DeviceCodeResponse;
console.log("\n ┌─────────────────────────────────────────────────────────────");
console.log(" │ Öppna i webbläsaren, logga in och godkänn klienten:");
console.log(" │");
console.log(`${code.verificationUriComplete}`);
console.log(" │");
console.log(` │ Kod: ${code.userCode} (väntar på godkännande …)`);
console.log(" └─────────────────────────────────────────────────────────────\n");
const interval = Math.max(2, code.interval) * 1000;
const deadline = Date.now() + code.expiresIn * 1000;
while (Date.now() < deadline) {
await sleep(interval);
const tRes = await fetch(`${httpBase}/api/device/token`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ deviceCode: code.deviceCode }),
});
const t = (await tRes.json()) as DeviceTokenResponse;
if (t.status === "ready" && t.token && t.sessionId) {
return { token: t.token, sessionId: t.sessionId, clientName: t.clientName ?? clientName, user: t.user ?? "?" };
}
if (t.status === "denied") throw new Error("godkännandet nekades i webben");
if (t.status === "expired") throw new Error("koden gick ut innan godkännande");
// pending / slow_down -> fortsätt polla
}
throw new Error("tidsgräns nådd utan godkännande");
}
/* --------------------------------- connect --------------------------------- */
async function runConnect(): Promise<void> {
const input = flag("--server") || process.env.SERVER_URL || loadCreds()?.server;
if (!input) {
console.error("Ingen server angiven. Kör: agent-helm connect --server https://din-helm-server");
process.exit(1);
}
const { http: httpBase, ws: wsBase } = deriveUrls(input);
const clientName = flag("--name") || process.env.AGENT_TITLE || `${AGENT_CMD} @ ${os.hostname()}`;
let creds = loadCreds();
const sameServer = creds && deriveUrls(creds.server).ws === wsBase;
if (!creds || !sameServer || has("--login")) {
console.log(`[daemon] loggar in mot ${httpBase}`);
try {
const r = await deviceFlow(httpBase, clientName);
creds = { server: input, token: r.token, clientName: r.clientName, user: r.user, lastSessionId: r.sessionId };
saveCreds(creds);
console.log(`[daemon] ✓ ansluten som ${r.user}.`);
} catch (e) {
console.error(`[daemon] inloggning misslyckades: ${(e as Error).message}`);
process.exit(1);
}
}
// Sessionsval: --new -> ny parallell session; annars återuppta den senaste.
const isNew = has("--new") || !creds!.lastSessionId;
const sessionId = isNew ? randomUUID() : creds!.lastSessionId!;
creds!.lastSessionId = sessionId;
saveCreds(creds!);
console.log(`[daemon] ${isNew ? "ny" : "återupptar"} session ${sessionId.slice(0, 8)} (${clientName})`);
runAgent(wsBase, creds!.token, sessionId, clientName);
}
/* ----------------------------- agent + WS-loop ----------------------------- */
function runAgent(wsUrl: string, token: string, sessionId: string, clientName: string): void {
const AGENT_ARGS = process.env.AGENT_ARGS ? process.env.AGENT_ARGS.split(" ").filter(Boolean) : [];
const AGENT_CWD = process.env.AGENT_CWD ?? process.cwd();
const HOOK_DEFAULT: "allow" | "deny" = process.env.AGENT_HELM_HOOK_DEFAULT === "deny" ? "deny" : "allow";
const HOOK_TIMEOUT = Number(process.env.AGENT_HELM_HOOK_TIMEOUT ?? 120000);
const COLS = 100;
const ROWS = 30;
const pty: IPty = spawn(AGENT_CMD, AGENT_ARGS, {
name: "xterm-256color",
cols: COLS,
rows: ROWS,
cwd: AGENT_CWD,
env: { ...process.env, AGENT_HELM_HOOK_PORT: String(HOOK_PORT) } as Record<string, string>,
});
console.log(`[daemon] startade '${AGENT_CMD}' (pid ${pty.pid})`);
let ws: WebSocket | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let authFailed = false;
const pending = new Map<string, (d: Decision) => void>();
function send(msg: DaemonMessage): void {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
}
function connect(): void {
console.log(`[daemon] ansluter till ${wsUrl}`);
ws = new WebSocket(wsUrl);
ws.on("open", () => {
send({ type: "daemon:hello", token, sessionId, clientName, cols: COLS, rows: ROWS });
});
ws.on("message", (raw) => {
const msg = parseMessage<{ type: string; [k: string]: any }>(raw.toString());
if (!msg) return;
if (msg.type === "server:input") {
pty.write(msg.data);
} else if (msg.type === "server:approval-decision") {
const resolve = pending.get(msg.approvalId);
if (resolve) {
pending.delete(msg.approvalId);
resolve({ decision: msg.decision, reason: msg.reason });
}
} else if (msg.type === "server:error") {
console.error(`[daemon] server: ${msg.message}`);
if (String(msg.message).toLowerCase().includes("token") || String(msg.message).includes("inloggad")) {
authFailed = true;
console.error("[daemon] klient-token avvisat — kör 'agent-helm connect --login' för att logga in på nytt.");
}
}
});
ws.on("close", () => {
if (authFailed) {
pty.kill();
process.exit(1);
}
console.log("[daemon] anslutning stängd — återansluter om 2s");
scheduleReconnect();
});
ws.on("error", (err: Error) => console.error("[daemon] ws-fel:", err.message));
}
function scheduleReconnect(): void {
if (reconnectTimer || authFailed) return;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connect();
}, 2000);
}
pty.onData((data) => send({ type: "daemon:output", data }));
pty.onExit(({ exitCode }) => {
console.log(`[daemon] agenten avslutades (kod ${exitCode})`);
send({ type: "daemon:exit", code: exitCode });
setTimeout(() => process.exit(exitCode), 200);
});
// Lokal hook-endpoint (endast 127.0.0.1).
const hookServer = http.createServer((req, res) => {
if (req.method !== "POST" || req.url !== "/approve") {
res.writeHead(404);
res.end();
return;
}
let body = "";
req.on("data", (c) => (body += c));
req.on("end", () => {
let parsed: { toolName?: string; toolInput?: unknown } = {};
try {
parsed = JSON.parse(body);
} catch {
/* tom */
}
const toolName = parsed.toolName ?? "okänt verktyg";
const approvalId = randomUUID();
const finish = (d: Decision) => {
if (res.writableEnded) return;
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(d));
};
if (!ws || ws.readyState !== WebSocket.OPEN) {
finish({ decision: HOOK_DEFAULT });
return;
}
const timer = setTimeout(() => {
if (pending.delete(approvalId)) finish({ decision: HOOK_DEFAULT, reason: "timeout" });
}, HOOK_TIMEOUT);
pending.set(approvalId, (d) => {
clearTimeout(timer);
finish(d);
});
send({ type: "daemon:approval", approvalId, toolName, toolInput: parsed.toolInput });
});
});
hookServer.listen(HOOK_PORT, "127.0.0.1", () => {
console.log(`[daemon] hook-endpoint på 127.0.0.1:${HOOK_PORT} (default vid ingen UI: ${HOOK_DEFAULT})`);
});
function shutdown(): void {
pty.kill();
process.exit(0);
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
connect();
}
/* --------------------------------- hook-läge ------------------------------- */
function readStdin(): Promise<string> {
return new Promise((resolve) => {
@@ -38,7 +315,7 @@ function readStdin(): Promise<string> {
process.stdin.setEncoding("utf8");
process.stdin.on("data", (c) => (data += c));
process.stdin.on("end", () => resolve(data));
setTimeout(() => resolve(data), 1500); // fortsätt även om stdin aldrig stängs
setTimeout(() => resolve(data), 1500);
});
}
@@ -50,12 +327,9 @@ async function runHookMode(): Promise<void> {
} catch {
payload = {};
}
// Geminis hook-payload kan variera mellan versioner — plocka defensivt.
const p = payload as Record<string, any>;
const toolName: string =
p.tool_name ?? p.toolName ?? p.tool?.name ?? p.name ?? "okänt verktyg";
const toolInput: unknown =
p.tool_input ?? p.toolInput ?? p.tool?.input ?? p.arguments ?? p.args ?? payload;
const toolName: string = p.tool_name ?? p.toolName ?? p.tool?.name ?? p.name ?? "okänt verktyg";
const toolInput: unknown = p.tool_input ?? p.toolInput ?? p.tool?.input ?? p.arguments ?? p.args ?? payload;
const decision = await askDaemon(toolName, toolInput);
if (decision.decision === "deny") {
@@ -89,7 +363,6 @@ function askDaemon(toolName: string, toolInput: unknown): Promise<Decision> {
});
},
);
// Ingen daemon igång / fel -> tillåt (graceful: agenten funkar som utan hook).
req.on("error", () => resolve({ decision: "allow" }));
req.setTimeout(180000, () => {
req.destroy();
@@ -99,140 +372,3 @@ function askDaemon(toolName: string, toolInput: unknown): Promise<Decision> {
req.end();
});
}
/* -------------------------------- normal daemon ----------------------------- */
function runDaemon(): void {
const TOKEN = process.env.AGENT_HELM_TOKEN ?? "";
const SERVER_URL = process.env.SERVER_URL ?? "ws://localhost:8787";
const AGENT_CMD = process.env.AGENT_CMD ?? "gemini";
const AGENT_ARGS = process.env.AGENT_ARGS ? process.env.AGENT_ARGS.split(" ").filter(Boolean) : [];
const AGENT_CWD = process.env.AGENT_CWD ?? process.cwd();
const TITLE = process.env.AGENT_TITLE ?? `${AGENT_CMD} @ ${os.hostname()}`;
const HOOK_DEFAULT: "allow" | "deny" = process.env.AGENT_HELM_HOOK_DEFAULT === "deny" ? "deny" : "allow";
const HOOK_TIMEOUT = Number(process.env.AGENT_HELM_HOOK_TIMEOUT ?? 120000);
const COLS = 100;
const ROWS = 30;
if (!TOKEN) {
console.error("[daemon] AGENT_HELM_TOKEN saknas — sätt den i .env eller miljön.");
process.exit(1);
}
const sessionId = randomUUID();
const pty: IPty = spawn(AGENT_CMD, AGENT_ARGS, {
name: "xterm-256color",
cols: COLS,
rows: ROWS,
cwd: AGENT_CWD,
// Skicka hook-porten vidare så att `agent-helm hook` (som gemini spawnar) hittar oss.
env: { ...process.env, AGENT_HELM_HOOK_PORT: String(HOOK_PORT) } as Record<string, string>,
});
console.log(`[daemon] startade '${AGENT_CMD}' (pid ${pty.pid}), session ${sessionId}`);
let ws: WebSocket | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
const pending = new Map<string, (d: Decision) => void>();
function send(msg: DaemonMessage): void {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
}
function connect(): void {
console.log(`[daemon] ansluter till ${SERVER_URL} ...`);
ws = new WebSocket(SERVER_URL);
ws.on("open", () => {
console.log("[daemon] ansluten — registrerar session");
send({ type: "daemon:hello", token: TOKEN, sessionId, title: TITLE, cols: COLS, rows: ROWS });
});
ws.on("message", (raw) => {
const msg = parseMessage<ServerToDaemon>(raw.toString());
if (!msg) return;
if (msg.type === "server:input") {
pty.write(msg.data);
} else if (msg.type === "server:approval-decision") {
const resolve = pending.get(msg.approvalId);
if (resolve) {
pending.delete(msg.approvalId);
resolve({ decision: msg.decision, reason: msg.reason });
}
}
});
ws.on("close", () => {
console.log("[daemon] anslutning stängd — återansluter om 2s");
scheduleReconnect();
});
ws.on("error", (err: Error) => console.error("[daemon] ws-fel:", err.message));
}
function scheduleReconnect(): void {
if (reconnectTimer) return;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connect();
}, 2000);
}
pty.onData((data) => send({ type: "daemon:output", data }));
pty.onExit(({ exitCode }) => {
console.log(`[daemon] agenten avslutades (kod ${exitCode})`);
send({ type: "daemon:exit", code: exitCode });
setTimeout(() => process.exit(exitCode), 200);
});
// Lokal hook-endpoint (endast 127.0.0.1 — aldrig nåbar utifrån).
const hookServer = http.createServer((req, res) => {
if (req.method !== "POST" || req.url !== "/approve") {
res.writeHead(404);
res.end();
return;
}
let body = "";
req.on("data", (c) => (body += c));
req.on("end", () => {
let parsed: { toolName?: string; toolInput?: unknown } = {};
try {
parsed = JSON.parse(body);
} catch {
/* tom */
}
const toolName = parsed.toolName ?? "okänt verktyg";
const approvalId = randomUUID();
const finish = (d: Decision) => {
if (res.writableEnded) return;
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(d));
};
// Ingen server ansluten -> svara med default direkt (graceful degradation).
if (!ws || ws.readyState !== WebSocket.OPEN) {
finish({ decision: HOOK_DEFAULT });
return;
}
const timer = setTimeout(() => {
if (pending.delete(approvalId)) finish({ decision: HOOK_DEFAULT, reason: "timeout" });
}, HOOK_TIMEOUT);
pending.set(approvalId, (d) => {
clearTimeout(timer);
finish(d);
});
send({ type: "daemon:approval", approvalId, toolName, toolInput: parsed.toolInput });
});
});
hookServer.listen(HOOK_PORT, "127.0.0.1", () => {
console.log(`[daemon] hook-endpoint på 127.0.0.1:${HOOK_PORT} (default vid ingen UI: ${HOOK_DEFAULT})`);
});
function shutdown(): void {
pty.kill();
process.exit(0);
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
connect();
}

View File

@@ -1,52 +1,94 @@
/**
* agent-helm control-plane (körs på Pi5).
* agent-helm control-plane.
*
* - WebSocket-mux: både daemons och frontends ansluter hit. Rollen avgörs av
* det första meddelandet (daemon:hello / web:hello), båda kräver rätt token.
* - Session-registry: håller reda på vilken daemon som äger vilken session.
* - Scrollback: en headless xterm per session matas med all output, så en
* frontend som ansluter (eller en mobil som väcks) får en ögonblicksbild via
* SerializeAddon — inte en tom skärm.
* - Routing: web:input -> rätt daemons pty. daemon:output -> alla prenumeranter.
* 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";
// @xterm/headless och addon-serialize är CommonJS — default-importera värdet och
// destrukturera; ta typen via `import type` (raderas i runtime).
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 TOKEN = process.env.AGENT_HELM_TOKEN ?? "";
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
if (!TOKEN) {
console.error("[server] AGENT_HELM_TOKEN saknas — sätt den i .env eller miljön.");
process.exit(1);
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,
});
}
// Persistent katalog (host-mountad på Pi5: /srv/docker/agent-helm -> /app/data).
// Här hamnar framtida db/auth-state. Skapas vid start om den är satt.
const DATA_DIR = process.env.DATA_DIR ?? "";
if (DATA_DIR) fs.mkdirSync(DATA_DIR, { recursive: true });
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;
@@ -56,14 +98,17 @@ interface Session {
type Role =
| { kind: "pending" }
| { kind: "daemon"; sessionId: string }
| { kind: "web" };
| { 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 sessionList(): SessionMeta[] {
return [...sessions.values()].map((s) => s.meta);
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 {
@@ -71,8 +116,11 @@ function sendWeb(ws: WebSocket, msg: ServerToWeb): void {
}
function broadcastSessions(): void {
const msg: ServerToWeb = { type: "server:sessions", sessions: sessionList() };
for (const ws of webClients) sendWeb(ws, msg);
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 {
@@ -80,14 +128,342 @@ function closeWithError(ws: WebSocket, message: string): void {
ws.close();
}
function registerDaemon(ws: WebSocket, hello: DaemonHello): void {
roles.set(ws, { kind: "daemon", sessionId: hello.sessionId });
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) {
// Daemonen återansluter — behåll scrollback, byt ut socketen.
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();
@@ -95,18 +471,20 @@ function registerDaemon(ws: WebSocket, hello: DaemonHello): void {
sessions.set(hello.sessionId, {
meta: {
sessionId: hello.sessionId,
title: hello.title,
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.title})`);
console.log(`[server] daemon registrerad: ${hello.sessionId} (${hello.clientName}) ägare=${userDisplay}`);
broadcastSessions();
}
@@ -115,15 +493,21 @@ function handleHello(ws: WebSocket, text: string): void {
if (!msg) return closeWithError(ws, "ogiltigt meddelande");
if (msg.type === "daemon:hello") {
if (msg.token !== TOKEN) return closeWithError(ws, "fel token");
return registerDaemon(ws, msg);
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") {
if (msg.token !== TOKEN) return closeWithError(ws, "fel token");
roles.set(ws, { kind: "web" });
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: sessionList() });
sendWeb(ws, { type: "server:sessions", sessions: sessionListForRole(role) });
return;
}
return closeWithError(ws, "förväntade hello");
@@ -142,9 +526,7 @@ function handleDaemonMessage(sessionId: string, text: string): void {
} 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 });
}
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 = {
@@ -158,13 +540,13 @@ function handleDaemonMessage(sessionId: string, text: string): void {
}
}
function handleWebMessage(ws: WebSocket, text: string): void {
function handleWebMessage(ws: WebSocket, role: Role, text: string): void {
const msg = parseMessage<WebMessage>(text);
if (!msg) return;
if (!msg || role.kind !== "web") return;
if (msg.type === "web:subscribe") {
const session = sessions.get(msg.sessionId);
if (!session) return sendWeb(ws, { type: "server:error", message: "okänd session" });
if (!session || !ownsSession(role, session)) return sendWeb(ws, { type: "server:error", message: "okänd session" });
session.subscribers.add(ws);
sendWeb(ws, {
type: "server:snapshot",
@@ -174,13 +556,14 @@ function handleWebMessage(ws: WebSocket, text: string): void {
});
} else if (msg.type === "web:input") {
const session = sessions.get(msg.sessionId);
if (!session?.daemon || session.daemon.readyState !== WebSocket.OPEN) return;
// Terminal-input (text/tangenter) -> pty. Hook-svar går via web:approval-decision.
session.daemon.send(JSON.stringify({ type: "server:input", data: msg.data }));
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) return;
if (session.daemon && session.daemon.readyState === WebSocket.OPEN) {
if (!session || !ownsSession(role, session)) return;
if (session.daemon?.readyState === WebSocket.OPEN) {
session.daemon.send(
JSON.stringify({
type: "server:approval-decision",
@@ -190,7 +573,6 @@ function handleWebMessage(ws: WebSocket, text: string): void {
}),
);
}
// Dölj kortet hos alla andra klienter som ser samma session.
const resolved: ServerToWeb = {
type: "server:approval-resolved",
sessionId: msg.sessionId,
@@ -204,22 +586,23 @@ function handleWebMessage(ws: WebSocket, text: string): void {
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; // behåll session + scrollback ifall daemonen kommer tillbaka
session.daemon = null;
console.log(`[server] daemon för ${role.sessionId} kopplade ner`);
}
}
}
const WEB_DIST = process.env.WEB_DIST ?? "";
/* ------------------------------ statisk web-UI ----------------------------- */
const WEB_DIST = process.env.WEB_DIST ?? "";
const MIME: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "text/javascript",
@@ -234,16 +617,12 @@ const MIME: Record<string, string> = {
".map": "application/json",
};
/** Serverar den byggda web-frontenden (om WEB_DIST är satt) med SPA-fallback. */
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));
// Path-traversal-skydd + SPA: "/" och rutter utan filändelse -> index.html
if (!filePath.startsWith(WEB_DIST) || urlPath === "/" || !path.extname(filePath)) {
filePath = indexHtml;
}
if (!filePath.startsWith(WEB_DIST) || urlPath === "/" || !path.extname(filePath)) filePath = indexHtml;
fs.readFile(filePath, (err, buf) => {
if (err) {
fs.readFile(indexHtml, (err2, idx) => {
@@ -263,10 +642,25 @@ function serveStatic(req: IncomingMessage, res: ServerResponse): boolean {
return true;
}
/* ---------------------------------- server --------------------------------- */
const server = http.createServer((req, res) => {
if (req.url === "/health") {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ ok: true, sessions: sessions.size }));
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;
@@ -275,23 +669,23 @@ const server = http.createServer((req, res) => {
});
const wss = new WebSocketServer({ server });
wss.on("connection", (ws) => {
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, text);
else handleWebMessage(ws, role, text);
});
ws.on("close", () => handleClose(ws));
ws.on("error", () => {});
});
server.listen(PORT, () => {
console.log(`[server] lyssnar på :${PORT} (WebSocket + /health)`);
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}`);
if (DATA_DIR) console.log(`[server] data-dir: ${DATA_DIR}`);
console.log(`[server] data-dir: ${DATA_DIR}`);
});

112
packages/server/src/oidc.ts Normal file
View File

@@ -0,0 +1,112 @@
/**
* Minimal OIDC Authorization-Code + PKCE-klient (beroendefri).
*
* Vi byter koden mot tokens server-till-server över TLS, så id_token-signaturen
* behöver inte verifieras separat (OIDC Core §3.1.3.7: TLS mot token-endpointen
* räcker som äkthetskontroll i code-flödet). Vi validerar iss/aud/exp ur payloaden.
*
* Funkar mot vilken OIDC-provider som helst med discovery: Authentik, Keycloak,
* Authelia, Google, m.fl. — agent-helm är inte bundet till någon särskild IdP.
*/
import crypto from "node:crypto";
import type { OidcConfig } from "./store";
interface Discovery {
authorization_endpoint: string;
token_endpoint: string;
}
export interface OidcClaims {
sub: string;
username: string;
displayName: string;
groups: string[];
email?: string;
}
const discoveryCache = new Map<string, { at: number; doc: Discovery }>();
async function discover(issuer: string): Promise<Discovery> {
const cached = discoveryCache.get(issuer);
if (cached && Date.now() - cached.at < 3600_000) return cached.doc;
const base = issuer.replace(/\/$/, "");
const res = await fetch(`${base}/.well-known/openid-configuration`);
if (!res.ok) throw new Error(`OIDC discovery misslyckades (${res.status})`);
const doc = (await res.json()) as Discovery;
discoveryCache.set(issuer, { at: Date.now(), doc });
return doc;
}
export function pkce(): { verifier: string; challenge: string } {
const verifier = crypto.randomBytes(32).toString("base64url");
const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");
return { verifier, challenge };
}
export async function buildAuthUrl(
cfg: OidcConfig,
redirectUri: string,
state: string,
challenge: string,
): Promise<string> {
const { authorization_endpoint } = await discover(cfg.issuer);
const q = new URLSearchParams({
response_type: "code",
client_id: cfg.clientId,
redirect_uri: redirectUri,
scope: "openid profile email groups",
state,
code_challenge: challenge,
code_challenge_method: "S256",
});
return `${authorization_endpoint}?${q.toString()}`;
}
export async function exchangeCode(
cfg: OidcConfig,
redirectUri: string,
code: string,
verifier: string,
): Promise<OidcClaims> {
const { token_endpoint } = await discover(cfg.issuer);
const body = new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: redirectUri,
client_id: cfg.clientId,
client_secret: cfg.clientSecret,
code_verifier: verifier,
});
const res = await fetch(token_endpoint, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body,
});
if (!res.ok) throw new Error(`token-utbyte misslyckades (${res.status})`);
const tok = (await res.json()) as { id_token?: string };
if (!tok.id_token) throw new Error("inget id_token i svaret");
return decodeIdToken(cfg, tok.id_token);
}
function decodeIdToken(cfg: OidcConfig, idToken: string): OidcClaims {
const parts = idToken.split(".");
if (parts.length !== 3) throw new Error("trasigt id_token");
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) as Record<string, any>;
// iss/aud/exp-kontroll (signaturen täcks av TLS i code-flödet).
const issBase = cfg.issuer.replace(/\/$/, "");
if (payload.iss && payload.iss.replace(/\/$/, "") !== issBase) throw new Error("fel issuer");
const aud = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
if (!aud.includes(cfg.clientId)) throw new Error("fel audience");
if (typeof payload.exp === "number" && payload.exp < Math.floor(Date.now() / 1000)) {
throw new Error("id_token utgånget");
}
return {
sub: String(payload.sub),
username: payload.preferred_username || payload.email || String(payload.sub),
displayName: payload.name || payload.preferred_username || String(payload.sub),
groups: Array.isArray(payload.groups) ? payload.groups.map(String) : [],
email: payload.email,
};
}

View File

@@ -0,0 +1,256 @@
/**
* Beroendefri JSON-store + auth-primitiver för agent-helm-servern.
*
* Allt state (config, användare, klient-credentials) ligger i en enda fil i
* DATA_DIR. Inga native-beroenden -> bundlas rakt av esbuild och funkar för vem
* som helst som hostar imagen. Lösenord hashas med scrypt; cookies signeras med
* HMAC. För en homelab-server med måttlig samtidighet räcker det gott.
*/
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
export type AuthMode = "local" | "oidc";
export interface User {
id: string;
username: string; // unik nyckel. lokal: valfri. oidc: preferred_username/sub
displayName: string;
isAdmin: boolean;
source: "local" | "oidc";
passwordHash?: string; // endast lokal: "saltHex:hashHex"
oidcSub?: string; // endast oidc
}
export interface ClientCred {
id: string;
userId: string;
name: string;
tokenHash: string; // sha256(token) hex — själva tokenet lagras aldrig
createdAt: number;
lastSeenAt: number;
revoked: boolean;
}
export interface OidcConfig {
issuer: string; // discovery-bas, t.ex. https://authentik.../application/o/agent-helm/
clientId: string;
clientSecret: string;
label?: string;
allowedGroup?: string; // valfri grupp-gate
}
interface StoreData {
version: 1;
secret: string; // HMAC-nyckel för cookies (genereras en gång om ej satt via env)
authMode: AuthMode;
oidc?: OidcConfig;
users: User[];
clients: ClientCred[];
}
const b64url = (b: Buffer) => b.toString("base64url");
const sha256 = (s: string) => crypto.createHash("sha256").update(s).digest("hex");
export const hashToken = sha256;
export function hashPassword(pw: string): string {
const salt = crypto.randomBytes(16);
const hash = crypto.scryptSync(pw, salt, 64);
return `${salt.toString("hex")}:${hash.toString("hex")}`;
}
function verifyPassword(pw: string, stored: string): boolean {
const [saltHex, hashHex] = stored.split(":");
if (!saltHex || !hashHex) return false;
const expected = Buffer.from(hashHex, "hex");
const got = crypto.scryptSync(pw, Buffer.from(saltHex, "hex"), 64);
return got.length === expected.length && crypto.timingSafeEqual(got, expected);
}
export class Store {
private data: StoreData;
private readonly file: string;
constructor(dataDir: string, envSecret?: string) {
this.file = path.join(dataDir, "store.json");
if (fs.existsSync(this.file)) {
this.data = JSON.parse(fs.readFileSync(this.file, "utf8")) as StoreData;
} else {
this.data = {
version: 1,
secret: envSecret || b64url(crypto.randomBytes(32)),
authMode: "local",
users: [],
clients: [],
};
this.save();
}
// Env-secret vinner alltid (stabil över data-wipe).
if (envSecret && this.data.secret !== envSecret) {
this.data.secret = envSecret;
this.save();
}
}
private save(): void {
const tmp = `${this.file}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(this.data, null, 2));
fs.renameSync(tmp, this.file); // atomiskt
}
/* --------------------------------- config -------------------------------- */
get authMode(): AuthMode {
return this.data.authMode;
}
setAuthMode(mode: AuthMode): void {
this.data.authMode = mode;
this.save();
}
get oidc(): OidcConfig | undefined {
return this.data.oidc;
}
setOidc(cfg: OidcConfig): void {
this.data.oidc = cfg;
this.save();
}
get setupComplete(): boolean {
return this.data.users.some((u) => u.isAdmin);
}
/* --------------------------------- users --------------------------------- */
listUsers(): User[] {
return this.data.users.map((u) => ({ ...u, passwordHash: undefined }));
}
userById(id: string): User | undefined {
return this.data.users.find((u) => u.id === id);
}
userByUsername(username: string): User | undefined {
return this.data.users.find((u) => u.username.toLowerCase() === username.toLowerCase());
}
userByOidcSub(sub: string): User | undefined {
return this.data.users.find((u) => u.oidcSub === sub);
}
createLocalUser(username: string, password: string, displayName: string, isAdmin: boolean): User {
if (this.userByUsername(username)) throw new Error("användarnamnet finns redan");
const user: User = {
id: crypto.randomUUID(),
username,
displayName: displayName || username,
isAdmin,
source: "local",
passwordHash: hashPassword(password),
};
this.data.users.push(user);
this.save();
return user;
}
/** Skapa eller uppdatera en OIDC-användare utifrån token-claims. */
upsertOidcUser(sub: string, username: string, displayName: string, makeAdminIfFirst: boolean): User {
let user = this.userByOidcSub(sub);
if (user) {
user.displayName = displayName || user.displayName;
user.username = username || user.username;
this.save();
return user;
}
const isFirst = this.data.users.length === 0;
user = {
id: crypto.randomUUID(),
username: username || sub,
displayName: displayName || username || sub,
isAdmin: makeAdminIfFirst && isFirst,
source: "oidc",
oidcSub: sub,
};
this.data.users.push(user);
this.save();
return user;
}
verifyLocalLogin(username: string, password: string): User | null {
const user = this.userByUsername(username);
if (!user || user.source !== "local" || !user.passwordHash) return null;
return verifyPassword(password, user.passwordHash) ? user : null;
}
deleteUser(id: string): void {
this.data.users = this.data.users.filter((u) => u.id !== id);
this.data.clients = this.data.clients.filter((c) => c.userId !== id);
this.save();
}
/* ------------------------------ klient-creds ----------------------------- */
/** Skapa ett klient-credential och returnera det klartext-token som visas en gång. */
issueClient(userId: string, name: string): { token: string; cred: ClientCred } {
const token = b64url(crypto.randomBytes(32));
const cred: ClientCred = {
id: crypto.randomUUID(),
userId,
name,
tokenHash: sha256(token),
createdAt: Date.now(),
lastSeenAt: Date.now(),
revoked: false,
};
this.data.clients.push(cred);
this.save();
return { token, cred };
}
clientByToken(token: string): ClientCred | undefined {
const h = sha256(token);
return this.data.clients.find((c) => c.tokenHash === h && !c.revoked);
}
listClients(userId?: string): ClientCred[] {
return this.data.clients.filter((c) => !userId || c.userId === userId);
}
touchClient(id: string): void {
const c = this.data.clients.find((x) => x.id === id);
if (c) {
c.lastSeenAt = Date.now();
this.save();
}
}
revokeClient(id: string, userId?: string): boolean {
const c = this.data.clients.find((x) => x.id === id && (!userId || x.userId === userId));
if (!c) return false;
c.revoked = true;
this.save();
return true;
}
/* --------------------------------- cookies ------------------------------- */
/** Signera en stateless session-cookie: base64url("userId.exp.hmac"). */
signSession(userId: string, ttlSec: number): string {
const exp = Math.floor(Date.now() / 1000) + ttlSec;
const body = `${userId}.${exp}`;
const sig = crypto.createHmac("sha256", this.data.secret).update(body).digest("base64url");
return Buffer.from(`${body}.${sig}`).toString("base64url");
}
verifySession(cookie: string | undefined): User | null {
if (!cookie) return null;
let decoded: string;
try {
decoded = Buffer.from(cookie, "base64url").toString("utf8");
} catch {
return null;
}
const parts = decoded.split(".");
if (parts.length !== 3) return null;
const [userId, expStr, sig] = parts;
const body = `${userId}.${expStr}`;
const expected = crypto.createHmac("sha256", this.data.secret).update(body).digest("base64url");
if (sig.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
return null;
}
if (Number(expStr) < Math.floor(Date.now() / 1000)) return null;
return this.userById(userId) ?? null;
}
}

View File

@@ -13,15 +13,19 @@ export interface SessionMeta {
cols: number;
rows: number;
exitCode?: number;
/** Visningsnamn för ägaren (sätts av servern utifrån klient-tokenet). */
user?: string;
}
/* ----------------------------- Daemon -> Server ---------------------------- */
export interface DaemonHello {
type: "daemon:hello";
/** Klient-token från device-flödet (identifierar användaren), INTE en delad token. */
token: string;
sessionId: string;
title: string;
/** Människovänligt namn på klienten/sessionen (visas i UI). */
clientName: string;
cols: number;
rows: number;
}
@@ -69,7 +73,7 @@ export type ServerToDaemon = ServerInput | ServerApprovalDecision;
export interface WebHello {
type: "web:hello";
token: string;
/** Ingen token här — webben autentiseras via sin inloggnings-cookie på WS-handskaket. */
}
export interface WebSubscribe {
@@ -158,6 +162,63 @@ export type ServerToWeb =
| ServerApproval
| ServerApprovalResolved;
/* ------------------------------- HTTP-API (REST) --------------------------- */
/* Device Authorization Grant (RFC 8628-liknande) + auth-status. Delas så att
* daemon, server och web håller samma format. */
export type AuthMode = "local" | "oidc";
/** GET /api/config — publik info som inloggningsvyn behöver (ingen auth). */
export interface PublicConfig {
authMode: AuthMode;
/** Knapptext för OIDC-login, t.ex. "Logga in med Authentik". */
oidcLabel?: string;
/** Har servern fått sitt första admin-konto? */
setupComplete: boolean;
}
/** GET /api/me — vem är inloggad (via cookie). 401 om ingen. */
export interface MeResponse {
userId: string;
username: string;
displayName: string;
isAdmin: boolean;
}
/** POST /api/device/code — daemon startar device-flödet (ingen auth). */
export interface DeviceCodeRequest {
clientName: string;
}
export interface DeviceCodeResponse {
deviceCode: string;
userCode: string;
/** Sida att öppna i webbläsaren. */
verificationUri: string;
/** Samma sida med koden förifylld. */
verificationUriComplete: string;
/** Sekunder mellan pollningar. */
interval: number;
/** Sekunder tills koden går ut. */
expiresIn: number;
}
/** GET /api/device/info?user_code=… — webben hämtar info inför consent (cookie-auth). */
export interface DeviceInfoResponse {
clientName: string;
status: "pending" | "approved" | "denied" | "expired";
}
/** POST /api/device/token — daemon pollar (ingen auth; device_code ÄR hemligheten). */
export type DeviceTokenStatus = "pending" | "slow_down" | "denied" | "expired" | "ready";
export interface DeviceTokenResponse {
status: DeviceTokenStatus;
/** Endast när status === "ready": */
token?: string;
sessionId?: string;
clientName?: string;
user?: string;
}
/* --------------------------------- Helpers --------------------------------- */
export function parseMessage<T>(raw: string): T | null {

View File

@@ -1,24 +1,47 @@
<script setup lang="ts">
import { ref, computed } from "vue";
import { ref, computed, onMounted } from "vue";
import { createHelmClient } from "./lib/helm";
import TerminalView from "./components/TerminalView.vue";
import * as api from "./lib/api";
import type { ClientCredDto } from "./lib/api";
import type { MeResponse, PublicConfig } from "@agent-helm/shared";
// I dev: peka mot lokal server. I prod (serverad av servern bakom NPM): samma
// origin, så bygget funkar på vilken host som helst (t.ex. wss://rcai.brasse-pc.eu).
const sameOriginWs = `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}`;
const defaultUrl =
import.meta.env.VITE_SERVER_WS || (import.meta.env.DEV ? "ws://localhost:8787" : sameOriginWs);
const defaultToken = import.meta.env.VITE_AGENT_HELM_TOKEN ?? "";
type View = "loading" | "login" | "setup" | "device" | "app";
const view = ref<View>("loading");
const config = ref<PublicConfig | null>(null);
const meUser = ref<MeResponse | null>(null);
const authError = ref<string | null>(null);
const busy = ref(false);
const url = ref(localStorage.getItem("helm.url") ?? defaultUrl);
const token = ref(localStorage.getItem("helm.token") ?? defaultToken);
// Inloggningsformulär
const username = ref("");
const password = ref("");
const displayName = ref("");
// Device-consent (klienten skickade hit dig via copy-paste-URL:en)
const isDeviceRoute = location.pathname === "/device";
const deviceCode = ref(new URLSearchParams(location.search).get("code") ?? "");
const deviceClient = ref<string | null>(null);
const deviceState = ref<"loading" | "pending" | "done" | "denied" | "error">("loading");
const deviceMsg = ref("");
// WS mot control-plane (same-origin /ws → cookie-auth)
const wsUrl = `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/ws`;
const client = createHelmClient();
const { connected, connecting, error, sessions, approvals } = client;
const selected = ref<string | null>(null);
const sidebarOpen = ref(true);
// Inställningar/admin-panel
const panelOpen = ref(false);
const clients = ref<ClientCredDto[]>([]);
const adminUsers = ref<MeResponse[]>([]);
const newUser = ref({ username: "", password: "", displayName: "", isAdmin: false });
const oidcForm = ref({ issuer: "", clientId: "", clientSecret: "", label: "OIDC", allowedGroup: "" });
const panelMsg = ref("");
const activeMeta = computed(() => sessions.value.find((s) => s.sessionId === selected.value) ?? null);
function sessionTitle(id: string): string {
return sessions.value.find((s) => s.sessionId === id)?.title ?? id.slice(0, 8);
}
@@ -32,41 +55,232 @@ function fmtInput(v: unknown): string {
}
return s.length > 600 ? `${s.slice(0, 600)}` : s;
}
const activeMeta = computed(
() => sessions.value.find((s) => s.sessionId === selected.value) ?? null,
);
function connect(): void {
localStorage.setItem("helm.url", url.value);
localStorage.setItem("helm.token", token.value);
client.connect(url.value, token.value);
}
function select(id: string): void {
selected.value = id;
if (window.matchMedia("(max-width: 720px)").matches) sidebarOpen.value = false;
}
/* ----------------------------------- boot ---------------------------------- */
async function boot(): Promise<void> {
try {
config.value = await api.getConfig();
} catch {
authError.value = "Kunde inte nå servern.";
view.value = "login";
return;
}
meUser.value = await api.getMe();
if (isDeviceRoute) {
if (!meUser.value) {
view.value = config.value.setupComplete === false && config.value.authMode === "local" ? "setup" : "login";
return;
}
view.value = "device";
void loadDevice();
return;
}
if (meUser.value) enterApp();
else if (!config.value.setupComplete && config.value.authMode === "local") view.value = "setup";
else view.value = "login";
}
function enterApp(): void {
view.value = "app";
client.connect(wsUrl);
}
function afterAuth(): void {
if (isDeviceRoute) {
view.value = "device";
void loadDevice();
} else enterApp();
}
async function doLogin(): Promise<void> {
busy.value = true;
authError.value = null;
try {
meUser.value = await api.login(username.value, password.value);
afterAuth();
} catch (e) {
authError.value = (e as Error).message;
} finally {
busy.value = false;
}
}
async function doSetup(): Promise<void> {
busy.value = true;
authError.value = null;
try {
meUser.value = await api.setup(username.value, password.value, displayName.value);
afterAuth();
} catch (e) {
authError.value = (e as Error).message;
} finally {
busy.value = false;
}
}
function oidcLogin(): void {
const next = isDeviceRoute ? location.pathname + location.search : "/";
location.href = `/auth/oidc/login?next=${encodeURIComponent(next)}`;
}
async function doLogout(): Promise<void> {
await api.logout();
client.disconnect();
meUser.value = null;
panelOpen.value = false;
view.value = config.value?.authMode === "local" ? "login" : "login";
}
/* --------------------------------- device ---------------------------------- */
async function loadDevice(): Promise<void> {
if (!deviceCode.value) {
deviceState.value = "pending";
return;
}
deviceState.value = "loading";
try {
const info = await api.deviceInfo(deviceCode.value);
deviceClient.value = info.clientName;
deviceState.value =
info.status === "approved" ? "done" : info.status === "denied" ? "denied" : "pending";
if (info.status === "approved") deviceMsg.value = "Klienten är redan godkänd.";
} catch (e) {
deviceState.value = "error";
deviceMsg.value = (e as Error).message;
}
}
async function approveDevice(decision: "allow" | "deny"): Promise<void> {
busy.value = true;
try {
await api.deviceApprove(deviceCode.value, decision);
deviceState.value = decision === "allow" ? "done" : "denied";
} catch (e) {
deviceState.value = "error";
deviceMsg.value = (e as Error).message;
} finally {
busy.value = false;
}
}
/* ------------------------------ inställningar ------------------------------- */
async function openPanel(): Promise<void> {
panelOpen.value = true;
panelMsg.value = "";
try {
clients.value = (await api.listClients()).clients;
if (meUser.value?.isAdmin) adminUsers.value = (await api.adminListUsers()).users;
} catch (e) {
panelMsg.value = (e as Error).message;
}
}
async function revokeClient(id: string): Promise<void> {
await api.revokeClient(id);
clients.value = (await api.listClients()).clients;
}
async function createUser(): Promise<void> {
panelMsg.value = "";
try {
await api.adminCreateUser(newUser.value.username, newUser.value.password, newUser.value.displayName, newUser.value.isAdmin);
adminUsers.value = (await api.adminListUsers()).users;
newUser.value = { username: "", password: "", displayName: "", isAdmin: false };
panelMsg.value = "Användare skapad.";
} catch (e) {
panelMsg.value = (e as Error).message;
}
}
async function deleteUser(id: string): Promise<void> {
await api.adminDeleteUser(id);
adminUsers.value = (await api.adminListUsers()).users;
}
async function saveOidc(): Promise<void> {
panelMsg.value = "";
try {
await api.adminSetOidc({ ...oidcForm.value, allowedGroup: oidcForm.value.allowedGroup || undefined });
await api.adminSetAuthMode("oidc");
panelMsg.value = "OIDC sparat + aktiverat. Logga ut för att testa.";
} catch (e) {
panelMsg.value = (e as Error).message;
}
}
async function setMode(mode: "local" | "oidc"): Promise<void> {
await api.adminSetAuthMode(mode);
config.value = await api.getConfig();
panelMsg.value = `Auth-läge: ${mode}.`;
}
function fmtTime(t: number): string {
return new Date(t).toLocaleString();
}
function goHome(): void {
location.href = "/";
}
onMounted(boot);
</script>
<template>
<!-- Inloggning -->
<div v-if="!connected" class="login">
<!-- Laddar -->
<div v-if="view === 'loading'" class="login"><div class="card"><p class="sub">Laddar </p></div></div>
<!-- First-run: skapa admin -->
<div v-else-if="view === 'setup'" class="login">
<div class="card">
<h1>agent-helm</h1>
<p class="sub">Fjärrstyr dina agent-sessioner</p>
<label>
Server (WebSocket)
<input v-model="url" autocapitalize="off" autocorrect="off" spellcheck="false" />
</label>
<label>
Token
<input v-model="token" type="password" placeholder="delad token" @keyup.enter="connect" />
</label>
<button class="primary" :disabled="connecting" @click="connect">
{{ connecting ? "Ansluter" : "Anslut" }}
<p class="sub">Första start skapa ett admin-konto</p>
<label>Användarnamn<input v-model="username" autocapitalize="off" autocorrect="off" spellcheck="false" /></label>
<label>Visningsnamn<input v-model="displayName" placeholder="(valfritt)" /></label>
<label>Lösenord<input v-model="password" type="password" @keyup.enter="doSetup" /></label>
<button class="primary" :disabled="busy || !username || !password" @click="doSetup">
{{ busy ? "Skapar" : "Skapa admin & logga in" }}
</button>
<p v-if="error" class="err">{{ error }}</p>
<p v-if="authError" class="err">{{ authError }}</p>
</div>
</div>
<!-- Inloggning -->
<div v-else-if="view === 'login'" class="login">
<div class="card">
<h1>agent-helm</h1>
<p class="sub">Logga in för att styra dina agent-sessioner</p>
<template v-if="config && config.authMode === 'oidc'">
<button class="primary" @click="oidcLogin">{{ config.oidcLabel || "Logga in med OIDC" }}</button>
</template>
<template v-else>
<label>Användarnamn<input v-model="username" autocapitalize="off" autocorrect="off" spellcheck="false" /></label>
<label>Lösenord<input v-model="password" type="password" placeholder="lösenord" @keyup.enter="doLogin" /></label>
<button class="primary" :disabled="busy || !username || !password" @click="doLogin">
{{ busy ? "Loggar in" : "Logga in" }}
</button>
</template>
<p v-if="authError" class="err">{{ authError }}</p>
</div>
</div>
<!-- Device-consent -->
<div v-else-if="view === 'device'" class="login">
<div class="card">
<h1>Koppla klient</h1>
<template v-if="deviceState === 'loading'"><p class="sub">Hämtar </p></template>
<template v-else-if="deviceState === 'pending'">
<p class="sub" v-if="!deviceCode">Klistra in koden från klienten:</p>
<label v-if="!deviceCode">Kod<input v-model="deviceCode" placeholder="XXXX-XXXX" @blur="loadDevice" /></label>
<p v-else>
Godkänn att klienten <b>{{ deviceClient }}</b> kopplas till ditt konto
(<code>{{ meUser?.displayName }}</code>)?
</p>
<div class="row" v-if="deviceCode">
<button class="deny" :disabled="busy" @click="approveDevice('deny')">Neka</button>
<button class="primary" :disabled="busy" @click="approveDevice('allow')">Godkänn</button>
</div>
</template>
<template v-else-if="deviceState === 'done'">
<p class="ok"> Klienten är godkänd. Den ansluter automatiskt du kan stänga fönstret.</p>
<button class="primary" @click="goHome">Till mina sessioner</button>
</template>
<template v-else-if="deviceState === 'denied'"><p class="err">Klienten nekades.</p></template>
<template v-else><p class="err">{{ deviceMsg || "Något gick fel." }}</p></template>
</div>
</div>
@@ -90,6 +304,11 @@ function select(id: string): void {
</li>
<li v-if="sessions.length === 0" class="empty">Inga anslutna sessioner än</li>
</ul>
<footer class="userbar">
<span class="who">{{ meUser?.displayName }}</span>
<button class="link" @click="openPanel">Inställningar</button>
<button class="link" @click="doLogout">Logga ut</button>
</footer>
</aside>
<main class="stage">
@@ -101,10 +320,12 @@ function select(id: string): void {
:session-id="selected"
:meta="activeMeta"
/>
<div v-else class="placeholder">Välj en session till vänster</div>
<div v-else class="placeholder">
{{ connected ? "Välj en session till vänster" : connecting ? "Ansluter…" : error || "Frånkopplad" }}
</div>
</main>
<!-- Strukturerade godkännanden (BeforeTool-hook) -->
<!-- Godkännanden -->
<div v-if="approvals.length" class="approvals">
<div v-for="a in approvals" :key="a.approvalId" class="approval-card">
<div class="ac-head">
@@ -119,19 +340,71 @@ function select(id: string): void {
</div>
</div>
</div>
<!-- Inställningar / admin -->
<div v-if="panelOpen" class="modal" @click.self="panelOpen = false">
<div class="sheet">
<header><h2>Inställningar</h2><button class="link" @click="panelOpen = false">Stäng</button></header>
<section>
<h3>Mina klienter</h3>
<p v-if="!clients.length" class="sub">Inga klienter kopplade än.</p>
<ul class="clients">
<li v-for="c in clients" :key="c.id" :class="{ revoked: c.revoked }">
<span class="title">{{ c.name }}</span>
<span class="meta">sedd {{ fmtTime(c.lastSeenAt) }}</span>
<button v-if="!c.revoked" class="deny sm" @click="revokeClient(c.id)">Återkalla</button>
<span v-else class="badge">återkallad</span>
</li>
</ul>
</section>
<template v-if="meUser?.isAdmin">
<section>
<h3>Användare (admin)</h3>
<ul class="clients">
<li v-for="u in adminUsers" :key="u.userId">
<span class="title">{{ u.displayName }} <small>@{{ u.username }}</small></span>
<span class="meta">{{ u.isAdmin ? "admin" : "" }}</span>
<button v-if="u.userId !== meUser?.userId" class="deny sm" @click="deleteUser(u.userId)">Ta bort</button>
</li>
</ul>
<div class="form">
<input v-model="newUser.username" placeholder="användarnamn" />
<input v-model="newUser.displayName" placeholder="visningsnamn" />
<input v-model="newUser.password" type="password" placeholder="lösenord" />
<label class="chk"><input type="checkbox" v-model="newUser.isAdmin" /> admin</label>
<button class="primary sm" @click="createUser">Skapa</button>
</div>
</section>
<section>
<h3>OIDC (admin)</h3>
<p class="sub">Läge: <b>{{ config?.authMode }}</b>
<button class="link" @click="setMode(config?.authMode === 'oidc' ? 'local' : 'oidc')">byt</button>
</p>
<div class="form col">
<input v-model="oidcForm.issuer" placeholder="issuer (…/application/o/agent-helm/)" />
<input v-model="oidcForm.clientId" placeholder="client_id" />
<input v-model="oidcForm.clientSecret" type="password" placeholder="client_secret" />
<input v-model="oidcForm.label" placeholder="knapptext" />
<input v-model="oidcForm.allowedGroup" placeholder="tillåten grupp (valfritt)" />
<button class="primary sm" @click="saveOidc">Spara OIDC & aktivera</button>
</div>
</section>
</template>
<p v-if="panelMsg" class="ok">{{ panelMsg }}</p>
</div>
</div>
</div>
</template>
<style scoped>
/* Inloggning */
.login {
display: grid;
place-items: center;
height: 100%;
padding: 1rem;
}
/* Inloggning / kort */
.login { display: grid; place-items: center; height: 100%; padding: 1rem; }
.card {
width: min(380px, 100%);
width: min(400px, 100%);
display: flex;
flex-direction: column;
gap: 0.85rem;
@@ -141,7 +414,7 @@ function select(id: string): void {
border-radius: 0.9rem;
}
.card h1 { margin: 0; font-size: 1.4rem; }
.card .sub { margin: -0.6rem 0 0.4rem; color: var(--muted); font-size: 0.9rem; }
.card .sub { margin: -0.4rem 0 0.2rem; color: var(--muted); font-size: 0.9rem; }
.card label { display: flex; flex-direction: column; gap: 0.35rem; font-size: 0.8rem; color: var(--muted); }
.card input {
padding: 0.6rem 0.7rem;
@@ -151,6 +424,9 @@ function select(id: string): void {
color: var(--text);
font: inherit;
}
.card code { background: var(--panel-2); padding: 0.05rem 0.3rem; border-radius: 0.3rem; }
.row { display: flex; gap: 0.6rem; }
.row > button { flex: 1; }
.primary {
margin-top: 0.4rem;
padding: 0.7rem;
@@ -159,47 +435,21 @@ function select(id: string): void {
background: var(--accent);
color: #06121f;
font-weight: 700;
cursor: pointer;
}
.primary:disabled { opacity: 0.6; }
.deny { padding: 0.7rem; border: 1px solid var(--border); border-radius: 0.55rem; background: var(--panel-2); color: var(--red); font-weight: 700; cursor: pointer; }
.err { color: var(--red); font-size: 0.85rem; margin: 0; }
.ok { color: var(--green); font-size: 0.9rem; margin: 0.3rem 0 0; }
/* Layout */
.app { display: flex; height: 100%; }
.sidebar {
width: 260px;
flex: 0 0 260px;
background: var(--panel);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
min-height: 0;
}
.sidebar header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.85rem 1rem;
border-bottom: 1px solid var(--border);
}
.sidebar { width: 260px; flex: 0 0 260px; background: var(--panel); border-right: 1px solid var(--border); display: flex; flex-direction: column; min-height: 0; }
.sidebar header { display: flex; align-items: center; justify-content: space-between; padding: 0.85rem 1rem; border-bottom: 1px solid var(--border); }
.sidebar .brand { font-weight: 700; }
.sidebar .count {
font-size: 0.75rem;
color: var(--muted);
border: 1px solid var(--border);
border-radius: 999px;
padding: 0.05rem 0.5rem;
}
.sidebar ul { list-style: none; margin: 0; padding: 0.5rem; overflow-y: auto; }
.sidebar li {
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.6rem 0.65rem;
border-radius: 0.5rem;
cursor: pointer;
font-size: 0.88rem;
}
.sidebar .count { font-size: 0.75rem; color: var(--muted); border: 1px solid var(--border); border-radius: 999px; padding: 0.05rem 0.5rem; }
.sidebar ul { list-style: none; margin: 0; padding: 0.5rem; overflow-y: auto; flex: 1; }
.sidebar li { display: flex; align-items: center; gap: 0.55rem; padding: 0.6rem 0.65rem; border-radius: 0.5rem; cursor: pointer; font-size: 0.88rem; }
.sidebar li:hover { background: var(--panel-2); }
.sidebar li.active { background: color-mix(in srgb, var(--accent) 22%, transparent); }
.sidebar li.exited .title { color: var(--muted); }
@@ -207,6 +457,9 @@ function select(id: string): void {
.sidebar li .badge { font-size: 0.68rem; color: var(--red); }
.sidebar li.empty { color: var(--muted); cursor: default; justify-content: center; padding: 1.2rem 0; }
.sidebar li.empty:hover { background: none; }
.userbar { display: flex; align-items: center; gap: 0.5rem; padding: 0.6rem 0.8rem; border-top: 1px solid var(--border); font-size: 0.8rem; }
.userbar .who { flex: 1; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.link { background: none; border: 0; color: var(--accent); cursor: pointer; font: inherit; font-size: 0.8rem; padding: 0.1rem 0.2rem; }
.dot { width: 0.55rem; height: 0.55rem; border-radius: 50%; background: var(--muted); flex: none; }
.dot.running { background: var(--green); }
@@ -214,89 +467,45 @@ function select(id: string): void {
.stage { flex: 1; min-width: 0; position: relative; display: flex; }
.stage > .terminal-view { flex: 1; }
.toggle {
display: none;
position: absolute;
top: 0.5rem;
left: 0.5rem;
z-index: 5;
width: 2.2rem;
height: 2.2rem;
border-radius: 0.5rem;
border: 1px solid var(--border);
background: var(--panel);
color: var(--text);
}
.toggle { display: none; position: absolute; top: 0.5rem; left: 0.5rem; z-index: 5; width: 2.2rem; height: 2.2rem; border-radius: 0.5rem; border: 1px solid var(--border); background: var(--panel); color: var(--text); }
.placeholder { margin: auto; color: var(--muted); }
/* Godkännande-kort (BeforeTool-hook) */
.approvals {
position: fixed;
right: 1rem;
bottom: 1rem;
z-index: 20;
display: flex;
flex-direction: column;
gap: 0.6rem;
width: min(380px, calc(100vw - 2rem));
}
.approval-card {
background: var(--panel);
border: 1px solid var(--accent);
border-radius: 0.7rem;
padding: 0.75rem 0.85rem;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.45);
}
/* Modal (inställningar/admin) */
.modal { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.5); display: grid; place-items: center; z-index: 30; padding: 1rem; }
.sheet { width: min(560px, 100%); max-height: 90vh; overflow: auto; background: var(--panel); border: 1px solid var(--border); border-radius: 0.9rem; padding: 1.2rem; }
.sheet header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem; }
.sheet h2 { margin: 0; font-size: 1.1rem; }
.sheet section { border-top: 1px solid var(--border); padding: 0.8rem 0; }
.sheet h3 { margin: 0 0 0.5rem; font-size: 0.9rem; }
.clients { list-style: none; margin: 0 0 0.6rem; padding: 0; display: flex; flex-direction: column; gap: 0.35rem; }
.clients li { display: flex; align-items: center; gap: 0.5rem; font-size: 0.82rem; }
.clients li.revoked { opacity: 0.5; }
.clients .title { flex: 1; }
.clients .meta { color: var(--muted); font-size: 0.72rem; }
.form { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; }
.form.col { flex-direction: column; align-items: stretch; }
.form input[type="text"], .form input:not([type]), .form input[type="password"] { flex: 1; min-width: 8rem; padding: 0.45rem 0.55rem; border-radius: 0.45rem; border: 1px solid var(--border); background: var(--bg); color: var(--text); font: inherit; }
.chk { display: flex; align-items: center; gap: 0.3rem; font-size: 0.8rem; color: var(--muted); }
.sm { padding: 0.45rem 0.7rem; margin: 0; font-size: 0.82rem; }
.badge { font-size: 0.68rem; color: var(--red); }
/* Godkännande-kort */
.approvals { position: fixed; right: 1rem; bottom: 1rem; z-index: 20; display: flex; flex-direction: column; gap: 0.6rem; width: min(380px, calc(100vw - 2rem)); }
.approval-card { background: var(--panel); border: 1px solid var(--accent); border-radius: 0.7rem; padding: 0.75rem 0.85rem; box-shadow: 0 8px 30px rgba(0, 0, 0, 0.45); }
.ac-head { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.4rem; }
.ac-badge {
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.05em;
background: color-mix(in srgb, var(--accent) 25%, transparent);
color: var(--accent);
padding: 0.1rem 0.45rem;
border-radius: 999px;
}
.ac-badge { font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.05em; background: color-mix(in srgb, var(--accent) 25%, transparent); color: var(--accent); padding: 0.1rem 0.45rem; border-radius: 999px; }
.ac-session { font-size: 0.75rem; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ac-tool { font-weight: 700; font-size: 0.95rem; margin-bottom: 0.35rem; word-break: break-word; }
.ac-input {
margin: 0 0 0.6rem;
max-height: 9rem;
overflow: auto;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 0.45rem;
padding: 0.5rem;
font-size: 0.78rem;
white-space: pre-wrap;
word-break: break-word;
font-family: ui-monospace, Menlo, Consolas, monospace;
color: var(--text);
}
.ac-input { margin: 0 0 0.6rem; max-height: 9rem; overflow: auto; background: var(--bg); border: 1px solid var(--border); border-radius: 0.45rem; padding: 0.5rem; font-size: 0.78rem; white-space: pre-wrap; word-break: break-word; font-family: ui-monospace, Menlo, Consolas, monospace; color: var(--text); }
.ac-actions { display: flex; gap: 0.5rem; justify-content: flex-end; }
.ac-actions button {
flex: 1;
padding: 0.55rem;
border-radius: 0.5rem;
border: 1px solid var(--border);
font-weight: 700;
font-size: 0.9rem;
}
.ac-actions button { flex: 1; padding: 0.55rem; border-radius: 0.5rem; border: 1px solid var(--border); font-weight: 700; font-size: 0.9rem; }
.ac-actions .allow { background: var(--green); color: #06121f; border-color: var(--green); }
.ac-actions .deny { background: var(--panel-2); color: var(--red); }
.ac-actions button:active { opacity: 0.8; }
@media (max-width: 720px) {
.approvals { left: 0.6rem; right: 0.6rem; bottom: 0.6rem; width: auto; }
}
@media (max-width: 720px) {
.sidebar {
position: absolute;
z-index: 10;
height: 100%;
transform: translateX(-100%);
transition: transform 0.18s ease;
}
.approvals { left: 0.6rem; right: 0.6rem; bottom: 0.6rem; width: auto; }
.sidebar { position: absolute; z-index: 10; height: 100%; transform: translateX(-100%); transition: transform 0.18s ease; }
.sidebar.open { transform: translateX(0); }
.toggle { display: block; }
}

View File

@@ -0,0 +1,79 @@
/** Tunn fetch-wrapper mot serverns REST-API. Allt same-origin → cookien följer med. */
import type {
DeviceInfoResponse,
MeResponse,
PublicConfig,
} from "@agent-helm/shared";
async function jsonOrThrow<T>(r: Response): Promise<T> {
if (!r.ok) {
let msg = `HTTP ${r.status}`;
try {
msg = (await r.json()).error || msg;
} catch {
/* tom */
}
throw new Error(msg);
}
return r.json() as Promise<T>;
}
const post = (path: string, body?: unknown) =>
fetch(path, {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify(body ?? {}),
});
export const getConfig = () => fetch("/api/config", { credentials: "include" }).then((r) => jsonOrThrow<PublicConfig>(r));
export async function getMe(): Promise<MeResponse | null> {
const r = await fetch("/api/me", { credentials: "include" });
return r.ok ? ((await r.json()) as MeResponse) : null;
}
export const login = (username: string, password: string) =>
post("/api/login", { username, password }).then((r) => jsonOrThrow<MeResponse>(r));
export const setup = (username: string, password: string, displayName?: string) =>
post("/api/setup", { username, password, displayName }).then((r) => jsonOrThrow<MeResponse>(r));
export const logout = () => post("/api/logout");
export const deviceInfo = (userCode: string) =>
fetch(`/api/device/info?user_code=${encodeURIComponent(userCode)}`, { credentials: "include" }).then((r) =>
jsonOrThrow<DeviceInfoResponse>(r),
);
export const deviceApprove = (userCode: string, decision: "allow" | "deny") =>
post("/api/device/approve", { userCode, decision }).then((r) => jsonOrThrow<{ ok: boolean }>(r));
export interface ClientCredDto {
id: string;
name: string;
createdAt: number;
lastSeenAt: number;
revoked: boolean;
}
export const listClients = () =>
fetch("/api/clients", { credentials: "include" }).then((r) => jsonOrThrow<{ clients: ClientCredDto[] }>(r));
export const revokeClient = (id: string) =>
fetch(`/api/clients/${id}`, { method: "DELETE", credentials: "include" }).then((r) => jsonOrThrow<{ ok: boolean }>(r));
/* ---- admin ---- */
export const adminListUsers = () =>
fetch("/api/admin/users", { credentials: "include" }).then((r) => jsonOrThrow<{ users: MeResponse[] }>(r));
export const adminCreateUser = (username: string, password: string, displayName: string, isAdmin: boolean) =>
post("/api/admin/users", { username, password, displayName, isAdmin }).then((r) => jsonOrThrow<MeResponse>(r));
export const adminDeleteUser = (id: string) =>
fetch(`/api/admin/users/${id}`, { method: "DELETE", credentials: "include" }).then((r) => jsonOrThrow<{ ok: boolean }>(r));
export const adminSetOidc = (cfg: {
issuer: string;
clientId: string;
clientSecret: string;
label?: string;
allowedGroup?: string;
}) => post("/api/admin/oidc", cfg).then((r) => jsonOrThrow<{ ok: boolean }>(r));
export const adminSetAuthMode = (mode: "local" | "oidc") =>
post("/api/admin/authmode", { mode }).then((r) => jsonOrThrow<{ ok: boolean }>(r));

View File

@@ -30,12 +30,13 @@ export function createHelmClient() {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(msg));
}
function connect(url: string, token: string): void {
function connect(url: string): void {
error.value = null;
connecting.value = true;
ws = new WebSocket(url);
ws.onopen = () => send({ type: "web:hello", token });
// Ingen token — vi autentiseras via inloggnings-cookien på WS-handskaket.
ws.onopen = () => send({ type: "web:hello" });
ws.onmessage = (ev) => {
let msg: ServerToWeb;

View File

@@ -18,5 +18,12 @@ export default defineConfig({
server: {
host: true,
fs: { allow: [repoRoot] },
// Proxa API + WS till control-plane så dev körs same-origin (cookie-auth funkar).
// Webben ansluter WS till `${origin}/ws`; servern accepterar WS på valfri path.
proxy: {
"/api": { target: "http://localhost:8787", changeOrigin: true },
"/auth": { target: "http://localhost:8787", changeOrigin: true },
"/ws": { target: "ws://localhost:8787", ws: true },
},
},
});