diff --git a/README.md b/README.md index 6e4a259..dfe9581 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,10 @@ tmux-skärmen är sanningskällan i stället för agent-specifika hooks. deployat, verifierat och vad som återstår) - Server + webbklient: klara — se [`helmd/README.md`](helmd/README.md) (API, säkerhet, test) +- **Hub**: central server på Pi5 som alla agenter ansluter utåt till + (delad agent-nyckel via configfil) och där web-/CLI-klienter loggar + in och styr allt — design i [`doc/hub-plan.md`](doc/hub-plan.md), + CLI-klient `helmd ctl` - Plan (server → webbklient → Android): [`doc/plan.md`](doc/plan.md) - Release: rullande [`helmd-latest`](https://gitea.brasse-pc.eu/brasse/agent-helm/releases) (`helmd-linux-x64` + arm64, webbklienten inbyggd); Pi5-instans på diff --git a/doc/hub-plan.md b/doc/hub-plan.md new file mode 100644 index 0000000..b294318 --- /dev/null +++ b/doc/hub-plan.md @@ -0,0 +1,252 @@ +# agent-helm — hub-plan (v2 steg 4) + +*Skriven 2026-08-06, innan implementation. Status hålls i +[status.md](status.md); v2-planen i övrigt i [plan.md](plan.md).* + +## Mål + +En **central hub på Pi5** som alla agent-klienter ansluter **utåt** +till, och som web- och CLI-klienter loggar in på för att styra **alla** +agenters sessioner från ett ställe: + +``` +brasse-linux01: helmd serve ──utåt──►┐ +Pi5-container: helmd serve ──utåt──►├─ helmd hub :8790 (Pi5) +(fler maskiner senare) ──────utåt──►┘ ▲ ▲ + │ login │ login + webbklient helmd ctl (CLI) +``` + +Krav (från Björn 2026-08-06): + +1. En server som agent-klienter ansluter till; styrklienter ansluter + till samma server och styr alla agenters sessioner. +2. Agent-klienten ska ha en **configfil** där man ställer in vilken + server den ansluter till. +3. Servern **godkänner agent-anslutningar utan inloggning**. +4. Styrklienter (web/CLI) **måste logga in** — i första steget ett + **admin-konto**, som längre fram byts mot **LDAP eller annan + lösning**. +5. Servern körs på **Pi5**, en **webbklient** kopplad till den, en + **agent-klient** på brasse-linux01, och en **CLI-klient** att testa + hela flödet med. + +## Arkitektur + +Allt byggs in i **samma helmd-binär** (fortsatt ren stdlib, en fil): + +| Del | Kommando | Roll | +|---|---|---| +| Hub | `helmd hub` | Central server på Pi5. Agentregister, proxy, login, merged SSE, webbklient. | +| Agent | `helmd serve` | Som idag (tmux-ägare, lokalt REST+SSE) **plus** en utåtgående hub-uplink om `hub` är satt i config.json. | +| CLI | `helmd ctl ` | Styrklient mot hubben: login, agents, sessions, prompt, screen, answer, events … | +| Web | inbäddad SPA | Samma SPA som idag, utökad: login med användarnamn/lösenord i hub-läge + agentväljare. | + +### Transport: long-poll över HTTP, inte WebSocket + +Agenten ansluter utåt med vanlig HTTP (**long-poll**): den håller +2–3 väntande `GET /hub/work` mot hubben; när en styrklient anropar +`/api/agents/{namn}/…` skickas anropet som ett **jobb** i svaret på en +väntande poll, agenten kör jobbet mot sin **egen lokala mux** (samma +handlers och auth som idag) och POST:ar resultatet tillbaka. + +Motivering (samma beslut som v2 i övrigt): ren stdlib (inget +WS-beroende), curl-testbart, överlever proxies, och all API-logik +återanvänds — hubben är bara en växel. + +### Hub-API + +**Agentsidan** (ingen inloggning/konto — men en **delad agent-nyckel**, +beslut 2026-08-06, se Auth-modell): + +| Metod & väg | Gör | +|---|---| +| `POST /hub/register` | `{name, host, version, key:}` → `{key:}`. Registrerar/ersätter agenten; 401 vid fel agent-nyckel. | +| `GET /hub/work?name&key` | long-poll (~25 s) → ett jobb `{id, method, path, content_type, body}` eller 204. | +| `POST /hub/result?name&key` | `{id, status, headers, body}` — svar på ett jobb. | +| `POST /hub/events?name&key` | `{events:[…]}` — agenten vidarebefordrar sina SSE-events (screen/question/session/share). | + +**Klientsidan** (login krävs på allt utom `login`/`mode`): + +| Metod & väg | Gör | +|---|---| +| `POST /api/login` | `{username, password}` → `{token, user}`. | +| `POST /api/logout` / `GET /api/me` | logga ut / vem är jag | +| `GET /api/mode` | `{"mode":"hub"}` (oautentiserad — webbklienten avgör läge; agentens serve svarar `"local"`). | +| `GET /api/agents` | alla registrerade agenter: namn, host, version, online, last_seen. | +| `GET /api/agentkey` | agent-nyckeln (för att koppla in nya agenter — kräver login). | +| `ANY /api/agents/{namn}/…` | proxas till agentens lokala `/api/…` — hela befintliga API-ytan (sessions, screen, prompt, question, answer, keys, mode, shares, config). | +| `GET /api/events` | merged SSE från alla agenter; varje event får fältet `agent`. Även `agent`-events (online/offline). | + +`/api/agents/{namn}/events` proxas **inte** (SSE genom jobbkanalen +vore en evighetsförfrågan) — hubbens egna `/api/events` ersätter den. + +Vidarebefordrade svar behåller `Content-Type` **och** +`Content-Security-Policy` (delad HTML måste förbli sandboxad även via +hubben), `Content-Disposition` och `Cache-Control`. Övriga headers +släpps. + +### Auth-modell + +- **Agenter: delad agent-nyckel, inga konton.** (Beslut 2026-08-06 + efter säkerhetsdiskussion — helt öppen registrering lät vem som + helst på LAN:et ersätta en agent och ta emot prompts.) Hubben + genererar en `agent_key` vid första start; agenten anger samma + nyckel i sin configfil och skickar den vid registrering — stämmer + den litar hubben på agenten. Nyckeln hämtas ur hub-konfigen + (`helmd hub agentkey`) **eller** via API/webb/CLI **efter + inloggning** (`GET /api/agentkey`). +- **Agent-isolering:** `/hub/*`-ytan exponerar ingenting om andra + agenter — en agent kan bara polla sina egna jobb, posta sina egna + resultat/events (via sin per-registrering-pollnyckel) och får aldrig + listor eller data om övriga agenter. Agent→agent-kommunikation finns + inte. (Obs: alla maskiner med agent-nyckeln kan registrera valfritt + *namn* — nyckelinnehavarna är egna maskiner, så namnkapning skyddas + inte mellan dem.) +- **Styrklienter: obligatorisk inloggning.** `Authenticator`-interface: + + ```go + type Authenticator interface { + Authenticate(username, password string) bool + } + ``` + + Första implementation: **lokala konton** i hub-konfigen + (pbkdf2-sha256, salt, 210k iterationer). `auth: "local"` i konfigen; + LDAP blir en ny implementation + `auth: "ldap"` längre fram — inget + annat i hubben behöver ändras. +- **Admin-seeding:** första starten utan användare skapar `admin` med + lösenord från `HELMD_HUB_ADMIN_PASSWORD` (för Docker) eller ett + slumpat som skrivs ut **en gång** i loggen. Byts med + `helmd hub setpass admin`. +- **Sessionstokens** (Bearer) i minnet, TTL 7 dagar (konfigurerbar). + Hub-omstart loggar ut alla — acceptabelt. + +### Konfigfiler + +**Agenten** — `~/.config/helmd/config.json` får en ny sektion (kravet +"configfil där man ställer in vilken server man ansluter till"): + +```json +"hub": { + "url": "http://192.168.0.19:8790", + "name": "brasse-linux01", + "key": "", + "enabled": true +} +``` + +Tom `url`/`enabled:false` (default) = dagens beteende, ingen uplink. +`name` default = hostname. Lokala API:t fortsätter fungera parallellt +(graceful degradation — hubben nere ⇒ styr lokalt som idag). + +**Hubben** — `~/.config/helmd/hub.json` (`HELMD_HUB_CONFIG`/`--config` +överstyr; i containern `/data/hub.json`): + +```json +{ + "listen": "127.0.0.1:8790", + "auth": "local", + "users": [{"username": "admin", "salt": "…", "hash": "…"}], + "agent_key": "", + "session_ttl_hours": 168 +} +``` + +**CLI:n** — `~/.config/helmd/ctl.json`: `{server, user, token}`, +skrivs av `helmd ctl login`. + +### CLI-klienten (`helmd ctl`) + +Testar hela flödet utan webbläsare: + +``` +helmd ctl login [--server URL] [--user admin] [--password PW] +helmd ctl logout | agents | events [--agent X] +helmd ctl sessions +helmd ctl new [--cmd K] [--cwd D] +helmd ctl screen [--ansi] [--history N] +helmd ctl prompt +helmd ctl question|answer|keys|kill […] +helmd ctl shares +helmd ctl agentkey # visar hubbens agent-nyckel (kräver login) +``` + +Lösenord: `--password`, annars läses en rad från stdin (funkar både +interaktivt och i pipe för test). + +### Webbklienten + +Samma inbäddade SPA i båda lägena; `GET /api/mode` avgör vid load: + +- **hub**: loginform användarnamn + lösenord → `/api/login`; + **agentväljare** ovanför sessionslistan; alla API-anrop prefixas + `/api/agents/{vald agent}`; SSE filtreras på `agent`-fältet; + agent-events uppdaterar väljaren. +- **local**: exakt dagens beteende (token-inklistring). + +## Uppfyllnad av kraven + +| Krav | Lösning | +|---|---| +| Server som agenter ansluter till, klienter styr alla sessioner | `helmd hub` + proxy `/api/agents/{namn}/…` + merged SSE | +| Configfil på agenten för val av server | `hub`-sektionen i `config.json` | +| Agent-anslutning utan inloggning, hubben litar på delad nyckel | `POST /hub/register` med `agent_key` — inga konton; nyckeln hämtas ur hub-config eller efter login (beslut 2026-08-06) | +| Agenter isolerade från varandra | `/hub/*` exponerar inget om andra agenter; ingen agent→agent-väg | +| Klientlogin: admin-konto nu, LDAP sen | lokala konton + `Authenticator`-interface, `auth`-nyckel i hub-konfig | +| Server på Pi5 + webbklient | ny Docker-stack `helm-hub` (:8790), samma image `localhost:5000/helmd`, SPA inbäddad | +| Agent-klient på denna dator | `helmd serve` på brasse-linux01 med `hub.enabled:true` | +| CLI-klient för att testa flödet | `helmd ctl` | + +## Implementationsordning + +1. **Kod** (`helmd/`): `hubauth.go` (konton/sessioner/Authenticator), + `hub.go` (register/work/result/events + login + proxy + SSE), + `uplink.go` (agentens utåtgående anslutning), `ctl.go` (CLI), + `main.go` (subkommandon `hub`, `ctl`), `config.go` (`hub`-sektion), + `/api/mode` i `api.go`. +2. **Webb** (`helmd/web/`): mode-detektering, login, agentväljare. +3. **Tester**: Go-enhetstester (auth, register/work/result-roundtrip + via riktiga uplink-koden mot stub-mux) + lokal e2e-smoke + (hub + serve + ctl på brasse-linux01, bash-agent). +4. **Docs**: helmd/README.md (API-tabeller), README, status.md. +5. **Push** → CI bygger `helmd-latest` + arm64-image (serialiserat — + max 2 tunga byggen på Pi5-runnern). +6. **Deploy**: stack `helm-hub` på Pi5 (port 8790, volym + `/srv/docker/helm-hub → /data`, `HELMD_HUB_ADMIN_PASSWORD` sätts + inte — slumpat lösenord läses ur loggen och byts). Agent på + brasse-linux01 får `hub`-config. Verifiera med `helmd ctl` mot Pi5 + + webbklienten. Ev. koppla även Pi5:s befintliga helmd-container + som andra agent (`hub.url` mot hub-containern). +7. **infra-Doc**: uppdatera `services/agent-helm.md` + `hosts/pi5.md` + (ny stack/port). + +## Beslut + +- **Samma binär, nya subkommandon** — inte ett nytt repo/paket; följer + "hela v2 är EN fil". +- **Long-poll i stället för WebSocket** — noll beroenden, curl-testbart. +- **Delad agent-nyckel i stället för öppen registrering eller + godkännande-flöde** (Björn 2026-08-06) — inga konton på agenterna, + men LAN-grannar utan nyckeln kan varken registrera eller kapa + agenter. Nyckeln distribueras manuellt via configfilen. +- **Hubben är en växel, inte en kopia av API:t** — agentens handlers + återanvänds oförändrade via jobb-exekvering mot lokala muxen; nya + endpoints i agenten dyker automatiskt upp genom hubben. +- **Port 8790** för hubben (8788 = lokal helmd, som idag). +- **Delningar bor kvar hos agenten** — hubben proxar även rå-filer; + ingen lagring på hubben. +- **Sessionstokens i minnet** — enkelt; persistens kan läggas till om + utloggning-vid-omstart stör. + +## Öppna punkter (tas efter första deployen) + +- LDAP/Authentik-implementation av `Authenticator` (identity-sso finns + redan i infra:n). +- Ska Pi5:s helmd-container alltid vara agent mot hubben? (Lutar åt ja + när hubben visat sig stabil — då kan LAN-porten 8788 stängas.) +- Roller/rättigheter per användare (admin vs läsklient) — allt är + admin i första versionen. +- ntfy: hubben kunde ta över frågenotiserna så att inte varje agent + pushar själv (dubbelnotiser undviks idag genom att bara agenten + pushar). diff --git a/doc/plan.md b/doc/plan.md index 39afbf2..b0ac2a0 100644 --- a/doc/plan.md +++ b/doc/plan.md @@ -60,6 +60,11 @@ webb-PWA (LAN/tunnel) ─────┼──► helmd :8788 (Go, REST+SSE, tok 3. ⬜ **Android-klient**: nytt projekt eller Capacitor-port av PWA:n. - Egen SSH-tunnel (t.ex. sshj/JSch) → Pi5 → helmd; token + värdprofil i appen; push via ntfy-appen eller UnifiedPush. +4. 🔨 **Hub på Pi5** (påbörjad 2026-08-06): central server som alla + agenter ansluter utåt till (delad agent-nyckel, inga konton) och + där web-/CLI-klienter loggar in (lokalt admin-konto → + `Authenticator`-interface för LDAP senare) och styr alla agenters + sessioner. Full design: **[hub-plan.md](hub-plan.md)**. ## Beslut diff --git a/doc/status.md b/doc/status.md index e983705..5a5cb8b 100644 --- a/doc/status.md +++ b/doc/status.md @@ -1,10 +1,10 @@ -# agent-helm v2 — statusnotering 2026-08-05 +# agent-helm v2 — statusnotering 2026-08-06 Var vi står i implementationen. Uppdatera denna fil när läget ändras. -(Planen och besluten: [plan.md](plan.md). Full teknisk doc: -[../helmd/README.md](../helmd/README.md).) +(Planen och besluten: [plan.md](plan.md) + [hub-plan.md](hub-plan.md). +Full teknisk doc: [../helmd/README.md](../helmd/README.md).) -## Läge: steg 1 + 2 klara och deployade, steg 3 (Android) kvar +## Läge: steg 1 + 2 klara och deployade; steg 4 (hub) pågår; steg 3 (Android) kvar | Del | Status | Var | |-----|--------|-----| @@ -13,6 +13,7 @@ Var vi står i implementationen. Uppdatera denna fil när läget ändras. | **Release-binärer** (x64 + arm64, webbklient inbyggd) | ✅ rullande `helmd-latest` | [releases](https://gitea.brasse-pc.eu/brasse/agent-helm/releases) | | **Docker-image** (arm64, alpine + tmux/bash) | ✅ byggs i CI | `localhost:5000/helmd:latest` | | **Pi5-deploy** (LAN-only, ingen NPM-vhost) | ✅ kör | stack `/srv/dockge-staks/helmd/`, `http://192.168.0.19:8788` | +| **Hub** (`helmd hub`: agent-uplink + login + web + `helmd ctl`) | ✅ kod klar, testad lokalt (enhetstester + 15-stegs smoke); Pi5-deploy återstår | [hub-plan.md](hub-plan.md), `helmd/README.md` | | **Android-klient** (egen SSH-tunnel) | ⬜ ej påbörjad | steg 3 i plan.md | | v1 (Node: daemon/server/web + rcai.brasse-pc.eu) | 🟡 kvar orörd tills v2 helt ersatt | `packages/` | diff --git a/helmd/README.md b/helmd/README.md index 8ca0444..00f155b 100644 --- a/helmd/README.md +++ b/helmd/README.md @@ -34,12 +34,67 @@ skickar tangenter. Fungerar därför med vilken TUI-agent som helst, och ## Kommandon ``` -helmd serve [--listen host:port] [--config fil] starta servern +helmd serve [--listen host:port] [--config fil] starta agent-servern helmd share [--note text] [--session namn] dela en fil till klienterna helmd token skriv ut API-token +helmd hub [--listen host:port] [--config fil] starta hubben (Pi5) +helmd hub setpass [--password PW] sätt/skapa hub-konto +helmd hub users lista hub-konton +helmd hub agentkey skriv ut agent-nyckeln +helmd ctl CLI-styrklient mot hubben helmd version ``` +## Hubben (`helmd hub`) + +Central server (Pi5) som **agenter ansluter utåt till** och där web- +och CLI-klienter **loggar in** och styr alla agenters sessioner. Full +design och beslut: [../doc/hub-plan.md](../doc/hub-plan.md). + +- **Agent-anslutning:** ingen inloggning/konto — agenten skickar den + **delade agent-nyckeln** (`agent_key` i hubbens config) vid + registrering. Hämta den med `helmd hub agentkey` på hubben, eller + efter inloggning med `helmd ctl agentkey` / Inställningar-fliken i + webben. Transporten är long-poll över HTTP (`/hub/register`, + `/hub/work`, `/hub/result`, `/hub/events`) — agenten kör jobben mot + sin egen lokala mux, så hela API-tabellen ovan funkar oförändrad + genom hubben. Agenter ser aldrig något om varandra. +- **Klient-login:** `POST /api/login` med användarnamn + lösenord → + Bearer-token. Konton ligger lokalt i hub-konfigen (pbkdf2-sha256); + `Authenticator`-interfacet i `hubauth.go` är utbytespunkten för + LDAP/SSO längre fram. Första starten seedas `admin` (lösenord från + `HELMD_HUB_ADMIN_PASSWORD` eller slumpat — skrivs ut i loggen). +- **Klient-API:** `GET /api/agents` (lista + online-status), + `ANY /api/agents/{namn}/…` (proxas till agentens `/api/…`), + `GET /api/events` (merged SSE, fältet `agent` på varje event), + `GET /api/agentkey`, `GET/POST /api/login|logout|me`. + `GET /api/mode` (oautentiserad) svarar `hub`/`local` så att den + inbäddade webbklienten kan välja login-form — i hub-läge får den + användarnamn/lösenord och en agentväljare. +- **Agentens config** (`config.json`) pekar ut hubben: + + ```json + "hub": { + "url": "http://192.168.0.19:8790", + "name": "brasse-linux01", + "key": "", + "enabled": true + } + ``` + + Det lokala API:t på `:8788` fortsätter fungera parallellt — hubben + nere betyder bara att uplinken backar och försöker igen. +- **Hubbens config** (`~/.config/helmd/hub.json`, `HELMD_HUB_CONFIG` + överstyr): `listen` (default `127.0.0.1:8790`), `auth` (`local`), + `users`, `agent_key`, `session_ttl_hours` (default 168). +- **CLI-klienten:** `helmd ctl login --server http://192.168.0.19:8790` + → `ctl agents`, `ctl sessions `, `ctl new `, + `ctl screen|prompt|question|answer|keys|kill …`, + `ctl shares `, `ctl events`. Sparar server + token i + `~/.config/helmd/ctl.json`. +- **Smoke-test av hela flödet:** `bash scripts/run-hub-smoke.sh` + (hub + agent + ctl lokalt, 15 kontroller). + `helmd share` är **agentens verktyg för att dela filer** (html, md, png, jpg, …) till klienterna: agenten kör kommandot i sin shell, filen laddas upp till servern och dyker upp i klientens delningsflik. diff --git a/helmd/api.go b/helmd/api.go index 4bd9e71..fb6fc76 100644 --- a/helmd/api.go +++ b/helmd/api.go @@ -55,6 +55,12 @@ func readJSON(r *http.Request, v interface{}) error { func (a *API) routes() *http.ServeMux { mux := http.NewServeMux() + // oautentiserad läges-detektering: webbklienten är samma SPA för + // lokal helmd och hub och avgör läget med det här svaret + mux.HandleFunc("GET /api/mode", func(w http.ResponseWriter, r *http.Request) { + jsonOut(w, 200, map[string]string{"mode": "local", "version": version}) + }) + mux.HandleFunc("GET /api/health", a.auth(func(w http.ResponseWriter, r *http.Request) { jsonOut(w, 200, map[string]interface{}{ "ok": true, "version": version, "sessions": len(a.mgr.List()), @@ -357,6 +363,9 @@ func serve(cfg *Config) error { } api := &API{cfg: cfg, mgr: mgr, shares: shares, ntfy: ntfy} mux := api.routes() + if cfg.Hub.Enabled && cfg.Hub.URL != "" { + startUplink(cfg, mux, mgr) + } mountWeb(mux) log.Printf("helmd %s lyssnar på http://%s (token i %s)", version, cfg.Listen, cfg.path) return http.ListenAndServe(cfg.Listen, mux) diff --git a/helmd/config.go b/helmd/config.go index 88ff121..bc66fa6 100644 --- a/helmd/config.go +++ b/helmd/config.go @@ -18,6 +18,17 @@ type NtfyConfig struct { NotifyOnQuestion bool `json:"notify_on_question"` } +// HubUplink pekar ut vilken hub agenten ska ansluta till. Anslutningen +// sker utåt (agenten ringer hubben) utan konto — hubben litar på den +// delade agent-nyckeln (hämtas med `helmd hub agentkey` på hubben +// eller efter inloggning i webben/CLI:n). +type HubUplink struct { + URL string `json:"url"` // t.ex. http://192.168.0.19:8790 + Name string `json:"name"` // agentens namn i hubben (tomt = hostname) + Key string `json:"key"` // hubbens agent_key + Enabled bool `json:"enabled"` +} + type Config struct { Listen string `json:"listen"` Token string `json:"token"` @@ -30,6 +41,7 @@ type Config struct { Cols int `json:"cols"` Rows int `json:"rows"` Ntfy NtfyConfig `json:"ntfy"` + Hub HubUplink `json:"hub"` path string } @@ -53,6 +65,11 @@ func defaultConfig() *Config { Enabled: true, NotifyOnQuestion: true, }, + Hub: HubUplink{ + URL: "", + Name: hostname(), + Enabled: false, + }, } } @@ -107,6 +124,14 @@ func (c *Config) Save() error { return os.WriteFile(c.path, append(data, '\n'), 0o600) } +func hostname() string { + h, err := os.Hostname() + if err != nil || h == "" { + return "agent" + } + return h +} + func newToken() string { b := make([]byte, 24) if _, err := rand.Read(b); err != nil { diff --git a/helmd/ctl.go b/helmd/ctl.go new file mode 100644 index 0000000..9245466 --- /dev/null +++ b/helmd/ctl.go @@ -0,0 +1,456 @@ +package main + +import ( + "bufio" + "bytes" + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +// helmd ctl — CLI-styrklient mot hubben. Loggar in med användarnamn + +// lösenord (admin-kontot i första versionen) och styr alla anslutna +// agenters sessioner. Server + token sparas i ~/.config/helmd/ctl.json. + +type ctlConfig struct { + Server string `json:"server"` + User string `json:"user"` + Token string `json:"token"` +} + +func ctlConfigPath() string { + dir, err := os.UserConfigDir() + if err != nil { + dir = "." + } + return filepath.Join(dir, "helmd", "ctl.json") +} + +func loadCtlConfig() *ctlConfig { + cfg := &ctlConfig{Server: "http://127.0.0.1:8790"} + if data, err := os.ReadFile(ctlConfigPath()); err == nil { + json.Unmarshal(data, cfg) + } + return cfg +} + +func (c *ctlConfig) save() error { + path := ctlConfigPath() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + data, _ := json.MarshalIndent(c, "", " ") + return os.WriteFile(path, append(data, '\n'), 0o600) +} + +// ctlDo gör ett API-anrop mot hubben med sparad token. +func (c *ctlConfig) do(method, path string, body interface{}) ([]byte, error) { + var rd io.Reader + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return nil, err + } + rd = bytes.NewReader(data) + } + req, err := http.NewRequest(method, c.Server+path, rd) + if err != nil { + return nil, err + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + req.Header.Set("Authorization", "Bearer "+c.Token) + client := &http.Client{Timeout: 100 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + data, _ := io.ReadAll(io.LimitReader(resp.Body, 96<<20)) + if resp.StatusCode == 401 { + return nil, fmt.Errorf("inte inloggad — kör: helmd ctl login --server %s", c.Server) + } + if resp.StatusCode >= 300 { + var e struct { + Error string `json:"error"` + } + if json.Unmarshal(data, &e) == nil && e.Error != "" { + return nil, fmt.Errorf("%s", e.Error) + } + return nil, fmt.Errorf("hubben svarade %s", resp.Status) + } + return data, nil +} + +func agentPath(agent, rest string) string { + return "/api/agents/" + url.PathEscape(agent) + "/" + rest +} + +func runCtl(args []string) error { + if len(args) == 0 { + return fmt.Errorf(`användning: helmd ctl + + login [--server URL] [--user NAMN] [--password PW] logga in mot hubben + logout logga ut + agents lista anslutna agenter + agentkey visa hubbens agent-nyckel + events [--agent NAMN] strömma händelser + sessions lista en agents sessioner + new [--cmd K] [--cwd D] [--adopt] starta/adoptera session + screen [--ansi] [--history N] visa skärmen + prompt skicka prompt + question visa väntande fråga + answer svara på frågan + keys råa tangenter (Enter, Escape, C-c …) + kill döda sessionen + shares lista delningar`) + } + cfg := loadCtlConfig() + cmd, rest := args[0], args[1:] + + switch cmd { + case "login": + fs := flag.NewFlagSet("login", flag.ExitOnError) + server := fs.String("server", cfg.Server, "hubbens adress") + user := fs.String("user", firstNonEmpty(cfg.User, "admin"), "användarnamn") + password := fs.String("password", "", "lösenord (utelämna för att läsa från stdin)") + fs.Parse(rest) + pw := *password + if pw == "" { + fmt.Fprintf(os.Stderr, "lösenord för %s: ", *user) + sc := bufio.NewScanner(os.Stdin) + if sc.Scan() { + pw = strings.TrimSpace(sc.Text()) + } + } + body, _ := json.Marshal(map[string]string{"username": *user, "password": pw}) + resp, err := (&http.Client{Timeout: 15 * time.Second}).Post( + strings.TrimRight(*server, "/")+"/api/login", "application/json", bytes.NewReader(body)) + if err != nil { + return err + } + defer resp.Body.Close() + data, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode != 200 { + return fmt.Errorf("inloggningen misslyckades: %s", strings.TrimSpace(string(data))) + } + var out struct { + Token string `json:"token"` + User string `json:"user"` + } + if err := json.Unmarshal(data, &out); err != nil { + return err + } + cfg.Server = strings.TrimRight(*server, "/") + cfg.User = out.User + cfg.Token = out.Token + if err := cfg.save(); err != nil { + return err + } + fmt.Printf("inloggad som %s mot %s\n", out.User, cfg.Server) + return nil + + case "logout": + cfg.do("POST", "/api/logout", map[string]bool{}) + cfg.Token = "" + if err := cfg.save(); err != nil { + return err + } + fmt.Println("utloggad") + return nil + + case "agents": + data, err := cfg.do("GET", "/api/agents", nil) + if err != nil { + return err + } + var agents []struct { + Name string `json:"name"` + Host string `json:"host"` + Version string `json:"version"` + Online bool `json:"online"` + LastSeen time.Time `json:"last_seen"` + } + if err := json.Unmarshal(data, &agents); err != nil { + return err + } + if len(agents) == 0 { + fmt.Println("inga agenter registrerade") + return nil + } + fmt.Printf("%-20s %-8s %-20s %-12s %s\n", "NAMN", "ONLINE", "HOST", "VERSION", "SENAST SEDD") + for _, a := range agents { + on := "offline" + if a.Online { + on = "online" + } + fmt.Printf("%-20s %-8s %-20s %-12s %s\n", a.Name, on, a.Host, a.Version, + a.LastSeen.Local().Format("2006-01-02 15:04:05")) + } + return nil + + case "agentkey": + data, err := cfg.do("GET", "/api/agentkey", nil) + if err != nil { + return err + } + var out struct { + AgentKey string `json:"agent_key"` + } + if err := json.Unmarshal(data, &out); err != nil { + return err + } + fmt.Println(out.AgentKey) + return nil + + case "sessions": + if len(rest) != 1 { + return fmt.Errorf("användning: helmd ctl sessions ") + } + data, err := cfg.do("GET", agentPath(rest[0], "sessions"), nil) + if err != nil { + return err + } + var sessions []SessionState + if err := json.Unmarshal(data, &sessions); err != nil { + return err + } + if len(sessions) == 0 { + fmt.Println("inga sessioner") + return nil + } + fmt.Printf("%-20s %-8s %-6s %s\n", "NAMN", "LEVER", "FRÅGA", "KOMMANDO") + for _, s := range sessions { + alive, q := "nej", "" + if s.Alive { + alive = "ja" + } + if s.Question != nil { + q = "❓" + } + fmt.Printf("%-20s %-8s %-6s %s\n", s.Name, alive, q, s.Cmd) + } + return nil + + case "new": + fs := flag.NewFlagSet("new", flag.ExitOnError) + cmdline := fs.String("cmd", "", "kommando (tomt = agentens config)") + cwd := fs.String("cwd", "", "arbetskatalog") + adopt := fs.Bool("adopt", false, "adoptera befintlig tmux-session") + pos := parseMixed(fs, rest, 2) + if len(pos) != 2 { + return fmt.Errorf("användning: helmd ctl new [--cmd K] [--cwd D] [--adopt]") + } + _, err := cfg.do("POST", agentPath(pos[0], "sessions"), map[string]interface{}{ + "name": pos[1], "cmd": *cmdline, "cwd": *cwd, "adopt": *adopt, + }) + if err != nil { + return err + } + fmt.Printf("session %s startad hos %s\n", pos[1], pos[0]) + return nil + + case "screen": + fs := flag.NewFlagSet("screen", flag.ExitOnError) + ansi := fs.Bool("ansi", false, "behåll ANSI-färger") + history := fs.Int("history", 0, "rader scrollback") + pos := parseMixed(fs, rest, 2) + if len(pos) != 2 { + return fmt.Errorf("användning: helmd ctl screen [--ansi] [--history N]") + } + q := "screen?history=" + strconv.Itoa(*history) + if *ansi { + q += "&ansi=1" + } + data, err := cfg.do("GET", agentPath(pos[0], "sessions/"+url.PathEscape(pos[1])+"/"+q), nil) + if err != nil { + return err + } + fmt.Println(string(data)) + return nil + + case "prompt": + if len(rest) < 3 { + return fmt.Errorf("användning: helmd ctl prompt ") + } + _, err := cfg.do("POST", agentPath(rest[0], "sessions/"+url.PathEscape(rest[1])+"/prompt"), + map[string]string{"text": strings.Join(rest[2:], " ")}) + if err != nil { + return err + } + fmt.Println("skickat") + return nil + + case "question": + if len(rest) != 2 { + return fmt.Errorf("användning: helmd ctl question ") + } + data, err := cfg.do("GET", agentPath(rest[0], "sessions/"+url.PathEscape(rest[1])+"/question"), nil) + if err != nil { + return err + } + var out struct { + Question *Question `json:"question"` + } + if err := json.Unmarshal(data, &out); err != nil { + return err + } + if out.Question == nil { + fmt.Println("ingen väntande fråga") + return nil + } + fmt.Println(out.Question.Text) + for i, o := range out.Question.Options { + sel := " " + if i == out.Question.Selected { + sel = "> " + } + fmt.Printf("%s%d. %s\n", sel, i+1, o.Label) + } + return nil + + case "answer": + if len(rest) != 3 { + return fmt.Errorf("användning: helmd ctl answer ") + } + n, err := strconv.Atoi(rest[2]) + if err != nil { + return fmt.Errorf("%q är inte ett nummer", rest[2]) + } + _, err = cfg.do("POST", agentPath(rest[0], "sessions/"+url.PathEscape(rest[1])+"/answer"), + map[string]int{"option": n}) + if err != nil { + return err + } + fmt.Println("svarat") + return nil + + case "keys": + if len(rest) < 3 { + return fmt.Errorf("användning: helmd ctl keys ") + } + _, err := cfg.do("POST", agentPath(rest[0], "sessions/"+url.PathEscape(rest[1])+"/keys"), + map[string][]string{"keys": rest[2:]}) + if err != nil { + return err + } + fmt.Println("skickat") + return nil + + case "kill": + if len(rest) != 2 { + return fmt.Errorf("användning: helmd ctl kill ") + } + _, err := cfg.do("DELETE", agentPath(rest[0], "sessions/"+url.PathEscape(rest[1])), nil) + if err != nil { + return err + } + fmt.Println("dödad") + return nil + + case "shares": + if len(rest) != 1 { + return fmt.Errorf("användning: helmd ctl shares ") + } + data, err := cfg.do("GET", agentPath(rest[0], "shares"), nil) + if err != nil { + return err + } + var shares []struct { + ID string `json:"id"` + Name string `json:"name"` + Note string `json:"note"` + Session string `json:"session"` + Created time.Time `json:"created"` + } + if err := json.Unmarshal(data, &shares); err != nil { + return err + } + if len(shares) == 0 { + fmt.Println("inga delningar") + return nil + } + for _, sh := range shares { + fmt.Printf("%s %-30s %s %s\n", sh.ID, sh.Name, + sh.Created.Local().Format("2006-01-02 15:04"), sh.Note) + } + return nil + + case "events": + fs := flag.NewFlagSet("events", flag.ExitOnError) + agent := fs.String("agent", "", "visa bara events från denna agent") + fs.Parse(rest) + req, err := http.NewRequest("GET", cfg.Server+"/api/events?token="+url.QueryEscape(cfg.Token), nil) + if err != nil { + return err + } + resp, err := (&http.Client{}).Do(req) // ingen timeout — strömmar tills Ctrl+C + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return fmt.Errorf("inte inloggad — kör: helmd ctl login") + } + fmt.Fprintln(os.Stderr, "strömmar events (avsluta med Ctrl+C) …") + sc := bufio.NewScanner(resp.Body) + sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for sc.Scan() { + line := sc.Text() + if !strings.HasPrefix(line, "data: ") { + continue + } + var ev hubEvent + if json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &ev) != nil { + continue + } + if *agent != "" && ev.Agent != *agent { + continue + } + data, _ := json.Marshal(ev.Data) + fmt.Printf("%s %-16s %-10s %-16s %s\n", + time.Now().Format("15:04:05"), ev.Agent, ev.Type, ev.Session, truncate(string(data), 80)) + } + return sc.Err() + + default: + return fmt.Errorf("okänt ctl-kommando %q — kör `helmd ctl` för hjälp", cmd) + } +} + +// parseMixed tillåter flaggor både före och efter de positionella +// argumenten (stdlib-flag stannar annars vid första icke-flaggan). +func parseMixed(fs *flag.FlagSet, args []string, npos int) []string { + fs.Parse(args) + pos := fs.Args() + if len(pos) > npos { + fs.Parse(pos[npos:]) + pos = append(pos[:npos:npos], fs.Args()...) + } + return pos +} + +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if v != "" { + return v + } + } + return "" +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "…" +} diff --git a/helmd/hub.go b/helmd/hub.go new file mode 100644 index 0000000..a251679 --- /dev/null +++ b/helmd/hub.go @@ -0,0 +1,490 @@ +package main + +import ( + "crypto/subtle" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "sort" + "strings" + "sync" + "time" +) + +const ( + workPollTimeout = 25 * time.Second // hur länge en agent-poll får hänga + proxyTimeout = 90 * time.Second // hur länge hubben väntar på ett jobbsvar + offlineAfter = 15 * time.Second // ingen poll på så länge ⇒ offline +) + +// hubJob är ett HTTP-anrop som hubben ber agenten utföra mot sitt +// lokala API. Body är base64 i JSON ([]byte). +type hubJob struct { + ID string `json:"id"` + Method string `json:"method"` + Path string `json:"path"` // t.ex. /api/sessions/x/screen?ansi=1 + ContentType string `json:"content_type,omitempty"` + Body []byte `json:"body,omitempty"` +} + +type hubResult struct { + ID string `json:"id"` + Status int `json:"status"` + Headers map[string][]string `json:"headers,omitempty"` + Body []byte `json:"body,omitempty"` +} + +// hubAgent är en registrerad agent. Agenter ser aldrig varandra: hela +// /hub/*-ytan är avgränsad till den egna pollnyckeln. +type hubAgent struct { + Name string `json:"name"` + Host string `json:"host"` + Version string `json:"version"` + Online bool `json:"online"` + Connected time.Time `json:"connected"` + LastSeen time.Time `json:"last_seen"` + + pollKey string + jobs chan *hubJob + pollers int +} + +type hubEvent struct { + Agent string `json:"agent,omitempty"` + Type string `json:"type"` + Session string `json:"session,omitempty"` + Data interface{} `json:"data,omitempty"` +} + +type Hub struct { + cfg *HubConfig + auth Authenticator + sess *sessionStore + + mu sync.Mutex + agents map[string]*hubAgent + results map[string]chan *hubResult + + subMu sync.Mutex + subs map[chan hubEvent]struct{} +} + +func NewHub(cfg *HubConfig) *Hub { + h := &Hub{ + cfg: cfg, + auth: &localAuth{cfg: cfg}, + sess: newSessionStore(time.Duration(cfg.SessionTTLHours) * time.Hour), + agents: map[string]*hubAgent{}, + results: map[string]chan *hubResult{}, + subs: map[chan hubEvent]struct{}{}, + } + go h.watchOffline() + return h +} + +// ---------- events ---------- + +func (h *Hub) subscribe() chan hubEvent { + ch := make(chan hubEvent, 32) + h.subMu.Lock() + h.subs[ch] = struct{}{} + h.subMu.Unlock() + return ch +} + +func (h *Hub) unsubscribe(ch chan hubEvent) { + h.subMu.Lock() + delete(h.subs, ch) + h.subMu.Unlock() +} + +func (h *Hub) emit(ev hubEvent) { + h.subMu.Lock() + for ch := range h.subs { + select { + case ch <- ev: + default: // långsam klient: släpp hellre än blockera + } + } + h.subMu.Unlock() +} + +// watchOffline flaggar agenter som slutat polla och skickar events när +// online-läget ändras. +func (h *Hub) watchOffline() { + for { + time.Sleep(5 * time.Second) + h.mu.Lock() + for _, a := range h.agents { + online := a.pollers > 0 || time.Since(a.LastSeen) < offlineAfter + if online != a.Online { + a.Online = online + state := "offline" + if online { + state = "online" + } + go h.emit(hubEvent{Agent: a.Name, Type: "agent", Data: state}) + log.Printf("agent %s är %s", a.Name, state) + } + } + h.mu.Unlock() + } +} + +// ---------- agentsidan (/hub/*) ---------- + +// checkAgent letar upp agenten och verifierar dess pollnyckel. +func (h *Hub) checkAgent(r *http.Request) *hubAgent { + name := r.URL.Query().Get("name") + key := r.URL.Query().Get("key") + h.mu.Lock() + defer h.mu.Unlock() + a := h.agents[name] + if a == nil || subtle.ConstantTimeCompare([]byte(key), []byte(a.pollKey)) != 1 { + return nil + } + a.LastSeen = time.Now() + return a +} + +func (h *Hub) register(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + Host string `json:"host"` + Version string `json:"version"` + Key string `json:"key"` + } + if err := readJSON(r, &req); err != nil { + jsonErr(w, 400, err.Error()) + return + } + if subtle.ConstantTimeCompare([]byte(req.Key), []byte(h.cfg.AgentKey)) != 1 { + time.Sleep(400 * time.Millisecond) + jsonErr(w, 401, "fel agent-nyckel — hämta rätt med `helmd hub agentkey` på hubben") + return + } + if req.Name == "" || strings.ContainsAny(req.Name, " \t/?#%") { + jsonErr(w, 400, fmt.Sprintf("ogiltigt agentnamn %q", req.Name)) + return + } + a := &hubAgent{ + Name: req.Name, Host: req.Host, Version: req.Version, + Online: true, Connected: time.Now(), LastSeen: time.Now(), + pollKey: newToken(), jobs: make(chan *hubJob, 64), + } + h.mu.Lock() + if old := h.agents[req.Name]; old != nil { + log.Printf("agent %s omregistrerad (ersätter anslutning från %s)", req.Name, old.Host) + } else { + log.Printf("agent %s registrerad (host %s, version %s)", req.Name, req.Host, req.Version) + } + h.agents[req.Name] = a + h.mu.Unlock() + h.emit(hubEvent{Agent: a.Name, Type: "agent", Data: "online"}) + jsonOut(w, 200, map[string]string{"key": a.pollKey}) +} + +// work är agentens long-poll: häng tills ett jobb finns eller timeout. +func (h *Hub) work(w http.ResponseWriter, r *http.Request) { + a := h.checkAgent(r) + if a == nil { + jsonErr(w, 401, "okänd agent eller fel nyckel — registrera om") + return + } + h.mu.Lock() + a.pollers++ + h.mu.Unlock() + defer func() { + h.mu.Lock() + a.pollers-- + a.LastSeen = time.Now() + h.mu.Unlock() + }() + select { + case job := <-a.jobs: + jsonOut(w, 200, job) + case <-time.After(workPollTimeout): + w.WriteHeader(204) + case <-r.Context().Done(): + } +} + +func (h *Hub) result(w http.ResponseWriter, r *http.Request) { + a := h.checkAgent(r) + if a == nil { + jsonErr(w, 401, "okänd agent eller fel nyckel — registrera om") + return + } + var res hubResult + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 96<<20)).Decode(&res); err != nil { + jsonErr(w, 400, err.Error()) + return + } + h.mu.Lock() + ch := h.results[res.ID] + h.mu.Unlock() + if ch != nil { + select { + case ch <- &res: + default: + } + } + jsonOut(w, 200, map[string]bool{"ok": true}) +} + +func (h *Hub) agentEvents(w http.ResponseWriter, r *http.Request) { + a := h.checkAgent(r) + if a == nil { + jsonErr(w, 401, "okänd agent eller fel nyckel — registrera om") + return + } + var req struct { + Events []Event `json:"events"` + } + if err := readJSON(r, &req); err != nil { + jsonErr(w, 400, err.Error()) + return + } + for _, ev := range req.Events { + h.emit(hubEvent{Agent: a.Name, Type: ev.Type, Session: ev.Session, Data: ev.Data}) + } + jsonOut(w, 200, map[string]bool{"ok": true}) +} + +// ---------- klientsidan (/api/*, kräver login) ---------- + +func (h *Hub) requireAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + tok := r.Header.Get("X-Api-Token") + if tok == "" { + if hdr := r.Header.Get("Authorization"); strings.HasPrefix(hdr, "Bearer ") { + tok = strings.TrimPrefix(hdr, "Bearer ") + } + } + if tok == "" { + tok = r.URL.Query().Get("token") // SSE/EventSource + -länkar + } + if _, ok := h.sess.Lookup(tok); !ok { + jsonErr(w, http.StatusUnauthorized, "inte inloggad") + return + } + next(w, r) + } +} + +func (h *Hub) login(w http.ResponseWriter, r *http.Request) { + var req struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := readJSON(r, &req); err != nil { + jsonErr(w, 400, err.Error()) + return + } + if !h.auth.Authenticate(req.Username, req.Password) { + time.Sleep(400 * time.Millisecond) // dämpa gissningsförsök + jsonErr(w, 401, "fel användarnamn eller lösenord") + return + } + tok := h.sess.Create(req.Username) + log.Printf("användare %s loggade in", req.Username) + jsonOut(w, 200, map[string]interface{}{ + "token": tok, "user": req.Username, "ttl_hours": h.cfg.SessionTTLHours, + }) +} + +func (h *Hub) listAgents() []*hubAgent { + h.mu.Lock() + defer h.mu.Unlock() + out := make([]*hubAgent, 0, len(h.agents)) + for _, a := range h.agents { + a.Online = a.pollers > 0 || time.Since(a.LastSeen) < offlineAfter + out = append(out, a) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// proxy vidarebefordrar /api/agents/{agent}/{rest...} till agentens +// lokala /api/{rest} genom jobb-kanalen. +func (h *Hub) proxy(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("agent") + rest := r.PathValue("rest") + if rest == "events" || strings.HasPrefix(rest, "events/") { + jsonErr(w, 404, "events proxas inte per agent — använd hubbens /api/events") + return + } + h.mu.Lock() + a := h.agents[name] + h.mu.Unlock() + if a == nil { + jsonErr(w, 404, "ingen sådan agent") + return + } + + var body []byte + if r.Body != nil { + var err error + body, err = io.ReadAll(http.MaxBytesReader(w, r.Body, 64<<20)) + if err != nil { + jsonErr(w, 400, err.Error()) + return + } + } + path := "/api/" + rest + if r.URL.RawQuery != "" { + path += "?" + r.URL.RawQuery + } + job := &hubJob{ + ID: newToken(), Method: r.Method, Path: path, + ContentType: r.Header.Get("Content-Type"), Body: body, + } + + ch := make(chan *hubResult, 1) + h.mu.Lock() + h.results[job.ID] = ch + h.mu.Unlock() + defer func() { + h.mu.Lock() + delete(h.results, job.ID) + h.mu.Unlock() + }() + + select { + case a.jobs <- job: + default: + jsonErr(w, 503, "agentens jobbkö är full") + return + } + + select { + case res := <-ch: + // Behåll bara ofarliga headers — särskilt CSP måste följa med + // så att delad HTML förblir sandboxad även genom hubben. + for _, k := range []string{"Content-Type", "Content-Security-Policy", "Content-Disposition", "Cache-Control"} { + if vs := res.Headers[k]; len(vs) > 0 { + w.Header()[k] = vs + } + } + w.WriteHeader(res.Status) + w.Write(res.Body) + case <-time.After(proxyTimeout): + jsonErr(w, 504, "agenten svarade inte i tid") + case <-r.Context().Done(): + } +} + +func (h *Hub) routes() *http.ServeMux { + mux := http.NewServeMux() + + // agentsidan — delad agent-nyckel vid registrering, pollnyckel därefter + mux.HandleFunc("POST /hub/register", h.register) + mux.HandleFunc("GET /hub/work", h.work) + mux.HandleFunc("POST /hub/result", h.result) + mux.HandleFunc("POST /hub/events", h.agentEvents) + + // oautentiserat: läges-detektering för webbklienten + mux.HandleFunc("GET /api/mode", func(w http.ResponseWriter, r *http.Request) { + jsonOut(w, 200, map[string]string{"mode": "hub", "version": version}) + }) + mux.HandleFunc("POST /api/login", h.login) + + mux.HandleFunc("POST /api/logout", func(w http.ResponseWriter, r *http.Request) { + tok := r.Header.Get("X-Api-Token") + if hdr := r.Header.Get("Authorization"); strings.HasPrefix(hdr, "Bearer ") { + tok = strings.TrimPrefix(hdr, "Bearer ") + } + h.sess.Delete(tok) + jsonOut(w, 200, map[string]bool{"ok": true}) + }) + + mux.HandleFunc("GET /api/me", h.requireAuth(func(w http.ResponseWriter, r *http.Request) { + tok := r.Header.Get("X-Api-Token") + if hdr := r.Header.Get("Authorization"); strings.HasPrefix(hdr, "Bearer ") { + tok = strings.TrimPrefix(hdr, "Bearer ") + } + user, _ := h.sess.Lookup(tok) + jsonOut(w, 200, map[string]string{"user": user}) + })) + + mux.HandleFunc("GET /api/health", h.requireAuth(func(w http.ResponseWriter, r *http.Request) { + jsonOut(w, 200, map[string]interface{}{ + "ok": true, "version": version, "agents": len(h.listAgents()), + }) + })) + + mux.HandleFunc("GET /api/agents", h.requireAuth(func(w http.ResponseWriter, r *http.Request) { + jsonOut(w, 200, h.listAgents()) + })) + + // agent-nyckeln — för att koppla in nya agenter (kravet: nås från + // hub-config ELLER efter inloggning) + mux.HandleFunc("GET /api/agentkey", h.requireAuth(func(w http.ResponseWriter, r *http.Request) { + jsonOut(w, 200, map[string]string{"agent_key": h.cfg.AgentKey}) + })) + + // per metod — en metodlös route krockar med webbens "GET /" + for _, m := range []string{"GET", "POST", "PUT", "DELETE"} { + mux.HandleFunc(m+" /api/agents/{agent}/{rest...}", h.requireAuth(h.proxy)) + } + + mux.HandleFunc("GET /api/events", h.requireAuth(func(w http.ResponseWriter, r *http.Request) { + fl, ok := w.(http.Flusher) + if !ok { + jsonErr(w, 500, "streaming stöds inte") + return + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + ch := h.subscribe() + defer h.unsubscribe(ch) + fmt.Fprintf(w, "event: hello\ndata: {\"version\":%q,\"mode\":\"hub\"}\n\n", version) + fl.Flush() + ping := time.NewTicker(25 * time.Second) + defer ping.Stop() + for { + select { + case <-r.Context().Done(): + return + case <-ping.C: + fmt.Fprint(w, ": ping\n\n") + fl.Flush() + case ev := <-ch: + data, _ := json.Marshal(ev) + fmt.Fprintf(w, "event: %s\ndata: %s\n\n", ev.Type, data) + fl.Flush() + } + } + })) + + return mux +} + +func hubServe(cfg *HubConfig) error { + if len(cfg.Users) == 0 { + pw := os.Getenv("HELMD_HUB_ADMIN_PASSWORD") + generated := pw == "" + if generated { + pw = newToken()[:16] + } + if err := cfg.SetPassword("admin", pw); err != nil { + return err + } + if err := cfg.Save(); err != nil { + return err + } + if generated { + log.Printf("skapade användaren admin med lösenord: %s (byt med `helmd hub setpass admin`)", pw) + } else { + log.Printf("skapade användaren admin (lösenord från HELMD_HUB_ADMIN_PASSWORD)") + } + } + h := NewHub(cfg) + mux := h.routes() + mountWeb(mux) + log.Printf("helm-hub %s lyssnar på http://%s (config %s)", version, cfg.Listen, cfg.path) + return http.ListenAndServe(cfg.Listen, mux) +} diff --git a/helmd/hub_test.go b/helmd/hub_test.go new file mode 100644 index 0000000..67e9104 --- /dev/null +++ b/helmd/hub_test.go @@ -0,0 +1,313 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" +) + +func testHubConfig(t *testing.T) *HubConfig { + t.Helper() + cfg := defaultHubConfig() + cfg.path = filepath.Join(t.TempDir(), "hub.json") + cfg.AgentKey = "test-agent-key" + if err := cfg.SetPassword("admin", "hemligt"); err != nil { + t.Fatal(err) + } + return cfg +} + +func loginToken(t *testing.T, srv *httptest.Server, user, pass string) (string, int) { + t.Helper() + body, _ := json.Marshal(map[string]string{"username": user, "password": pass}) + resp, err := http.Post(srv.URL+"/api/login", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var out struct { + Token string `json:"token"` + } + json.NewDecoder(resp.Body).Decode(&out) + return out.Token, resp.StatusCode +} + +func authGet(t *testing.T, srv *httptest.Server, token, path string) (*http.Response, []byte) { + t.Helper() + req, _ := http.NewRequest("GET", srv.URL+path, nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + data, _ := io.ReadAll(resp.Body) + return resp, data +} + +func TestPasswordHashing(t *testing.T) { + cfg := testHubConfig(t) + if !cfg.Verify("admin", "hemligt") { + t.Error("rätt lösenord underkändes") + } + if cfg.Verify("admin", "fel") { + t.Error("fel lösenord godkändes") + } + if cfg.Verify("ingen", "hemligt") { + t.Error("okänd användare godkändes") + } + // uppdatering av befintligt konto ska inte skapa dubblett + if err := cfg.SetPassword("admin", "nytt"); err != nil { + t.Fatal(err) + } + if len(cfg.Users) != 1 { + t.Errorf("förväntade 1 konto, fick %d", len(cfg.Users)) + } + if cfg.Verify("admin", "hemligt") || !cfg.Verify("admin", "nytt") { + t.Error("lösenordsbyte slog inte igenom") + } +} + +func TestHubLoginAndAuth(t *testing.T) { + h := NewHub(testHubConfig(t)) + mux := h.routes() + mountWeb(mux) // fångar mux-mönsterkonflikter med webbklienten (panikar annars) + srv := httptest.NewServer(mux) + defer srv.Close() + + if _, code := loginToken(t, srv, "admin", "fel"); code != 401 { + t.Errorf("fel lösenord: förväntade 401, fick %d", code) + } + tok, code := loginToken(t, srv, "admin", "hemligt") + if code != 200 || tok == "" { + t.Fatalf("inloggning misslyckades: %d", code) + } + + if resp, _ := authGet(t, srv, "", "/api/agents"); resp.StatusCode != 401 { + t.Errorf("utan token: förväntade 401, fick %d", resp.StatusCode) + } + resp, data := authGet(t, srv, tok, "/api/agents") + if resp.StatusCode != 200 { + t.Errorf("med token: förväntade 200, fick %d", resp.StatusCode) + } + if strings.TrimSpace(string(data)) != "[]" { + t.Errorf("förväntade tom agentlista, fick %s", data) + } + + // agent-nyckeln nås efter inloggning (kravet) + resp, data = authGet(t, srv, tok, "/api/agentkey") + if resp.StatusCode != 200 || !strings.Contains(string(data), "test-agent-key") { + t.Errorf("agentkey: %d %s", resp.StatusCode, data) + } +} + +func TestHubRegisterRequiresAgentKey(t *testing.T) { + h := NewHub(testHubConfig(t)) + srv := httptest.NewServer(h.routes()) + defer srv.Close() + + body, _ := json.Marshal(map[string]string{"name": "a1", "host": "h", "version": "t", "key": "fel-nyckel"}) + resp, err := http.Post(srv.URL+"/hub/register", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != 401 { + t.Errorf("fel agent-nyckel: förväntade 401, fick %d", resp.StatusCode) + } +} + +// TestHubUplinkRoundtrip kör riktiga uplink-koden mot en hub och en +// stub-mux och verifierar hela kedjan: registrering → jobb → svar, +// inklusive att headers (CSP) följer med och att events vidarebefordras. +func TestHubUplinkRoundtrip(t *testing.T) { + h := NewHub(testHubConfig(t)) + srv := httptest.NewServer(h.routes()) + + // stub för agentens lokala API: kräver Bearer-token som en riktig helmd + stub := http.NewServeMux() + stub.HandleFunc("GET /api/ping", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer lokal-token" { + jsonErr(w, 401, "saknad token") + return + } + w.Header().Set("Content-Security-Policy", "sandbox") + jsonOut(w, 200, map[string]string{"pong": r.URL.Query().Get("x")}) + }) + stub.HandleFunc("POST /api/echo", func(w http.ResponseWriter, r *http.Request) { + data, _ := io.ReadAll(r.Body) + w.Header().Set("Content-Type", r.Header.Get("Content-Type")) + w.WriteHeader(201) + w.Write(data) + }) + + u := &uplink{ + url: srv.URL, name: "a1", host: "testhost", agentKey: "test-agent-key", + token: "lokal-token", handler: stub, + client: &http.Client{Timeout: 40 * time.Second}, + stop: make(chan struct{}), + } + go u.run() + // stäng uplinken FÖRE servern — annars ligger zombie-pollers kvar + // och kan registrera sig på en senare testservers återanvända port + t.Cleanup(func() { + u.shutdown() + srv.CloseClientConnections() // avbryt hängande long-polls direkt + srv.Close() + }) + + tok, _ := loginToken(t, srv, "admin", "hemligt") + + // vänta tills agenten är online + deadline := time.Now().Add(5 * time.Second) + for { + _, data := authGet(t, srv, tok, "/api/agents") + var agents []hubAgent + json.Unmarshal(data, &agents) + if len(agents) == 1 && agents[0].Online { + break + } + if time.Now().After(deadline) { + t.Fatalf("agenten kom aldrig online: %s", data) + } + time.Sleep(50 * time.Millisecond) + } + + // GET med query proxas och headers följer med + resp, data := authGet(t, srv, tok, "/api/agents/a1/ping?x=42") + if resp.StatusCode != 200 || !strings.Contains(string(data), `"pong":"42"`) { + t.Errorf("proxy GET: %d %s", resp.StatusCode, data) + } + if resp.Header.Get("Content-Security-Policy") != "sandbox" { + t.Errorf("CSP-headern följde inte med genom hubben: %q", resp.Header.Get("Content-Security-Policy")) + } + + // POST med body proxas med status + content-type + req, _ := http.NewRequest("POST", srv.URL+"/api/agents/a1/echo", strings.NewReader("hej hubben")) + req.Header.Set("Authorization", "Bearer "+tok) + req.Header.Set("Content-Type", "text/plain") + presp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + pdata, _ := io.ReadAll(presp.Body) + presp.Body.Close() + if presp.StatusCode != 201 || string(pdata) != "hej hubben" { + t.Errorf("proxy POST: %d %q", presp.StatusCode, pdata) + } + if ct := presp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") { + t.Errorf("content-type tappades: %q", ct) + } + + // events-proxy ska vara blockerad per agent + resp, _ = authGet(t, srv, tok, "/api/agents/a1/events") + if resp.StatusCode != 404 { + t.Errorf("events-proxy: förväntade 404, fick %d", resp.StatusCode) + } + + // okänd agent + resp, _ = authGet(t, srv, tok, "/api/agents/finns-inte/ping") + if resp.StatusCode != 404 { + t.Errorf("okänd agent: förväntade 404, fick %d", resp.StatusCode) + } + + // events från agenten dyker upp i hubbens ström med agent-fältet satt + sub := h.subscribe() + defer h.unsubscribe(sub) + evBody, _ := json.Marshal(map[string]interface{}{"events": []Event{{Type: "screen", Session: "s1", Data: 7}}}) + eresp, err := http.Post(srv.URL+"/hub/events?name=a1&key="+u.key(), "application/json", bytes.NewReader(evBody)) + if err != nil { + t.Fatal(err) + } + eresp.Body.Close() + select { + case ev := <-sub: + if ev.Agent != "a1" || ev.Type != "screen" || ev.Session != "s1" { + t.Errorf("oväntat event: %+v", ev) + } + case <-time.After(2 * time.Second): + t.Error("inget event nådde hubbens ström") + } +} + +// TestHubAgentIsolation: /hub-ytan får inte läcka något om andra +// agenter, och fel pollnyckel ska avvisas. +func TestHubAgentIsolation(t *testing.T) { + h := NewHub(testHubConfig(t)) + srv := httptest.NewServer(h.routes()) + defer srv.Close() + + register := func(name string) string { + body, _ := json.Marshal(map[string]string{"name": name, "host": "h", "version": "t", "key": "test-agent-key"}) + resp, err := http.Post(srv.URL+"/hub/register", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var out struct { + Key string `json:"key"` + } + json.NewDecoder(resp.Body).Decode(&out) + return out.Key + } + k1 := register("a1") + register("a2") + + // a1:s nyckel funkar inte för a2:s kanal + resp, err := http.Get(srv.URL + "/hub/work?name=a2&key=" + k1) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != 401 { + t.Errorf("korsad pollnyckel: förväntade 401, fick %d", resp.StatusCode) + } + + // registreringssvaret innehåller bara den egna nyckeln — inget om andra + body, _ := json.Marshal(map[string]string{"name": "a3", "host": "h", "version": "t", "key": "test-agent-key"}) + rresp, err := http.Post(srv.URL+"/hub/register", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + rdata, _ := io.ReadAll(rresp.Body) + rresp.Body.Close() + // exakt ett fält (key) — inga agentlistor eller annan info; en + // substrängkoll mot agentnamn funkar inte (slumpad hex innehåller + // gärna "a1") + var full map[string]interface{} + json.Unmarshal(rdata, &full) + if len(full) != 1 || full["key"] == nil { + t.Errorf("registreringssvaret ska bara innehålla key: %s", rdata) + } +} + +func TestSessionStoreExpiry(t *testing.T) { + s := newSessionStore(1 * time.Millisecond) + tok := s.Create("admin") + if _, ok := s.Lookup(tok); !ok { + t.Error("färsk token underkändes") + } + time.Sleep(5 * time.Millisecond) + if _, ok := s.Lookup(tok); ok { + t.Error("utgången token godkändes") + } +} + +func TestProxyPathBuilding(t *testing.T) { + // säkerställ att agentPath inte öppnar för path-traversal via + // agentnamn med specialtecken + p := agentPath("kon/stig", "sessions") + if strings.Contains(p, "kon/stig") { + t.Errorf("agentnamn ska path-escapas: %s", p) + } + if want := fmt.Sprintf("/api/agents/%s/sessions", "kon%2Fstig"); p != want { + t.Errorf("fick %s, ville ha %s", p, want) + } +} diff --git a/helmd/hubauth.go b/helmd/hubauth.go new file mode 100644 index 0000000..a02e547 --- /dev/null +++ b/helmd/hubauth.go @@ -0,0 +1,213 @@ +package main + +import ( + "crypto/pbkdf2" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "time" +) + +// pbkdf2Iters: avvägning mellan OWASP-rekommendation och att hubben ska +// köra på en Pi5 — inloggning sker sällan så kostnaden syns inte. +const pbkdf2Iters = 210_000 + +// HubUser är ett lokalt konto på hubben (auth: "local"). +type HubUser struct { + Username string `json:"username"` + Salt string `json:"salt"` + Hash string `json:"hash"` // pbkdf2-sha256 +} + +type HubConfig struct { + Listen string `json:"listen"` + Auth string `json:"auth"` // "local" — byts mot "ldap" e.d. längre fram + Users []HubUser `json:"users"` + AgentKey string `json:"agent_key"` // delad nyckel som agenter registrerar sig med + SessionTTLHours int `json:"session_ttl_hours"` + + path string +} + +func defaultHubConfig() *HubConfig { + return &HubConfig{ + Listen: "127.0.0.1:8790", + Auth: "local", + SessionTTLHours: 24 * 7, + } +} + +func hubConfigPath() string { + if p := os.Getenv("HELMD_HUB_CONFIG"); p != "" { + return p + } + dir, err := os.UserConfigDir() + if err != nil { + dir = "." + } + return filepath.Join(dir, "helmd", "hub.json") +} + +func loadHubConfig(path string) (*HubConfig, error) { + cfg := defaultHubConfig() + cfg.path = path + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + cfg.AgentKey = newToken() + if err := cfg.Save(); err != nil { + return nil, err + } + fmt.Fprintf(os.Stderr, "helmd: skapade %s (ny agent-nyckel genererad)\n", path) + return cfg, nil + } + if err != nil { + return nil, err + } + if err := json.Unmarshal(data, cfg); err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + if cfg.SessionTTLHours <= 0 { + cfg.SessionTTLHours = 24 * 7 + } + if cfg.AgentKey == "" { + cfg.AgentKey = newToken() + if err := cfg.Save(); err != nil { + return nil, err + } + } + return cfg, nil +} + +func (c *HubConfig) Save() error { + if err := os.MkdirAll(filepath.Dir(c.path), 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(c, "", " ") + if err != nil { + return err + } + return os.WriteFile(c.path, append(data, '\n'), 0o600) +} + +func hashPassword(pass string, salt []byte) (string, error) { + key, err := pbkdf2.Key(sha256.New, pass, salt, pbkdf2Iters, 32) + if err != nil { + return "", err + } + return hex.EncodeToString(key), nil +} + +// SetPassword skapar eller uppdaterar ett lokalt konto. +func (c *HubConfig) SetPassword(user, pass string) error { + if user == "" || pass == "" { + return fmt.Errorf("användarnamn och lösenord får inte vara tomma") + } + salt := make([]byte, 16) + if _, err := rand.Read(salt); err != nil { + return err + } + hash, err := hashPassword(pass, salt) + if err != nil { + return err + } + for i := range c.Users { + if c.Users[i].Username == user { + c.Users[i].Salt = hex.EncodeToString(salt) + c.Users[i].Hash = hash + return nil + } + } + c.Users = append(c.Users, HubUser{Username: user, Salt: hex.EncodeToString(salt), Hash: hash}) + return nil +} + +// Verify kontrollerar användarnamn + lösenord. Kör alltid en +// hash-beräkning så att svarstiden inte avslöjar om kontot finns. +func (c *HubConfig) Verify(user, pass string) bool { + var found *HubUser + for i := range c.Users { + if c.Users[i].Username == user { + found = &c.Users[i] + } + } + salt := make([]byte, 16) + want := "ingen-träff" + if found != nil { + if s, err := hex.DecodeString(found.Salt); err == nil { + salt = s + } + want = found.Hash + } + got, err := hashPassword(pass, salt) + if err != nil { + return false + } + return found != nil && subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1 +} + +// Authenticator kontrollerar styrklienters inloggning. Lokala konton är +// den första implementationen; LDAP eller annan backend pluggas in som +// en ny implementation och väljs med "auth" i hub-konfigen. +type Authenticator interface { + Authenticate(username, password string) bool +} + +type localAuth struct{ cfg *HubConfig } + +func (l *localAuth) Authenticate(user, pass string) bool { return l.cfg.Verify(user, pass) } + +// sessionStore håller inloggade klienters tokens i minnet — en omstart +// av hubben loggar ut alla, vilket är acceptabelt. +type sessionStore struct { + ttl time.Duration + mu sync.Mutex + toks map[string]clientSession +} + +type clientSession struct { + User string + Expires time.Time +} + +func newSessionStore(ttl time.Duration) *sessionStore { + return &sessionStore{ttl: ttl, toks: map[string]clientSession{}} +} + +func (s *sessionStore) Create(user string) string { + tok := newToken() + s.mu.Lock() + defer s.mu.Unlock() + for t, c := range s.toks { + if time.Now().After(c.Expires) { + delete(s.toks, t) + } + } + s.toks[tok] = clientSession{User: user, Expires: time.Now().Add(s.ttl)} + return tok +} + +func (s *sessionStore) Lookup(tok string) (string, bool) { + if tok == "" { + return "", false + } + s.mu.Lock() + defer s.mu.Unlock() + c, ok := s.toks[tok] + if !ok || time.Now().After(c.Expires) { + delete(s.toks, tok) + return "", false + } + return c.User, true +} + +func (s *sessionStore) Delete(tok string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.toks, tok) +} diff --git a/helmd/main.go b/helmd/main.go index de98bfb..8e0405a 100644 --- a/helmd/main.go +++ b/helmd/main.go @@ -1,6 +1,7 @@ package main import ( + "bufio" "bytes" "encoding/json" "flag" @@ -11,6 +12,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "time" ) @@ -66,6 +68,16 @@ func main() { } fmt.Printf("delad: %s (id %s)\n%s/api/shares/%s/raw\n", filepath.Base(fs.Arg(0)), id, *server, id) + case "hub": + if err := runHub(os.Args[2:]); err != nil { + fatal(err) + } + + case "ctl": + if err := runCtl(os.Args[2:]); err != nil { + fatal(err) + } + case "version", "--version", "-v": fmt.Println("helmd", version) @@ -75,13 +87,95 @@ func main() { } } +// runHub hanterar hub-subkommandona: starta hubben eller administrera +// dess konton/agent-nyckel. +func runHub(args []string) error { + if len(args) > 0 && !strings.HasPrefix(args[0], "-") { + switch args[0] { + case "setpass": + fs := flag.NewFlagSet("hub setpass", flag.ExitOnError) + confPath := fs.String("config", hubConfigPath(), "sökväg till hub.json") + password := fs.String("password", "", "lösenord (utelämna för att läsa från stdin)") + fs.Parse(args[1:]) + if fs.NArg() != 1 { + return fmt.Errorf("användning: helmd hub setpass [--password PW]") + } + cfg, err := loadHubConfig(*confPath) + if err != nil { + return err + } + pw := *password + if pw == "" { + fmt.Fprintf(os.Stderr, "nytt lösenord för %s (syns när du skriver): ", fs.Arg(0)) + sc := bufio.NewScanner(os.Stdin) + if sc.Scan() { + pw = strings.TrimSpace(sc.Text()) + } + } + if err := cfg.SetPassword(fs.Arg(0), pw); err != nil { + return err + } + if err := cfg.Save(); err != nil { + return err + } + fmt.Printf("lösenord satt för %s — starta om hubben så att det börjar gälla\n", fs.Arg(0)) + return nil + + case "users": + fs := flag.NewFlagSet("hub users", flag.ExitOnError) + confPath := fs.String("config", hubConfigPath(), "sökväg till hub.json") + fs.Parse(args[1:]) + cfg, err := loadHubConfig(*confPath) + if err != nil { + return err + } + for _, u := range cfg.Users { + fmt.Println(u.Username) + } + return nil + + case "agentkey": + fs := flag.NewFlagSet("hub agentkey", flag.ExitOnError) + confPath := fs.String("config", hubConfigPath(), "sökväg till hub.json") + fs.Parse(args[1:]) + cfg, err := loadHubConfig(*confPath) + if err != nil { + return err + } + fmt.Println(cfg.AgentKey) + return nil + + default: + return fmt.Errorf("okänt hub-kommando %q (setpass, users, agentkey — eller flaggor för att starta)", args[0]) + } + } + + fs := flag.NewFlagSet("hub", flag.ExitOnError) + listen := fs.String("listen", "", "override listen-adress (t.ex. 0.0.0.0:8790)") + confPath := fs.String("config", hubConfigPath(), "sökväg till hub.json") + fs.Parse(args) + cfg, err := loadHubConfig(*confPath) + if err != nil { + return err + } + if *listen != "" { + cfg.Listen = *listen + } + return hubServe(cfg) +} + func usage() { - fmt.Fprintln(os.Stderr, `helmd — agent-helm-server: styr en CLI-agent i tmux via REST-API + fmt.Fprintln(os.Stderr, `helmd — agent-helm: styr CLI-agenter i tmux via REST-API + hub användning: - helmd serve [--listen host:port] [--config fil] starta servern + helmd serve [--listen host:port] [--config fil] starta agent-servern helmd share [--note text] [--session namn] dela en fil till klienterna helmd token skriv ut API-token + helmd hub [--listen host:port] [--config fil] starta hubben (Pi5) + helmd hub setpass [--password PW] sätt/skapa hub-konto + helmd hub users lista hub-konton + helmd hub agentkey skriv ut agent-nyckeln + helmd ctl CLI-styrklient mot hubben helmd version`) } diff --git a/helmd/scripts/run-hub-smoke.sh b/helmd/scripts/run-hub-smoke.sh new file mode 100755 index 0000000..baff4cd --- /dev/null +++ b/helmd/scripts/run-hub-smoke.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# End-to-end-smoke för hub-flödet: hub + agent (bash i tmux) + ctl. +# Kör allt lokalt och isolerat (egen XDG_CONFIG_HOME i en temp-katalog) +# — rör inte dina riktiga configs, men kräver tmux på maskinen. +# +# bash scripts/run-hub-smoke.sh +set -u +cd "$(dirname "$0")/.." +BIN="$PWD/build/helmd" +[ -x "$BIN" ] || { echo "bygg först: CGO_ENABLED=0 go build -o build/helmd ."; exit 2; } + +SCRATCH=$(mktemp -d /tmp/helm-hub-smoke.XXXXXX) +export XDG_CONFIG_HOME="$SCRATCH/cfg" +mkdir -p "$XDG_CONFIG_HOME/helmd" + +PASS=0; FAIL=0 +ok() { PASS=$((PASS+1)); echo "ok $1"; } +fail() { FAIL=$((FAIL+1)); echo "FAIL $1"; } + +cleanup() { + kill "${HUB_PID:-}" "${AGENT_PID:-}" 2>/dev/null + tmux kill-session -t helm-smoketest 2>/dev/null + rm -rf "$SCRATCH" +} +trap cleanup EXIT + +# --- 1. starta hubben --- +HELMD_HUB_ADMIN_PASSWORD=testpass "$BIN" hub --listen 127.0.0.1:18790 \ + >"$SCRATCH/hub.log" 2>&1 & +HUB_PID=$! +sleep 1 +curl -sf http://127.0.0.1:18790/api/mode | grep -q '"hub"' && ok "hubben svarar med mode=hub" || fail "hubben svarar inte" + +AGENTKEY=$("$BIN" hub agentkey) +[ -n "$AGENTKEY" ] && ok "agentkey läsbar ur hub-config" || fail "ingen agentkey" + +# --- 2. agent-config med hub-sektion (configfilen väljer server) --- +cat > "$XDG_CONFIG_HOME/helmd/config.json" <"$SCRATCH/agent.log" 2>&1 & +AGENT_PID=$! +sleep 2 + +# --- 3. ctl: login (fel + rätt lösenord) --- +"$BIN" ctl login --server http://127.0.0.1:18790 --user admin --password fellösen >/dev/null 2>&1 \ + && fail "login med fel lösenord gick igenom" || ok "login med fel lösenord avvisas" +"$BIN" ctl login --server http://127.0.0.1:18790 --user admin --password testpass >/dev/null \ + && ok "ctl login" || fail "ctl login" + +# --- 4. agenten syns och är online --- +"$BIN" ctl agents | grep -q "smokeagent.*online" && ok "agenten online i ctl agents" || fail "agent inte online" + +# --- 5. agentkey via inloggad klient --- +[ "$("$BIN" ctl agentkey)" = "$AGENTKEY" ] && ok "ctl agentkey matchar hub-config" || fail "ctl agentkey" + +# --- 6. session: skapa, prompta, läsa skärm --- +"$BIN" ctl new smokeagent smoketest --cmd bash >/dev/null && ok "ctl new" || fail "ctl new" +sleep 1 +"$BIN" ctl prompt smokeagent smoketest "echo HUB_FLOW_WORKS_42" >/dev/null && ok "ctl prompt" || fail "ctl prompt" +sleep 1.5 +"$BIN" ctl screen smokeagent smoketest | grep -q "HUB_FLOW_WORKS_42" && ok "ctl screen visar output" || fail "ctl screen" +"$BIN" ctl sessions smokeagent | grep -q "smoketest" && ok "ctl sessions listar" || fail "ctl sessions" +"$BIN" ctl keys smokeagent smoketest Enter >/dev/null && ok "ctl keys" || fail "ctl keys" + +# --- 7. events-strömmen --- +timeout 6 "$BIN" ctl events > "$SCRATCH/events.out" 2>/dev/null & +EV_PID=$! +sleep 1 +"$BIN" ctl prompt smokeagent smoketest "echo EVENT_TRIGGER" >/dev/null +wait $EV_PID 2>/dev/null +grep -q "smokeagent" "$SCRATCH/events.out" && ok "events strömmar med agentnamn" || fail "inga events" + +# --- 8. lokala API:t funkar parallellt (graceful degradation) --- +curl -sf -H "Authorization: Bearer smoke-local-token" http://127.0.0.1:18788/api/sessions \ + | grep -q "smoketest" && ok "lokalt API parallellt med hubben" || fail "lokalt API" + +# --- 9. städa session --- +"$BIN" ctl kill smokeagent smoketest >/dev/null && ok "ctl kill" || fail "ctl kill" + +# --- 10. fel agent-nyckel avvisas --- +CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST http://127.0.0.1:18790/hub/register \ + -H 'Content-Type: application/json' -d '{"name":"evil","host":"x","version":"x","key":"fel"}') +[ "$CODE" = "401" ] && ok "registrering med fel agent-nyckel → 401" || fail "fel nyckel gav $CODE" + +echo +echo "RESULTAT: $PASS ok, $FAIL fail" +[ "$FAIL" = 0 ] diff --git a/helmd/uplink.go b/helmd/uplink.go new file mode 100644 index 0000000..a10a12d --- /dev/null +++ b/helmd/uplink.go @@ -0,0 +1,220 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +// uplink är agentens utåtgående anslutning till en hub: registrera med +// den delade agent-nyckeln, long-polla efter jobb, kör jobben mot den +// egna lokala muxen (samma handlers och token-auth som LAN-API:t) och +// posta tillbaka svaren. Hubben nere ⇒ backoff + omregistrering; +// det lokala API:t fungerar hela tiden oberoende av hubben. +type uplink struct { + url string // hubbens bas-URL, utan avslutande / + name string // agentens namn i hubben + host string + agentKey string // delad nyckel ur config (hub.key) + token string // lokala API-token — injiceras i jobb-requests + handler http.Handler + client *http.Client + stop chan struct{} // stänger ner alla loopar (används av tester) + + mu sync.Mutex + pollKey string // per-registrering, utfärdas av hubben +} + +func startUplink(cfg *Config, handler http.Handler, mgr *Manager) { + name := cfg.Hub.Name + if name == "" { + name = hostname() + } + u := &uplink{ + url: strings.TrimRight(cfg.Hub.URL, "/"), name: name, host: hostname(), + agentKey: cfg.Hub.Key, token: cfg.Token, handler: handler, + // klient-timeout > hubbens poll-timeout (25 s) så att pollen + // hinner svara 204 i lugn och ro + client: &http.Client{Timeout: 40 * time.Second}, + stop: make(chan struct{}), + } + go u.run() + go u.forwardEvents(mgr) + log.Printf("hub-uplink aktiv mot %s som %q", u.url, u.name) +} + +func (u *uplink) key() string { + u.mu.Lock() + defer u.mu.Unlock() + return u.pollKey +} + +func (u *uplink) shutdown() { close(u.stop) } + +func (u *uplink) run() { + backoff := time.Second + for { + select { + case <-u.stop: + return + default: + } + if err := u.register(); err != nil { + log.Printf("hub: registrering mot %s misslyckades: %v (nytt försök om %s)", u.url, err, backoff) + time.Sleep(backoff) + if backoff < 30*time.Second { + backoff *= 2 + } + continue + } + backoff = time.Second + log.Printf("hub: ansluten till %s som %q", u.url, u.name) + + // tre parallella pollers så att flera jobb kan köras samtidigt; + // när någon får auth-fel (hubben omstartad) registrerar vi om + lost := make(chan struct{}) + var once sync.Once + var wg sync.WaitGroup + for i := 0; i < 3; i++ { + wg.Add(1) + go func() { + defer wg.Done() + u.pollLoop(lost, &once) + }() + } + wg.Wait() + } +} + +func (u *uplink) register() error { + body, _ := json.Marshal(map[string]string{ + "name": u.name, "host": u.host, "version": version, "key": u.agentKey, + }) + resp, err := u.client.Post(u.url+"/hub/register", "application/json", bytes.NewReader(body)) + if err != nil { + return err + } + defer resp.Body.Close() + data, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode != 200 { + return fmt.Errorf("hubben svarade %s: %s", resp.Status, bytes.TrimSpace(data)) + } + var out struct { + Key string `json:"key"` + } + if err := json.Unmarshal(data, &out); err != nil || out.Key == "" { + return fmt.Errorf("oväntat registreringssvar") + } + u.mu.Lock() + u.pollKey = out.Key + u.mu.Unlock() + return nil +} + +func (u *uplink) qs() string { + return "?name=" + url.QueryEscape(u.name) + "&key=" + url.QueryEscape(u.key()) +} + +func (u *uplink) pollLoop(lost chan struct{}, once *sync.Once) { + for { + select { + case <-lost: + return + case <-u.stop: + return + default: + } + resp, err := u.client.Get(u.url + "/hub/work" + u.qs()) + if err != nil { + // hubben nere/onåbar — låt run() börja om från registrering + once.Do(func() { close(lost) }) + return + } + switch resp.StatusCode { + case 200: + var job hubJob + err := json.NewDecoder(io.LimitReader(resp.Body, 96<<20)).Decode(&job) + resp.Body.Close() + if err != nil { + continue + } + res := u.execute(&job) + u.postResult(res) + case 204: + resp.Body.Close() // inget jobb — polla igen direkt + default: + resp.Body.Close() // 401/404: hubben har glömt oss — registrera om + once.Do(func() { close(lost) }) + return + } + } +} + +// execute kör ett jobb mot den lokala muxen med agentens egna token. +func (u *uplink) execute(j *hubJob) *hubResult { + if strings.HasPrefix(j.Path, "/api/events") { + // SSE genom jobbkanalen vore en evighetsförfrågan + return &hubResult{ID: j.ID, Status: 404, Body: []byte(`{"error":"events proxas inte"}`)} + } + req, err := http.NewRequest(j.Method, j.Path, bytes.NewReader(j.Body)) + if err != nil { + return &hubResult{ID: j.ID, Status: 400, Body: []byte(`{"error":"ogiltigt jobb"}`)} + } + req.Header.Set("Authorization", "Bearer "+u.token) + if j.ContentType != "" { + req.Header.Set("Content-Type", j.ContentType) + } + rec := &recorder{hdr: http.Header{}, code: 200} + u.handler.ServeHTTP(rec, req) + return &hubResult{ID: j.ID, Status: rec.code, Headers: rec.hdr, Body: rec.buf.Bytes()} +} + +func (u *uplink) postResult(res *hubResult) { + body, err := json.Marshal(res) + if err != nil { + return + } + resp, err := u.client.Post(u.url+"/hub/result"+u.qs(), "application/json", bytes.NewReader(body)) + if err == nil { + io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + resp.Body.Close() + } +} + +// forwardEvents speglar agentens lokala SSE-events upp till hubben. +func (u *uplink) forwardEvents(mgr *Manager) { + ch := mgr.Subscribe() + for ev := range ch { + if u.key() == "" { + continue // inte registrerad än + } + body, _ := json.Marshal(map[string]interface{}{"events": []Event{ev}}) + resp, err := u.client.Post(u.url+"/hub/events"+u.qs(), "application/json", bytes.NewReader(body)) + if err == nil { + io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + resp.Body.Close() + } + // fel ignoreras — pollers sköter återanslutningen + } +} + +// recorder är en minimal http.ResponseWriter som fångar svaret på ett +// jobb (httptest används inte i produktionskod). +type recorder struct { + hdr http.Header + code int + buf bytes.Buffer +} + +func (r *recorder) Header() http.Header { return r.hdr } +func (r *recorder) WriteHeader(code int) { r.code = code } +func (r *recorder) Write(b []byte) (int, error) { + return r.buf.Write(b) +} diff --git a/helmd/web/app.js b/helmd/web/app.js index b5ecf01..70060b3 100644 --- a/helmd/web/app.js +++ b/helmd/web/app.js @@ -1,7 +1,12 @@ import { ansiToHtml } from "./ansi.js"; // ---------- state ---------- +// Samma SPA för båda lägena: "local" (en helmd, token-login) och +// "hub" (Pi5-hubben: konto-login + agentväljare). GET /api/mode avgör. +let mode = "local"; let token = localStorage.getItem("helmd_token") || ""; +let agents = []; +let agent = localStorage.getItem("helmd_agent") || ""; let sessions = []; let current = null; // valt sessionsnamn let currentSeq = -1; @@ -12,11 +17,15 @@ let pollTimer = null; const $ = (id) => document.getElementById(id); +// base är prefixet för allt agent-scopat (sessions, shares, config) +const base = () => (mode === "hub" ? `/api/agents/${encodeURIComponent(agent)}` : "/api"); +const hasAgent = () => mode !== "hub" || !!agent; + // ---------- api ---------- async function api(path, opts = {}) { opts.headers = Object.assign({ Authorization: "Bearer " + token }, opts.headers); const r = await fetch(path, opts); - if (r.status === 401) { showLogin("Ogiltig token."); throw new Error("401"); } + if (r.status === 401) { showLogin(mode === "hub" ? "Inte inloggad." : "Ogiltig token."); throw new Error("401"); } if (!r.ok) { let msg = r.statusText; try { msg = (await r.json()).error || msg; } catch {} @@ -28,6 +37,17 @@ async function api(path, opts = {}) { const post = (p, body) => api(p, { method: "POST", body: JSON.stringify(body) }); // ---------- login ---------- +function applyModeUI() { + const hub = mode === "hub"; + $("userInput").classList.toggle("hidden", !hub); + $("tokenInput").placeholder = hub ? "lösenord" : "token"; + $("loginHint").innerHTML = hub + ? "Logga in med ditt hub-konto." + : "Klistra in API-token från servern (helmd token)."; + $("agentBar").classList.toggle("hidden", !hub); + $("hubSettings").classList.toggle("hidden", !hub); +} + function showLogin(err = "") { stopLive(); $("login").classList.remove("hidden"); @@ -37,25 +57,91 @@ function showLogin(err = "") { async function tryConnect() { try { + if (mode === "hub") await refreshAgents(); const h = await api("/api/health"); $("login").classList.add("hidden"); $("app").classList.remove("hidden"); - $("verInfo").textContent = "helmd " + h.version; + $("verInfo").textContent = (mode === "hub" ? "hub " : "helmd ") + h.version; startLive(); - await Promise.all([refreshSessions(), refreshShares(), loadConfig()]); - if (!current && sessions.length) selectSession(sessions[0].name); + if (mode === "hub") { + if (!agent || !agents.some((a) => a.name === agent)) { + const first = agents.find((a) => a.online) || agents[0]; + agent = first ? first.name : ""; + } + renderAgentSelect(); + if (agent) await switchAgent(agent, true); + } else { + await Promise.all([refreshSessions(), refreshShares(), loadConfig()]); + if (!current && sessions.length) selectSession(sessions[0].name); + } } catch (e) { if (e.message !== "401") showLogin("Kunde inte nå servern: " + e.message); } } -$("tokenSave").onclick = () => { - token = $("tokenInput").value.trim(); +$("tokenSave").onclick = async () => { + if (mode === "hub") { + try { + const r = await fetch("/api/login", { + method: "POST", + body: JSON.stringify({ username: $("userInput").value.trim(), password: $("tokenInput").value }), + }); + if (!r.ok) { $("loginError").textContent = "Fel användarnamn eller lösenord."; return; } + token = (await r.json()).token; + } catch (e) { $("loginError").textContent = "Kunde inte nå hubben: " + e.message; return; } + } else { + token = $("tokenInput").value.trim(); + } localStorage.setItem("helmd_token", token); + $("tokenInput").value = ""; tryConnect(); }; $("tokenInput").addEventListener("keydown", (e) => { if (e.key === "Enter") $("tokenSave").click(); }); -$("logoutBtn").onclick = () => { localStorage.removeItem("helmd_token"); token = ""; showLogin(); }; +$("logoutBtn").onclick = async () => { + if (mode === "hub") { try { await post("/api/logout", {}); } catch {} } + localStorage.removeItem("helmd_token"); + token = ""; + showLogin(); +}; + +// ---------- agenter (hub-läge) ---------- +async function refreshAgents() { + agents = (await api("/api/agents")) || []; + agents.sort((a, b) => a.name.localeCompare(b.name)); + renderAgentSelect(); +} + +function renderAgentSelect() { + const sel = $("agentSelect"); + sel.innerHTML = ""; + for (const a of agents) { + const o = document.createElement("option"); + o.value = a.name; + o.textContent = (a.online ? "● " : "○ ") + a.name; + if (a.name === agent) o.selected = true; + sel.appendChild(o); + } + if (!agents.length) { + const o = document.createElement("option"); + o.textContent = "(inga agenter anslutna)"; + o.disabled = true; + sel.appendChild(o); + } +} + +async function switchAgent(name, force = false) { + if (!force && name === agent) return; + agent = name; + localStorage.setItem("helmd_agent", agent); + current = null; currentSeq = -1; currentShare = null; + $("sessPane").classList.add("hidden"); + $("noSess").classList.remove("hidden"); + $("shareView").innerHTML = "Välj en delning i listan"; + await Promise.all([refreshSessions(), refreshShares(), loadConfig().catch(() => {})]); + if (!current && sessions.length) selectSession(sessions[0].name); +} + +$("agentSelect").onchange = (e) => switchAgent(e.target.value); // ---------- tabs ---------- document.querySelectorAll(".tab").forEach((b) => { @@ -68,22 +154,32 @@ document.querySelectorAll(".tab").forEach((b) => { }); // ---------- SSE + poll ---------- +// I hub-läge har alla events fältet "agent" — vi bryr oss bara om den +// valda agentens events, plus "agent"-events som uppdaterar väljaren. function startLive() { stopLive(); es = new EventSource("/api/events?token=" + encodeURIComponent(token)); es.onopen = () => $("connDot").classList.add("ok"); es.onerror = () => $("connDot").classList.remove("ok"); + const mine = (ev) => mode !== "hub" || ev.agent === agent; es.addEventListener("screen", (e) => { const ev = JSON.parse(e.data); + if (!mine(ev)) return; if (ev.session === current) refreshScreen(); refreshSessions(); }); es.addEventListener("question", (e) => { const ev = JSON.parse(e.data); - if (ev.session === current) renderQuestion(ev.data); + if (mine(ev) && ev.session === current) renderQuestion(ev.data); }); - es.addEventListener("session", () => refreshSessions()); + es.addEventListener("session", (e) => { + const ev = JSON.parse(e.data); + if (mine(ev)) refreshSessions(); + }); + es.addEventListener("agent", () => refreshAgents()); es.addEventListener("share", (e) => { + const ev = JSON.parse(e.data); + if (!mine(ev)) return; refreshShares(); const b = $("shareBadge"); if ($("tab-shares").classList.contains("hidden")) { @@ -105,7 +201,8 @@ function stopLive() { // ---------- sessioner ---------- async function refreshSessions() { - sessions = (await api("/api/sessions")) || []; + if (!hasAgent()) { $("sessItems").innerHTML = ""; return; } + sessions = (await api(base() + "/sessions")) || []; sessions.sort((a, b) => a.name.localeCompare(b.name)); const box = $("sessItems"); box.innerHTML = ""; @@ -136,16 +233,16 @@ async function selectSession(name) { } async function refreshScreen() { - if (!current) return; + if (!current || !hasAgent()) return; try { const s = sessions.find((x) => x.name === current); if (s) currentSeq = s.seq; - const txt = await api(`/api/sessions/${current}/screen?ansi=1`); + const txt = await api(`${base()}/sessions/${current}/screen?ansi=1`); const el = $("screen"); const stick = el.scrollTop + el.clientHeight >= el.scrollHeight - 30; el.innerHTML = ansiToHtml(txt); if (stick) el.scrollTop = el.scrollHeight; - const st = await api(`/api/sessions/${current}`); + const st = await api(`${base()}/sessions/${current}`); renderQuestion(st.question); } catch {} } @@ -162,7 +259,7 @@ function renderQuestion(q) { if (i === q.selected) b.classList.add("selected"); b.onclick = async () => { b.disabled = true; - try { await post(`/api/sessions/${current}/answer`, { option: i + 1 }); } + try { await post(`${base()}/sessions/${current}/answer`, { option: i + 1 }); } catch (e) { alert("Kunde inte svara: " + e.message); b.disabled = false; } }; opts.appendChild(b); @@ -173,42 +270,44 @@ $("newSessBtn").onclick = () => $("newSessForm").classList.toggle("hidden"); $("newSessCancel").onclick = () => $("newSessForm").classList.add("hidden"); $("newSessForm").onsubmit = async (e) => { e.preventDefault(); + if (!hasAgent()) { alert("Ingen agent vald."); return; } try { - await post("/api/sessions", { + await post(base() + "/sessions", { name: $("newSessName").value.trim(), cmd: $("newSessCmd").value.trim(), cwd: $("newSessCwd").value.trim(), }); + const name = $("newSessName").value.trim(); $("newSessForm").reset(); $("newSessForm").classList.add("hidden"); await refreshSessions(); - selectSession($("newSessName").value.trim() || sessions[sessions.length - 1]?.name); + selectSession(name || sessions[sessions.length - 1]?.name); } catch (err) { alert(err.message); } }; $("killBtn").onclick = async () => { if (!current || !confirm(`Döda sessionen ${current}?`)) return; - await api(`/api/sessions/${current}`, { method: "DELETE" }); + await api(`${base()}/sessions/${current}`, { method: "DELETE" }); current = null; refreshSessions(); $("sessPane").classList.add("hidden"); $("noSess").classList.remove("hidden"); }; -const sendKeys = (keys) => current && post(`/api/sessions/${current}/keys`, { keys }).catch(() => {}); +const sendKeys = (keys) => current && post(`${base()}/sessions/${current}/keys`, { keys }).catch(() => {}); $("keyEsc").onclick = () => sendKeys(["Escape"]); $("keyUp").onclick = () => sendKeys(["Up"]); $("keyDown").onclick = () => sendKeys(["Down"]); $("keyEnter").onclick = () => sendKeys(["Enter"]); $("keyCtrlC").onclick = () => sendKeys(["C-c"]); -$("modeBtn").onclick = () => current && post(`/api/sessions/${current}/mode`, { action: "cycle" }).catch(() => {}); +$("modeBtn").onclick = () => current && post(`${base()}/sessions/${current}/mode`, { action: "cycle" }).catch(() => {}); $("promptForm").onsubmit = async (e) => { e.preventDefault(); const t = $("promptText").value; if (!t.trim() || !current) return; try { - await post(`/api/sessions/${current}/prompt`, { text: t }); + await post(`${base()}/sessions/${current}/prompt`, { text: t }); $("promptText").value = ""; } catch (err) { alert("Kunde inte skicka: " + err.message); } }; @@ -221,7 +320,8 @@ $("promptText").addEventListener("keydown", (e) => { // ---------- delningar ---------- async function refreshShares() { - shares = (await api("/api/shares")) || []; + if (!hasAgent()) { $("shareItems").innerHTML = ""; return; } + shares = (await api(base() + "/shares")) || []; const box = $("shareItems"); box.innerHTML = ""; for (const sh of shares) { @@ -233,7 +333,7 @@ async function refreshShares() {
${when}${sh.session ? " · " + escapeHtml(sh.session) : ""}${sh.note ? "
" + escapeHtml(sh.note) : ""}
`; d.querySelector(".del").onclick = async (e) => { e.stopPropagation(); - await api(`/api/shares/${sh.id}`, { method: "DELETE" }); + await api(`${base()}/shares/${sh.id}`, { method: "DELETE" }); if (currentShare === sh.id) { currentShare = null; $("shareView").innerHTML = "Välj en delning i listan"; } refreshShares(); }; @@ -247,24 +347,25 @@ async function viewShare(sh) { refreshShares(); const v = $("shareView"); v.classList.remove("empty"); - const raw = `/api/shares/${sh.id}/raw?token=${encodeURIComponent(token)}`; + const raw = `${base()}/shares/${sh.id}/raw?token=${encodeURIComponent(token)}`; const ext = sh.name.toLowerCase().split(".").pop(); if (["png", "jpg", "jpeg", "gif", "svg"].includes(ext)) { v.innerHTML = `${escapeHtml(sh.name)}`; } else if (["html", "htm", "pdf"].includes(ext)) { v.innerHTML = ``; } else if (ext === "md") { - const txt = await api(`/api/shares/${sh.id}/raw`); + const txt = await api(`${base()}/shares/${sh.id}/raw`); v.innerHTML = `
${mdToHtml(txt)}
`; } else { - const txt = await api(`/api/shares/${sh.id}/raw`); + const txt = await api(`${base()}/shares/${sh.id}/raw`); v.innerHTML = `
${escapeHtml(txt)}
`; } } // ---------- inställningar ---------- async function loadConfig() { - const c = await api("/api/config"); + if (!hasAgent()) return; + const c = await api(base() + "/config"); $("cfgCmd").value = c.agent_cmd || ""; $("cfgArgs").value = (c.agent_args || []).join(" "); $("cfgWorkdir").value = c.workdir || ""; @@ -279,7 +380,7 @@ async function loadConfig() { $("cfgForm").onsubmit = async (e) => { e.preventDefault(); try { - await api("/api/config", { + await api(base() + "/config", { method: "PUT", body: JSON.stringify({ agent_cmd: $("cfgCmd").value.trim(), @@ -300,6 +401,14 @@ $("cfgForm").onsubmit = async (e) => { } catch (err) { $("cfgStatus").textContent = "Fel: " + err.message; } }; +// agent-nyckeln (hub-läge): visas på begäran för att koppla in nya agenter +$("agentKeyBtn").onclick = async () => { + try { + const r = await api("/api/agentkey"); + $("agentKeyOut").textContent = r.agent_key; + } catch (e) { $("agentKeyOut").textContent = "Fel: " + e.message; } +}; + // ---------- hjälp ---------- function escapeHtml(s) { return String(s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); @@ -332,4 +441,11 @@ function mdToHtml(md) { } // ---------- start ---------- -if (token) tryConnect(); else showLogin(); +(async () => { + try { + const m = await fetch("/api/mode"); + if (m.ok) mode = (await m.json()).mode || "local"; + } catch {} + applyModeUI(); + if (token) tryConnect(); else showLogin(); +})(); diff --git a/helmd/web/index.html b/helmd/web/index.html index b80188d..aa6654e 100644 --- a/helmd/web/index.html +++ b/helmd/web/index.html @@ -13,7 +13,8 @@