// 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 ____, 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 // 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" "net/url" "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 // HeaderUntil: voraussichtliches Wartungsende (RFC 3339), nur bei befristeter Wartung. HeaderUntil = "X-Sultan-Until" // HeaderPublicReason: öffentlicher Wartungsgrund, URL-kodiert (Header sind // einzeilig; Umlaute kommen so unverändert beim Lander an). HeaderPublicReason = "X-Sultan-Public-Reason" ) // Maintenance ist eine angemeldete Wartung (Knoten oder Dienst). type Maintenance struct { Until time.Time // Nullwert = unbefristet PublicReason string } func (m *Maintenance) active(now time.Time) bool { return m != nil && (m.Until.IsZero() || now.Before(m.Until)) } // 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 // Maintenance: Wartung des ganzen Knotens (nil = keine). Maintenance *Maintenance // ServiceMaintenance: Wartung einzelner Services (Originalnamen), z. B. // per Label sultan.maintenance=true. ServiceMaintenance map[string]Maintenance // Disconnected: Der Knoten ist ungeplant getrennt, seine Lease gilt aber // noch – Besucher sehen die Wartungsseite (ohne Ende und Grund). Disconnected 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 // Community wird automatisch neu lizenziert: nur der explizite Status zählt. case t.Status == store.TenantExpired || (l.Tier != license.TierCommunity && 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 } at := now() stage := StageFor(in.Tenant, in.License, at) 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]) } // Vorrang: Ablauf/Paywall > Wartung (Knoten, Dienst, Healthcheck) > // Mahnhinweis > normal. (Sperre und drain liefern oben gar keine Routen.) routerStage := stage maint, inMaint := maintenanceFor(in, r.Service, at) if inMaint && (stage == StageNormal || slices.Contains(ackStages, stage)) { 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), } if routerStage == StageMaintenance { if !maint.Until.IsZero() { headers[HeaderUntil] = maint.Until.UTC().Format(time.RFC3339) } if maint.PublicReason != "" { headers[HeaderPublicReason] = url.QueryEscape(maint.PublicReason) } } 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 } // maintenanceFor liefert die geltende Wartung eines Services: zuerst die des // Knotens, dann die des Services, dann ein Healthcheck-Ausfall oder ein // getrennter Knoten (beides ohne Grund). func maintenanceFor(in Input, service string, now time.Time) (Maintenance, bool) { if in.Maintenance.active(now) { return *in.Maintenance, true } if m, ok := in.ServiceMaintenance[service]; ok && m.active(now) { return m, true } if in.DownServices[service] || in.Disconnected { return Maintenance{}, true } return Maintenance{}, false } 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 }