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')}>