Accountant: Inventar, where, Checkmk, Abrechnung
This commit is contained in:
1 parent
9f16148cde
commit
dd93277750
59 files changed
+7522
-113
No files matched your search
@@ -0,0 +1,333 @@
|
||||
// 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
|
||||
}
|
||||
Reference in new issue
Block a user