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
214 lines
5.1 KiB
Go
214 lines
5.1 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/pbkdf2"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// pbkdf2Iters: avvägning mellan OWASP-rekommendation och att hubben ska
|
|
// köra på en Pi5 — inloggning sker sällan så kostnaden syns inte.
|
|
const pbkdf2Iters = 210_000
|
|
|
|
// HubUser är ett lokalt konto på hubben (auth: "local").
|
|
type HubUser struct {
|
|
Username string `json:"username"`
|
|
Salt string `json:"salt"`
|
|
Hash string `json:"hash"` // pbkdf2-sha256
|
|
}
|
|
|
|
type HubConfig struct {
|
|
Listen string `json:"listen"`
|
|
Auth string `json:"auth"` // "local" — byts mot "ldap" e.d. längre fram
|
|
Users []HubUser `json:"users"`
|
|
AgentKey string `json:"agent_key"` // delad nyckel som agenter registrerar sig med
|
|
SessionTTLHours int `json:"session_ttl_hours"`
|
|
|
|
path string
|
|
}
|
|
|
|
func defaultHubConfig() *HubConfig {
|
|
return &HubConfig{
|
|
Listen: "127.0.0.1:8790",
|
|
Auth: "local",
|
|
SessionTTLHours: 24 * 7,
|
|
}
|
|
}
|
|
|
|
func hubConfigPath() string {
|
|
if p := os.Getenv("HELMD_HUB_CONFIG"); p != "" {
|
|
return p
|
|
}
|
|
dir, err := os.UserConfigDir()
|
|
if err != nil {
|
|
dir = "."
|
|
}
|
|
return filepath.Join(dir, "helmd", "hub.json")
|
|
}
|
|
|
|
func loadHubConfig(path string) (*HubConfig, error) {
|
|
cfg := defaultHubConfig()
|
|
cfg.path = path
|
|
data, err := os.ReadFile(path)
|
|
if os.IsNotExist(err) {
|
|
cfg.AgentKey = newToken()
|
|
if err := cfg.Save(); err != nil {
|
|
return nil, err
|
|
}
|
|
fmt.Fprintf(os.Stderr, "helmd: skapade %s (ny agent-nyckel genererad)\n", path)
|
|
return cfg, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := json.Unmarshal(data, cfg); err != nil {
|
|
return nil, fmt.Errorf("%s: %w", path, err)
|
|
}
|
|
if cfg.SessionTTLHours <= 0 {
|
|
cfg.SessionTTLHours = 24 * 7
|
|
}
|
|
if cfg.AgentKey == "" {
|
|
cfg.AgentKey = newToken()
|
|
if err := cfg.Save(); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
func (c *HubConfig) Save() error {
|
|
if err := os.MkdirAll(filepath.Dir(c.path), 0o755); err != nil {
|
|
return err
|
|
}
|
|
data, err := json.MarshalIndent(c, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(c.path, append(data, '\n'), 0o600)
|
|
}
|
|
|
|
func hashPassword(pass string, salt []byte) (string, error) {
|
|
key, err := pbkdf2.Key(sha256.New, pass, salt, pbkdf2Iters, 32)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(key), nil
|
|
}
|
|
|
|
// SetPassword skapar eller uppdaterar ett lokalt konto.
|
|
func (c *HubConfig) SetPassword(user, pass string) error {
|
|
if user == "" || pass == "" {
|
|
return fmt.Errorf("användarnamn och lösenord får inte vara tomma")
|
|
}
|
|
salt := make([]byte, 16)
|
|
if _, err := rand.Read(salt); err != nil {
|
|
return err
|
|
}
|
|
hash, err := hashPassword(pass, salt)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for i := range c.Users {
|
|
if c.Users[i].Username == user {
|
|
c.Users[i].Salt = hex.EncodeToString(salt)
|
|
c.Users[i].Hash = hash
|
|
return nil
|
|
}
|
|
}
|
|
c.Users = append(c.Users, HubUser{Username: user, Salt: hex.EncodeToString(salt), Hash: hash})
|
|
return nil
|
|
}
|
|
|
|
// Verify kontrollerar användarnamn + lösenord. Kör alltid en
|
|
// hash-beräkning så att svarstiden inte avslöjar om kontot finns.
|
|
func (c *HubConfig) Verify(user, pass string) bool {
|
|
var found *HubUser
|
|
for i := range c.Users {
|
|
if c.Users[i].Username == user {
|
|
found = &c.Users[i]
|
|
}
|
|
}
|
|
salt := make([]byte, 16)
|
|
want := "ingen-träff"
|
|
if found != nil {
|
|
if s, err := hex.DecodeString(found.Salt); err == nil {
|
|
salt = s
|
|
}
|
|
want = found.Hash
|
|
}
|
|
got, err := hashPassword(pass, salt)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return found != nil && subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1
|
|
}
|
|
|
|
// Authenticator kontrollerar styrklienters inloggning. Lokala konton är
|
|
// den första implementationen; LDAP eller annan backend pluggas in som
|
|
// en ny implementation och väljs med "auth" i hub-konfigen.
|
|
type Authenticator interface {
|
|
Authenticate(username, password string) bool
|
|
}
|
|
|
|
type localAuth struct{ cfg *HubConfig }
|
|
|
|
func (l *localAuth) Authenticate(user, pass string) bool { return l.cfg.Verify(user, pass) }
|
|
|
|
// sessionStore håller inloggade klienters tokens i minnet — en omstart
|
|
// av hubben loggar ut alla, vilket är acceptabelt.
|
|
type sessionStore struct {
|
|
ttl time.Duration
|
|
mu sync.Mutex
|
|
toks map[string]clientSession
|
|
}
|
|
|
|
type clientSession struct {
|
|
User string
|
|
Expires time.Time
|
|
}
|
|
|
|
func newSessionStore(ttl time.Duration) *sessionStore {
|
|
return &sessionStore{ttl: ttl, toks: map[string]clientSession{}}
|
|
}
|
|
|
|
func (s *sessionStore) Create(user string) string {
|
|
tok := newToken()
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for t, c := range s.toks {
|
|
if time.Now().After(c.Expires) {
|
|
delete(s.toks, t)
|
|
}
|
|
}
|
|
s.toks[tok] = clientSession{User: user, Expires: time.Now().Add(s.ttl)}
|
|
return tok
|
|
}
|
|
|
|
func (s *sessionStore) Lookup(tok string) (string, bool) {
|
|
if tok == "" {
|
|
return "", false
|
|
}
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
c, ok := s.toks[tok]
|
|
if !ok || time.Now().After(c.Expires) {
|
|
delete(s.toks, tok)
|
|
return "", false
|
|
}
|
|
return c.User, true
|
|
}
|
|
|
|
func (s *sessionStore) Delete(tok string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
delete(s.toks, tok)
|
|
}
|