138 lines
4.8 KiB
Go
138 lines
4.8 KiB
Go
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)
|
||
}
|