package haproxy import ( "bytes" "strings" "testing" "git.netcell-it.de/projekte/edgeguard-native/internal/models" ) func renderView(t *testing.T, v View) string { t.Helper() var buf bytes.Buffer if err := tpl.Execute(&buf, v); err != nil { t.Fatalf("template execute: %v", err) } return buf.String() } func mkBackend(id int64, name string, hcp *string) models.Backend { return models.Backend{ ID: id, Name: name, Scheme: "http", LBAlgorithm: "roundrobin", Active: true, HealthCheckPath: hcp, } } func mkServer(backendID int64, name, addr string, port int) models.BackendServer { return models.BackendServer{ BackendID: backendID, Name: name, Address: addr, Port: port, Weight: 100, Active: true, } } func TestRender_BaselineHasFrontendsAndApiBackend(t *testing.T) { out := renderView(t, View{}) for _, w := range []string{ "frontend public_http", "frontend public_https", "frontend internal_stats", "backend api_backend", "server api1 127.0.0.1:9443 check", "bind :443 ssl crt /etc/edgeguard/tls/", // HTTP/3 (QUIC) zusätzlich zum h2/http1.1-Listener. "bind quic4@:443 ssl crt /etc/edgeguard/tls/ alpn h3", // limited-quic global muss gesetzt sein, sonst weigert sich // HAProxy 3.0 das quic4-bind anzunehmen (OpenSSL-Kompat-Layer). "limited-quic", // Alt-Svc damit Browser auf h3 upgraden. `Alt-Svc "h3=\":443\"; ma=86400"`, "path_beg /.well-known/acme-challenge/", "http-request redirect scheme https", // Client-IP-Weiterleitung an Backends — XFF kommt aus // defaults (option forwardfor), Proto + RealIP setzen wir // pro public-Frontend explizit. "option forwardfor", "http-request set-header X-Forwarded-Proto https", "http-request set-header X-Real-IP %[src]", } { if !strings.Contains(out, w) { t.Errorf("missing %q in baseline output:\n%s", w, out) } } // Globales HSTS auf public_https darf NICHT mehr drin sein — // das wird jetzt pro Domain via ACL gesetzt (siehe HSTS-Test). // mgmt_https hat aber weiterhin ein globales HSTS. publicIdx := strings.Index(out, "frontend public_https") mgmtIdx := strings.Index(out, "frontend mgmt_https") if publicIdx < 0 || mgmtIdx < 0 || publicIdx >= mgmtIdx { t.Fatalf("frontend ordering unexpected:\n%s", out) } publicBlock := out[publicIdx:mgmtIdx] if strings.Contains(publicBlock, "set-header Strict-Transport-Security") { t.Errorf("public_https soll KEIN globales HSTS mehr enthalten (pro-Domain ACL):\n%s", publicBlock) } } func TestRender_HSTSPerDomain(t *testing.T) { v := View{ Domains: []DomainView{ { Domain: models.Domain{ ID: 1, Name: "a.example.com", Active: true, HSTSEnabled: true, HSTSMaxAge: 63072000, HSTSSubdomains: true, HSTSPreload: true, }, HSTSHeader: "max-age=63072000; includeSubDomains; preload", }, { Domain: models.Domain{ ID: 2, Name: "b.example.com", Active: true, HSTSEnabled: false, }, }, }, } out := renderView(t, v) for _, w := range []string{ // Erst löschen (gegen Backend-set HSTS), dann setzen. `http-response del-header Strict-Transport-Security if { hdr(host) -i a.example.com }`, `http-response set-header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" if { hdr(host) -i a.example.com }`, } { if !strings.Contains(out, w) { t.Errorf("missing %q in per-domain HSTS output:\n%s", w, out) } } // HSTS soll für die Domain ohne HSTSEnabled gar nicht erst gerendert // werden: keine einzige HSTS-Zeile darf sich auf b.example.com beziehen. for _, line := range strings.Split(out, "\n") { if strings.Contains(line, "Strict-Transport-Security") && strings.Contains(line, "hdr(host) -i b.example.com") { t.Errorf("unexpected HSTS ACL for HSTS-disabled domain b.example.com: %q", line) } } } func TestRender_MaintenanceModeBlocksWith503(t *testing.T) { msg := `Wartung — bitte später wiederkommen.` v := View{ Domains: []DomainView{ { Domain: models.Domain{ ID: 1, Name: "down.example.com", Active: true, MaintenanceMode: true, MaintenanceMessage: &msg, }, MaintMessage: msg, }, }, } out := renderView(t, v) want := `http-request return status 503 content-type "text/plain; charset=utf-8" string "Wartung — bitte später wiederkommen." if { hdr(host) -i down.example.com }` if !strings.Contains(out, want) { t.Errorf("missing maintenance-503 line:\n%s", out) } } func TestRender_WWWRedirectToNaked(t *testing.T) { v := View{ Domains: []DomainView{ { Domain: models.Domain{ ID: 1, Name: "example.com", Active: true, WWWRedirect: "to-naked", }, RedirectFromHost: "www.example.com", }, }, } out := renderView(t, v) want := `http-request redirect prefix https://example.com code 301 if { hdr(host) -i www.example.com }` if !strings.Contains(out, want) { t.Errorf("missing www→naked redirect line:\n%s", out) } } func TestRender_WWWRedirectToWWW(t *testing.T) { v := View{ Domains: []DomainView{ { Domain: models.Domain{ ID: 1, Name: "www.example.com", Active: true, WWWRedirect: "to-www", }, RedirectFromHost: "example.com", }, }, } out := renderView(t, v) want := `http-request redirect prefix https://www.example.com code 301 if { hdr(host) -i example.com }` if !strings.Contains(out, want) { t.Errorf("missing naked→www redirect line:\n%s", out) } } func TestRender_RedirectTo(t *testing.T) { v := View{ Domains: []DomainView{ { Domain: models.Domain{ ID: 1, Name: "kvs.netcell-it.de", Active: true, RedirectTo: "https://zkm.netcell-it.de", }, RedirectTo: "https://zkm.netcell-it.de", }, }, } out := renderView(t, v) want := `http-request redirect location https://zkm.netcell-it.de code 301 if { hdr(host) -i kvs.netcell-it.de }` if !strings.Contains(out, want) { t.Errorf("missing domain→domain 301 redirect line:\n%s", out) } } func TestBuildRedirectTo(t *testing.T) { cases := []struct{ in, want string }{ {"https://zkm.netcell-it.de", "https://zkm.netcell-it.de"}, {" https://zkm.netcell-it.de ", "https://zkm.netcell-it.de"}, // getrimmt {"http://x.de", "http://x.de"}, {"", ""}, {"zkm.netcell-it.de", ""}, // kein Schema {"ftp://x.de", ""}, // falsches Schema {"https://x .de", ""}, // Whitespace → unsafe {"https://x\"de", ""}, // Quote → unsafe {"javascript:alert(1)", ""}, // kein http(s) } for _, c := range cases { got := buildRedirectTo(models.Domain{RedirectTo: c.in}) if got != c.want { t.Errorf("buildRedirectTo(%q) = %q, want %q", c.in, got, c.want) } } } func TestBuildHSTSHeader(t *testing.T) { cases := []struct { name string d models.Domain want string }{ {"disabled", models.Domain{HSTSEnabled: false}, ""}, {"defaults", models.Domain{HSTSEnabled: true}, "max-age=31536000"}, {"explicit", models.Domain{HSTSEnabled: true, HSTSMaxAge: 7200}, "max-age=7200"}, {"sub", models.Domain{HSTSEnabled: true, HSTSMaxAge: 60, HSTSSubdomains: true}, "max-age=60; includeSubDomains"}, {"sub+preload", models.Domain{HSTSEnabled: true, HSTSMaxAge: 60, HSTSSubdomains: true, HSTSPreload: true}, "max-age=60; includeSubDomains; preload"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if got := buildHSTSHeader(tc.d); got != tc.want { t.Errorf("buildHSTSHeader: got %q want %q", got, tc.want) } }) } } func TestBuildMaintMessage(t *testing.T) { msg := `He said "hi"` + "\n" + `and left` d := models.Domain{MaintenanceMode: true, MaintenanceMessage: &msg} got := buildMaintMessage(d) // Quotes durch ' ersetzt, Newline → Space. want := `He said 'hi' and left` if got != want { t.Errorf("buildMaintMessage: got %q want %q", got, want) } if buildMaintMessage(models.Domain{MaintenanceMode: false}) != "" { t.Errorf("buildMaintMessage should be empty when MaintenanceMode is off") } if got := buildMaintMessage(models.Domain{MaintenanceMode: true}); got == "" { t.Errorf("buildMaintMessage should emit fallback text when no message set") } } func TestRender_GlobalMaintenanceBlocksAllCustomerTraffic(t *testing.T) { v := View{ GlobalMaintenance: true, GlobalMaintenanceMessage: "Wartung läuft.", Domains: []DomainView{ {Domain: models.Domain{ID: 1, Name: "site.example.com", Active: true}}, }, } out := renderView(t, v) want := `http-request return status 503 content-type "text/plain; charset=utf-8" string "Wartung läuft."` if !strings.Contains(out, want) { t.Errorf("missing global-maintenance 503 block:\n%s", out) } // Block muss VOR den use_backend-Zeilen IM SELBEN public_https- // Frontend stehen (sonst ineffektiv — HAProxy execut'ed in-order, // return-actions terminieren die Chain). public_http hat ein // frühes use_backend api_backend für ACME — das hier nicht // matchen. pubIdx := strings.Index(out, "frontend public_https") mgmtIdxStart := strings.Index(out, "frontend mgmt_https") if pubIdx < 0 || mgmtIdxStart < 0 { t.Fatalf("frontends not found in output:\n%s", out) } publicBlock := out[pubIdx:mgmtIdxStart] idxBlock := strings.Index(publicBlock, want) idxUseBackend := strings.Index(publicBlock, "use_backend") if idxBlock < 0 || (idxUseBackend > 0 && idxBlock > idxUseBackend) { t.Errorf("global-maintenance block must precede use_backend in public_https\n block at %d, use_backend at %d", idxBlock, idxUseBackend) } // mgmt_https darf NICHT betroffen sein. mgmtIdx := strings.Index(out, "frontend mgmt_https") if mgmtIdx > 0 { mgmtBlock := out[mgmtIdx:] if strings.Contains(mgmtBlock, want) { t.Errorf("mgmt_https must NOT contain global-maintenance block:\n%s", mgmtBlock) } } } func TestRender_GlobalMaintenanceOff_NoBlock(t *testing.T) { v := View{ Domains: []DomainView{ {Domain: models.Domain{ID: 1, Name: "site.example.com", Active: true}}, }, } out := renderView(t, v) if strings.Contains(out, "Whole-Box-Maintenance") { t.Errorf("global-maintenance comment should not be rendered when off:\n%s", out) } } func TestRender_RateLimitEmitsStickTableAndDeny(t *testing.T) { v := View{ Domains: []DomainView{ { Domain: models.Domain{ ID: 7, Name: "api.example.com", Active: true, RateLimitRPS: 50, }, RateLimitThreshold: 500, // 50 rps × 10s }, }, } out := renderView(t, v) for _, w := range []string{ "backend rl_7", "stick-table type ip size 100k expire 10s store http_req_rate(10s)", `http-request track-sc0 src table rl_7 if { hdr(host) -i api.example.com }`, `http-request deny deny_status 429 if { hdr(host) -i api.example.com } { sc_http_req_rate(0) gt 500 }`, } { if !strings.Contains(out, w) { t.Errorf("missing %q in rate-limit output:\n%s", w, out) } } } func TestRender_NoRateLimitNoStickTable(t *testing.T) { v := View{ Domains: []DomainView{ { Domain: models.Domain{ID: 9, Name: "a.example.com", Active: true}, }, }, } out := renderView(t, v) if strings.Contains(out, "backend rl_9") { t.Errorf("stick-table backend rendered for domain without rate-limit:\n%s", out) } if strings.Contains(out, "track-sc0") { t.Errorf("track-sc0 emitted without rate-limit:\n%s", out) } } func TestRender_BodySizeDeny413(t *testing.T) { v := View{ Domains: []DomainView{ { Domain: models.Domain{ ID: 1, Name: "upload.example.com", Active: true, MaxBodyKB: 2048, }, MaxBodyBytes: 2048 * 1024, }, }, } out := renderView(t, v) want := `http-request deny deny_status 413 if { hdr(host) -i upload.example.com } { req.hdr_val(content-length) -m int gt 2097152 }` if !strings.Contains(out, want) { t.Errorf("missing 413 body-size deny:\n%s", out) } } func TestRender_CustomResponseHeaders(t *testing.T) { v := View{ Domains: []DomainView{ { Domain: models.Domain{ID: 1, Name: "x.example.com", Active: true}, ResponseHeaders: []ResponseHeaderView{ {Name: "X-Frame-Options", Value: "DENY"}, {Name: "Content-Security-Policy", Value: "default-src 'self'"}, }, }, }, } out := renderView(t, v) for _, w := range []string{ // del + set pro Custom-Header, damit Upstream-Werte // garantiert überschrieben werden. `http-response del-header X-Frame-Options if { hdr(host) -i x.example.com }`, `http-response set-header X-Frame-Options "DENY" if { hdr(host) -i x.example.com }`, `http-response del-header Content-Security-Policy if { hdr(host) -i x.example.com }`, `http-response set-header Content-Security-Policy "default-src 'self'" if { hdr(host) -i x.example.com }`, } { if !strings.Contains(out, w) { t.Errorf("missing %q:\n%s", w, out) } } } func TestSanitizeHeaderValue(t *testing.T) { cases := map[string]string{ `plain`: `plain`, `with "quotes"`: `with 'quotes'`, "with\nnewline": "with newline", "crlf\r\nattack": "crlf attack", `csp default-src 'self'`: `csp default-src 'self'`, } for in, want := range cases { if got := sanitizeHeaderValue(in); got != want { t.Errorf("sanitizeHeaderValue(%q) = %q want %q", in, got, want) } } } func TestBuildRedirectFromHost(t *testing.T) { cases := []struct { name string d models.Domain want string }{ {"none", models.Domain{Name: "example.com"}, ""}, {"to-naked", models.Domain{Name: "example.com", WWWRedirect: "to-naked"}, "www.example.com"}, {"to-naked invalid (name already has www)", models.Domain{Name: "www.example.com", WWWRedirect: "to-naked"}, ""}, {"to-www", models.Domain{Name: "www.example.com", WWWRedirect: "to-www"}, "example.com"}, {"to-www invalid (name lacks www)", models.Domain{Name: "example.com", WWWRedirect: "to-www"}, ""}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if got := buildRedirectFromHost(tc.d); got != tc.want { t.Errorf("buildRedirectFromHost: got %q want %q", got, tc.want) } }) } } func TestRender_DomainRoutesEmitUseBackend(t *testing.T) { v := View{ Backends: []BackendView{ {Backend: mkBackend(1, "app", nil), Servers: []models.BackendServer{ mkServer(1, "app", "10.0.0.10", 8080), }}, {Backend: mkBackend(2, "api", nil), Servers: []models.BackendServer{ mkServer(2, "api", "10.0.0.20", 9000), }}, }, Domains: []DomainView{{ Domain: models.Domain{ID: 1, Name: "example.com", Active: true}, Routes: []RouteView{ {PathPrefix: "/", BackendID: 1}, {PathPrefix: "/api", BackendID: 2}, }, }}, } out := renderView(t, v) for _, w := range []string{ "backend eg_backend_1", "server app 10.0.0.10:8080", "backend eg_backend_2", "server api 10.0.0.20:9000", "balance roundrobin", "use_backend eg_backend_1 if { hdr(host) -i example.com } { path_beg / }", "use_backend eg_backend_2 if { hdr(host) -i example.com } { path_beg /api }", } { if !strings.Contains(out, w) { t.Errorf("missing %q in output:\n%s", w, out) } } } func TestRender_HealthCheckPathAddsCheckInter(t *testing.T) { hcp := "/health" v := View{ Backends: []BackendView{ {Backend: mkBackend(1, "app", &hcp), Servers: []models.BackendServer{ mkServer(1, "app", "10.0.0.10", 8080), }}, }, } out := renderView(t, v) if !strings.Contains(out, "server app 10.0.0.10:8080 check inter 5s") { t.Errorf("expected `check inter 5s` for backend with health_check_path:\n%s", out) } if !strings.Contains(out, "option httpchk") { t.Errorf("expected `option httpchk` when health_check_path set:\n%s", out) } } func TestRender_HTTPSHealthcheckPinsAlpnHTTP1(t *testing.T) { // L7TOUT-Bug: ohne `check-alpn http/1.1` handelt der Check h2 // aus (vom server-Stmt geerbt) und hängt, weil option httpchk // HTTP/1.x sendet. Test stellt sicher dass HTTPS+Healthcheck // das ALPN für den Check pinnt. hcp := "/" v := View{ Backends: []BackendView{ { Backend: models.Backend{ID: 9, Name: "tls-app", Scheme: "https", LBAlgorithm: "roundrobin", HealthCheckPath: &hcp, Active: true}, Servers: []models.BackendServer{ {BackendID: 9, Name: "tls-1", Address: "10.0.0.30", Port: 8443, Weight: 100, Active: true}, }, }, { // Gegenprobe: HTTP-Backend mit Healthcheck darf KEIN // check-alpn bekommen (ALPN gibt's nur bei SSL). Backend: models.Backend{ID: 10, Name: "plain-app", Scheme: "http", LBAlgorithm: "roundrobin", HealthCheckPath: &hcp, Active: true}, Servers: []models.BackendServer{ {BackendID: 10, Name: "plain-1", Address: "10.0.0.31", Port: 80, Weight: 100, Active: true}, }, }, }, } out := renderView(t, v) idxTLS := strings.Index(out, "backend eg_backend_9") idxPlain := strings.Index(out, "backend eg_backend_10") if idxTLS < 0 || idxPlain < 0 { t.Fatalf("backend sections missing:\n%s", out) } tlsBlock := out[idxTLS:idxPlain] plainBlock := out[idxPlain:] if !strings.Contains(tlsBlock, "check-alpn http/1.1") { t.Errorf("HTTPS+healthcheck soll check-alpn http/1.1 pinnen:\n%s", tlsBlock) } if strings.Contains(plainBlock, "check-alpn") { t.Errorf("HTTP-Backend darf KEIN check-alpn bekommen:\n%s", plainBlock) } } func TestRender_WebSocketEmitsTunnelTimeout(t *testing.T) { v := View{ Backends: []BackendView{ { Backend: models.Backend{ID: 7, Name: "vmm", Scheme: "https", LBAlgorithm: "source", WebSocket: true, Active: true}, Servers: []models.BackendServer{ {BackendID: 7, Name: "vmm-1", Address: "10.0.5.14", Port: 8006, Weight: 100, Active: true}, }, }, { Backend: models.Backend{ID: 8, Name: "api", Scheme: "http", LBAlgorithm: "roundrobin", WebSocket: false, Active: true}, Servers: []models.BackendServer{ {BackendID: 8, Name: "api-1", Address: "10.0.5.20", Port: 8080, Weight: 100, Active: true}, }, }, }, } out := renderView(t, v) // vmm soll tunnel-Timeout haben, api nicht. idxVmm := strings.Index(out, "backend eg_backend_7") idxApi := strings.Index(out, "backend eg_backend_8") if idxVmm < 0 || idxApi < 0 { t.Fatalf("backend sections missing in output:\n%s", out) } vmmBlock := out[idxVmm:idxApi] apiBlock := out[idxApi:] if !strings.Contains(vmmBlock, "timeout tunnel 1h") { t.Errorf("vmm-Block sollte `timeout tunnel 1h` enthalten:\n%s", vmmBlock) } if strings.Contains(apiBlock, "timeout tunnel") { t.Errorf("api-Block soll KEIN `timeout tunnel` enthalten:\n%s", apiBlock) } } func TestRender_MultiServerPool(t *testing.T) { v := View{ Backends: []BackendView{ { Backend: models.Backend{ID: 1, Name: "vmm", Scheme: "http", LBAlgorithm: "leastconn", Active: true}, Servers: []models.BackendServer{ {BackendID: 1, Name: "vmm-1", Address: "10.0.0.11", Port: 8080, Weight: 100, Active: true}, {BackendID: 1, Name: "vmm-2", Address: "10.0.0.12", Port: 8080, Weight: 100, Active: true}, {BackendID: 1, Name: "vmm-3", Address: "10.0.0.13", Port: 8080, Weight: 50, Backup: true, Active: true}, }, }, }, } out := renderView(t, v) for _, w := range []string{ "backend eg_backend_1", "balance leastconn", "server vmm-1 10.0.0.11:8080", "server vmm-2 10.0.0.12:8080", "server vmm-3 10.0.0.13:8080", "weight 50", " backup", } { if !strings.Contains(out, w) { t.Errorf("missing %q in multi-server output:\n%s", w, out) } } }