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
457 lines
12 KiB
Go
457 lines
12 KiB
Go
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] + "…"
|
|
}
|