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/ 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://: bzw. : (tcp). Backend string `json:"backend"` // BackendFQDN: : – 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) }