Files

138 lines
4.8 KiB
Go
Raw Permalink 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
import (
"context"
"encoding/json"
"errors"
"net/http"
"time"
"sultan/pkg/auth"
"sultan/pkg/ledger"
"sultan/pkg/store"
)
// PathReport ist der Endpunkt für Berichte der Payees.
const PathReport = "/api/v1/report"
// maxReportSize begrenzt einen Bericht (signierter Body). Ein Eintrag je
// Route ist einige hundert Bytes groß; 8 MiB reichen für zehntausende Routen.
const maxReportSize = 8 << 20
// Report ist der vollständige Schnappschuss eines Payees (Spec 5.3) – kein
// Delta: Was fehlt, gibt es beim Payee nicht mehr.
type Report struct {
Version string `json:"version"` // sultan-Version des Payees
GeneratedAt time.Time `json:"generated_at"`
Tenants []ReportTenant `json:"tenants"`
Nodes []ReportNode `json:"nodes"`
Routes []ReportRoute `json:"routes"`
}
// ReportTenant ist ein Mandant des Payees.
type ReportTenant struct {
ID string `json:"id"`
Name string `json:"name"`
Tier string `json:"tier"`
Status string `json:"status"`
}
// ReportNode ist ein Knoten (Backend-Host) des Payees.
type ReportNode struct {
ID string `json:"id"`
Tenant string `json:"tenant"`
Hostname string `json:"hostname"`
FQDN string `json:"fqdn,omitempty"`
IP string `json:"ip"` // Absender-IP beim Server
Status string `json:"status"`
Connected bool `json:"connected"`
Maintenance bool `json:"maintenance,omitempty"`
LastSeen time.Time `json:"last_seen,omitzero"`
OS string `json:"os,omitempty"`
DockerVersion string `json:"docker_version,omitempty"`
NetBirdIP string `json:"netbird_ip,omitempty"`
}
// ReportRoute ist ein gemeldeter Router mit seinem Ziel.
type ReportRoute struct {
Tenant string `json:"tenant"`
Node string `json:"node"`
Workload string `json:"workload"` // Container bzw. sultan.d/<name>
Router string `json:"router"` // Name aus den Labels
Protocol string `json:"protocol"` // http oder tcp
Hosts []string `json:"hosts"`
// Backend wie Traefik es erreicht: http://<ip>:<port> bzw. <ip>:<port> (tcp).
Backend string `json:"backend"`
// BackendFQDN: <fqdn>:<port> – zum Springen auf den Host im Störfall.
BackendFQDN string `json:"backend_fqdn,omitempty"`
// Active: Router steht in der Traefik-Konfiguration (nicht von der Policy
// verworfen, z. B. fremde Domain oder Routenlimit).
Active bool `json:"active"`
// Down: Service gestoppt bzw. Healthcheck fehlgeschlagen (Wartungsseite).
Down bool `json:"down,omitempty"`
Product string `json:"product,omitempty"` // Label sultan.accountant.billing
ComposeProject string `json:"compose_project,omitempty"`
ComposeService string `json:"compose_service,omitempty"`
ComposeDir string `json:"compose_dir,omitempty"`
State string `json:"state,omitempty"`
StartedAt time.Time `json:"started_at,omitzero"`
Health string `json:"health,omitempty"`
}
// LabelBilling ordnet einer Route ein Produkt zu (Spec 5.3).
const LabelBilling = "sultan.accountant.billing"
func (a *Accountant) handleReport(w http.ResponseWriter, r *http.Request) {
pub, err := a.reportVerifier.VerifyUnregistered(r)
if err != nil {
code := auth.StatusCode(err)
http.Error(w, http.StatusText(code), code)
return
}
p, err := a.ledger.GetPayeeByPublicKey(r.Context(), auth.EncodePublicKey(pub))
if errors.Is(err, store.ErrNotFound) {
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
return
}
if err != nil {
a.log.Error("Payee nachschlagen", "err", err)
http.Error(w, "interner Fehler", http.StatusInternalServerError)
return
}
var rep Report
if err := json.NewDecoder(r.Body).Decode(&rep); err != nil {
http.Error(w, "ungültiger Bericht", http.StatusBadRequest)
return
}
if rep.GeneratedAt.IsZero() {
http.Error(w, "generated_at fehlt", http.StatusBadRequest)
return
}
if err := StoreReport(r.Context(), a.ledger, p.ID, rep); err != nil {
a.log.Error("Bericht speichern", "payee", p.ID, "err", err)
http.Error(w, "interner Fehler", http.StatusInternalServerError)
return
}
a.log.Debug("Bericht empfangen", "payee", p.ID, "routes", len(rep.Routes), "nodes", len(rep.Nodes))
w.WriteHeader(http.StatusNoContent)
}
// StoreReport legt einen Bericht ab: letzter Stand (normalisiert: nur
// bekannte Felder), Historie und automatisch angelegte Kunden für neue
// Mandanten.
func StoreReport(ctx context.Context, l *ledger.Ledger, payeeID string, rep Report) error {
body, err := json.Marshal(rep)
if err != nil {
return err
}
if err := l.SaveReport(ctx, payeeID, body, RouteRecords(rep)); err != nil {
return err
}
tenants := make([]ledger.TenantRef, 0, len(rep.Tenants))
for _, t := range rep.Tenants {
tenants = append(tenants, ledger.TenantRef{ID: t.ID, Name: t.Name})
}
return l.EnsureCustomers(ctx, payeeID, tenants)
}