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 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 lista en agents sessioner new [--cmd K] [--cwd D] [--adopt] starta/adoptera session screen [--ansi] [--history N] visa skärmen prompt skicka prompt question visa väntande fråga answer svara på frågan keys råa tangenter (Enter, Escape, C-c …) kill döda sessionen shares 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 ") } 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 [--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 [--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 ") } _, 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 ") } 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 ") } 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 ") } _, 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 ") } _, 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 ") } 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] + "…" }