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:
@@ -48,7 +48,7 @@ import (
|
|||||||
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
|
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "1.0.50"
|
var version = "1.0.51"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
addr := os.Getenv("EDGEGUARD_API_ADDR")
|
addr := os.Getenv("EDGEGUARD_API_ADDR")
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "1.0.50"
|
var version = "1.0.51"
|
||||||
|
|
||||||
const usage = `edgeguard-ctl — EdgeGuard CLI
|
const usage = `edgeguard-ctl — EdgeGuard CLI
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import (
|
|||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "1.0.50"
|
var version = "1.0.51"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// renewTickInterval — how often we re-evaluate expiring certs.
|
// renewTickInterval — how often we re-evaluate expiring certs.
|
||||||
|
|||||||
28
internal/database/migrations/0017_backend_websocket.sql
Normal file
28
internal/database/migrations/0017_backend_websocket.sql
Normal 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
|
||||||
@@ -21,10 +21,9 @@ defaults
|
|||||||
timeout client 60s
|
timeout client 60s
|
||||||
timeout server 60s
|
timeout server 60s
|
||||||
timeout http-request 10s
|
timeout http-request 10s
|
||||||
# WebSocket/long-poll: nach HTTP-Upgrade greift `timeout tunnel`
|
# timeout tunnel wird PER BACKEND aktiviert (websocket-Flag),
|
||||||
# statt client/server-timeout. 1h hält Proxmox-noVNC + VS-Code-
|
# damit nur WS-Workloads die lange Idle-Toleranz haben und normale
|
||||||
# SSH-Tunnel + andere langlebige Streams offen.
|
# HTTP-Backends saubere Connection-Hygiene behalten.
|
||||||
timeout tunnel 1h
|
|
||||||
|
|
||||||
# ── Public :80 ─────────────────────────────────────────────────────────
|
# ── Public :80 ─────────────────────────────────────────────────────────
|
||||||
# ACME-01 challenges proxy to edgeguard-api which serves the webroot.
|
# ACME-01 challenges proxy to edgeguard-api which serves the webroot.
|
||||||
@@ -91,6 +90,9 @@ backend api_backend
|
|||||||
|
|
||||||
backend eg_backend_{{$b.ID}}
|
backend eg_backend_{{$b.ID}}
|
||||||
balance {{$b.LBAlgorithm}}
|
balance {{$b.LBAlgorithm}}
|
||||||
|
{{- if $b.WebSocket}}
|
||||||
|
timeout tunnel 1h
|
||||||
|
{{- end}}
|
||||||
{{- if $b.HealthCheckPath}}
|
{{- if $b.HealthCheckPath}}
|
||||||
option httpchk
|
option httpchk
|
||||||
http-check send meth GET uri {{$b.HealthCheckPath}}
|
http-check send meth GET uri {{$b.HealthCheckPath}}
|
||||||
|
|||||||
@@ -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) {
|
func TestRender_MultiServerPool(t *testing.T) {
|
||||||
v := View{
|
v := View{
|
||||||
Backends: []BackendView{
|
Backends: []BackendView{
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type Backend struct {
|
|||||||
Scheme string `gorm:"column:scheme" json:"scheme"`
|
Scheme string `gorm:"column:scheme" json:"scheme"`
|
||||||
HealthCheckPath *string `gorm:"column:health_check_path" json:"health_check_path,omitempty"`
|
HealthCheckPath *string `gorm:"column:health_check_path" json:"health_check_path,omitempty"`
|
||||||
LBAlgorithm string `gorm:"column:lb_algorithm" json:"lb_algorithm"`
|
LBAlgorithm string `gorm:"column:lb_algorithm" json:"lb_algorithm"`
|
||||||
|
WebSocket bool `gorm:"column:websocket" json:"websocket"`
|
||||||
Active bool `gorm:"column:active" json:"active"`
|
Active bool `gorm:"column:active" json:"active"`
|
||||||
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
||||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Package backends implements CRUD against the `backends` table.
|
// Package backends implements CRUD against the `backends` table.
|
||||||
// Ein Backend ist ein Pool — Name, Scheme, Healthcheck, LB-Algorithm.
|
// Ein Backend ist ein Pool — Name, Scheme, Healthcheck, LB-Algorithm,
|
||||||
// Die konkreten Upstream-Server liegen in backend_servers (siehe
|
// WebSocket-Flag. Die konkreten Upstream-Server liegen in
|
||||||
// services/backendservers).
|
// backend_servers (siehe services/backendservers).
|
||||||
package backends
|
package backends
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -23,7 +23,7 @@ type Repo struct {
|
|||||||
func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} }
|
func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} }
|
||||||
|
|
||||||
const baseSelect = `
|
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
|
created_at, updated_at
|
||||||
FROM backends
|
FROM backends
|
||||||
`
|
`
|
||||||
@@ -62,11 +62,11 @@ func (r *Repo) Create(ctx context.Context, b models.Backend) (*models.Backend, e
|
|||||||
b.LBAlgorithm = "roundrobin"
|
b.LBAlgorithm = "roundrobin"
|
||||||
}
|
}
|
||||||
row := r.Pool.QueryRow(ctx, `
|
row := r.Pool.QueryRow(ctx, `
|
||||||
INSERT INTO backends (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)
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
RETURNING id, name, scheme, health_check_path, lb_algorithm, active,
|
RETURNING id, name, scheme, health_check_path, lb_algorithm, websocket, active,
|
||||||
created_at, updated_at`,
|
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)
|
return scanBackend(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,12 +80,13 @@ UPDATE backends SET
|
|||||||
scheme = $2,
|
scheme = $2,
|
||||||
health_check_path = $3,
|
health_check_path = $3,
|
||||||
lb_algorithm = $4,
|
lb_algorithm = $4,
|
||||||
active = $5,
|
websocket = $5,
|
||||||
|
active = $6,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $6
|
WHERE id = $7
|
||||||
RETURNING id, name, scheme, health_check_path, lb_algorithm, active,
|
RETURNING id, name, scheme, health_check_path, lb_algorithm, websocket, active,
|
||||||
created_at, updated_at`,
|
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)
|
out, err := scanBackend(row)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
@@ -111,7 +112,7 @@ func scanBackend(row interface{ Scan(...any) error }) (*models.Backend, error) {
|
|||||||
var b models.Backend
|
var b models.Backend
|
||||||
if err := row.Scan(
|
if err := row.Scan(
|
||||||
&b.ID, &b.Name, &b.Scheme,
|
&b.ID, &b.Name, &b.Scheme,
|
||||||
&b.HealthCheckPath, &b.LBAlgorithm, &b.Active,
|
&b.HealthCheckPath, &b.LBAlgorithm, &b.WebSocket, &b.Active,
|
||||||
&b.CreatedAt, &b.UpdatedAt,
|
&b.CreatedAt, &b.UpdatedAt,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ const NAV: NavSection[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const VERSION = '1.0.50'
|
const VERSION = '1.0.51'
|
||||||
|
|
||||||
export default function Sidebar({ isOpen, onClose }: SidebarProps) {
|
export default function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|||||||
@@ -217,6 +217,8 @@
|
|||||||
"selectDomains": "Domains wählen",
|
"selectDomains": "Domains wählen",
|
||||||
"lbAlgo": "Load-Balancing",
|
"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).",
|
"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",
|
"servers": "Server",
|
||||||
"noServers": "kein Server",
|
"noServers": "kein Server",
|
||||||
"nServers": "{{n}} Server",
|
"nServers": "{{n}} Server",
|
||||||
|
|||||||
@@ -217,6 +217,8 @@
|
|||||||
"selectDomains": "Select domains",
|
"selectDomains": "Select domains",
|
||||||
"lbAlgo": "Load balancing",
|
"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).",
|
"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",
|
"servers": "Servers",
|
||||||
"noServers": "no server",
|
"noServers": "no server",
|
||||||
"nServers": "{{n}} servers",
|
"nServers": "{{n}} servers",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ interface Backend {
|
|||||||
scheme: string
|
scheme: string
|
||||||
health_check_path?: string | null
|
health_check_path?: string | null
|
||||||
lb_algorithm: 'roundrobin' | 'leastconn' | 'source'
|
lb_algorithm: 'roundrobin' | 'leastconn' | 'source'
|
||||||
|
websocket: boolean
|
||||||
active: boolean
|
active: boolean
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
@@ -43,6 +44,7 @@ interface BackendFormValues {
|
|||||||
scheme: 'http' | 'https'
|
scheme: 'http' | 'https'
|
||||||
health_check_path?: string
|
health_check_path?: string
|
||||||
lb_algorithm: 'roundrobin' | 'leastconn' | 'source'
|
lb_algorithm: 'roundrobin' | 'leastconn' | 'source'
|
||||||
|
websocket: boolean
|
||||||
active: boolean
|
active: boolean
|
||||||
domain_ids?: number[]
|
domain_ids?: number[]
|
||||||
}
|
}
|
||||||
@@ -193,7 +195,12 @@ export default function BackendsPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('backends.lbAlgo'), dataIndex: 'lb_algorithm', key: 'lb',
|
title: t('backends.lbAlgo'), dataIndex: 'lb_algorithm', key: 'lb',
|
||||||
render: (v: string) => <Tag>{v}</Tag>,
|
render: (v: string, row) => (
|
||||||
|
<Space size={4}>
|
||||||
|
<Tag>{v}</Tag>
|
||||||
|
{row.websocket && <Tag color="cyan">WS</Tag>}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{ title: t('backends.healthCheck'), dataIndex: 'health_check_path', key: 'hc', render: (v?: string) => v ?? '—' },
|
{ 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',
|
scheme: row.scheme as 'http' | 'https',
|
||||||
health_check_path: row.health_check_path ?? undefined,
|
health_check_path: row.health_check_path ?? undefined,
|
||||||
lb_algorithm: row.lb_algorithm,
|
lb_algorithm: row.lb_algorithm,
|
||||||
|
websocket: row.websocket,
|
||||||
active: row.active,
|
active: row.active,
|
||||||
domain_ids: domainsForBackend(row.id).map(d => d.id),
|
domain_ids: domainsForBackend(row.id).map(d => d.id),
|
||||||
})
|
})
|
||||||
@@ -246,7 +254,7 @@ export default function BackendsPage() {
|
|||||||
extraActions={
|
extraActions={
|
||||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
|
||||||
setCreating(true); form.resetFields()
|
setCreating(true); form.resetFields()
|
||||||
form.setFieldsValue({ scheme: 'http', lb_algorithm: 'roundrobin', active: true })
|
form.setFieldsValue({ scheme: 'http', lb_algorithm: 'roundrobin', websocket: false, active: true })
|
||||||
}}>
|
}}>
|
||||||
{t('backends.addBackend')}
|
{t('backends.addBackend')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -294,6 +302,10 @@ export default function BackendsPage() {
|
|||||||
<Form.Item label={t('backends.healthCheck')} name="health_check_path">
|
<Form.Item label={t('backends.healthCheck')} name="health_check_path">
|
||||||
<Input placeholder="/health" />
|
<Input placeholder="/health" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item label={t('backends.websocket')} name="websocket" valuePropName="checked"
|
||||||
|
extra={t('backends.websocketHint')}>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
<Form.Item label={t('backends.active')} name="active" valuePropName="checked">
|
<Form.Item label={t('backends.active')} name="active" valuePropName="checked">
|
||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
Reference in New Issue
Block a user