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:
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user