feat: per-Backend timeout server + Alerts-Deeplink + Retention + Cert-Prune — v1.3.4

- feat(backends): per-Backend `server_timeout_seconds` (nullable, Default 60s).
  Rendert `timeout server <N>s` im HAProxy-Backend-Block — für langsam
  antwortende Upstreams (KI-/Inferenz-Server mit gepufferter Antwort).
  Migration 0044 (+CHECK 1..86400), Model/Repo/Template/UI + Render-Test.
- fix(ui): Dashboard-Alert-Karte verlinkt auf /alerts?tab=events; Alerts-Seite
  respektiert ?tab= (Deeplink landete bisher auf leerem Channels-Tab).
- feat(scheduler): alert_events-Retention (90d) im täglichen Cleanup-Tick —
  Schutz vor unbounded growth der node-lokalen Health-Event-History.
- fix(cluster): Cert-Sync prunt jetzt lokale .pem die der Primary nicht mehr
  hat (Waisen gelöschter Domains); schützt _default.pem + eigenen Node-Cert.
- fix(security): x/text v0.37→v0.39 (GO-2026-5970, Infinite-Loop; via ACME+goose
  aktiv aufgerufen — govulncheck-Release-Gate).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-07-27 15:57:35 +02:00
parent 32ab2c7f47
commit 0ac91a7c59
17 changed files with 227 additions and 41 deletions

View File

@@ -194,6 +194,24 @@ FROM alert_events ORDER BY fired_at DESC, id DESC LIMIT $1`, limit)
return out, rows.Err()
}
// Cleanup löscht alert_events älter als keepDays und liefert die Anzahl
// gelöschter Rows. make_interval(days => $1) nimmt $1 sauber als int —
// der frühere ($1 || ' days')::interval-Ansatz erzwang text und scheiterte
// unter pgx mit einem Encode-Fehler (vgl. waf PurgeAlerts, v1.3.3).
func (s *Service) Cleanup(ctx context.Context, keepDays int) (int64, error) {
if keepDays <= 0 {
return 0, nil
}
tag, err := s.Pool.Exec(ctx,
`DELETE FROM alert_events WHERE fired_at < NOW() - make_interval(days => $1)`,
keepDays,
)
if err != nil {
return 0, err
}
return tag.RowsAffected(), nil
}
// Fire dispatch'ed einen Event an alle aktiven Channels und persistiert
// das Ergebnis. Non-fatal — Send-Failures werden im sent_to-JSON
// dokumentiert, der Event selbst landet in jedem Fall in der History.

View File

@@ -26,8 +26,8 @@ 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, force_http1, active,
created_at, updated_at
SELECT id, name, scheme, health_check_path, lb_algorithm, websocket, force_http1,
server_timeout_seconds, active, created_at, updated_at
FROM backends
`
@@ -65,11 +65,13 @@ 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, 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.ForceHTTP1, b.Active)
INSERT INTO backends (name, scheme, health_check_path, lb_algorithm, websocket, force_http1,
server_timeout_seconds, active)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, name, scheme, health_check_path, lb_algorithm, websocket, force_http1,
server_timeout_seconds, active, created_at, updated_at`,
b.Name, b.Scheme, b.HealthCheckPath, b.LBAlgorithm, b.WebSocket, b.ForceHTTP1,
b.ServerTimeoutSeconds, b.Active)
return scanBackend(row)
}
@@ -85,12 +87,14 @@ UPDATE backends SET
lb_algorithm = $4,
websocket = $5,
force_http1 = $6,
active = $7,
server_timeout_seconds = $7,
active = $8,
updated_at = NOW()
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.ForceHTTP1, b.Active, id)
WHERE id = $9
RETURNING id, name, scheme, health_check_path, lb_algorithm, websocket, force_http1,
server_timeout_seconds, active, created_at, updated_at`,
b.Name, b.Scheme, b.HealthCheckPath, b.LBAlgorithm, b.WebSocket, b.ForceHTTP1,
b.ServerTimeoutSeconds, b.Active, id)
out, err := scanBackend(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
@@ -125,7 +129,8 @@ 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.ForceHTTP1, &b.Active,
&b.HealthCheckPath, &b.LBAlgorithm, &b.WebSocket, &b.ForceHTTP1,
&b.ServerTimeoutSeconds, &b.Active,
&b.CreatedAt, &b.UpdatedAt,
); err != nil {
return nil, err