Files
agent-helm/helmd/hubauth.go
Bjorn Blomberg 77682e91b5
All checks were successful
helmd-image / build (push) Successful in 3m22s
helmd-release / build-release (push) Successful in 4m28s
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
2026-08-06 23:19:18 +02:00

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)
}