Files
edgeguard-native/internal/haproxy/haproxy_test.go
Debian 3178e25e78 feat(haproxy): X-Forwarded-Proto + X-Real-IP an alle Backends weiterleiten
User-Frage: „Werden via haproxy die echten IPs durchgereicht?". Antwort:
X-Forwarded-For ja (option forwardfor), aber Apps wie WordPress/Mailcow
brauchen zusätzlich X-Forwarded-Proto=https um Redirect-Loops zu
vermeiden, und X-Real-IP ist die bequeme single-value-Variante die viele
Tools out-of-the-box lesen (ohne die XFF-Chain parsen zu müssen).

Beide Frontends (public_https + mgmt_https) emittieren jetzt:
  http-request set-header X-Forwarded-Proto https
  http-request set-header X-Real-IP %[src]

Was Backends sehen:
  X-Forwarded-For:  <client-ip>             (defaults: option forwardfor)
  X-Forwarded-Proto: https                  (NEW)
  X-Real-IP:        <client-ip>             (NEW, single value)

PROXY-Protocol-Toggle pro Backend kommt nicht in diesem Release — der
Operator hat „nur Header-Variante" gewählt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 19:28:41 +02:00

217 lines
6.7 KiB
Go

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/",
"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)
}
}
}
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)
}
}
}