server läuft
This commit is contained in:
1 parent
c8df9d7651
commit
42f0c0accb
21 files changed
+4185
-10
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user