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>
217 lines
6.7 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|