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