- 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>
153 lines
4.6 KiB
Go
153 lines
4.6 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"git.netcell-it.de/projekte/edgeguard-native/internal/aggregator"
|
|
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
|
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
|
)
|
|
|
|
const tlsCertDir = "/etc/edgeguard/tls"
|
|
|
|
// AgentTLSCerts liefert alle .pem-Dateien aus /etc/edgeguard/tls/ als
|
|
// Base64-Map. Wird vom Secondary via mTLS aufgerufen um Zertifikate
|
|
// des Primary zu spiegeln.
|
|
func (h *ClusterHandler) AgentTLSCerts(c *gin.Context) {
|
|
entries, err := os.ReadDir(tlsCertDir)
|
|
if err != nil {
|
|
response.Internal(c, err)
|
|
return
|
|
}
|
|
certs := make(map[string]string, len(entries))
|
|
for _, e := range entries {
|
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".pem") {
|
|
continue
|
|
}
|
|
data, err := os.ReadFile(filepath.Join(tlsCertDir, e.Name()))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
certs[e.Name()] = base64.StdEncoding.EncodeToString(data)
|
|
}
|
|
response.OK(c, gin.H{"certs": certs})
|
|
}
|
|
|
|
// SyncTLSCertsFromPrimary holt alle TLS-Zertifikate vom Primary via mTLS
|
|
// und schreibt geänderte Dateien nach /etc/edgeguard/tls/. Relädt HAProxy
|
|
// wenn mindestens ein Zertifikat aktualisiert wurde.
|
|
//
|
|
// Läuft auf dem Secondary bei jedem runSecondaryConfigRender-Tick —
|
|
// nicht hash-gated, da certbot-Renewals den config_hash nicht ändern.
|
|
func SyncTLSCertsFromPrimary(ctx context.Context, pool *pgxpool.Pool, agg *aggregator.Aggregator, localID string) error {
|
|
if agg == nil {
|
|
return nil
|
|
}
|
|
// Primary-Peer aus ha_nodes ermitteln
|
|
rows, err := pool.Query(ctx,
|
|
`SELECT id, fqdn, api_url FROM ha_nodes WHERE id != $1 LIMIT 1`, localID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close()
|
|
var primary *models.HANode
|
|
for rows.Next() {
|
|
n := &models.HANode{}
|
|
if err := rows.Scan(&n.ID, &n.FQDN, &n.APIURL); err != nil {
|
|
continue
|
|
}
|
|
primary = n
|
|
}
|
|
if primary == nil {
|
|
return nil // kein Peer → Single-Node
|
|
}
|
|
|
|
results := agg.FanOut(ctx, []models.HANode{*primary}, "/agent/cluster/tls-certs", localID)
|
|
if len(results) == 0 || !results[0].OK {
|
|
return nil // Primary nicht erreichbar — nächster Tick
|
|
}
|
|
|
|
var payload struct {
|
|
Certs map[string]string `json:"certs"`
|
|
}
|
|
if err := json.Unmarshal(results[0].Data, &payload); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := os.MkdirAll(tlsCertDir, 0o750); err != nil {
|
|
return err
|
|
}
|
|
|
|
changed := false
|
|
for name, b64 := range payload.Certs {
|
|
data, err := base64.StdEncoding.DecodeString(b64)
|
|
if err != nil {
|
|
slog.Warn("cert-sync: base64 decode failed", "file", name, "error", err)
|
|
continue
|
|
}
|
|
path := filepath.Join(tlsCertDir, name)
|
|
existing, readErr := os.ReadFile(path)
|
|
if readErr == nil && bytes.Equal(existing, data) {
|
|
continue // unverändert
|
|
}
|
|
if err := os.WriteFile(path, data, 0o640); err != nil {
|
|
slog.Warn("cert-sync: write failed", "file", name, "error", err)
|
|
continue
|
|
}
|
|
changed = true
|
|
slog.Info("cert-sync: updated", "file", name)
|
|
}
|
|
|
|
// Prune: lokale .pem entfernen, die der Primary NICHT (mehr) hat.
|
|
// Ohne diesen Schritt bleiben Zertifikate gelöschter Domains auf dem
|
|
// Secondary als Waisen liegen — der Sync oben ist write-only, „nicht
|
|
// mitgeschickt" ≠ „gelöscht". Geschützt bleiben:
|
|
// _default.pem — Self-Signed-Fallback
|
|
// <lokaler-FQDN>.pem — eigener Node-Cert (steht NICHT im Primary-Payload)
|
|
// Nur prunen wenn der Payload nicht leer ist — Schutz gegen ein
|
|
// versehentliches Leerräumen bei unvollständiger Primary-Antwort.
|
|
if len(payload.Certs) > 0 {
|
|
protected := map[string]bool{"_default.pem": true}
|
|
var localFQDN string
|
|
if err := pool.QueryRow(ctx,
|
|
`SELECT fqdn FROM ha_nodes WHERE id = $1`, localID).Scan(&localFQDN); err == nil && localFQDN != "" {
|
|
protected[localFQDN+".pem"] = true
|
|
}
|
|
if entries, err := os.ReadDir(tlsCertDir); err == nil {
|
|
for _, e := range entries {
|
|
name := e.Name()
|
|
if e.IsDir() || !strings.HasSuffix(name, ".pem") || protected[name] {
|
|
continue
|
|
}
|
|
if _, ok := payload.Certs[name]; ok {
|
|
continue // vom Primary gepflegt — behalten
|
|
}
|
|
if err := os.Remove(filepath.Join(tlsCertDir, name)); err != nil {
|
|
slog.Warn("cert-sync: prune failed", "file", name, "error", err)
|
|
continue
|
|
}
|
|
changed = true
|
|
slog.Info("cert-sync: pruned orphan", "file", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
if changed {
|
|
if err := exec.Command("sudo", "-n", "/usr/bin/systemctl", "reload", "haproxy.service").Run(); err != nil {
|
|
slog.Warn("cert-sync: haproxy reload failed", "error", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|