feat(backends): WebSocket-Toggle pro Backend

Migration 0017 fügt backends.websocket BOOL. Wenn aktiv emittiert der
HAProxy-Renderer `timeout tunnel 1h` IM Backend-Block; defaults-Section
hat den Global-Timeout dafür verloren. Backends ohne WS-Workload bleiben
bei strikten HTTP-Timeouts (Connection-Hygiene). Migrations-Heuristik
schaltet vm-pool/proxmox/console/vnc-Namen auto auf true damit Proxmox-
Konsole nach Deploy weiterhin durchläuft.

UI: Switch im Backend-Modal + WS-Tag in der Übersichtstabelle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-11 21:51:09 +02:00
parent da35097041
commit 26f321de9d
13 changed files with 108 additions and 24 deletions

View File

@@ -0,0 +1,28 @@
-- +goose Up
-- +goose StatementBegin
-- Per-Backend WebSocket-Toggle. Wenn aktiv rendert der HAProxy-Renderer
-- `timeout tunnel 1h` IN dem Backend-Block, sodass HTTP-Upgrade-
-- Verbindungen (noVNC, VS-Code-SSH, AsyncAPI-Streams) nicht vom
-- defaults-Timeout-Server=60s gekappt werden. Backends ohne WS-Workload
-- behalten kurze Timeouts — gut für Connection-Hygiene.
ALTER TABLE backends
ADD COLUMN IF NOT EXISTS websocket BOOLEAN NOT NULL DEFAULT FALSE;
-- Erhalte das alte Verhalten für bekannte WebSocket-Pools: alles was
-- Proxmox / vm-Pool / Konsole im Namen hat bekommt websocket=true.
-- Falls der Operator das nicht will → einfach im UI ausschalten.
UPDATE backends
SET websocket = TRUE
WHERE name ILIKE '%vm%pool%'
OR name ILIKE '%proxmox%'
OR name ILIKE '%console%'
OR name ILIKE '%vnc%';
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
ALTER TABLE backends DROP COLUMN IF EXISTS websocket;
-- +goose StatementEnd

View File

@@ -21,10 +21,9 @@ defaults
timeout client 60s
timeout server 60s
timeout http-request 10s
# WebSocket/long-poll: nach HTTP-Upgrade greift `timeout tunnel`
# statt client/server-timeout. 1h hält Proxmox-noVNC + VS-Code-
# SSH-Tunnel + andere langlebige Streams offen.
timeout tunnel 1h
# timeout tunnel wird PER BACKEND aktiviert (websocket-Flag),
# damit nur WS-Workloads die lange Idle-Toleranz haben und normale
# HTTP-Backends saubere Connection-Hygiene behalten.
# ── Public :80 ─────────────────────────────────────────────────────────
# ACME-01 challenges proxy to edgeguard-api which serves the webroot.
@@ -91,6 +90,9 @@ backend api_backend
backend eg_backend_{{$b.ID}}
balance {{$b.LBAlgorithm}}
{{- if $b.WebSocket}}
timeout tunnel 1h
{{- end}}
{{- if $b.HealthCheckPath}}
option httpchk
http-check send meth GET uri {{$b.HealthCheckPath}}

View File

@@ -102,6 +102,42 @@ func TestRender_HealthCheckPathAddsCheckInter(t *testing.T) {
}
}
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{

View File

@@ -10,6 +10,7 @@ type Backend struct {
Scheme string `gorm:"column:scheme" json:"scheme"`
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"`
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"`

View File

@@ -1,7 +1,7 @@
// Package backends implements CRUD against the `backends` table.
// Ein Backend ist ein Pool — Name, Scheme, Healthcheck, LB-Algorithm.
// Die konkreten Upstream-Server liegen in backend_servers (siehe
// services/backendservers).
// Ein Backend ist ein Pool — Name, Scheme, Healthcheck, LB-Algorithm,
// WebSocket-Flag. Die konkreten Upstream-Server liegen in
// backend_servers (siehe services/backendservers).
package backends
import (
@@ -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, active,
SELECT id, name, scheme, health_check_path, lb_algorithm, websocket, 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, active)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, name, scheme, health_check_path, lb_algorithm, active,
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,
created_at, updated_at`,
b.Name, b.Scheme, b.HealthCheckPath, b.LBAlgorithm, b.Active)
b.Name, b.Scheme, b.HealthCheckPath, b.LBAlgorithm, b.WebSocket, b.Active)
return scanBackend(row)
}
@@ -80,12 +80,13 @@ UPDATE backends SET
scheme = $2,
health_check_path = $3,
lb_algorithm = $4,
active = $5,
websocket = $5,
active = $6,
updated_at = NOW()
WHERE id = $6
RETURNING id, name, scheme, health_check_path, lb_algorithm, active,
WHERE id = $7
RETURNING id, name, scheme, health_check_path, lb_algorithm, websocket, active,
created_at, updated_at`,
b.Name, b.Scheme, b.HealthCheckPath, b.LBAlgorithm, b.Active, id)
b.Name, b.Scheme, b.HealthCheckPath, b.LBAlgorithm, b.WebSocket, b.Active, id)
out, err := scanBackend(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
@@ -111,7 +112,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.Active,
&b.HealthCheckPath, &b.LBAlgorithm, &b.WebSocket, &b.Active,
&b.CreatedAt, &b.UpdatedAt,
); err != nil {
return nil, err