270 lines
9.6 KiB
Go
270 lines
9.6 KiB
Go
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")
|
||
}
|
||
}
|