server läuft

This commit is contained in:
br committed 2026-10-01 23:52:15 +02:00
1 parent c8df9d7651
commit 42f0c0accb
21 files changed
+4185 -10

No files matched your search

+471
View File
@@ -0,0 +1,471 @@
// Package policy ist die Policy- & Interception-Engine des Servers (Spec
// 3.3/5.2). Sie nimmt die Traefik-Konfiguration eines Knotens (aus dessen
// Labels) und macht daraus, was tatsächlich ausgeliefert werden darf:
//
// - Domain-Schutz: Jede Regel muss auf Hosts aus allowed_domains
// beschränkt sein (echter Regel-Parser, kein Textvergleich).
// - Routenlimit (max_routes), Feature-Gates für Middlewares, keine
// Referenzen auf fremde Provider (api@internal …) außer freigegebenen.
// - Namensraum: Alle Namen werden zu <tenant>__<node>__<name>, damit sich
// Mandanten nicht gegenseitig überschreiben oder referenzieren können.
// - Interception: Je nach Stufe (Mahnwesen, Ablauf, Wartung) wird der
// Router zum Sultan-Interceptor (Web-Lander) umgeleitet. Bei
// Mahnstufe 1/2 zeigt er einen Hinweis und leitet per
// <meta http-equiv="refresh"> zurück; ein Cookie lässt den Besucher
// danach über einen zusätzlichen Router direkt zum Original durch.
//
// Verstöße entfernen den betroffenen Router und werden als Violation
// gemeldet (für das Audit-Log); der Rest des Knotens bleibt aktiv.
package policy
import (
"errors"
"fmt"
"regexp"
"slices"
"strings"
"time"
"sultan/pkg/license"
"sultan/pkg/store"
"sultan/pkg/traefik"
)
var (
// ErrInvalidInput: IDs ungültig oder Lizenz gehört zu einem anderen Mandanten.
ErrInvalidInput = errors.New("policy: ungültige Eingabe")
// ErrNoInterceptor: Eine Umleitung ist nötig, aber kein Interceptor konfiguriert.
ErrNoInterceptor = errors.New("policy: Interceptor-URL nicht konfiguriert")
)
// Stage ist die Durchsetzungsstufe für einen Mandanten bzw. Router.
type Stage string
const (
StageNormal Stage = "normal"
StageWatermark Stage = "watermark" // Mahnstufe 1: Hinweisseite, kurze Weiterleitung zum Original
StageFiremark Stage = "firemark" // Mahnstufe 2: Hinweisseite, längere Weiterleitung zum Original
StagePaywall Stage = "paywall" // Mahnstufe 3 / Zahlung ausgesetzt: 402-Seite
StageLanding Stage = "landing" // abgelaufen: Informations-Landingpage
StageMaintenance Stage = "maintenance" // Dienst laut Healthcheck down: 503, Retry-After 60
StageBlocked Stage = "blocked" // Quarantäne: keine Routen
)
// InterceptorService ist der Name des gemeinsamen Interceptor-Services.
// Das Präfix "sultan__" ist reserviert (Mandanten-ID "sultan" ist verboten).
const InterceptorService = "sultan__interceptor"
// Header, mit denen der Interceptor seinen Auftrag erhält. Traefik setzt sie
// per customRequestHeaders und überschreibt damit Werte des Besuchers.
const (
HeaderStage = "X-Sultan-Stage"
HeaderTenant = "X-Sultan-Tenant"
HeaderTier = "X-Sultan-Tier" // z. B. für den Pro-Hinweis auf Community-Wartungsseiten
)
// AckCookie setzt der Lander, nachdem er den Mahnhinweis gezeigt hat; der
// Wert ist die Stufe. Ein bestätigter Hinweis gilt nicht für eine höhere
// Stufe. Das Cookie ist bewusst kein Sicherheitsmerkmal – wer es selbst
// setzt, überspringt nur den Hinweis, nicht die Paywall.
const AckCookie = "sultan_ack"
// ackStages sind die Stufen, die nach dem Hinweis zum Original durchlassen.
var ackStages = []Stage{StageWatermark, StageFiremark}
// DunningSchedule legt fest, ab wie vielen Tagen Zahlungsverzug welche
// Mahnstufe gilt. Der Nullwert entspricht den Standardwerten (8/25/32 Tage,
// angelehnt an Bank-Zahlungsfristen).
type DunningSchedule struct {
Level1, Level2, Level3 time.Duration
}
// Level liefert die Mahnstufe für eine seit overdueSince offene Zahlung
// (Nullwert: nichts offen).
func (d DunningSchedule) Level(overdueSince, now time.Time) int {
if overdueSince.IsZero() {
return 0
}
day := 24 * time.Hour
l1, l2, l3 := orDefault(d.Level1, 8*day), orDefault(d.Level2, 25*day), orDefault(d.Level3, 32*day)
switch overdue := now.Sub(overdueSince); {
case overdue >= l3:
return 3
case overdue >= l2:
return 2
case overdue >= l1:
return 1
}
return 0
}
func orDefault(d, def time.Duration) time.Duration {
if d > 0 {
return d
}
return def
}
// Tenant ist der für die Policy relevante Mandantenzustand.
type Tenant struct {
ID string
Status store.TenantStatus
// DunningLevel ist die Mahnstufe (nur kommerzielle Tiers): 0 keine,
// 1 Wasserzeichen, 2 Firemark, ≥3 Paywall.
DunningLevel int
}
// Input beschreibt einen Knoten zum Zeitpunkt der Aggregation.
type Input struct {
Tenant Tenant
License license.License // bereits per license.Parse geprüft
NodeID string
NodeDrained bool
Config *traefik.Configuration // aus traefik.FromLabels
// DownServices: Services (Originalnamen), die der Healthcheck des
// Agenten als ausgefallen meldet.
DownServices map[string]bool
}
// ViolationKind klassifiziert einen Verstoß.
type ViolationKind string
const (
ViolationDomain ViolationKind = "domain"
ViolationCatchAll ViolationKind = "catch_all"
ViolationHostRegexp ViolationKind = "host_regexp"
ViolationRuleSyntax ViolationKind = "rule_syntax"
ViolationRouteLimit ViolationKind = "route_limit"
ViolationExternalRef ViolationKind = "external_ref"
ViolationUnknownRef ViolationKind = "unknown_ref"
ViolationFeature ViolationKind = "feature"
ViolationForbiddenMiddleware ViolationKind = "forbidden_middleware"
ViolationMiddlewareTarget ViolationKind = "middleware_target"
)
// Violation ist ein Verstoß eines Routers (Originalname).
type Violation struct {
Kind ViolationKind
Router string
Detail string
// Observed: nur beobachtet (Audit-Log), der Router bleibt aktiv.
Observed bool
}
// Result ist die bereinigte Konfiguration eines Knotens.
type Result struct {
Config *traefik.Configuration
Stage Stage
Violations []Violation
}
// Engine wendet die Policy an.
type Engine struct {
// InterceptorURL ist die Adresse des Sultan-Interceptors (z. B.
// http://127.0.0.1:8081); Pflicht, sobald umgeleitet werden muss.
InterceptorURL string
// AllowedExternalRefs sind Referenzen auf andere Provider, die Mandanten
// nutzen dürfen (z. B. "auth@file"). Alles andere mit "@" wird abgelehnt.
AllowedExternalRefs []string
// MiddlewareTargetCheck ist die Andockstelle für die geplante
// Begrenzung von Middleware-Zielen (z. B. forwardAuth nur auf das eigene
// Backend). Sie wird für jede lokale Middleware eines Routers mit der
// Backend-URL seines Services aufgerufen (leer bei externem Service).
// nil = alles dulden.
MiddlewareTargetCheck func(mwName string, def traefik.Middleware, backend string) error
// EnforceMiddlewareTargets: true entfernt den Router bei einem Fehler
// von MiddlewareTargetCheck; false meldet ihn nur (Beobachtungsmodus).
EnforceMiddlewareTargets bool
now func() time.Time
}
// Middleware-Typen, die ohne Feature custom_middleware erlaubt sind.
var basicMiddlewares = []string{
"redirectscheme", "redirectregex", "stripprefix", "stripprefixregex",
"addprefix", "replacepath", "replacepathregex", "compress", "headers",
}
// Middleware-Typen, die per Namen auf andere Middlewares/Services verweisen
// und so den Namensraum umgehen könnten – nie erlaubt.
var forbiddenMiddlewares = []string{"chain", "errors"}
// IDs: alphanumerisch mit - und _, nicht am Rand; "__" ist als Trenner reserviert.
var idRe = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9_-]*[A-Za-z0-9])?$`)
// ValidID meldet, ob id als Mandanten- oder Knoten-ID taugt (Namensraum-sicher).
func ValidID(id string) bool { return idRe.MatchString(id) && !strings.Contains(id, "__") }
// StageFor bestimmt die Stufe eines Mandanten. Reihenfolge: Quarantäne,
// Ablauf, dann (nur kommerziell) Zahlungsstatus und Mahnstufe.
func StageFor(t Tenant, l license.License, now time.Time) Stage {
switch {
case t.Status == store.TenantQuarantined:
return StageBlocked
case t.Status == store.TenantExpired || l.Expired(now):
return StageLanding
case l.Tier == license.TierCommunity:
return StageNormal // kein Mahnwesen im Community-Betrieb
case t.Status == store.TenantSuspendedPayment || t.DunningLevel >= 3:
return StagePaywall
case t.DunningLevel == 2:
return StageFiremark
case t.DunningLevel == 1:
return StageWatermark
}
return StageNormal
}
// Apply wendet die Policy auf einen Knoten an. Die Eingabe wird nicht
// verändert.
func (e *Engine) Apply(in Input) (Result, error) {
if err := validateInput(in); err != nil {
return Result{}, err
}
now := time.Now
if e.now != nil {
now = e.now
}
stage := StageFor(in.Tenant, in.License, now())
res := Result{Config: &traefik.Configuration{}, Stage: stage}
if stage == StageBlocked || in.NodeDrained || in.Config == nil || in.Config.HTTP == nil {
return res, nil
}
src := in.Config.HTTP
// 1. Router einzeln prüfen (sortiert, damit Ergebnis und Routenlimit
// deterministisch sind).
var accepted []string
for _, name := range sortedKeys(src.Routers) {
blocked, observed := e.checkRouter(name, src.Routers[name], src, in.License)
res.Violations = append(res.Violations, observed...)
if blocked != nil {
res.Violations = append(res.Violations, *blocked)
continue
}
accepted = append(accepted, name)
}
// 2. Routenlimit: überzählige Router (nach Namen) verwerfen.
if !in.License.AllowsRoutes(len(accepted)) {
for _, name := range accepted[in.License.MaxRoutes:] {
res.Violations = append(res.Violations, Violation{Kind: ViolationRouteLimit, Router: name,
Detail: fmt.Sprintf("max_routes = %d", in.License.MaxRoutes)})
}
accepted = accepted[:in.License.MaxRoutes]
}
if len(accepted) == 0 {
return res, nil
}
// 3. Ausgabe mit Namensraum und ggf. Umleitung zum Interceptor.
prefix := in.Tenant.ID + "__" + in.NodeID + "__"
out := &traefik.HTTPConfiguration{
Routers: map[string]*traefik.Router{},
Services: map[string]*traefik.Service{},
Middlewares: map[string]traefik.Middleware{},
}
for _, name := range accepted {
r := src.Routers[name]
nr := *r
nr.EntryPoints = slices.Clone(r.EntryPoints)
nr.Middlewares = nil
if r.TLS != nil {
tlsCopy := *r.TLS
nr.TLS = &tlsCopy
}
for _, mw := range r.Middlewares {
if isExternal(mw) {
nr.Middlewares = append(nr.Middlewares, mw)
continue
}
nr.Middlewares = append(nr.Middlewares, prefix+mw)
out.Middlewares[prefix+mw] = copyMiddleware(src.Middlewares[mw])
}
if isExternal(r.Service) {
nr.Service = r.Service
} else {
nr.Service = prefix + r.Service
out.Services[nr.Service] = copyService(src.Services[r.Service])
}
routerStage := stage
if routerStage == StageNormal && in.DownServices[r.Service] {
routerStage = StageMaintenance
}
if routerStage != StageNormal {
if e.InterceptorURL == "" {
return Result{}, ErrNoInterceptor
}
if slices.Contains(ackStages, routerStage) {
// Durchlass nach bestätigtem Hinweis: gleiche Regel + Cookie,
// direkt zum Original. Ohne gesetzte Priorität gewinnt er
// über die längere Regel, sonst über Priorität+1.
ack := nr
ack.Middlewares = slices.Clone(nr.Middlewares)
ack.EntryPoints = slices.Clone(nr.EntryPoints)
if nr.TLS != nil {
tlsCopy := *nr.TLS
ack.TLS = &tlsCopy
}
ack.Rule = fmt.Sprintf("(%s) && HeaderRegexp(`Cookie`, `(^|;\\s*)%s=%s(;|$)`)", r.Rule, AckCookie, routerStage)
if nr.Priority > 0 {
ack.Priority = nr.Priority + 1
}
out.Routers["sultan__"+prefix+name] = &ack
}
headers := map[string]any{
HeaderStage: string(routerStage),
HeaderTenant: in.Tenant.ID,
HeaderTier: string(in.License.Tier),
}
mwName := "sultan__" + prefix + name
out.Middlewares[mwName] = traefik.Middleware{"headers": map[string]any{"customRequestHeaders": headers}}
nr.Middlewares = append(slices.Clone(nr.Middlewares), mwName)
nr.Service = InterceptorService
out.Services[InterceptorService] = &traefik.Service{LoadBalancer: &traefik.ServersLoadBalancer{
Servers: []traefik.Server{{URL: e.InterceptorURL}},
}}
}
out.Routers[prefix+name] = &nr
}
if len(out.Middlewares) == 0 {
out.Middlewares = nil
}
res.Config = &traefik.Configuration{HTTP: out}
return res, nil
}
func validateInput(in Input) error {
switch {
case !ValidID(in.Tenant.ID) || in.Tenant.ID == "sultan":
return fmt.Errorf("%w: Mandanten-ID %q", ErrInvalidInput, in.Tenant.ID)
case !ValidID(in.NodeID):
return fmt.Errorf("%w: Knoten-ID %q", ErrInvalidInput, in.NodeID)
case in.License.TenantID != in.Tenant.ID:
return fmt.Errorf("%w: Lizenz gehört zu %q, nicht %q", ErrInvalidInput, in.License.TenantID, in.Tenant.ID)
}
return nil
}
// checkRouter prüft einen Router. blocked != nil entfernt ihn; observed sind
// nur gemeldete Verstöße (Beobachtungsmodus).
func (e *Engine) checkRouter(name string, r *traefik.Router, src *traefik.HTTPConfiguration, l license.License) (blocked *Violation, observed []Violation) {
v, observed := e.checkRouterRules(name, r, src, l)
if v != nil {
return v, nil
}
if e.MiddlewareTargetCheck == nil {
return nil, nil
}
var backend string
if svc := src.Services[r.Service]; svc != nil && svc.LoadBalancer != nil && len(svc.LoadBalancer.Servers) > 0 {
backend = svc.LoadBalancer.Servers[0].URL
}
local := slices.DeleteFunc(slices.Clone(r.Middlewares), isExternal)
slices.Sort(local)
for _, mw := range local {
if err := e.MiddlewareTargetCheck(mw, src.Middlewares[mw], backend); err != nil {
viol := Violation{Kind: ViolationMiddlewareTarget, Router: name, Detail: fmt.Sprintf("Middleware %q: %v", mw, err)}
if e.EnforceMiddlewareTargets {
return &viol, nil
}
viol.Observed = true
observed = append(observed, viol)
}
}
return nil, observed
}
func (e *Engine) checkRouterRules(name string, r *traefik.Router, src *traefik.HTTPConfiguration, l license.License) (*Violation, []Violation) {
v := func(kind ViolationKind, format string, args ...any) *Violation {
return &Violation{Kind: kind, Router: name, Detail: fmt.Sprintf(format, args...)}
}
hosts, err := HostsFromRule(r.Rule)
switch {
case errors.Is(err, ErrUnconstrained):
return v(ViolationCatchAll, "Regel %q nicht auf Hosts beschränkt", r.Rule), nil
case errors.Is(err, ErrHostRegexp):
return v(ViolationHostRegexp, "Regel %q", r.Rule), nil
case err != nil:
return v(ViolationRuleSyntax, "%v", err), nil
}
for _, h := range hosts {
if !l.AllowsDomain(h) {
return v(ViolationDomain, "Host %q nicht in allowed_domains", h), nil
}
}
if isExternal(r.Service) {
if !slices.Contains(e.AllowedExternalRefs, r.Service) {
return v(ViolationExternalRef, "Service %q", r.Service), nil
}
} else if src.Services[r.Service] == nil {
return v(ViolationUnknownRef, "Service %q nicht auf diesem Knoten definiert", r.Service), nil
}
for _, mw := range r.Middlewares {
if isExternal(mw) {
if !slices.Contains(e.AllowedExternalRefs, mw) {
return v(ViolationExternalRef, "Middleware %q", mw), nil
}
continue
}
def, ok := src.Middlewares[mw]
if !ok {
return v(ViolationUnknownRef, "Middleware %q nicht auf diesem Knoten definiert", mw), nil
}
for typ := range def {
t := strings.ToLower(typ)
switch {
case slices.Contains(forbiddenMiddlewares, t):
return v(ViolationForbiddenMiddleware, "Middleware %q vom Typ %s", mw, typ), nil
case !slices.Contains(basicMiddlewares, t) && !l.HasFeature(license.FeatureCustomMiddleware):
return v(ViolationFeature, "Middleware %q vom Typ %s erfordert %s", mw, typ, license.FeatureCustomMiddleware), nil
}
}
}
return nil, nil
}
func isExternal(ref string) bool { return strings.Contains(ref, "@") }
func sortedKeys[V any](m map[string]V) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
slices.Sort(keys)
return keys
}
func copyService(s *traefik.Service) *traefik.Service {
c := &traefik.Service{}
if s.LoadBalancer != nil {
lb := *s.LoadBalancer
lb.Servers = slices.Clone(s.LoadBalancer.Servers)
if s.LoadBalancer.PassHostHeader != nil {
v := *s.LoadBalancer.PassHostHeader
lb.PassHostHeader = &v
}
c.LoadBalancer = &lb
}
return c
}
func copyMiddleware(m traefik.Middleware) traefik.Middleware {
return traefik.Middleware(copyMap(map[string]any(m)))
}
func copyMap(m map[string]any) map[string]any {
out := make(map[string]any, len(m))
for k, v := range m {
if sub, ok := v.(map[string]any); ok {
out[k] = copyMap(sub)
} else {
out[k] = v
}
}
return out
}