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