Files
sultan/pkg/policy/tcp_test.go
T

270 lines
9.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package policy
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")
}
}