Files
edgeguard-native/internal/haproxy/haproxy_test.go
Debian 32ab2c7f47 chore(lint): Backlog auf 0 + golangci-lint als HARTER Gate — v1.3.3
Go-Quality-Baseline-Rollout ABGESCHLOSSEN.

Code-Quality-Backlog (55 → 0):
- errcheck: unbehandelte Close/Rollback/Remove explizit `_ =`; fmt.Sscanf
  `_, _ =` (Zero-Value degradiert sauber).
- unused: toter Code entfernt (nodeIDOrHostname, stripTrailingNewline,
  acme.Service.user, strFold + ungenutzter Import).
- noctx (net/http): http.NewRequestWithContext mit vorhandenem ctx.
- staticcheck: QF1001/S1009/ST1005/SA9003.
- contextcheck: detached-by-design-Stellen mit begründetem //nolint.

Zwei echte Bugs beim Aufräumen gefunden+gefixt:
- backup/remote SFTP-Upload: dst.Close()-Flush-Fehler wurde verschluckt →
  unvollständiges Remote-File galt als Erfolg. Jetzt geprüft+gemeldet.
- haproxy_test: leere if-Assertion (SA9003) testete faktisch nichts →
  echte t.Errorf-Prüfung (kein HSTS für HSTS-disabled Domain).

Bewusste Config-Entscheidungen (.golangci.yml):
- noctx-on-os/exec ausgeschlossen: System-Command-Reloads (systemctl/nft/
  wg/pg) dürfen NICHT an den Request-Context gebunden werden — ein Client-
  Disconnect darf keinen laufenden Reload mitten in der Ausführung killen.
  net/http-noctx bleibt voll aktiv. KEINE exec-Zeile im Code angefasst.
- rowserrcheck/sqlclosecheck raus (database/sql-Linter, bei pgx nur FPs).

Gate scharf gestellt: Makefile release-check ruft golangci-lint jetzt als
HARTEN Gate (install-if-missing, pinned v2.12.2). `make release-check`
grün: vet, golangci-lint, govulncheck, build, test -race.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 00:49:07 +02:00

585 lines
19 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 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)
}
}
}