Files
agent-helm/helmd/hub.go
Bjorn Blomberg 77682e91b5
All checks were successful
helmd-image / build (push) Successful in 3m22s
helmd-release / build-release (push) Successful in 4m28s
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
2026-08-06 23:19:18 +02:00

491 lines
13 KiB
Go

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)
}