Files
sultan/pkg/accountant/accountant.go
T

334 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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_<secret>, 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
}