auth: OIDC/lokal inloggning + device-flow för klienter, sessioner per användare
Ersätter den delade AGENT_HELM_TOKEN med en riktig auth-modell: - Webben loggar in med lokala konton ELLER OIDC (Authentik/Keycloak/…), valt per server. Inloggning ger en HMAC-signerad cookie; WS autentiseras via cookien. - Klienten (daemon) kopplas via device-flödet (RFC 8628-likt): `agent-helm connect` skriver ut en URL, du loggar in + godkänner i webben, och får ett klient-token som identifierar din användare. Token sparas i ~/.config/agent-helm/. - Sessioner är per användare (web ser bara sina egna; admin ser alla). Flera klient-instanser = flera parallella sessioner; --new tvingar ny, annars resume. - Identitet är utbytbar (lokal/OIDC) men device-flödet + sessionerna är alltid agent-helms egna → funkar för vem som helst som hostar detta, med eller utan IdP. - Bootstrap-admin via env (engångs) eller first-run-setup i UI:t. Klient-creds kan listas/återkallas; minimal admin-UI (skapa användare, konfigurera OIDC). Nya filer: server/store.ts (JSON-store, scrypt, cookies), server/oidc.ts (auth-code+PKCE), web/lib/api.ts. Verifierat end-to-end lokalt (login → device → approve → daemon-WS → web ser sessionen; web utan cookie nekas). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U68YyHsxU91ecsen84WQV4
This commit is contained in:
50
.env.example
50
.env.example
@@ -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
|
||||
|
||||
24
README.md
24
README.md
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
112
packages/server/src/oidc.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
256
packages/server/src/store.ts
Normal file
256
packages/server/src/store.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
79
packages/web/src/lib/api.ts
Normal file
79
packages/web/src/lib/api.ts
Normal 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));
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user