helmd hub: central server på Pi5 + agent-uplink + helmd ctl + web-login
Hub-läge i samma binär (doc/hub-plan.md): agenter ansluter utåt med delad agent-nyckel (hub-sektion i config.json), web-/CLI-klienter loggar in (lokala konton, pbkdf2; Authenticator-interface för LDAP senare) och styr alla agenters sessioner via proxy + merged SSE. Webbklienten autodetekterar hub/local via /api/mode. Nya kommandon: helmd hub [setpass|users|agentkey], helmd ctl (login/agents/sessions/ prompt/screen/answer/events …). Enhetstester + scripts/run-hub-smoke.sh (15-stegs e2e: hub + agent + ctl). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011KikHkfCiC3yELbsMN8fT9
This commit is contained in:
@@ -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å
|
||||
|
||||
252
doc/hub-plan.md
Normal file
252
doc/hub-plan.md
Normal file
@@ -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 <cmd>` | 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:<agent_key>}` → `{key:<pollnyckel>}`. 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": "<agent_key från hubben>",
|
||||
"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": "<genereras vid första start>",
|
||||
"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 <agent>
|
||||
helmd ctl new <agent> <namn> [--cmd K] [--cwd D]
|
||||
helmd ctl screen <agent> <session> [--ansi] [--history N]
|
||||
helmd ctl prompt <agent> <session> <text …>
|
||||
helmd ctl question|answer|keys|kill <agent> <session> […]
|
||||
helmd ctl shares <agent>
|
||||
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).
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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/` |
|
||||
|
||||
|
||||
@@ -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] <fil> dela en fil till klienterna
|
||||
helmd token skriv ut API-token
|
||||
helmd hub [--listen host:port] [--config fil] starta hubben (Pi5)
|
||||
helmd hub setpass <användare> [--password PW] sätt/skapa hub-konto
|
||||
helmd hub users lista hub-konton
|
||||
helmd hub agentkey skriv ut agent-nyckeln
|
||||
helmd ctl <kommando> 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": "<agent_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 <agent>`, `ctl new <agent> <namn>`,
|
||||
`ctl screen|prompt|question|answer|keys|kill <agent> <session> …`,
|
||||
`ctl shares <agent>`, `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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
456
helmd/ctl.go
Normal file
456
helmd/ctl.go
Normal file
@@ -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 <kommando>
|
||||
|
||||
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 <agent> lista en agents sessioner
|
||||
new <agent> <namn> [--cmd K] [--cwd D] [--adopt] starta/adoptera session
|
||||
screen <agent> <session> [--ansi] [--history N] visa skärmen
|
||||
prompt <agent> <session> <text …> skicka prompt
|
||||
question <agent> <session> visa väntande fråga
|
||||
answer <agent> <session> <nr> svara på frågan
|
||||
keys <agent> <session> <tangent …> råa tangenter (Enter, Escape, C-c …)
|
||||
kill <agent> <session> döda sessionen
|
||||
shares <agent> 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 <agent>")
|
||||
}
|
||||
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 <agent> <namn> [--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 <agent> <session> [--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 <agent> <session> <text …>")
|
||||
}
|
||||
_, 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 <agent> <session>")
|
||||
}
|
||||
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 <agent> <session> <nr>")
|
||||
}
|
||||
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 <agent> <session> <tangent …>")
|
||||
}
|
||||
_, 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 <agent> <session>")
|
||||
}
|
||||
_, 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 <agent>")
|
||||
}
|
||||
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] + "…"
|
||||
}
|
||||
490
helmd/hub.go
Normal file
490
helmd/hub.go
Normal file
@@ -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 + <img>-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)
|
||||
}
|
||||
313
helmd/hub_test.go
Normal file
313
helmd/hub_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
213
helmd/hubauth.go
Normal file
213
helmd/hubauth.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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 <användare> [--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] <fil> dela en fil till klienterna
|
||||
helmd token skriv ut API-token
|
||||
helmd hub [--listen host:port] [--config fil] starta hubben (Pi5)
|
||||
helmd hub setpass <användare> [--password PW] sätt/skapa hub-konto
|
||||
helmd hub users lista hub-konton
|
||||
helmd hub agentkey skriv ut agent-nyckeln
|
||||
helmd ctl <kommando> CLI-styrklient mot hubben
|
||||
helmd version`)
|
||||
}
|
||||
|
||||
|
||||
100
helmd/scripts/run-hub-smoke.sh
Executable file
100
helmd/scripts/run-hub-smoke.sh
Executable file
@@ -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" <<EOF
|
||||
{
|
||||
"listen": "127.0.0.1:18788",
|
||||
"token": "smoke-local-token",
|
||||
"agent_cmd": "bash",
|
||||
"agent_args": [],
|
||||
"workdir": "$SCRATCH",
|
||||
"share_dir": "$SCRATCH/shares",
|
||||
"share_exts": [".txt", ".md"],
|
||||
"poll_ms": 300,
|
||||
"cols": 120, "rows": 30,
|
||||
"ntfy": {"server": "", "topic": "", "allowed_topics": [], "enabled": false, "notify_on_question": false},
|
||||
"hub": {"url": "http://127.0.0.1:18790", "name": "smokeagent", "key": "$AGENTKEY", "enabled": true}
|
||||
}
|
||||
EOF
|
||||
"$BIN" serve >"$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 ]
|
||||
220
helmd/uplink.go
Normal file
220
helmd/uplink.go
Normal file
@@ -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)
|
||||
}
|
||||
172
helmd/web/app.js
172
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 (<code>helmd token</code>).";
|
||||
$("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() {
|
||||
<div class="meta">${when}${sh.session ? " · " + escapeHtml(sh.session) : ""}${sh.note ? "<br>" + escapeHtml(sh.note) : ""}</div>`;
|
||||
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 = `<img src="${raw}" alt="${escapeHtml(sh.name)}">`;
|
||||
} else if (["html", "htm", "pdf"].includes(ext)) {
|
||||
v.innerHTML = `<iframe src="${raw}" sandbox="allow-scripts"></iframe>`;
|
||||
} else if (ext === "md") {
|
||||
const txt = await api(`/api/shares/${sh.id}/raw`);
|
||||
const txt = await api(`${base()}/shares/${sh.id}/raw`);
|
||||
v.innerHTML = `<div class="md">${mdToHtml(txt)}</div>`;
|
||||
} else {
|
||||
const txt = await api(`/api/shares/${sh.id}/raw`);
|
||||
const txt = await api(`${base()}/shares/${sh.id}/raw`);
|
||||
v.innerHTML = `<pre>${escapeHtml(txt)}</pre>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 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, ">").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();
|
||||
})();
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
<div id="login" class="login hidden">
|
||||
<div class="login-card">
|
||||
<h1>⛵ agent-helm</h1>
|
||||
<p>Klistra in API-token från servern (<code>helmd token</code>).</p>
|
||||
<p id="loginHint">Klistra in API-token från servern (<code>helmd token</code>).</p>
|
||||
<input id="userInput" placeholder="användarnamn" autocomplete="username" class="hidden">
|
||||
<input id="tokenInput" type="password" placeholder="token" autocomplete="off">
|
||||
<button id="tokenSave" class="primary">Anslut</button>
|
||||
<p id="loginError" class="error"></p>
|
||||
@@ -33,6 +34,10 @@
|
||||
|
||||
<main id="tab-sessions" class="tab-page">
|
||||
<aside class="sess-list">
|
||||
<div id="agentBar" class="agent-bar hidden">
|
||||
<label for="agentSelect">Agent</label>
|
||||
<select id="agentSelect"></select>
|
||||
</div>
|
||||
<div class="sess-head">
|
||||
<span>Sessioner</span>
|
||||
<button id="newSessBtn" title="Ny session">+</button>
|
||||
@@ -96,9 +101,17 @@
|
||||
<button type="submit" class="primary">Spara</button>
|
||||
<span id="cfgStatus"></span>
|
||||
</div>
|
||||
<div id="hubSettings" class="hidden">
|
||||
<h2>Hub</h2>
|
||||
<p class="muted">Agent-nyckeln behövs i nya agenters <code>config.json</code> (<code>hub.key</code>).</p>
|
||||
<div class="row">
|
||||
<button type="button" id="agentKeyBtn">Visa agent-nyckel</button>
|
||||
<code id="agentKeyOut"></code>
|
||||
</div>
|
||||
</div>
|
||||
<h2>Klient</h2>
|
||||
<div class="row">
|
||||
<button type="button" id="logoutBtn" class="danger">Glöm token (logga ut)</button>
|
||||
<button type="button" id="logoutBtn" class="danger">Logga ut</button>
|
||||
<span id="verInfo" class="muted"></span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -77,6 +77,14 @@ nav { display: flex; gap: 4px; flex: 1; }
|
||||
width: 240px; border-right: 1px solid var(--border); background: var(--bg2);
|
||||
display: flex; flex-direction: column; padding: 10px; gap: 8px; overflow-y: auto;
|
||||
}
|
||||
.agent-bar { display: flex; flex-direction: column; gap: 4px; }
|
||||
.agent-bar label { color: var(--muted); font-size: 0.85rem; }
|
||||
.agent-bar select {
|
||||
background: var(--bg3); color: var(--fg); border: 1px solid var(--border);
|
||||
border-radius: 7px; padding: 7px 8px; font: inherit; width: 100%;
|
||||
}
|
||||
.agent-bar select:focus { outline: none; border-color: var(--accent); }
|
||||
#agentKeyOut { word-break: break-all; user-select: all; }
|
||||
.sess-head { display: flex; justify-content: space-between; align-items: center; color: var(--muted); }
|
||||
.sess-item {
|
||||
padding: 10px 12px; border-radius: 7px; cursor: pointer; border: 1px solid transparent;
|
||||
|
||||
Reference in New Issue
Block a user