helmd hub: central server på Pi5 + agent-uplink + helmd ctl + web-login
All checks were successful
helmd-image / build (push) Successful in 3m22s
helmd-release / build-release (push) Successful in 4m28s

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:
2026-08-06 23:19:18 +02:00
parent d0b57e8224
commit 77682e91b5
17 changed files with 2411 additions and 37 deletions

456
helmd/ctl.go Normal file
View 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] + "…"
}