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:
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] + "…"
|
||||
}
|
||||
Reference in New Issue
Block a user