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
}
+587
View File
@@ -0,0 +1,587 @@
package policy
import (
"errors"
"reflect"
"testing"
"time"
"sultan/pkg/license"
"sultan/pkg/store"
"sultan/pkg/traefik"
)
var now = time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC)
const interceptor = "http://127.0.0.1:8081"
func engine() *Engine {
return &Engine{InterceptorURL: interceptor, now: func() time.Time { return now }}
}
func lic(tier license.Tier, maxRoutes int, features ...license.Feature) license.License {
return license.License{
TenantID: "kunde",
Tier: tier,
MaxRoutes: maxRoutes,
AllowedDomains: []string{"kunde.de", "*.kunde.de"},
Features: features,
ValidUntil: now.AddDate(0, 3, 0),
}
}
// cfg baut eine Knoten-Konfiguration aus Labels – wie sie der Server vom
// Agenten bekommt.
func cfg(t *testing.T, labels map[string]string) *traefik.Configuration {
t.Helper()
labels["traefik.enable"] = "true"
c, err := traefik.FromLabels("100.64.0.42", labels)
if err != nil {
t.Fatalf("FromLabels: %v", err)
}
return c
}
func wpLabels() map[string]string {
return map[string]string{
"traefik.http.routers.wp.rule": "Host(`kunde.de`) || Host(`www.kunde.de`)",
"traefik.http.routers.wp.entrypoints": "websecure",
"traefik.http.routers.wp.tls.certresolver": "dns-resolver",
"traefik.http.routers.wp.middlewares": "wp-www",
"traefik.http.middlewares.wp-www.redirectregex.regex": `^https?://www\.kunde\.de/(.*)`,
"traefik.http.middlewares.wp-www.redirectregex.replacement": "https://kunde.de/${1}",
"traefik.http.services.wp.loadbalancer.server.port": "80",
}
}
func input(t *testing.T, labels map[string]string) Input {
return Input{
Tenant: Tenant{ID: "kunde", Status: store.TenantActive},
License: lic(license.TierPro, 10),
NodeID: "node-1",
Config: cfg(t, labels),
}
}
func kinds(vs []Violation) []ViolationKind {
var out []ViolationKind
for _, v := range vs {
out = append(out, v.Kind)
}
return out
}
func TestApply_NormalNamespacesEverything(t *testing.T) {
in := input(t, wpLabels())
res, err := engine().Apply(in)
if err != nil {
t.Fatalf("Apply: %v", err)
}
if len(res.Violations) != 0 || res.Stage != StageNormal {
t.Errorf("Violations=%v Stage=%q", res.Violations, res.Stage)
}
want := &traefik.Configuration{HTTP: &traefik.HTTPConfiguration{
Routers: map[string]*traefik.Router{
"kunde__node-1__wp": {
EntryPoints: []string{"websecure"},
Middlewares: []string{"kunde__node-1__wp-www"},
Service: "kunde__node-1__wp",
Rule: "Host(`kunde.de`) || Host(`www.kunde.de`)",
TLS: &traefik.RouterTLS{CertResolver: "dns-resolver"},
},
},
Services: map[string]*traefik.Service{
"kunde__node-1__wp": {LoadBalancer: &traefik.ServersLoadBalancer{Servers: []traefik.Server{{URL: "http://100.64.0.42:80"}}}},
},
Middlewares: map[string]traefik.Middleware{
"kunde__node-1__wp-www": in.Config.HTTP.Middlewares["wp-www"],
},
}}
if !reflect.DeepEqual(res.Config, want) {
t.Errorf("Konfiguration weicht ab\n got: %+v\nwant: %+v", res.Config.HTTP, want.HTTP)
}
}
func TestApply_DoesNotMutateInput(t *testing.T) {
in := input(t, wpLabels())
before := cfg(t, wpLabels())
if _, err := engine().Apply(in); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(in.Config, before) {
t.Error("Eingabe-Konfiguration wurde verändert")
}
}
func TestApply_TwoTenantsSameNamesMergeWithoutConflict(t *testing.T) {
a := input(t, wpLabels())
b := input(t, wpLabels())
b.Tenant.ID, b.License.TenantID, b.NodeID = "andere", "andere", "node-1"
b.License.AllowedDomains = []string{"kunde.de", "*.kunde.de"}
ra, _ := engine().Apply(a)
rb, _ := engine().Apply(b)
if _, err := traefik.Merge(ra.Config, rb.Config); err != nil {
t.Errorf("Merge nach Namensraum-Präfix: %v", err)
}
}
func TestApply_DomainViolation(t *testing.T) {
labels := wpLabels()
labels["traefik.http.routers.bank.rule"] = "Host(`bank.de`)"
labels["traefik.http.routers.bank.service"] = "wp"
res, err := engine().Apply(input(t, labels))
if err != nil {
t.Fatal(err)
}
if _, ok := res.Config.HTTP.Routers["kunde__node-1__bank"]; ok {
t.Error("Router mit fremder Domain wurde übernommen")
}
if _, ok := res.Config.HTTP.Routers["kunde__node-1__wp"]; !ok {
t.Error("erlaubter Router fehlt")
}
if !reflect.DeepEqual(kinds(res.Violations), []ViolationKind{ViolationDomain}) || res.Violations[0].Router != "bank" {
t.Errorf("Violations = %+v", res.Violations)
}
}
func TestApply_RuleViolations(t *testing.T) {
tests := map[string]ViolationKind{
"Host(`kunde.de`) || PathPrefix(`/`)": ViolationCatchAll,
"PathPrefix(`/`)": ViolationCatchAll,
"HostRegexp(`.+`)": ViolationHostRegexp,
"Host(`kunde.de`": ViolationRuleSyntax,
"Host(`sub.kunde.de`) || Host(`x.de`)": ViolationDomain,
}
for rule, want := range tests {
labels := wpLabels()
labels["traefik.http.routers.wp.rule"] = rule
res, err := engine().Apply(input(t, labels))
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(kinds(res.Violations), []ViolationKind{want}) {
t.Errorf("%s: Violations = %+v, erwartet %s", rule, res.Violations, want)
}
if res.Config.HTTP != nil {
t.Errorf("%s: Router trotz Verstoß übernommen: %+v", rule, res.Config.HTTP)
}
}
}
func TestApply_RouteLimit(t *testing.T) {
labels := map[string]string{"traefik.http.services.s.loadbalancer.server.port": "80"}
for _, r := range []string{"c", "a", "b"} {
labels["traefik.http.routers."+r+".rule"] = "Host(`" + r + ".kunde.de`)"
}
in := input(t, labels)
in.License.MaxRoutes = 2
res, err := engine().Apply(in)
if err != nil {
t.Fatal(err)
}
if len(res.Config.HTTP.Routers) != 2 || res.Config.HTTP.Routers["kunde__node-1__c"] != nil {
t.Errorf("erwartet a und b (sortiert), bekommen %v", keys(res.Config.HTTP.Routers))
}
if !reflect.DeepEqual(kinds(res.Violations), []ViolationKind{ViolationRouteLimit}) || res.Violations[0].Router != "c" {
t.Errorf("Violations = %+v", res.Violations)
}
in.License.MaxRoutes = -1
res, _ = engine().Apply(in)
if len(res.Config.HTTP.Routers) != 3 || len(res.Violations) != 0 {
t.Errorf("unbegrenzt: %d Router, %v", len(res.Config.HTTP.Routers), res.Violations)
}
}
func TestApply_ExternalReferences(t *testing.T) {
labels := wpLabels()
labels["traefik.http.routers.dash.rule"] = "Host(`dash.kunde.de`)"
labels["traefik.http.routers.dash.service"] = "api@internal"
labels["traefik.http.routers.wp.middlewares"] = "wp-www,auth@file"
res, err := engine().Apply(input(t, labels))
if err != nil {
t.Fatal(err)
}
if res.Config.HTTP != nil {
t.Errorf("Router mit externen Referenzen übernommen: %v", keys(res.Config.HTTP.Routers))
}
if !reflect.DeepEqual(kinds(res.Violations), []ViolationKind{ViolationExternalRef, ViolationExternalRef}) {
t.Errorf("Violations = %+v", res.Violations)
}
e := engine()
e.AllowedExternalRefs = []string{"auth@file"}
res, _ = e.Apply(input(t, labels))
r := res.Config.HTTP.Routers["kunde__node-1__wp"]
if r == nil || !reflect.DeepEqual(r.Middlewares, []string{"kunde__node-1__wp-www", "auth@file"}) {
t.Errorf("freigegebene Referenz: %+v", r)
}
}
func TestApply_UnknownLocalReferences(t *testing.T) {
labels := wpLabels()
labels["traefik.http.routers.x.rule"] = "Host(`x.kunde.de`)"
labels["traefik.http.routers.x.service"] = "andere__node-9__shop" // fremder Service per vollem Namen
labels["traefik.http.routers.y.rule"] = "Host(`y.kunde.de`)"
labels["traefik.http.routers.y.service"] = "wp"
labels["traefik.http.routers.y.middlewares"] = "gibtsnicht"
res, err := engine().Apply(input(t, labels))
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(kinds(res.Violations), []ViolationKind{ViolationUnknownRef, ViolationUnknownRef}) {
t.Errorf("Violations = %+v", res.Violations)
}
if len(res.Config.HTTP.Routers) != 1 {
t.Errorf("Router: %v", keys(res.Config.HTTP.Routers))
}
}
func TestApply_MiddlewareFeatureGate(t *testing.T) {
labels := wpLabels()
labels["traefik.http.middlewares.auth.basicauth.users"] = "u:$apr1$x"
labels["traefik.http.routers.wp.middlewares"] = "wp-www,auth"
res, _ := engine().Apply(input(t, labels))
if !reflect.DeepEqual(kinds(res.Violations), []ViolationKind{ViolationFeature}) || res.Config.HTTP != nil {
t.Errorf("ohne custom_middleware: Violations=%+v", res.Violations)
}
in := input(t, labels)
in.License = lic(license.TierPro, 10, license.FeatureCustomMiddleware)
res, _ = engine().Apply(in)
if len(res.Violations) != 0 || res.Config.HTTP.Middlewares["kunde__node-1__auth"] == nil {
t.Errorf("mit custom_middleware: Violations=%+v", res.Violations)
}
}
// chain und errors verweisen per Namen auf andere Middlewares/Services und
// würden den Namensraum umgehen – nie erlaubt.
func TestApply_ForbiddenMiddlewares(t *testing.T) {
for _, typ := range []string{"chain.middlewares", "errors.service"} {
labels := wpLabels()
labels["traefik.http.middlewares.m."+typ] = "andere__node-9__x"
labels["traefik.http.routers.wp.middlewares"] = "m"
in := input(t, labels)
in.License = lic(license.TierEnterprise, -1, license.FeatureCustomMiddleware)
res, _ := engine().Apply(in)
if !reflect.DeepEqual(kinds(res.Violations), []ViolationKind{ViolationForbiddenMiddleware}) || res.Config.HTTP != nil {
t.Errorf("%s: Violations=%+v", typ, res.Violations)
}
}
}
func TestApply_PrunesUnreferenced(t *testing.T) {
labels := wpLabels()
labels["traefik.http.middlewares.unused.compress"] = "true"
labels["traefik.http.services.unused.loadbalancer.server.port"] = "81"
labels["traefik.http.routers.wp.service"] = "wp"
res, _ := engine().Apply(input(t, labels))
if res.Config.HTTP.Middlewares["kunde__node-1__unused"] != nil || res.Config.HTTP.Services["kunde__node-1__unused"] != nil {
t.Error("nicht referenzierte Definitionen wurden übernommen")
}
}
func TestStageFor(t *testing.T) {
tests := []struct {
name string
tier license.Tier
status store.TenantStatus
level int
expire bool
want Stage
}{
{"Community aktiv", license.TierCommunity, store.TenantActive, 0, false, StageNormal},
{"Community ohne Mahnwesen", license.TierCommunity, store.TenantActive, 2, false, StageNormal},
{"Community abgelaufen", license.TierCommunity, store.TenantActive, 0, true, StageLanding},
{"Pro aktiv", license.TierPro, store.TenantActive, 0, false, StageNormal},
{"Mahnstufe 1", license.TierPro, store.TenantActive, 1, false, StageWatermark},
{"Mahnstufe 2", license.TierPro, store.TenantActive, 2, false, StageFiremark},
{"Mahnstufe 3", license.TierEnterprise, store.TenantActive, 3, false, StagePaywall},
{"Mahnstufe 9", license.TierPro, store.TenantActive, 9, false, StagePaywall},
{"Zahlung ausgesetzt", license.TierPro, store.TenantSuspendedPayment, 0, false, StagePaywall},
{"Status abgelaufen", license.TierPro, store.TenantExpired, 0, false, StageLanding},
{"Lizenz abgelaufen", license.TierPro, store.TenantActive, 1, true, StageLanding},
{"Quarantäne", license.TierPro, store.TenantQuarantined, 0, false, StageBlocked},
{"Quarantäne Community", license.TierCommunity, store.TenantQuarantined, 0, false, StageBlocked},
}
for _, tt := range tests {
l := lic(tt.tier, 5)
if tt.expire {
l.ValidUntil = now
}
if got := StageFor(Tenant{ID: "kunde", Status: tt.status, DunningLevel: tt.level}, l, now); got != tt.want {
t.Errorf("%s: Stage = %q, erwartet %q", tt.name, got, tt.want)
}
}
}
func TestApply_Interception(t *testing.T) {
tests := []struct {
stage Stage
level int
status store.TenantStatus
ack bool // Durchlass per Cookie nach Lander-Hinweis
}{
{StageWatermark, 1, store.TenantActive, true},
{StageFiremark, 2, store.TenantActive, true},
{StagePaywall, 0, store.TenantSuspendedPayment, false},
{StageLanding, 0, store.TenantExpired, false},
}
for _, tt := range tests {
t.Run(string(tt.stage), func(t *testing.T) {
in := input(t, wpLabels())
in.Tenant.Status, in.Tenant.DunningLevel = tt.status, tt.level
res, err := engine().Apply(in)
if err != nil {
t.Fatal(err)
}
if res.Stage != tt.stage {
t.Errorf("Stage = %q", res.Stage)
}
r := res.Config.HTTP.Routers["kunde__node-1__wp"]
if r == nil || r.Service != InterceptorService {
t.Fatalf("Router nicht zum Interceptor umgeleitet: %+v", r)
}
if r.Rule != "Host(`kunde.de`) || Host(`www.kunde.de`)" || r.TLS == nil {
t.Error("Regel/TLS dürfen sich nicht ändern")
}
mw := "sultan__kunde__node-1__wp"
if !reflect.DeepEqual(r.Middlewares, []string{"kunde__node-1__wp-www", mw}) {
t.Errorf("Middlewares = %v", r.Middlewares)
}
hdr := res.Config.HTTP.Middlewares[mw]["headers"].(map[string]any)["customRequestHeaders"].(map[string]any)
want := map[string]any{HeaderStage: string(tt.stage), HeaderTenant: "kunde", HeaderTier: "pro"}
if !reflect.DeepEqual(hdr, want) {
t.Errorf("Header = %v, erwartet %v", hdr, want)
}
svc := res.Config.HTTP.Services[InterceptorService]
if svc == nil || svc.LoadBalancer.Servers[0].URL != interceptor {
t.Errorf("Interceptor-Service fehlt: %+v", svc)
}
ack := res.Config.HTTP.Routers["sultan__kunde__node-1__wp"]
if !tt.ack {
if ack != nil {
t.Errorf("Stufe %s darf keinen Durchlass haben: %+v", tt.stage, ack)
}
return
}
if ack == nil {
t.Fatal("Durchlass-Router fehlt")
}
wantRule := "(Host(`kunde.de`) || Host(`www.kunde.de`)) && HeaderRegexp(`Cookie`, `(^|;\\s*)sultan_ack=" + string(tt.stage) + "(;|$)`)"
if ack.Rule != wantRule {
t.Errorf("Durchlass-Regel = %s\nerwartet %s", ack.Rule, wantRule)
}
if ack.Service != "kunde__node-1__wp" || !reflect.DeepEqual(ack.Middlewares, []string{"kunde__node-1__wp-www"}) {
t.Errorf("Durchlass-Router: %+v", ack)
}
if ack.TLS == nil || !reflect.DeepEqual(ack.EntryPoints, r.EntryPoints) {
t.Error("Durchlass-Router muss TLS/EntryPoints übernehmen")
}
})
}
}
// Hat der Mandant eine Priorität gesetzt, muss der Durchlass darüber liegen;
// ohne Priorität gewinnt er in Traefik ohnehin über die längere Regel.
func TestApply_AckPriority(t *testing.T) {
for _, prio := range []int{0, 50} {
labels := wpLabels()
if prio > 0 {
labels["traefik.http.routers.wp.priority"] = "50"
}
in := input(t, labels)
in.Tenant.DunningLevel = 1
res, _ := engine().Apply(in)
r := res.Config.HTTP.Routers["kunde__node-1__wp"]
ack := res.Config.HTTP.Routers["sultan__kunde__node-1__wp"]
wantAck := 0
if prio > 0 {
wantAck = prio + 1
}
if r.Priority != prio || ack.Priority != wantAck {
t.Errorf("prio=%d: Router %d, Durchlass %d", prio, r.Priority, ack.Priority)
}
}
}
func TestDunningLevel(t *testing.T) {
due := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
day := 24 * time.Hour
tests := []struct {
now time.Time
want int
}{
{due.Add(-day), 0},
{due, 0},
{due.Add(8*day - time.Second), 0},
{due.Add(8 * day), 1},
{due.Add(25*day - time.Second), 1},
{due.Add(25 * day), 2},
{due.Add(32 * day), 3},
{due.Add(365 * day), 3},
}
var sched DunningSchedule // Nullwert = Standard 8/25/32 Tage
for _, tt := range tests {
if got := sched.Level(due, tt.now); got != tt.want {
t.Errorf("%v überfällig: Stufe %d, erwartet %d", tt.now.Sub(due), got, tt.want)
}
}
if got := sched.Level(time.Time{}, due); got != 0 {
t.Errorf("ohne Fälligkeit: Stufe %d", got)
}
custom := DunningSchedule{Level1: day, Level2: 2 * day, Level3: 3 * day}
if got := custom.Level(due, due.Add(2*day)); got != 2 {
t.Errorf("eigener Zeitplan: Stufe %d, erwartet 2", got)
}
}
func TestApply_MaintenanceForDownService(t *testing.T) {
labels := wpLabels()
labels["traefik.http.routers.shop.rule"] = "Host(`shop.kunde.de`)"
labels["traefik.http.routers.shop.service"] = "shop"
labels["traefik.http.routers.wp.service"] = "wp"
labels["traefik.http.services.shop.loadbalancer.server.port"] = "8080"
in := input(t, labels)
in.DownServices = map[string]bool{"shop": true}
res, err := engine().Apply(in)
if err != nil {
t.Fatal(err)
}
if res.Stage != StageNormal {
t.Errorf("Mandanten-Stage = %q, Wartung betrifft nur den Router", res.Stage)
}
if res.Config.HTTP.Routers["kunde__node-1__shop"].Service != InterceptorService {
t.Error("ausgefallener Dienst nicht zum Interceptor umgeleitet")
}
if res.Config.HTTP.Routers["kunde__node-1__wp"].Service != "kunde__node-1__wp" {
t.Error("gesunder Dienst wurde umgeleitet")
}
hdr := res.Config.HTTP.Middlewares["sultan__kunde__node-1__shop"]["headers"].(map[string]any)["customRequestHeaders"].(map[string]any)
if hdr[HeaderStage] != string(StageMaintenance) || hdr[HeaderTier] != "pro" {
t.Errorf("Header = %v", hdr)
}
if res.Config.HTTP.Routers["sultan__kunde__node-1__shop"] != nil {
t.Error("Wartung darf keinen Cookie-Durchlass haben")
}
}
func TestApply_BlockedAndDrainedProduceNothing(t *testing.T) {
blocked := input(t, wpLabels())
blocked.Tenant.Status = store.TenantQuarantined
drained := input(t, wpLabels())
drained.NodeDrained = true
for name, in := range map[string]Input{"Quarantäne": blocked, "gedraint": drained} {
res, err := engine().Apply(in)
if err != nil || res.Config == nil || res.Config.HTTP != nil {
t.Errorf("%s: res=%+v err=%v", name, res.Config, err)
}
}
}
func TestApply_Errors(t *testing.T) {
needsInterceptor := input(t, wpLabels())
needsInterceptor.Tenant.DunningLevel = 1
if _, err := (&Engine{now: engine().now}).Apply(needsInterceptor); !errors.Is(err, ErrNoInterceptor) {
t.Errorf("ohne InterceptorURL: erwartet ErrNoInterceptor, bekommen %v", err)
}
tests := map[string]func(*Input){
"Lizenz eines anderen Mandanten": func(in *Input) { in.License.TenantID = "andere" },
"Mandanten-ID mit __": func(in *Input) { in.Tenant.ID, in.License.TenantID = "a__b", "a__b" },
"Mandanten-ID endet mit _": func(in *Input) { in.Tenant.ID, in.License.TenantID = "a_", "a_" },
"reservierte Mandanten-ID": func(in *Input) { in.Tenant.ID, in.License.TenantID = "sultan", "sultan" },
"Knoten-ID leer": func(in *Input) { in.NodeID = "" },
"Knoten-ID mit @": func(in *Input) { in.NodeID = "n@x" },
}
for name, mutate := range tests {
in := input(t, wpLabels())
mutate(&in)
if _, err := engine().Apply(in); !errors.Is(err, ErrInvalidInput) {
t.Errorf("%s: erwartet ErrInvalidInput, bekommen %v", name, err)
}
}
}
func TestApply_NilConfig(t *testing.T) {
in := input(t, wpLabels())
in.Config = nil
res, err := engine().Apply(in)
if err != nil || res.Config == nil || res.Config.HTTP != nil {
t.Errorf("res=%+v err=%v", res.Config, err)
}
}
func keys[V any](m map[string]V) []string {
var out []string
for k := range m {
out = append(out, k)
}
return out
}
// Andockstelle für die geplante Middleware-Zielbegrenzung: ohne Prüffunktion
// wird alles geduldet; mit Prüffunktion beobachten oder durchsetzen.
func TestApply_MiddlewareTargetHook(t *testing.T) {
labels := wpLabels()
labels["traefik.http.middlewares.auth.forwardauth.address"] = "http://10.0.0.99:9000/auth"
labels["traefik.http.routers.wp.middlewares"] = "wp-www,auth"
in := input(t, labels)
in.License = lic(license.TierPro, 10, license.FeatureCustomMiddleware)
// Ohne Hook: dulden.
res, _ := engine().Apply(in)
if len(res.Violations) != 0 || res.Config.HTTP.Routers["kunde__node-1__wp"] == nil {
t.Fatalf("ohne Hook: %+v", res.Violations)
}
var calls []string
hook := func(mwName string, def traefik.Middleware, backend string) error {
calls = append(calls, mwName+"→"+backend)
if _, ok := def["forwardauth"]; ok {
return errors.New("Ziel außerhalb des Backends")
}
return nil
}
observe := engine()
observe.MiddlewareTargetCheck = hook
res, _ = observe.Apply(in)
if res.Config.HTTP.Routers["kunde__node-1__wp"] == nil {
t.Error("Beobachtungsmodus darf nicht sperren")
}
if len(res.Violations) != 1 || res.Violations[0].Kind != ViolationMiddlewareTarget || !res.Violations[0].Observed {
t.Errorf("Beobachtungsmodus: %+v", res.Violations)
}
if !reflect.DeepEqual(calls, []string{"auth→http://100.64.0.42:80", "wp-www→http://100.64.0.42:80"}) {
t.Errorf("Hook-Aufrufe = %v", calls)
}
enforce := engine()
enforce.MiddlewareTargetCheck = hook
enforce.EnforceMiddlewareTargets = true
res, _ = enforce.Apply(in)
if res.Config.HTTP != nil {
t.Error("Durchsetzungsmodus: Router hätte entfernt werden müssen")
}
if len(res.Violations) != 1 || res.Violations[0].Kind != ViolationMiddlewareTarget || res.Violations[0].Observed {
t.Errorf("Durchsetzungsmodus: %+v", res.Violations)
}
}
func TestValidID(t *testing.T) {
for id, want := range map[string]bool{
"kunde": true, "kunde_schmidt": true, "node-proxmox-vm-42": true, "A1": true,
"": false, "a__b": false, "_a": false, "a_": false, "-a": false, "a.b": false, "a@b": false, "a b": false,
} {
if got := ValidID(id); got != want {
t.Errorf("ValidID(%q) = %v, erwartet %v", id, got, want)
}
}
}
+292
View File
@@ -0,0 +1,292 @@
package policy
import (
"errors"
"fmt"
"strings"
)
var (
// ErrSyntax: Die Traefik-Regel ist syntaktisch ungültig.
ErrSyntax = errors.New("policy: Regel-Syntax ungültig")
// ErrUnconstrained: Die Regel ist nicht in jedem Zweig auf eigene Hosts
// beschränkt und würde fremden Verkehr abgreifen (Catch-all).
ErrUnconstrained = errors.New("policy: Regel nicht auf Hosts beschränkt")
// ErrHostRegexp: HostRegexp lässt sich nicht gegen allowed_domains prüfen.
ErrHostRegexp = errors.New("policy: HostRegexp nicht erlaubt")
)
// HostsFromRule parst eine Traefik-Regel (Matcher, &&, ||, !, Klammern)
// und liefert alle positiv geforderten Hosts in Reihenfolge des Auftretens.
//
// Die Regel muss in jedem ||-Zweig durch Host(...) eingeschränkt sein:
// "Host(`a.de`) || PathPrefix(`/`)" passt sonst auf jeden Host. Negierte
// Hosts schränken nicht ein (sie schließen nur aus). Positives HostRegexp
// ist nicht erlaubt, weil sich ein Regex nicht gegen die Lizenz prüfen lässt.
func HostsFromRule(rule string) ([]string, error) {
toks, err := lex(rule)
if err != nil {
return nil, err
}
p := &parser{toks: toks}
root, err := p.parseOr()
if err != nil {
return nil, err
}
if p.peek().kind != tEOF {
return nil, p.errorf("unerwartetes %q", p.peek().text)
}
constrained, hosts, err := analyze(root, true)
if err != nil {
return nil, err
}
if !constrained {
return nil, ErrUnconstrained
}
return hosts, nil
}
// analyze ermittelt, ob ein Ausdruck auf Hosts beschränkt ist, und sammelt
// die Hosts aus positiven (nicht negierten) Host-Matchern.
func analyze(n *node, positive bool) (constrained bool, hosts []string, err error) {
switch n.op {
case opCall:
switch strings.ToLower(n.name) {
case "host":
if positive {
return true, n.args, nil
}
case "hostregexp":
if positive {
return false, nil, ErrHostRegexp
}
}
return false, nil, nil
case opNot:
_, h, err := analyze(n.kids[0], !positive)
return false, h, err
default: // opAnd, opOr
c1, h1, err := analyze(n.kids[0], positive)
if err != nil {
return false, nil, err
}
c2, h2, err := analyze(n.kids[1], positive)
if err != nil {
return false, nil, err
}
if n.op == opAnd {
return c1 || c2, append(h1, h2...), nil
}
return c1 && c2, append(h1, h2...), nil
}
}
// --- Lexer ---
type tokenKind int
const (
tEOF tokenKind = iota
tIdent
tString
tLParen
tRParen
tComma
tAnd
tOr
tNot
)
type token struct {
kind tokenKind
text string
pos int
}
func lex(s string) ([]token, error) {
var toks []token
for i := 0; i < len(s); {
c := s[i]
switch {
case c == ' ' || c == '\t' || c == '\n' || c == '\r':
i++
case c == '(':
toks = append(toks, token{tLParen, "(", i})
i++
case c == ')':
toks = append(toks, token{tRParen, ")", i})
i++
case c == ',':
toks = append(toks, token{tComma, ",", i})
i++
case c == '!':
toks = append(toks, token{tNot, "!", i})
i++
case strings.HasPrefix(s[i:], "&&"):
toks = append(toks, token{tAnd, "&&", i})
i += 2
case strings.HasPrefix(s[i:], "||"):
toks = append(toks, token{tOr, "||", i})
i += 2
case c == '`':
end := strings.IndexByte(s[i+1:], '`')
if end < 0 {
return nil, fmt.Errorf("%w: nicht geschlossener String ab Position %d", ErrSyntax, i)
}
toks = append(toks, token{tString, s[i+1 : i+1+end], i})
i += end + 2
case c == '"':
var b strings.Builder
j := i + 1
for ; j < len(s) && s[j] != '"'; j++ {
if s[j] == '\\' && j+1 < len(s) {
j++
}
b.WriteByte(s[j])
}
if j >= len(s) {
return nil, fmt.Errorf("%w: nicht geschlossener String ab Position %d", ErrSyntax, i)
}
toks = append(toks, token{tString, b.String(), i})
i = j + 1
case isIdentStart(c):
j := i
for j < len(s) && (isIdentStart(s[j]) || (s[j] >= '0' && s[j] <= '9')) {
j++
}
toks = append(toks, token{tIdent, s[i:j], i})
i = j
default:
return nil, fmt.Errorf("%w: unerwartetes Zeichen %q an Position %d", ErrSyntax, c, i)
}
}
return append(toks, token{tEOF, "Ende", len(s)}), nil
}
func isIdentStart(c byte) bool {
return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c == '_'
}
// --- Parser ---
type opKind int
const (
opCall opKind = iota
opNot
opAnd
opOr
)
type node struct {
op opKind
kids []*node
name string
args []string
}
type parser struct {
toks []token
pos int
}
func (p *parser) peek() token { return p.toks[p.pos] }
func (p *parser) next() token {
t := p.toks[p.pos]
if t.kind != tEOF {
p.pos++
}
return t
}
func (p *parser) errorf(format string, args ...any) error {
return fmt.Errorf("%w: %s an Position %d", ErrSyntax, fmt.Sprintf(format, args...), p.peek().pos)
}
func (p *parser) expect(k tokenKind, what string) error {
if p.peek().kind != k {
return p.errorf("erwartet %s, gefunden %q", what, p.peek().text)
}
p.next()
return nil
}
// parseOr: and ("||" and)*
func (p *parser) parseOr() (*node, error) {
left, err := p.parseAnd()
if err != nil {
return nil, err
}
for p.peek().kind == tOr {
p.next()
right, err := p.parseAnd()
if err != nil {
return nil, err
}
left = &node{op: opOr, kids: []*node{left, right}}
}
return left, nil
}
// parseAnd: unary ("&&" unary)*
func (p *parser) parseAnd() (*node, error) {
left, err := p.parseUnary()
if err != nil {
return nil, err
}
for p.peek().kind == tAnd {
p.next()
right, err := p.parseUnary()
if err != nil {
return nil, err
}
left = &node{op: opAnd, kids: []*node{left, right}}
}
return left, nil
}
// parseUnary: "!" unary | "(" or ")" | ident "(" string ("," string)* ")"
func (p *parser) parseUnary() (*node, error) {
switch p.peek().kind {
case tNot:
p.next()
kid, err := p.parseUnary()
if err != nil {
return nil, err
}
return &node{op: opNot, kids: []*node{kid}}, nil
case tLParen:
p.next()
inner, err := p.parseOr()
if err != nil {
return nil, err
}
if err := p.expect(tRParen, "')'"); err != nil {
return nil, err
}
return inner, nil
case tIdent:
name := p.next().text
if err := p.expect(tLParen, "'('"); err != nil {
return nil, err
}
var args []string
for {
if p.peek().kind != tString {
return nil, p.errorf("erwartet String-Argument, gefunden %q", p.peek().text)
}
args = append(args, p.next().text)
if p.peek().kind != tComma {
break
}
p.next()
}
if err := p.expect(tRParen, "')'"); err != nil {
return nil, err
}
return &node{op: opCall, name: name, args: args}, nil
default:
return nil, p.errorf("erwartet Matcher, '(' oder '!', gefunden %q", p.peek().text)
}
}
+87
View File
@@ -0,0 +1,87 @@
package policy
import (
"errors"
"reflect"
"testing"
)
func TestHostsFromRule(t *testing.T) {
tests := map[string][]string{
"Host(`a.de`)": {"a.de"},
"Host(\"a.de\")": {"a.de"},
"Host(`a.de`) || Host(`b.de`)": {"a.de", "b.de"},
"Host(`a.de`, `b.de`)": {"a.de", "b.de"}, // v2-Syntax
"Host(`a.de`) && PathPrefix(`/api`)": {"a.de"},
"(Host(`a.de`) || Host(`b.de`)) && Method(`GET`)": {"a.de", "b.de"},
"Host(`a.de`) && !Host(`b.de`)": {"a.de"}, // ausgeschlossene Hosts zählen nicht
"host(`a.de`)": {"a.de"},
" Host( `a.de` ) ": {"a.de"},
"Host(`a.de`) && (PathPrefix(`/x`) || Path(`/y`))": {"a.de"},
"PathPrefix(`/x`) && Host(`a.de`)": {"a.de"},
"Host(`a.de`) || (Host(`b.de`) && Header(`X`, `1`))": {"a.de", "b.de"},
}
for rule, want := range tests {
got, err := HostsFromRule(rule)
if err != nil {
t.Errorf("%s: unerwarteter Fehler %v", rule, err)
continue
}
if !reflect.DeepEqual(got, want) {
t.Errorf("%s: Hosts = %v, erwartet %v", rule, got, want)
}
}
}
// Regeln, die nicht auf eigene Hosts beschränkt sind, würden fremden
// Verkehr abgreifen (Catch-all).
func TestHostsFromRule_Unconstrained(t *testing.T) {
for _, rule := range []string{
"PathPrefix(`/`)",
"Host(`a.de`) || PathPrefix(`/`)",
"!Host(`a.de`)",
"Method(`GET`)",
"ClientIP(`10.0.0.0/8`)",
"!(Host(`a.de`) && Path(`/`))",
"(Host(`a.de`) && Path(`/`)) || Header(`X`, `1`)",
} {
if _, err := HostsFromRule(rule); !errors.Is(err, ErrUnconstrained) {
t.Errorf("%s: erwartet ErrUnconstrained, bekommen %v", rule, err)
}
}
}
func TestHostsFromRule_HostRegexp(t *testing.T) {
for _, rule := range []string{
"HostRegexp(`.+`)",
"Host(`a.de`) && HostRegexp(`^x`)",
"hostregexp(`.+`)",
} {
if _, err := HostsFromRule(rule); !errors.Is(err, ErrHostRegexp) {
t.Errorf("%s: erwartet ErrHostRegexp, bekommen %v", rule, err)
}
}
}
func TestHostsFromRule_SyntaxErrors(t *testing.T) {
for _, rule := range []string{
"",
"Host(",
"Host(`a.de`",
"Host(`a.de`) &&",
"Host(a.de)",
"Host(`a.de`) Host(`b.de`)",
")(",
"Host()",
"Host(`a.de)",
"&& Host(`a.de`)",
"Host(`a.de`) | Host(`b.de`)",
"Host(`a.de`,)",
"(Host(`a.de`)",
"Host(\"a.de)",
} {
if _, err := HostsFromRule(rule); !errors.Is(err, ErrSyntax) {
t.Errorf("%q: erwartet ErrSyntax, bekommen %v", rule, err)
}
}
}