package policy import ( "errors" "reflect" "testing" "time" "sultan/pkg/license" "sultan/pkg/store" "sultan/pkg/traefik" ) func TestHostsFromSNIRule(t *testing.T) { tests := map[string][]string{ "HostSNI(`a.de`)": {"a.de"}, "HostSNI(`a.de`) || HostSNI(`b.de`)": {"a.de", "b.de"}, "HostSNI(`a.de`, `b.de`)": {"a.de", "b.de"}, "hostsni(`a.de`) && ClientIP(`10.0.0.0/8`)": {"a.de"}, "HostSNI(`a.de`) && (ALPN(`h2`) || ALPN(`acme`))": {"a.de"}, "(HostSNI(`a.de`) || HostSNI(`b.de`)) && !ClientIP(`1.2.3.4`)": {"a.de", "b.de"}, } for rule, want := range tests { got, err := HostsFromSNIRule(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) } } } // Nur explizite Hosts: HostSNI(`*`) wäre ein Catch-all für den ganzen // Entrypoint, Wildcards wie *.x.de versteht Traefik in HostSNI ohnehin nicht. func TestHostsFromSNIRule_Rejected(t *testing.T) { tests := map[string]error{ "HostSNI(`*`)": ErrUnconstrained, "HostSNI(`*.lab.kunde.de`)": ErrUnconstrained, "HostSNI(`a.de`) || HostSNI(`*`)": ErrUnconstrained, "ClientIP(`10.0.0.0/8`)": ErrUnconstrained, "HostSNI(`a.de`) || ALPN(`h2`)": ErrUnconstrained, "Host(`a.de`)": ErrUnconstrained, // HTTP-Matcher zählt hier nicht "HostSNIRegexp(`^.+\\.kunde\\.de$`)": ErrHostRegexp, "HostSNI(`a.de`) && HostSNIRegexp(`x`)": ErrHostRegexp, "HostSNI(`a.de`": ErrSyntax, } for rule, want := range tests { if _, err := HostsFromSNIRule(rule); !errors.Is(err, want) { t.Errorf("%s: erwartet %v, bekommen %v", rule, want, err) } } } // Umgekehrt schränkt HostSNI eine HTTP-Regel nicht ein. func TestHostsFromRule_IgnoresHostSNI(t *testing.T) { if _, err := HostsFromRule("HostSNI(`a.de`)"); !errors.Is(err, ErrUnconstrained) { t.Errorf("erwartet ErrUnconstrained, bekommen %v", err) } } func caLabels() map[string]string { return map[string]string{ "traefik.tcp.routers.ca.rule": "HostSNI(`ca.lab.kunde.de`)", "traefik.tcp.routers.ca.entrypoints": "websecure", "traefik.tcp.routers.ca.tls.passthrough": "true", "traefik.tcp.services.ca.loadbalancer.server.port": "9000", } } func tcpInput(t *testing.T, labels map[string]string) Input { in := input(t, labels) in.License = lic(license.TierPro, 10, license.FeatureTCPStream) return in } func TestApply_TCPPassthrough(t *testing.T) { res, err := engine().Apply(tcpInput(t, caLabels())) if err != nil { t.Fatal(err) } if len(res.Violations) != 0 { t.Errorf("Violations = %+v", res.Violations) } want := &traefik.Configuration{TCP: &traefik.TCPConfiguration{ Routers: map[string]*traefik.TCPRouter{ "kunde__node-1__ca": { EntryPoints: []string{TCPEntryPoint}, Service: "kunde__node-1__ca", Rule: "HostSNI(`ca.lab.kunde.de`)", TLS: &traefik.TCPRouterTLS{Passthrough: true}, }, }, Services: map[string]*traefik.TCPService{ "kunde__node-1__ca": {LoadBalancer: &traefik.TCPServersLoadBalancer{Servers: []traefik.TCPServer{{Address: "100.64.0.42:9000"}}}}, }, }} if !reflect.DeepEqual(res.Config, want) { t.Errorf("Konfiguration weicht ab\n got: %+v\nwant: %+v", res.Config.TCP, want.TCP) } } // Ohne entrypoints-Label würde Traefik den Router auf alle Entrypoints legen // (auch :80) – Sultan setzt immer websecure. func TestApply_TCPEntryPointDefault(t *testing.T) { labels := caLabels() delete(labels, "traefik.tcp.routers.ca.entrypoints") res, err := engine().Apply(tcpInput(t, labels)) if err != nil { t.Fatal(err) } r := res.Config.TCP.Routers["kunde__node-1__ca"] if r == nil || !reflect.DeepEqual(r.EntryPoints, []string{TCPEntryPoint}) { t.Errorf("Router = %+v", r) } } func TestApply_TCPViolations(t *testing.T) { tests := map[string]struct { mutate func(map[string]string, *Input) want ViolationKind }{ "ohne Feature": {func(_ map[string]string, in *Input) { in.License = lic(license.TierPro, 10) }, ViolationFeature}, "ohne Passthrough": {func(l map[string]string, _ *Input) { delete(l, "traefik.tcp.routers.ca.tls.passthrough") l["traefik.tcp.routers.ca.tls"] = "true" }, ViolationTCPPassthrough}, "Passthrough aus": {func(l map[string]string, _ *Input) { l["traefik.tcp.routers.ca.tls.passthrough"] = "false" }, ViolationTCPPassthrough}, "anderer Entrypoint": {func(l map[string]string, _ *Input) { l["traefik.tcp.routers.ca.entrypoints"] = "websecure,postgres" }, ViolationEntryPoint}, "Catch-all": {func(l map[string]string, _ *Input) { l["traefik.tcp.routers.ca.rule"] = "HostSNI(`*`)" }, ViolationCatchAll}, "Wildcard": {func(l map[string]string, _ *Input) { l["traefik.tcp.routers.ca.rule"] = "HostSNI(`*.lab.kunde.de`)" }, ViolationCatchAll}, "Regexp": {func(l map[string]string, _ *Input) { l["traefik.tcp.routers.ca.rule"] = "HostSNIRegexp(`.+`)" }, ViolationHostRegexp}, "Syntax": {func(l map[string]string, _ *Input) { l["traefik.tcp.routers.ca.rule"] = "HostSNI(`ca.lab.kunde.de`" }, ViolationRuleSyntax}, "fremde Domain": {func(l map[string]string, _ *Input) { l["traefik.tcp.routers.ca.rule"] = "HostSNI(`ca.lab.kunde.de`) || HostSNI(`ca.fremd.de`)" }, ViolationDomain}, "externer Service": {func(l map[string]string, _ *Input) { l["traefik.tcp.routers.ca.service"] = "ca@file" }, ViolationExternalRef}, "unbekannter Service": {func(l map[string]string, _ *Input) { l["traefik.tcp.routers.ca.service"] = "andere" }, ViolationUnknownRef}, } for name, tt := range tests { t.Run(name, func(t *testing.T) { labels := caLabels() in := tcpInput(t, labels) tt.mutate(labels, &in) in.Config = cfg(t, labels) res, err := engine().Apply(in) if err != nil { t.Fatal(err) } if !reflect.DeepEqual(kinds(res.Violations), []ViolationKind{tt.want}) || res.Violations[0].Router != "ca" { t.Errorf("Violations = %+v, erwartet %s", res.Violations, tt.want) } if res.Config.TCP != nil { t.Errorf("Router trotz Verstoß übernommen: %+v", res.Config.TCP) } }) } } // Die Domainprüfung erlaubt einzelne Hosts unter einer Wildcard aus // allowed_domains (*.lab.kunde.de). func TestApply_TCPHostUnderLicenseWildcard(t *testing.T) { in := tcpInput(t, caLabels()) in.License.AllowedDomains = []string{"*.lab.kunde.de"} res, err := engine().Apply(in) if err != nil || len(res.Violations) != 0 || res.Config.TCP == nil { t.Errorf("res=%+v violations=%+v err=%v", res.Config, res.Violations, err) } } // TCP-Router zählen gegen max_routes; HTTP-Router werden zuerst vergeben. func TestApply_TCPRouteLimit(t *testing.T) { labels := caLabels() for k, v := range wpLabels() { labels[k] = v } in := tcpInput(t, labels) in.License.MaxRoutes = 1 res, err := engine().Apply(in) if err != nil { t.Fatal(err) } if res.Config.HTTP == nil || len(res.Config.HTTP.Routers) != 1 || res.Config.TCP != nil { t.Errorf("erwartet nur den HTTP-Router: %+v / %+v", res.Config.HTTP, res.Config.TCP) } if !reflect.DeepEqual(kinds(res.Violations), []ViolationKind{ViolationRouteLimit}) || res.Violations[0].Router != "ca" { t.Errorf("Violations = %+v", res.Violations) } in.License.MaxRoutes = 2 res, _ = engine().Apply(in) if res.Config.TCP == nil || len(res.Violations) != 0 { t.Errorf("max_routes=2: %+v %+v", res.Config.TCP, res.Violations) } } // Ohne Interceptor gilt: Hinweisstufen lassen durch, ab Paywall und bei jeder // Wartung ist die Route weg. func TestApply_TCPStages(t *testing.T) { future := now.Add(time.Hour) tests := map[string]struct { mutate func(*Input) keep bool }{ "normal": {func(*Input) {}, true}, "Mahnstufe 1": {func(in *Input) { in.Tenant.DunningLevel = 1 }, true}, "Mahnstufe 2": {func(in *Input) { in.Tenant.DunningLevel = 2 }, true}, "Paywall": {func(in *Input) { in.Tenant.DunningLevel = 3 }, false}, "ausgesetzt": {func(in *Input) { in.Tenant.Status = store.TenantSuspendedPayment }, false}, "abgelaufen": {func(in *Input) { in.Tenant.Status = store.TenantExpired }, false}, "Quarantäne": {func(in *Input) { in.Tenant.Status = store.TenantQuarantined }, false}, "gedraint": {func(in *Input) { in.NodeDrained = true }, false}, "Knotenwartung": {func(in *Input) { in.Maintenance = &Maintenance{Until: future} }, false}, "Knotenwartung vorbei": {func(in *Input) { in.Maintenance = &Maintenance{Until: now.Add(-time.Minute)} }, true}, "Dienstwartung": {func(in *Input) { in.ServiceMaintenance = map[string]Maintenance{"ca": {}} }, false}, "Healthcheck": {func(in *Input) { in.DownServices = map[string]bool{"ca": true} }, false}, "getrennt": {func(in *Input) { in.Disconnected = true }, false}, } for name, tt := range tests { t.Run(name, func(t *testing.T) { in := tcpInput(t, caLabels()) tt.mutate(&in) res, err := (&Engine{now: engine().now}).Apply(in) // ohne Interceptor: TCP braucht keinen if err != nil { t.Fatal(err) } if got := res.Config.TCP != nil; got != tt.keep { t.Errorf("Route vorhanden = %v, erwartet %v", got, tt.keep) } if len(res.Violations) != 0 { t.Errorf("Violations = %+v", res.Violations) } }) } } func TestApply_TCPDoesNotMutateInput(t *testing.T) { labels := caLabels() delete(labels, "traefik.tcp.routers.ca.entrypoints") in := tcpInput(t, labels) before := cfg(t, labels) if _, err := engine().Apply(in); err != nil { t.Fatal(err) } if !reflect.DeepEqual(in.Config, before) { t.Error("Eingabe-Konfiguration wurde verändert") } }