From 26f321de9d28d0d93b92a986433c3768f6c2c39c Mon Sep 17 00:00:00 2001 From: Debian Date: Mon, 11 May 2026 21:51:09 +0200 Subject: [PATCH] feat(backends): WebSocket-Toggle pro Backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- VERSION | 2 +- cmd/edgeguard-api/main.go | 2 +- cmd/edgeguard-ctl/main.go | 2 +- cmd/edgeguard-scheduler/main.go | 2 +- .../migrations/0017_backend_websocket.sql | 28 +++++++++++++++ internal/haproxy/haproxy.cfg.tpl | 10 +++--- internal/haproxy/haproxy_test.go | 36 +++++++++++++++++++ internal/models/backend.go | 1 + internal/services/backends/backends.go | 27 +++++++------- .../src/components/Layout/Sidebar.tsx | 2 +- management-ui/src/i18n/locales/de/common.json | 2 ++ management-ui/src/i18n/locales/en/common.json | 2 ++ management-ui/src/pages/Backends/index.tsx | 16 +++++++-- 13 files changed, 108 insertions(+), 24 deletions(-) create mode 100644 internal/database/migrations/0017_backend_websocket.sql diff --git a/VERSION b/VERSION index 3e9c4a6..73b4678 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.50 +1.0.51 diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index 7808ed1..7bd6a53 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -48,7 +48,7 @@ import ( wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard" ) -var version = "1.0.50" +var version = "1.0.51" func main() { addr := os.Getenv("EDGEGUARD_API_ADDR") diff --git a/cmd/edgeguard-ctl/main.go b/cmd/edgeguard-ctl/main.go index b95fd04..fda2eb4 100644 --- a/cmd/edgeguard-ctl/main.go +++ b/cmd/edgeguard-ctl/main.go @@ -9,7 +9,7 @@ import ( "os" ) -var version = "1.0.50" +var version = "1.0.51" const usage = `edgeguard-ctl — EdgeGuard CLI diff --git a/cmd/edgeguard-scheduler/main.go b/cmd/edgeguard-scheduler/main.go index 57d567b..91d56bc 100644 --- a/cmd/edgeguard-scheduler/main.go +++ b/cmd/edgeguard-scheduler/main.go @@ -24,7 +24,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts" ) -var version = "1.0.50" +var version = "1.0.51" const ( // renewTickInterval — how often we re-evaluate expiring certs. diff --git a/internal/database/migrations/0017_backend_websocket.sql b/internal/database/migrations/0017_backend_websocket.sql new file mode 100644 index 0000000..d9640f3 --- /dev/null +++ b/internal/database/migrations/0017_backend_websocket.sql @@ -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 diff --git a/internal/haproxy/haproxy.cfg.tpl b/internal/haproxy/haproxy.cfg.tpl index aa728fb..aa86b86 100644 --- a/internal/haproxy/haproxy.cfg.tpl +++ b/internal/haproxy/haproxy.cfg.tpl @@ -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}} diff --git a/internal/haproxy/haproxy_test.go b/internal/haproxy/haproxy_test.go index 586e59e..91a7aa5 100644 --- a/internal/haproxy/haproxy_test.go +++ b/internal/haproxy/haproxy_test.go @@ -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{ diff --git a/internal/models/backend.go b/internal/models/backend.go index fe9bd4a..771e325 100644 --- a/internal/models/backend.go +++ b/internal/models/backend.go @@ -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"` diff --git a/internal/services/backends/backends.go b/internal/services/backends/backends.go index 12afa8e..d877f65 100644 --- a/internal/services/backends/backends.go +++ b/internal/services/backends/backends.go @@ -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 diff --git a/management-ui/src/components/Layout/Sidebar.tsx b/management-ui/src/components/Layout/Sidebar.tsx index 5ce9cc8..b866f83 100644 --- a/management-ui/src/components/Layout/Sidebar.tsx +++ b/management-ui/src/components/Layout/Sidebar.tsx @@ -77,7 +77,7 @@ const NAV: NavSection[] = [ }, ] -const VERSION = '1.0.50' +const VERSION = '1.0.51' export default function Sidebar({ isOpen, onClose }: SidebarProps) { const { t } = useTranslation() diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json index d2149b8..7696599 100644 --- a/management-ui/src/i18n/locales/de/common.json +++ b/management-ui/src/i18n/locales/de/common.json @@ -217,6 +217,8 @@ "selectDomains": "Domains wählen", "lbAlgo": "Load-Balancing", "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.", "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 06fcd43..e6a01de 100644 --- a/management-ui/src/i18n/locales/en/common.json +++ b/management-ui/src/i18n/locales/en/common.json @@ -217,6 +217,8 @@ "selectDomains": "Select domains", "lbAlgo": "Load balancing", "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.", "servers": "Servers", "noServers": "no server", "nServers": "{{n}} servers", diff --git a/management-ui/src/pages/Backends/index.tsx b/management-ui/src/pages/Backends/index.tsx index 9640559..a41fa17 100644 --- a/management-ui/src/pages/Backends/index.tsx +++ b/management-ui/src/pages/Backends/index.tsx @@ -22,6 +22,7 @@ interface Backend { scheme: string health_check_path?: string | null lb_algorithm: 'roundrobin' | 'leastconn' | 'source' + websocket: boolean active: boolean created_at: string updated_at: string @@ -43,6 +44,7 @@ interface BackendFormValues { scheme: 'http' | 'https' health_check_path?: string lb_algorithm: 'roundrobin' | 'leastconn' | 'source' + websocket: boolean active: boolean domain_ids?: number[] } @@ -193,7 +195,12 @@ export default function BackendsPage() { }, { title: t('backends.lbAlgo'), dataIndex: 'lb_algorithm', key: 'lb', - render: (v: string) => {v}, + render: (v: string, row) => ( + + {v} + {row.websocket && WS} + + ), }, { title: t('backends.healthCheck'), dataIndex: 'health_check_path', key: 'hc', render: (v?: string) => v ?? '—' }, { @@ -216,6 +223,7 @@ export default function BackendsPage() { scheme: row.scheme as 'http' | 'https', health_check_path: row.health_check_path ?? undefined, lb_algorithm: row.lb_algorithm, + websocket: row.websocket, active: row.active, domain_ids: domainsForBackend(row.id).map(d => d.id), }) @@ -246,7 +254,7 @@ export default function BackendsPage() { extraActions={ @@ -294,6 +302,10 @@ export default function BackendsPage() { + + +