Files
sultan/pkg/policy/policy.go
T
br ca6cdcca63 software steht so weit.
jetzt build + docker build und testen
2026-10-02 13:25:34 +02:00

525 lines
18 KiB
Go
Raw 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 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"
"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
}