From 8293783fe6af22cc3d8c8d38cb10a33341b140be Mon Sep 17 00:00:00 2001 From: Debian Date: Fri, 22 May 2026 07:18:48 +0200 Subject: [PATCH] =?UTF-8?q?feat(backends+domains):=20HTTP-Protokoll-Flags?= =?UTF-8?q?=20=E2=80=94=20force=5Fhttp1=20+=20disable=5Fh3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: force_http1 (bool, default false) — zwingt HAProxy auf der Backend-Verbindung zu HTTP/1.1 statt h2,http/1.1 zu verhandeln. Nötig für Legacy-Apps die kein h2 sprechen. Domain: disable_h3 (bool, default false) — unterdrückt Alt-Svc- Response-Header für diese Domain. Browser erhalten keinen h3/QUIC- Hinweis und bleiben auf h2/http1.1. Migration 0027, Model+Service+HAProxy-Template+UI+i18n. Co-Authored-By: Claude Sonnet 4.6 --- VERSION | 2 +- .../migrations/0027_http_protocol_flags.sql | 22 +++++++++++++++++++ internal/haproxy/haproxy.cfg.tpl | 9 +++++++- internal/models/backend.go | 1 + internal/models/domain.go | 1 + internal/services/backends/backends.go | 21 +++++++++--------- internal/services/domains/domains.go | 21 +++++++++--------- management-ui/src/i18n/locales/de/common.json | 4 ++++ management-ui/src/i18n/locales/en/common.json | 4 ++++ management-ui/src/pages/Backends/Detail.tsx | 9 ++++++-- management-ui/src/pages/Domains/Detail.tsx | 8 +++++++ 11 files changed, 78 insertions(+), 24 deletions(-) create mode 100644 internal/database/migrations/0027_http_protocol_flags.sql diff --git a/VERSION b/VERSION index baeb5d0..6d4b6e1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.54 +1.1.55 diff --git a/internal/database/migrations/0027_http_protocol_flags.sql b/internal/database/migrations/0027_http_protocol_flags.sql new file mode 100644 index 0000000..e2a4921 --- /dev/null +++ b/internal/database/migrations/0027_http_protocol_flags.sql @@ -0,0 +1,22 @@ +-- +goose Up +-- +goose StatementBegin + +-- backends.force_http1: zwingt HAProxy, die Backend-Verbindung mit +-- HTTP/1.1 zu führen (alpn http/1.1) statt H2+H1.1 zu verhandeln. +-- Nötig für Backends die kein h2 sprechen (Legacy-Apps, manche nginx-Configs). +ALTER TABLE backends + ADD COLUMN IF NOT EXISTS force_http1 BOOLEAN NOT NULL DEFAULT FALSE; + +-- domains.disable_h3: unterdrückt den Alt-Svc-Response-Header für diese +-- Domain. Browser erhalten keinen Hinweis auf h3/QUIC und bleiben auf +-- h2/http1.1 — sinnvoll wenn Clients Probleme mit QUIC-Verbindungen melden. +ALTER TABLE domains + ADD COLUMN IF NOT EXISTS disable_h3 BOOLEAN NOT NULL DEFAULT FALSE; + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE backends DROP COLUMN IF EXISTS force_http1; +ALTER TABLE domains DROP COLUMN IF EXISTS disable_h3; +-- +goose StatementEnd diff --git a/internal/haproxy/haproxy.cfg.tpl b/internal/haproxy/haproxy.cfg.tpl index 850bbc4..74564fa 100644 --- a/internal/haproxy/haproxy.cfg.tpl +++ b/internal/haproxy/haproxy.cfg.tpl @@ -108,6 +108,13 @@ frontend public_https 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}} + {{- if $d.DisableH3}} + # HTTP/3 (QUIC) Werbung für diese Domain unterdrücken: Alt-Svc-Header + # entfernen damit Browser nicht auf h3 upgraden. Der globale set-header + # oben hat ihn bereits gesetzt; dieses del-header kommt DANACH und + # überschreibt ihn für Requests an diese Domain. + http-response del-header Alt-Svc 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}} } @@ -176,6 +183,6 @@ backend eg_backend_{{$b.ID}} http-check send meth GET uri {{$b.HealthCheckPath}} {{- end}} {{- range $s := $b.Servers}} - server {{$s.Name | safeID}} {{$s.Address}}:{{$s.Port}}{{if eq $b.Scheme "https"}} ssl verify none alpn h2,http/1.1{{end}}{{if $b.HealthCheckPath}} check inter 5s{{if eq $b.Scheme "https"}} check-alpn http/1.1{{end}}{{end}} weight {{$s.Weight}}{{if $s.Backup}} backup{{end}} + server {{$s.Name | safeID}} {{$s.Address}}:{{$s.Port}}{{if eq $b.Scheme "https"}} ssl verify none alpn {{if $b.ForceHTTP1}}http/1.1{{else}}h2,http/1.1{{end}}{{end}}{{if $b.HealthCheckPath}} check inter 5s{{if eq $b.Scheme "https"}} check-alpn http/1.1{{end}}{{end}} weight {{$s.Weight}}{{if $s.Backup}} backup{{end}} {{- end}} {{- end}} diff --git a/internal/models/backend.go b/internal/models/backend.go index 771e325..21a1e0e 100644 --- a/internal/models/backend.go +++ b/internal/models/backend.go @@ -11,6 +11,7 @@ type Backend struct { HealthCheckPath *string `gorm:"column:health_check_path" json:"health_check_path,omitempty"` LBAlgorithm string `gorm:"column:lb_algorithm" json:"lb_algorithm"` WebSocket bool `gorm:"column:websocket" json:"websocket"` + ForceHTTP1 bool `gorm:"column:force_http1" json:"force_http1"` Active bool `gorm:"column:active" json:"active"` CreatedAt time.Time `gorm:"column:created_at" json:"created_at"` UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"` diff --git a/internal/models/domain.go b/internal/models/domain.go index e942606..e8572d8 100644 --- a/internal/models/domain.go +++ b/internal/models/domain.go @@ -17,6 +17,7 @@ type Domain struct { WWWRedirect string `gorm:"column:www_redirect" json:"www_redirect"` // ""|"to-naked"|"to-www" RateLimitRPS int `gorm:"column:rate_limit_rps" json:"rate_limit_rps"` MaxBodyKB int `gorm:"column:max_body_kb" json:"max_body_kb"` + DisableH3 bool `gorm:"column:disable_h3" json:"disable_h3"` Notes *string `gorm:"column:notes" json:"notes,omitempty"` CreatedAt time.Time `gorm:"column:created_at" json:"created_at"` UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"` diff --git a/internal/services/backends/backends.go b/internal/services/backends/backends.go index d877f65..9edc95b 100644 --- a/internal/services/backends/backends.go +++ b/internal/services/backends/backends.go @@ -23,7 +23,7 @@ type Repo struct { func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} } const baseSelect = ` -SELECT id, name, scheme, health_check_path, lb_algorithm, websocket, active, +SELECT id, name, scheme, health_check_path, lb_algorithm, websocket, force_http1, active, created_at, updated_at FROM backends ` @@ -62,11 +62,11 @@ func (r *Repo) Create(ctx context.Context, b models.Backend) (*models.Backend, e b.LBAlgorithm = "roundrobin" } row := r.Pool.QueryRow(ctx, ` -INSERT INTO backends (name, scheme, health_check_path, lb_algorithm, websocket, active) -VALUES ($1, $2, $3, $4, $5, $6) -RETURNING id, name, scheme, health_check_path, lb_algorithm, websocket, active, +INSERT INTO backends (name, scheme, health_check_path, lb_algorithm, websocket, force_http1, active) +VALUES ($1, $2, $3, $4, $5, $6, $7) +RETURNING id, name, scheme, health_check_path, lb_algorithm, websocket, force_http1, active, created_at, updated_at`, - b.Name, b.Scheme, b.HealthCheckPath, b.LBAlgorithm, b.WebSocket, b.Active) + b.Name, b.Scheme, b.HealthCheckPath, b.LBAlgorithm, b.WebSocket, b.ForceHTTP1, b.Active) return scanBackend(row) } @@ -81,12 +81,13 @@ UPDATE backends SET health_check_path = $3, lb_algorithm = $4, websocket = $5, - active = $6, + force_http1 = $6, + active = $7, updated_at = NOW() -WHERE id = $7 -RETURNING id, name, scheme, health_check_path, lb_algorithm, websocket, active, +WHERE id = $8 +RETURNING id, name, scheme, health_check_path, lb_algorithm, websocket, force_http1, active, created_at, updated_at`, - b.Name, b.Scheme, b.HealthCheckPath, b.LBAlgorithm, b.WebSocket, b.Active, id) + b.Name, b.Scheme, b.HealthCheckPath, b.LBAlgorithm, b.WebSocket, b.ForceHTTP1, b.Active, id) out, err := scanBackend(row) if err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -112,7 +113,7 @@ func scanBackend(row interface{ Scan(...any) error }) (*models.Backend, error) { var b models.Backend if err := row.Scan( &b.ID, &b.Name, &b.Scheme, - &b.HealthCheckPath, &b.LBAlgorithm, &b.WebSocket, &b.Active, + &b.HealthCheckPath, &b.LBAlgorithm, &b.WebSocket, &b.ForceHTTP1, &b.Active, &b.CreatedAt, &b.UpdatedAt, ); err != nil { return nil, err diff --git a/internal/services/domains/domains.go b/internal/services/domains/domains.go index ab62a10..6a149bf 100644 --- a/internal/services/domains/domains.go +++ b/internal/services/domains/domains.go @@ -23,7 +23,7 @@ const baseSelect = ` SELECT id, name, active, primary_backend_id, http_to_https, hsts_enabled, hsts_max_age, hsts_subdomains, hsts_preload, maintenance_mode, maintenance_message, www_redirect, - rate_limit_rps, max_body_kb, + rate_limit_rps, max_body_kb, disable_h3, notes, created_at, updated_at FROM domains ` @@ -65,17 +65,17 @@ func (r *Repo) Create(ctx context.Context, d models.Domain) (*models.Domain, err INSERT INTO domains (name, active, primary_backend_id, http_to_https, hsts_enabled, hsts_max_age, hsts_subdomains, hsts_preload, maintenance_mode, maintenance_message, www_redirect, - rate_limit_rps, max_body_kb, notes) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + rate_limit_rps, max_body_kb, disable_h3, notes) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING id, name, active, primary_backend_id, http_to_https, hsts_enabled, hsts_max_age, hsts_subdomains, hsts_preload, maintenance_mode, maintenance_message, www_redirect, - rate_limit_rps, max_body_kb, + rate_limit_rps, max_body_kb, disable_h3, notes, created_at, updated_at`, d.Name, d.Active, d.PrimaryBackendID, d.HTTPToHTTPS, d.HSTSEnabled, d.HSTSMaxAge, d.HSTSSubdomains, d.HSTSPreload, d.MaintenanceMode, d.MaintenanceMessage, d.WWWRedirect, - d.RateLimitRPS, d.MaxBodyKB, d.Notes) + d.RateLimitRPS, d.MaxBodyKB, d.DisableH3, d.Notes) return scanDomain(row) } @@ -98,18 +98,19 @@ UPDATE domains SET www_redirect = $11, rate_limit_rps = $12, max_body_kb = $13, - notes = $14, + disable_h3 = $14, + notes = $15, updated_at = NOW() -WHERE id = $15 +WHERE id = $16 RETURNING id, name, active, primary_backend_id, http_to_https, hsts_enabled, hsts_max_age, hsts_subdomains, hsts_preload, maintenance_mode, maintenance_message, www_redirect, - rate_limit_rps, max_body_kb, + rate_limit_rps, max_body_kb, disable_h3, notes, created_at, updated_at`, d.Name, d.Active, d.PrimaryBackendID, d.HTTPToHTTPS, d.HSTSEnabled, d.HSTSMaxAge, d.HSTSSubdomains, d.HSTSPreload, d.MaintenanceMode, d.MaintenanceMessage, d.WWWRedirect, - d.RateLimitRPS, d.MaxBodyKB, d.Notes, id) + d.RateLimitRPS, d.MaxBodyKB, d.DisableH3, d.Notes, id) out, err := scanDomain(row) if err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -137,7 +138,7 @@ func scanDomain(row interface{ Scan(...any) error }) (*models.Domain, error) { &d.ID, &d.Name, &d.Active, &d.PrimaryBackendID, &d.HTTPToHTTPS, &d.HSTSEnabled, &d.HSTSMaxAge, &d.HSTSSubdomains, &d.HSTSPreload, &d.MaintenanceMode, &d.MaintenanceMessage, &d.WWWRedirect, - &d.RateLimitRPS, &d.MaxBodyKB, + &d.RateLimitRPS, &d.MaxBodyKB, &d.DisableH3, &d.Notes, &d.CreatedAt, &d.UpdatedAt, ); err != nil { return nil, err diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json index 3a72c2c..58a46a5 100644 --- a/management-ui/src/i18n/locales/de/common.json +++ b/management-ui/src/i18n/locales/de/common.json @@ -290,6 +290,8 @@ "rateLimitHint": "Max. Requests pro Sekunde je Client-IP. HAProxy zählt über ein 10-Sekunden-Fenster pro Stick-Table (max. 100k IPs). 0 = aus.", "maxBody": "Max. Request-Body", "maxBodyHint": "Cap auf Content-Length-Header. Größere Requests bekommen 413. Nicht erkannt: Chunked-Bodies (HAProxy bufferd nicht standardmäßig). 0 = aus.", + "disableH3": "HTTP/3 (QUIC) deaktivieren", + "disableH3Hint": "Unterdrückt den Alt-Svc-Response-Header für diese Domain. Browser erhalten keinen Hinweis auf h3/QUIC und bleiben auf h2/http1.1. Sinnvoll bei Clients die Probleme mit QUIC-Verbindungen melden.", "headersBtn": "Headers", "headersTitle": "Response-Headers — {{name}}", "headersHint": "Diese Header werden von HAProxy auf jede Response für diese Domain gesetzt (http-response set-header). Reihenfolge via Position.", @@ -331,6 +333,8 @@ "lbAlgoHint": "roundrobin = gleichmäßig, leastconn = an den Server mit wenigsten Verbindungen, source = sticky per Client-IP (für stateful Apps ohne shared session).", "websocket": "WebSocket-Support", "websocketHint": "An: erlaubt langlebige WebSocket-/Long-Poll-Verbindungen (z. B. Proxmox-Console, SSH-WS, AsyncAPI) — Tunnel-Idle 1h statt 60s. Aus: strikte HTTP-Timeouts.", + "forceHttp1": "HTTP/1.1 erzwingen", + "forceHttp1Hint": "Deaktiviert HTTP/2 (h2) auf der Backend-Verbindung — HAProxy handelt nur noch HTTP/1.1 aus. Nötig für Backends die kein h2 unterstützen (z. B. ältere nginx-Konfigurationen ohne h2-Modul, Legacy-Apps).", "servers": "Server", "noServers": "kein Server", "nServers": "{{n}} Server", diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json index 028734e..ed5f30b 100644 --- a/management-ui/src/i18n/locales/en/common.json +++ b/management-ui/src/i18n/locales/en/common.json @@ -290,6 +290,8 @@ "rateLimitHint": "Max requests per second per client IP. HAProxy counts over a 10-second window per stick table (max. 100k IPs). 0 = off.", "maxBody": "Max request body", "maxBodyHint": "Cap on the Content-Length header. Larger requests get 413. Chunked bodies are not detected (HAProxy doesn't buffer by default). 0 = off.", + "disableH3": "Disable HTTP/3 (QUIC)", + "disableH3Hint": "Suppresses the Alt-Svc response header for this domain. Browsers won't receive an h3/QUIC upgrade hint and stay on h2/http1.1. Useful when clients report issues with QUIC connections.", "headersBtn": "Headers", "headersTitle": "Response headers — {{name}}", "headersHint": "These headers are set by HAProxy on every response for this domain (http-response set-header). Ordering via position.", @@ -331,6 +333,8 @@ "lbAlgoHint": "roundrobin = evenly, leastconn = pick the server with fewest active connections, source = sticky per client-IP hash (for stateful apps without shared session).", "websocket": "WebSocket support", "websocketHint": "On: allow long-lived WebSocket / long-poll connections (Proxmox console, SSH-over-WS, AsyncAPI) — tunnel idle 1h instead of 60s. Off: strict HTTP timeouts.", + "forceHttp1": "Force HTTP/1.1", + "forceHttp1Hint": "Disables HTTP/2 (h2) on the backend connection — HAProxy negotiates HTTP/1.1 only. Required for backends that don't support h2 (e.g. older nginx configs without the h2 module, legacy apps).", "servers": "Servers", "noServers": "no server", "nServers": "{{n}} servers", diff --git a/management-ui/src/pages/Backends/Detail.tsx b/management-ui/src/pages/Backends/Detail.tsx index a8b3530..c0d7b8a 100644 --- a/management-ui/src/pages/Backends/Detail.tsx +++ b/management-ui/src/pages/Backends/Detail.tsx @@ -20,13 +20,13 @@ interface Backend { id: number; name: string; scheme: string health_check_path?: string | null lb_algorithm: 'roundrobin' | 'leastconn' | 'source' - websocket: boolean; active: boolean + websocket: boolean; force_http1: boolean; active: boolean } interface BackendFormValues { name: string; scheme: 'http' | 'https' health_check_path?: string lb_algorithm: 'roundrobin' | 'leastconn' | 'source' - websocket: boolean; active: boolean + websocket: boolean; force_http1: boolean; active: boolean domain_ids?: number[] } interface BackendServer { @@ -157,6 +157,7 @@ export default function BackendDetailPage() { health_check_path: backend.health_check_path ?? undefined, lb_algorithm: backend.lb_algorithm, websocket: backend.websocket, + force_http1: backend.force_http1, active: backend.active, domain_ids: attached.map(d => d.id), }} @@ -184,6 +185,10 @@ export default function BackendDetailPage() { extra={t('backends.websocketHint')}> + + + diff --git a/management-ui/src/pages/Domains/Detail.tsx b/management-ui/src/pages/Domains/Detail.tsx index 07134f4..d0f2c88 100644 --- a/management-ui/src/pages/Domains/Detail.tsx +++ b/management-ui/src/pages/Domains/Detail.tsx @@ -27,6 +27,7 @@ interface Domain { maintenance_mode: boolean; maintenance_message?: string | null www_redirect: '' | 'to-naked' | 'to-www' rate_limit_rps: number; max_body_kb: number + disable_h3: boolean notes?: string | null } @@ -39,6 +40,7 @@ interface DomainFormValues { maintenance_mode: boolean; maintenance_message?: string www_redirect: '' | 'to-naked' | 'to-www' rate_limit_rps: number; max_body_kb: number + disable_h3: boolean notes?: string } @@ -164,6 +166,7 @@ export default function DomainDetailPage() { www_redirect: domain.www_redirect ?? '', rate_limit_rps: domain.rate_limit_rps ?? 0, max_body_kb: domain.max_body_kb ?? 0, + disable_h3: domain.disable_h3 ?? false, notes: domain.notes ?? '', }} onFinish={(v) => update.mutate(v)} @@ -244,6 +247,11 @@ export default function DomainDetailPage() { + + + +