// Package accountant ist `sultan accountant` (Spec 5.3): die Zentrale, an // die Sultan-Server (Payees) ihr Inventar melden – für Störfall // (URL → Backend-Host, Compose-Verzeichnis), Mandanten-Übersicht und // Abrechnung. // // Gleiches Sicherheitsmodell wie Agent → Server: eigenes selbstsigniertes // Zertifikat (der Payee pinnt den Fingerprint aus der Einladung), nur TLS // 1.3, jeder Request Ed25519-signiert. Im Datenverzeichnis (Default // ./accdata) liegen accountant.db und server.crt/server.key. // // Umgesetzt: Datenverzeichnis, Zertifikat, Datenbank, Enrollment der // Payees, Empfang ihrer Berichte (letzter Stand je Payee) und // Checkmk-Ausgabe auf einem Loopback-Port. Historie und Abrechnung folgen // (Roadmap 6d). package accountant import ( "cmp" "context" "encoding/json" "errors" "fmt" "log/slog" "math" "net" "net/http" "os" "path/filepath" "strconv" "strings" "sync" "time" "sultan/pkg/auth" "sultan/pkg/crypto" "sultan/pkg/ledger" "sultan/pkg/ratelimit" "sultan/pkg/store" ) // PathEnroll ist der Enrollment-Endpunkt für Payees. const PathEnroll = "/api/v1/enroll" // SettingPublicURLs: gemerkte --public-url (JSON-Liste); die erste steckt in // Einladungen. const SettingPublicURLs = "public_urls" // EnrollRequest ist der Body von POST /api/v1/enroll (signiert mit dem // neuen payee.key). type EnrollRequest struct { Token string `json:"token"` // sltn_enroll_, ohne Fingerprint/URL Hostname string `json:"hostname"` } // EnrollResponse ist die Antwort auf ein erfolgreiches Enrollment. type EnrollResponse struct { PayeeID string `json:"payee_id"` } // Config ist die Konfiguration; Nullwerte bekommen Defaults. type Config struct { DataDir string // Default ./accdata Addr string // Default :8444 (nur TLS) PublicURLs []string // Adressen für Einladungen; werden gemerkt // MetricsAddr: Checkmk-Ausgabe (Klartext, nur Loopback), Default // 127.0.0.1:8082; "none" schaltet ab. MetricsAddr string // EnrollLimit: Enrollment-Versuche pro Minute und Client-IP (Default 10, // -1 = unbegrenzt) – gegen Fluten (Log, Datenbank). EnrollLimit int // StaleWarn/StaleCrit: ab welchem Alter des letzten Berichts ein Payee // (und seine URLs) WARN bzw. CRIT ist (Default 2h/6h). StaleWarn, StaleCrit time.Duration Logger *slog.Logger } func (c Config) withDefaults() Config { c.DataDir = cmp.Or(c.DataDir, "./accdata") c.Addr = cmp.Or(c.Addr, ":8444") c.MetricsAddr = cmp.Or(c.MetricsAddr, "127.0.0.1:8082") if c.EnrollLimit == 0 { c.EnrollLimit = 10 } if c.StaleWarn <= 0 { c.StaleWarn = 2 * time.Hour } if c.StaleCrit <= 0 { c.StaleCrit = 6 * time.Hour } if c.Logger == nil { c.Logger = slog.New(slog.NewTextHandler(os.Stderr, nil)) } return c } // Accountant ist ein startbereiter Accountant. type Accountant struct { cfg Config log *slog.Logger ledger *ledger.Ledger cert crypto.ServerCert verifier *auth.Verifier // reportVerifier: wie verifier, aber für große Berichte. reportVerifier *auth.Verifier enrollRL *ratelimit.Limiter mu sync.Mutex addr string metricsAddr string srv *http.Server metricsSrv *http.Server serveErr chan error shutdownOnce sync.Once shutdownErr error } // New legt das Datenverzeichnis an (0700) und lädt bzw. erzeugt Datenbank // und Zertifikat. Lauschen beginnt erst mit Start. func New(ctx context.Context, cfg Config) (*Accountant, error) { cfg = cfg.withDefaults() var urls []string for _, raw := range cfg.PublicURLs { if strings.TrimSpace(raw) == "" { continue } u, err := crypto.ValidateServerURL(raw) if err != nil { return nil, fmt.Errorf("accountant: --public-url: %w", err) } urls = append(urls, u) } if err := os.MkdirAll(cfg.DataDir, 0o700); err != nil { return nil, fmt.Errorf("accountant: Datenverzeichnis: %w", err) } l, err := ledger.Open(ctx, filepath.Join(cfg.DataDir, "accountant.db")) if err != nil { return nil, err } if len(urls) > 0 { raw, _ := json.Marshal(urls) if err := l.SetSetting(ctx, SettingPublicURLs, string(raw)); err != nil { l.Close() return nil, err } } cert, err := crypto.LoadOrCreateServerCert(cfg.DataDir) if err != nil { l.Close() return nil, err } return &Accountant{ cfg: cfg, log: cfg.Logger, ledger: l, cert: cert, verifier: &auth.Verifier{}, reportVerifier: &auth.Verifier{MaxBodySize: maxReportSize}, enrollRL: &ratelimit.Limiter{Limit: cfg.EnrollLimit, Window: time.Minute}, serveErr: make(chan error, 2), }, nil } // LoadPublicURLs liefert die gemerkten Adressen (für `accountant invite`). func LoadPublicURLs(ctx context.Context, l *ledger.Ledger) ([]string, error) { raw, err := l.Setting(ctx, SettingPublicURLs) if err != nil || raw == "" { return nil, err } var urls []string if err := json.Unmarshal([]byte(raw), &urls); err != nil { return nil, fmt.Errorf("accountant: Einstellung %s: %w", SettingPublicURLs, err) } return urls, nil } // Fingerprint ist der Fingerprint des TLS-Zertifikats (für Einladungen). func (a *Accountant) Fingerprint() crypto.Fingerprint { return a.cert.Fingerprint } // DataDir ist das Datenverzeichnis. func (a *Accountant) DataDir() string { return a.cfg.DataDir } // Addr ist die tatsächlich gebundene Adresse ("" vor Start). func (a *Accountant) Addr() string { a.mu.Lock(); defer a.mu.Unlock(); return a.addr } // MetricsAddr ist die gebundene Adresse der Checkmk-Ausgabe ("" wenn aus). func (a *Accountant) MetricsAddr() string { a.mu.Lock(); defer a.mu.Unlock(); return a.metricsAddr } // Start bindet die Ports und beginnt zu bedienen; er kehrt sofort zurück. func (a *Accountant) Start() error { ln, err := net.Listen("tcp", a.cfg.Addr) if err != nil { return fmt.Errorf("accountant: Port %s: %w", a.cfg.Addr, err) } var metricsLn net.Listener if a.cfg.MetricsAddr != "none" { if metricsLn, err = net.Listen("tcp", a.cfg.MetricsAddr); err != nil { ln.Close() return fmt.Errorf("accountant: Metrik-Port %s: %w", a.cfg.MetricsAddr, err) } } srv := &http.Server{ Handler: a.routes(), TLSConfig: crypto.ServerTLSConfig(a.cert), ReadHeaderTimeout: 10 * time.Second, ErrorLog: slog.NewLogLogger(a.log.Handler(), slog.LevelDebug), } a.mu.Lock() a.addr, a.srv = ln.Addr().String(), srv if metricsLn != nil { mux := http.NewServeMux() mux.HandleFunc("GET /metrics/checkmk", a.handleCheckmk) a.metricsAddr = metricsLn.Addr().String() a.metricsSrv = &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second} } metricsSrv := a.metricsSrv a.mu.Unlock() go a.serve(func() error { return srv.ServeTLS(ln, "", "") }) if metricsSrv != nil { go a.serve(func() error { return metricsSrv.Serve(metricsLn) }) } a.log.Info("Sultan-Accountant gestartet", "addr", a.Addr(), "metrics", a.MetricsAddr(), "Fingerprint", string(a.cert.Fingerprint), "data_dir", a.cfg.DataDir) return nil } func (a *Accountant) serve(fn func() error) { if err := fn(); err != nil && !errors.Is(err, http.ErrServerClosed) { a.serveErr <- err } } // Run startet den Accountant und fährt ihn herunter, sobald ctx endet oder // der Port ausfällt. func (a *Accountant) Run(ctx context.Context) error { if err := a.Start(); err != nil { a.Shutdown(context.Background()) return err } var runErr error select { case <-ctx.Done(): case runErr = <-a.serveErr: } sctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() return errors.Join(runErr, a.Shutdown(sctx)) } // Shutdown beendet den HTTP-Server und schließt die Datenbank; mehrfacher // Aufruf ist erlaubt. func (a *Accountant) Shutdown(ctx context.Context) error { a.shutdownOnce.Do(func() { a.mu.Lock() srv, metricsSrv := a.srv, a.metricsSrv a.mu.Unlock() var errs []error for _, s := range []*http.Server{srv, metricsSrv} { if s != nil { errs = append(errs, s.Shutdown(ctx)) } } errs = append(errs, a.ledger.Close()) a.shutdownErr = errors.Join(errs...) }) return a.shutdownErr } func (a *Accountant) routes() http.Handler { mux := http.NewServeMux() mux.HandleFunc("POST "+PathEnroll, a.handleEnroll) mux.HandleFunc("POST "+PathReport, a.handleReport) return mux } func (a *Accountant) handleEnroll(w http.ResponseWriter, r *http.Request) { // Vor der Signaturprüfung: Fluten kosten so kaum etwas. if ok, retry := a.enrollRL.Allow(remoteIP(r)); !ok { w.Header().Set("Retry-After", strconv.Itoa(int(math.Ceil(retry.Seconds())))) a.log.Warn("Payee-Enrollment gebremst (zu viele Versuche)", "ip", remoteIP(r)) http.Error(w, "zu viele Versuche", http.StatusTooManyRequests) return } pub, err := a.verifier.VerifyUnregistered(r) if err != nil { code := auth.StatusCode(err) http.Error(w, http.StatusText(code), code) return } var req EnrollRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "ungültiger Body", http.StatusBadRequest) return } if strings.TrimSpace(req.Hostname) == "" { http.Error(w, "hostname fehlt", http.StatusBadRequest) return } p, err := a.ledger.Enroll(r.Context(), req.Token, ledger.Payee{ Hostname: req.Hostname, PublicKey: auth.EncodePublicKey(pub), IPAddress: remoteIP(r), }) switch { case errors.Is(err, store.ErrNotFound), errors.Is(err, store.ErrTokenUsed), errors.Is(err, store.ErrTokenExpired): // Keine Details nach außen, ob unbekannt, verbraucht oder abgelaufen. a.log.Warn("Payee-Enrollment abgelehnt", "ip", remoteIP(r), "err", err) http.Error(w, "Einladung ungültig", http.StatusForbidden) return case errors.Is(err, store.ErrExists): http.Error(w, "Payee oder Schlüssel bereits registriert", http.StatusConflict) return case errors.Is(err, store.ErrInvalid): http.Error(w, err.Error(), http.StatusBadRequest) return case err != nil: a.log.Error("Payee-Enrollment", "err", err) http.Error(w, "interner Fehler", http.StatusInternalServerError) return } a.log.Info("Payee registriert", "payee", p.ID, "hostname", p.Hostname, "ip", p.IPAddress) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(EnrollResponse{PayeeID: p.ID}) } func remoteIP(r *http.Request) string { host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { return r.RemoteAddr } return host }