feat: umfangreiches UI+API-Polish (v1.1.36–1.1.42)

Backend:
- Audit-Log: Search-Endpoint mit ILIKE-Filter (actor/action/subject/date)
- NTP: /ntp/status via chronyc tracking (Stratum, Offset, Quelle)
- System: /service-restart mit Allowlist (haproxy/squid/unbound/chrony/scheduler)
- Domain-Response-Headers + Rate-Limit (Migration 0024)
- Join-Tokens (Migration 0025), Cluster-mTLS, Aggregator-Fan-Out
- apt-Service für Update-Banner (apt-get update + Versionsprüfung)
- Backup-Retry mit exponential backoff (retry_apt 3×)
- publish.sh fail-fast + cleanup-old.sh (max 10 Versionen)

Frontend:
- Audit-Log-Page (/audit) mit Filter + Pagination
- ErrorBoundary an React-Root + Vite build-target festgenagelt (iOS 15+)
- Storage-Schema-Stamp: auto-wipe bei Versions-Mismatch (blank-page-Fix)
- EmptyState-Komponente überall ausgerollt
- SSL: Aggregate-Karte (total/expiring/expired/errors)
- Backups: Aggregate-Karte (letzter Backup/Größe/Fehlschläge 24h) + Backup-Now
- NTP: Sync-Status-Karte (chronyc tracking live)
- Domains: Backend-UP/DOWN-Chip aus HAProxy-Stats
- Backends: HAProxy-Status-Spalte (UP/DEGRADED/DOWN)
- Settings: Service-Neustart-Karte (haproxy/squid/unbound/chrony/scheduler)
- Settings: Upgrade-Status-Card, Wartungsmodus, Auto-Update, Retention
- Dashboard: Recent-Alerts, Cluster-Health, License-Chip, Onboarding-Hint
- System-Regeln im Firewall als eigener Tab

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-19 16:18:41 +02:00
parent 3178e25e78
commit 35b7308ce2
82 changed files with 8408 additions and 392 deletions

View File

@@ -10,6 +10,11 @@ global
daemon
ssl-default-bind-options ssl-min-ver TLSv1.2 no-tls-tickets
ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
# HAProxy 3.0.x in Trixie ist mit USE_QUIC=1 + USE_QUIC_OPENSSL_COMPAT=1
# gebaut; die System-OpenSSL kann QUIC aber nur über den Kompat-Layer.
# Ohne dieses Flag verweigert HAProxy `bind quic4@…` mit "this SSL
# library does not support the QUIC protocol".
limited-quic
defaults
log global
@@ -50,8 +55,14 @@ frontend public_http
# Admin-Panel fällt. mailgateway/enconf-Pattern.
frontend public_https
bind :443 ssl crt /etc/edgeguard/tls/ alpn h2,http/1.1
# HTTP/3 via QUIC (UDP/443). HAProxy 3.0.x ist mit USE_QUIC=1
# gebaut; Browser fallen via Alt-Svc-Header (siehe unten) für
# Folge-Requests auf h3 zurück.
bind quic4@:443 ssl crt /etc/edgeguard/tls/ alpn h3
http-response set-header Strict-Transport-Security "max-age=31536000"
# Alt-Svc: signalisiert dass h3 auf demselben Port verfügbar ist.
# ma=86400 = Browser darf den Hinweis 24h cachen.
http-response set-header Alt-Svc "h3=\":443\"; ma=86400"
# Client-IP-Weiterleitung an Backends. `option forwardfor` (defaults)
# setzt X-Forwarded-For; wir ergänzen Proto + RealIP damit Apps
@@ -60,7 +71,47 @@ frontend public_https
http-request set-header X-Forwarded-Proto https
http-request set-header X-Real-IP %[src]
{{- if .GlobalMaintenance}}
# Whole-Box-Maintenance — Settings → Maintenance-Mode aktiv. Dieser
# Block kommt VOR allen per-Domain ACLs und blockt JEDEN Customer-
# Request. Mgmt-UI auf :3443 (mgmt_https) ist davon nicht betroffen.
http-request return status 503 content-type "text/plain; charset=utf-8" string "{{.GlobalMaintenanceMessage}}"
{{- end}}
{{- range $d := .Domains}}
{{- if $d.RedirectFromHost}}
# www-Redirect: {{$d.RedirectFromHost}}{{$d.Name}}
http-request redirect prefix https://{{$d.Name}} code 301 if { hdr(host) -i {{$d.RedirectFromHost}} }
{{- end}}
{{- if $d.MaintenanceMode}}
# Wartungs-Modus für {{$d.Name}} — alle Requests werden mit 503 beantwortet.
http-request return status 503 content-type "text/plain; charset=utf-8" string "{{$d.MaintMessage}}" if { hdr(host) -i {{$d.Name}} }
{{- end}}
{{- if $d.MaxBodyBytes}}
# Body-Size-Cap für {{$d.Name}}: {{$d.MaxBodyBytes}} Bytes.
# Nur Content-Length wird geprüft — Chunked-Bodies wären erst nach
# http-buffer-request abgreifbar, das wollen wir auf Frontend-Level
# nicht aktivieren (verbraucht RAM pro Connection).
http-request deny deny_status 413 if { hdr(host) -i {{$d.Name}} } { req.hdr_val(content-length) -m int gt {{$d.MaxBodyBytes}} }
{{- end}}
{{- if $d.RateLimitThreshold}}
# Rate-Limit für {{$d.Name}}: {{$d.RateLimitRPS}} req/s pro Client-IP
# (Schwelle = rps × 10s-Fenster = {{$d.RateLimitThreshold}} hits).
http-request track-sc0 src table rl_{{$d.ID}} if { hdr(host) -i {{$d.Name}} }
http-request deny deny_status 429 if { hdr(host) -i {{$d.Name}} } { sc_http_req_rate(0) gt {{$d.RateLimitThreshold}} }
{{- end}}
{{- if $d.HSTSHeader}}
# del + set damit ein vom Backend gesetztes HSTS (z. B. nginx mit
# eigener add_header-Direktive) garantiert von unserer Policy
# überschrieben wird — sonst tauchen 2 Strict-Transport-Security
# Header in der Response auf.
http-response del-header Strict-Transport-Security if { hdr(host) -i {{$d.Name}} }
http-response set-header Strict-Transport-Security "{{$d.HSTSHeader}}" if { hdr(host) -i {{$d.Name}} }
{{- end}}
{{- range $h := $d.ResponseHeaders}}
http-response del-header {{$h.Name}} if { hdr(host) -i {{$d.Name}} }
http-response set-header {{$h.Name}} "{{$h.Value}}" if { hdr(host) -i {{$d.Name}} }
{{- end}}
{{- range $r := $d.Routes}}
use_backend eg_backend_{{$r.BackendID}} if { hdr(host) -i {{$d.Name}} } { path_beg {{$r.PathPrefix}} }
{{- end}}
@@ -99,6 +150,20 @@ backend api_backend
timeout tunnel 1h
server api1 127.0.0.1:9443 check
{{- /* Per-Domain Rate-Limit Stick-Tables.
Eigenes Backend pro Domain damit verschiedene Schwellen nicht
miteinander interferieren; HAProxy 3.0 erlaubt mehrere
stick-table-Backends die per Frontend mit track-sc0 angesteuert
werden. expire 10s hält den Speicher knapp. */ -}}
{{- range $d := .Domains}}
{{- if $d.RateLimitThreshold}}
backend rl_{{$d.ID}}
# Rate-Limit-Counter für {{$d.Name}}; gefüllt von public_https.
stick-table type ip size 100k expire 10s store http_req_rate(10s)
{{- end}}
{{- end}}
{{- range $b := .Backends}}
backend eg_backend_{{$b.ID}}

View File

@@ -20,8 +20,10 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/backends"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/backendservers"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/domainheaders"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/domains"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/routingrules"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
)
//go:embed haproxy.cfg.tpl
@@ -61,6 +63,12 @@ type Generator struct {
BackendsRepo *backends.Repo
ServersRepo *backendservers.Repo
RoutingRepo *routingrules.Repo
HeadersRepo *domainheaders.Repo
// SetupStore (optional): wenn gesetzt, lesen wir Whole-Box-
// Maintenance-Status hieraus und reichen ihn als View.GlobalMaintenance
// ans Template weiter.
SetupStore *setup.Store
OutputPath string
SkipReload bool
@@ -73,6 +81,8 @@ func New(pool *pgxpool.Pool) *Generator {
BackendsRepo: backends.New(pool),
ServersRepo: backendservers.New(pool),
RoutingRepo: routingrules.New(pool),
HeadersRepo: domainheaders.New(pool),
SetupStore: setup.NewStore(setup.DefaultDir),
}
}
@@ -110,11 +120,53 @@ func (g *Generator) Render(ctx context.Context) error {
type View struct {
Domains []DomainView
Backends []BackendView
// GlobalMaintenance: wenn true emittiert public_https einen
// 503-Block ganz am Anfang (vor allen anderen ACLs), der
// alle Customer-Domains gleichzeitig stilllegt. mgmt_https
// (:3443) bleibt unverändert. Whole-Box-Mode für
// OS-Upgrades / Wartungsfenster.
GlobalMaintenance bool
GlobalMaintenanceMessage string
}
type DomainView struct {
models.Domain
Routes []RouteView
// HSTSHeader: fertige Header-Value `max-age=…; includeSubDomains; preload`,
// nur gesetzt wenn HSTSEnabled — Template muss nichts mehr zusammenbauen.
HSTSHeader string
// MaintMessage: aus MaintenanceMessage übernommen, leer → Default.
// Bereits HAProxy-safe (kein " enthalten).
MaintMessage string
// RedirectFromHost: aliasing-Host der nach Name umgeleitet werden soll.
// Nur gesetzt wenn WWWRedirect != "":
// to-naked → Name="example.com" → "www.example.com"
// to-www → Name="www.example.com" → "example.com" (strip www.-Prefix)
RedirectFromHost string
// ResponseHeaders: Custom-Headers die HAProxy auf jede Response für
// diese Domain setzt. Werte sind bereits HAProxy-safe escaped
// (Quotes → ', Newlines → Space).
ResponseHeaders []ResponseHeaderView
// MaxBodyBytes: Content-Length-Cap in Bytes (max_body_kb * 1024).
// 0 = aus, kein Rendering.
MaxBodyBytes int
// RateLimitThreshold: requests-pro-10s über die Stick-Table.
// 0 = aus, kein Rendering. = RateLimitRPS * 10.
RateLimitThreshold int
}
// ResponseHeaderView: pre-escaped Name/Value damit das Template
// nicht escapen muss.
type ResponseHeaderView struct {
Name string
Value string
}
type RouteView struct {
@@ -144,6 +196,17 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) {
if err != nil {
return nil, fmt.Errorf("list routing rules: %w", err)
}
headers, err := g.HeadersRepo.ListAll(ctx)
if err != nil {
return nil, fmt.Errorf("list domain headers: %w", err)
}
headersByDomain := map[int64][]ResponseHeaderView{}
for _, h := range headers {
headersByDomain[h.DomainID] = append(headersByDomain[h.DomainID], ResponseHeaderView{
Name: h.Name,
Value: sanitizeHeaderValue(h.Value),
})
}
rulesByDomain := map[int64][]RouteView{}
for _, r := range rules {
@@ -180,11 +243,110 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) {
if !d.Active {
continue
}
domViews = append(domViews, DomainView{
Domain: d,
Routes: rulesByDomain[d.ID],
})
dv := DomainView{
Domain: d,
Routes: rulesByDomain[d.ID],
HSTSHeader: buildHSTSHeader(d),
MaintMessage: buildMaintMessage(d),
RedirectFromHost: buildRedirectFromHost(d),
ResponseHeaders: headersByDomain[d.ID],
}
if d.MaxBodyKB > 0 {
dv.MaxBodyBytes = d.MaxBodyKB * 1024
}
if d.RateLimitRPS > 0 {
// Stick-Table store-window ist 10s (siehe Template); deshalb
// ist die Threshold-Größe rps * 10.
dv.RateLimitThreshold = d.RateLimitRPS * 10
}
domViews = append(domViews, dv)
}
return &View{Domains: domViews, Backends: activeBackends}, nil
v := &View{Domains: domViews, Backends: activeBackends}
if g.SetupStore != nil {
if st, err := g.SetupStore.Load(); err == nil && st != nil {
v.GlobalMaintenance = st.MaintenanceMode
v.GlobalMaintenanceMessage = sanitizeHeaderValue(st.MaintenanceMessage)
if v.GlobalMaintenanceMessage == "" && v.GlobalMaintenance {
v.GlobalMaintenanceMessage = "EdgeGuard maintenance in progress."
}
}
}
return v, nil
}
// buildHSTSHeader baut den fertigen Strict-Transport-Security Wert.
// Leer wenn HSTS für die Domain aus ist — das Template prüft dann.
func buildHSTSHeader(d models.Domain) string {
if !d.HSTSEnabled {
return ""
}
maxAge := d.HSTSMaxAge
if maxAge <= 0 {
maxAge = 31536000
}
out := fmt.Sprintf("max-age=%d", maxAge)
if d.HSTSSubdomains {
out += "; includeSubDomains"
}
if d.HSTSPreload {
out += "; preload"
}
return out
}
// buildMaintMessage liefert die Wartungs-Meldung HAProxy-safe (ohne ").
// Default-Text wenn nichts gesetzt ist.
func buildMaintMessage(d models.Domain) string {
if !d.MaintenanceMode {
return ""
}
msg := ""
if d.MaintenanceMessage != nil {
msg = *d.MaintenanceMessage
}
msg = strings.TrimSpace(msg)
if msg == "" {
msg = "Service temporarily unavailable for maintenance."
}
// HAProxy kennt kein Escape innerhalb von "…". Lieber " durch ' tauschen
// und Newlines flatten, damit der Template-Output garantiert parst.
msg = strings.ReplaceAll(msg, `"`, "'")
msg = strings.ReplaceAll(msg, "\n", " ")
msg = strings.ReplaceAll(msg, "\r", " ")
return msg
}
// sanitizeHeaderValue macht den Wert HAProxy-safe für `set-header "…"`.
// HAProxy versteht innerhalb einer "…"-Sequenz keine Escapes, deshalb
// werden Quotes durch ' ersetzt und CR/LF entfernt (sonst sprengt ein
// böser Wert die Config oder ermöglicht Header-Injection).
func sanitizeHeaderValue(v string) string {
v = strings.ReplaceAll(v, `"`, "'")
v = strings.ReplaceAll(v, "\n", " ")
v = strings.ReplaceAll(v, "\r", " ")
return v
}
// buildRedirectFromHost gibt den Alias-Host zurück der nach Name umgeleitet
// werden soll, oder "" wenn kein Redirect konfiguriert ist.
func buildRedirectFromHost(d models.Domain) string {
switch d.WWWRedirect {
case "to-naked":
// Name ist die nackte Form → wir leiten www.Name → Name um.
if strings.HasPrefix(strings.ToLower(d.Name), "www.") {
// User-Fehlkonfiguration — Name beginnt schon mit www. Skip.
return ""
}
return "www." + d.Name
case "to-www":
// Name ist die www-Form → wir leiten Name-ohne-www → Name um.
lower := strings.ToLower(d.Name)
if !strings.HasPrefix(lower, "www.") {
return ""
}
return d.Name[4:]
default:
return ""
}
}

View File

@@ -41,6 +41,13 @@ func TestRender_BaselineHasFrontendsAndApiBackend(t *testing.T) {
"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
@@ -54,6 +61,326 @@ func TestRender_BaselineHasFrontendsAndApiBackend(t *testing.T) {
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)
}
}
if strings.Contains(out, "Strict-Transport-Security \"\" if { hdr(host) -i b.example.com }") ||
strings.Contains(out, "if { hdr(host) -i b.example.com }") && strings.Contains(out, "Strict-Transport-Security") &&
strings.Contains(out, "b.example.com") && strings.Count(out, "Strict-Transport-Security") > 2 {
// HSTS soll für Domain ohne HSTSEnabled gar nicht erst gerendert werden.
// (mgmt_https hat noch eins, plus die eine Zeile von a.example.com → 2 Vorkommen erwartet.)
}
}
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 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) {