feat: umfangreiches UI+API-Polish (v1.1.36–1.1.42)

Backend:
- Audit-Log: Search-Endpoint mit ILIKE-Filter (actor/action/subject/date)
- NTP: /ntp/status via chronyc tracking (Stratum, Offset, Quelle)
- System: /service-restart mit Allowlist (haproxy/squid/unbound/chrony/scheduler)
- Domain-Response-Headers + Rate-Limit (Migration 0024)
- Join-Tokens (Migration 0025), Cluster-mTLS, Aggregator-Fan-Out
- apt-Service für Update-Banner (apt-get update + Versionsprüfung)
- Backup-Retry mit exponential backoff (retry_apt 3×)
- publish.sh fail-fast + cleanup-old.sh (max 10 Versionen)

Frontend:
- Audit-Log-Page (/audit) mit Filter + Pagination
- ErrorBoundary an React-Root + Vite build-target festgenagelt (iOS 15+)
- Storage-Schema-Stamp: auto-wipe bei Versions-Mismatch (blank-page-Fix)
- EmptyState-Komponente überall ausgerollt
- SSL: Aggregate-Karte (total/expiring/expired/errors)
- Backups: Aggregate-Karte (letzter Backup/Größe/Fehlschläge 24h) + Backup-Now
- NTP: Sync-Status-Karte (chronyc tracking live)
- Domains: Backend-UP/DOWN-Chip aus HAProxy-Stats
- Backends: HAProxy-Status-Spalte (UP/DEGRADED/DOWN)
- Settings: Service-Neustart-Karte (haproxy/squid/unbound/chrony/scheduler)
- Settings: Upgrade-Status-Card, Wartungsmodus, Auto-Update, Retention
- Dashboard: Recent-Alerts, Cluster-Health, License-Chip, Onboarding-Hint
- System-Regeln im Firewall als eigener Tab

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-19 16:18:41 +02:00
parent 3178e25e78
commit 35b7308ce2
82 changed files with 8408 additions and 392 deletions

View File

@@ -37,6 +37,11 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/services/backup"
backupremote "git.netcell-it.de/projekte/edgeguard-native/internal/services/backup/remote"
dnssvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/dns"
"git.netcell-it.de/projekte/edgeguard-native/internal/aggregator"
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/clustertls"
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/jointoken"
aptsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/apt"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/domainheaders"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/domains"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/firewall"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/firewalllog"
@@ -105,9 +110,27 @@ func main() {
requireAuth := handlers.RequireAuth(signer)
handlers.NewSetupHandler(setupStore).Register(v1)
handlers.NewSystemHandler(version).Register(v1)
handlers.NewAuthHandler(setupStore, signer).Register(v1, requireAuth)
setupHdl := handlers.NewSetupHandler(setupStore)
setupHdl.Register(v1)
// systemHdl exists früh damit sowohl der frühe (DB-pool nicht
// nötige) Pfad als auch der späte WithMaintenance-Hookup gehen.
systemHdl := handlers.NewSystemHandler(version)
systemHdl.Register(v1)
authHdl := handlers.NewAuthHandler(setupStore, signer)
authHdl.Register(v1, requireAuth)
// Background-Refresh für apt-cache: hält die Apt-Lists alle 5 min
// frisch, damit der UI-Update-Banner kurz nach `make publish` ein
// verfügbares Update sieht — ohne den Background-Timer wäre der
// Cache nur nach UI-Polls aktuell und der Throttle würde
// Aktualisierungen zwischen den Polls schlucken.
aptsvc.StartBackgroundRefresh(context.Background())
// agentHdl wird vom Agent-Listener mit-gemountet (Phase 3.5).
// Nil-safe — wenn DB nicht offen ist, läuft der Agent-Listener
// nur mit den read-only System-Endpoints.
var agentHdl *handlers.ClusterHandler
// Open the DB pool best-effort. Without a reachable PG, CRUD
// handlers stay unregistered and only Auth/Setup/System answer —
@@ -141,8 +164,63 @@ func main() {
}
cancel()
// Phase 3.2: alle 30s Heartbeat (last_seen, status, version,
// config_hash) für die eigene Row. Goroutine läuft so lange wie
// die API — beim graceful Shutdown stoppt sie via ctx.Done().
// Hält den eigenen Node-Status auch dann frisch wenn der
// Scheduler gerade down ist; ein crashender API stoppt den
// Heartbeat → Peer-Sweeper markiert binnen 2 min "offline".
if nodeID != "" {
go runClusterHeartbeat(context.Background(), pool, nodeID, version)
}
// Phase 3.3: Cluster-CA + Peer-Cert. Founder-Pfad — auf einem
// frisch installierten Single-Node generieren wir die CA und
// signieren uns selbst, damit der Agent-Listener auf :8443
// gleich hochfahren kann. Joining-Nodes (Phase 3.4) werden den
// Pfad nicht durchlaufen: sie kriegen CA+Peer-Cert vom Primary
// gepusht und finden die Files bereits vor.
clusterTLSStore := clustertls.New("")
if !clusterTLSStore.HasCA() {
org := "edgeguard.local"
if st != nil && st.FQDN != "" {
org = st.FQDN
}
if err := clusterTLSStore.InitCA(org, nil); err != nil {
slog.Warn("cluster-tls: InitCA failed", "error", err)
} else {
slog.Info("cluster-tls: CA generated", "dir", clusterTLSStore.Dir, "org", org)
}
}
if clusterTLSStore.HasCA() && !clusterTLSStore.HasPeer() {
cn := "edgeguard-node"
var dnsNames []string
if st != nil && st.FQDN != "" {
cn = st.FQDN
dnsNames = []string{st.FQDN}
}
if err := clusterTLSStore.EnsureSelfSigned(cn, dnsNames, nil, nil); err != nil {
slog.Warn("cluster-tls: EnsureSelfSigned failed", "error", err)
} else {
slog.Info("cluster-tls: peer cert generated", "cn", cn)
}
}
// Aggregator nur aufsetzen wenn Cert-Material da ist — sonst
// fan-out scheitert sowieso am Handshake.
var clusterAggregator *aggregator.Aggregator
if clusterTLSStore.HasPeer() {
if clientTLS, err := clusterTLSStore.ClientTLSConfig(); err == nil {
clusterAggregator = aggregator.New(clientTLS)
slog.Info("cluster: aggregator ready", "agent_port", aggregator.DefaultAgentPort)
} else {
slog.Warn("cluster: ClientTLSConfig failed", "error", err)
}
}
auditRepo := audit.New(pool)
domainsRepo := domains.New(pool)
domainHeadersRepo := domainheaders.New(pool)
backendsRepo := backends.New(pool)
backendServersRepo := backendservers.New(pool)
routingRepo := routingrules.New(pool)
@@ -176,13 +254,29 @@ func main() {
// injiziert, damit jede Änderung ohne expliziten render-config-
// Aufruf live geht. Errors werden geloggt, nicht failed
// (Row schon committed, Operator kann manuell re-triggern).
// Maintenance-Endpoints brauchen den Reloader — späte Wiring
// nachdem haproxyReloader-closure existiert.
haproxyReloaderForLater := func(ctx context.Context) error {
return haproxy.New(pool).Render(ctx)
}
systemHdl.WithMaintenance(setupStore, haproxyReloaderForLater)
// Audit-Wiring (Phase Polish): Settings + Auth-Mutationen
// landen jetzt im audit_log. Nodes-id ist die persistente
// /var/lib/edgeguard/node-id.
systemHdl.WithAudit(auditRepo, nodeID)
systemHdl.WithDB(pool)
setupHdl.WithAudit(auditRepo, nodeID)
authHdl.WithAudit(auditRepo, nodeID)
haproxyReloader := func(ctx context.Context) error {
return haproxy.New(pool).Render(ctx)
}
authed := v1.Group("")
authed.Use(requireAuth)
handlers.NewDomainsHandler(domainsRepo, routingRepo, auditRepo, nodeID, haproxyReloader).Register(authed)
setupHdl.RegisterAuthed(authed)
handlers.NewDomainsHandler(domainsRepo, routingRepo, domainHeadersRepo, auditRepo, nodeID, haproxyReloader).Register(authed)
handlers.NewBackendsHandler(backendsRepo, auditRepo, nodeID, haproxyReloader).Register(authed)
handlers.NewBackendServersHandler(backendServersRepo, auditRepo, nodeID, haproxyReloader).Register(authed)
handlers.NewRoutingRulesHandler(routingRepo, auditRepo, nodeID, haproxyReloader).Register(authed)
@@ -190,7 +284,37 @@ func main() {
handlers.NewIPAddressesHandler(ipsRepo, auditRepo, nodeID).Register(authed)
handlers.NewRoutesHandler(staticroutes.New(pool), staticroutes.NewGenerator(pool),
auditRepo, nodeID).Register(authed)
handlers.NewClusterHandler(clusterStore, nodeID).Register(authed)
// Phase 3.4 — Join-Token-Service. CA-Fingerprint kommt aus
// dem clustertls.Store; ohne CA = nil Tokens, GenerateToken
// scheitert, IssueCert wird gar nicht erst gemountet.
var joinTokens *jointoken.Service
if clusterTLSStore.HasCA() {
joinTokens = jointoken.New(pool, func() (string, error) {
caCert, _, err := clusterTLSStore.LoadCA()
if err != nil {
return "", err
}
return jointoken.CAFingerprint16(caCert.Raw), nil
})
}
// PeerReloader: nach Auto-Register triggert das den firewall-
// Render damit peer_ipv4 frisch ist und der mTLS-Listener für
// den neuen Peer erreichbar wird. Best-effort.
peerReloader := func(ctx context.Context) error {
return firewallrender.New(pool).Render(ctx)
}
clusterHdl := handlers.NewClusterHandler(clusterStore, nodeID).
WithAggregator(clusterAggregator).
WithJoinFlow(clusterTLSStore, joinTokens).
WithPeerReloader(peerReloader)
clusterHdl.Register(authed)
// /cluster/issue-cert läuft PUBLIC — joining Peer hat noch
// keine Session/Cert. Token + Nonce-Tracking ist die einzige
// Auth-Stufe.
clusterHdl.RegisterPublic(v1)
// Agent-Listener (mTLS) bekommt clusterHdl mit, damit Joiner
// sich via /agent/cluster/peers eintragen können.
agentHdl = clusterHdl
handlers.NewAuditHandler(auditRepo).Register(authed)
handlers.NewHAProxyStatsHandler().Register(authed)
@@ -284,6 +408,14 @@ func main() {
mountUI(r)
// Phase 3.3: zweiter Listener auf :8443 mit mTLS für Cluster-Peer-
// Reads. RequireAndVerifyClientCert gegen unsere Cluster-CA — wer
// keinen CA-signierten Cert hat, kommt nicht durch den Handshake.
// Listener wird nur gestartet wenn Cert-Material vorhanden ist;
// auf einer frisch installierten Box hat die Init-Phase oben das
// schon erledigt.
startAgentListener(version, agentHdl)
log.Printf("edgeguard-api %s listening on %s", version, addr)
srv := &http.Server{Addr: addr, Handler: r}
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
@@ -291,6 +423,51 @@ func main() {
}
}
// startAgentListener startet den mTLS-Agent-Listener auf :8443 als
// Goroutine. Mountet nur read-only Endpoints (siehe SystemHandler.
// RegisterAgent — health + resources). Fehler im Cert-Load = no-op
// + log; Fehler beim Listen.Serve loggen wir aber lassen die API
// weiterlaufen.
func startAgentListener(version string, clusterHdl *handlers.ClusterHandler) {
store := clustertls.New("")
serverTLS, err := store.ServerTLSConfig()
if err != nil {
slog.Info("cluster: agent listener disabled (no cert material)", "error", err)
return
}
addr := os.Getenv("EDGEGUARD_AGENT_LISTEN")
if addr == "" {
// 0.0.0.0:8443 — Auth via mTLS, also unbedenklich auf Public-IP.
// nft anti-lockout-Regel + Peer-IP-Set bestimmen wer überhaupt
// connecten darf. Loopback-Tests gehen direkt.
addr = "0.0.0.0:8443"
}
r := gin.New()
r.Use(gin.Recovery())
// Kein /api/v1-Prefix auf dem Agent-Listener: das Versioning kommt
// hier implizit aus dem Binary (Peer-Roundtrip ist immer same-major).
// Aggregator-Aufrufer sehen /agent/... direkt.
root := r.Group("")
handlers.NewSystemHandler(version).RegisterAgent(root)
if clusterHdl != nil {
// Phase 3.5: /agent/cluster/peers (Auto-Register).
clusterHdl.RegisterAgent(root)
}
srv := &http.Server{
Addr: addr,
Handler: r,
TLSConfig: serverTLS,
ReadHeaderTimeout: 10 * time.Second,
}
go func() {
slog.Info("cluster: agent (mTLS) listener starting", "addr", addr)
if err := srv.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {
slog.Error("cluster: agent listener", "error", err)
}
}()
}
// mountUI serves the management UI — Vite-built static assets under
// /usr/share/edgeguard/ui/ — with SPA fallback (any path that isn't
// /api/* or /healthz and isn't a real file → index.html). When the
@@ -428,6 +605,34 @@ func (a backupRemoteAdapter) UploadAll(ctx context.Context, localPath string) ([
return out, err
}
// runClusterHeartbeat tickt alle 30s und bumpt die eigene ha_nodes-Row
// (last_seen, status, version, config_hash) via cluster.Heartbeat.
// Fehler werden geloggt aber nicht zurückgegeben — der nächste Tick
// versucht es erneut. Beendet beim ctx.Done() (graceful API shutdown).
func runClusterHeartbeat(ctx context.Context, pool *pgxpoolPool, localID, version string) {
const tick = 30 * time.Second
t := time.NewTicker(tick)
defer t.Stop()
// Erster Schlag direkt nach Start damit die UI nicht 30s wartet.
if err := cluster.Heartbeat(ctx, pool, localID, version); err != nil {
slog.Warn("cluster: initial heartbeat failed", "error", err)
}
slog.Info("cluster: heartbeat goroutine started", "tick", tick.String(), "node_id", localID)
for {
select {
case <-ctx.Done():
slog.Info("cluster: heartbeat goroutine stopping")
return
case <-t.C:
hbCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
if err := cluster.Heartbeat(hbCtx, pool, localID, version); err != nil {
slog.Warn("cluster: heartbeat failed", "error", err)
}
cancel()
}
}
}
func randomEphemeralSecret() []byte {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {

View File

@@ -0,0 +1,296 @@
package main
import (
"bytes"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"strings"
"time"
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/clustertls"
)
// cmdClusterJoin: provisioniert auf diesem Node das Cluster-Cert-
// Material durch einen Aufruf an /api/v1/cluster/issue-cert beim
// Primary.
//
// Usage:
// edgeguard-ctl cluster-join <primary-fqdn-or-url> --token <token>
// [--insecure]
// [--cn <fqdn>]
//
// --insecure: TLS-Verify überspringen (für Bootstrap wenn der
// Primary mit self-signed Cert läuft und die CA noch
// nicht woanders verteilt ist — der Cert-Issue-Flow
// selbst läuft über HMAC-Token, nicht über TLS-Trust).
// --cn: Subject-CN für unseren CSR. Default: os.Hostname().
//
// Output: schreibt ca.crt + peer.{crt,key} nach /var/lib/edgeguard/
// cluster-tls/. Falls Cert-Material schon vorhanden, abort mit
// hint auf manuellen rm — wir wollen nicht aus Versehen einen
// laufenden Cluster-Node von seiner identity bringen.
func cmdClusterJoin(args []string) int {
fs := flag.NewFlagSet("cluster-join", flag.ContinueOnError)
tokenFlag := fs.String("token", "", "cluster join token (eg-join-v1.…)")
insecure := fs.Bool("insecure", false, "skip TLS verification on the primary (bootstrap mode)")
cn := fs.String("cn", "", "subject common name (default: hostname)")
clusterTLSDir := fs.String("tls-dir", clustertls.DefaultDir, "where to write ca.crt + peer.{crt,key}")
fs.SetOutput(os.Stderr)
if err := fs.Parse(args); err != nil {
return 2
}
if fs.NArg() < 1 {
fmt.Fprintln(os.Stderr, "usage: edgeguard-ctl cluster-join <primary-fqdn-or-url> --token <…>")
return 2
}
primary := fs.Arg(0)
if *tokenFlag == "" {
fmt.Fprintln(os.Stderr, "edgeguard-ctl cluster-join: --token required")
return 2
}
store := clustertls.New(*clusterTLSDir)
if store.HasPeer() {
fmt.Fprintf(os.Stderr,
"edgeguard-ctl cluster-join: peer cert already present under %s — "+
"refuse to overwrite. Run 'rm -rf %s' first if this is intentional.\n",
*clusterTLSDir, *clusterTLSDir)
return 1
}
commonName := *cn
if commonName == "" {
h, _ := os.Hostname()
commonName = h
}
if commonName == "" {
commonName = "edgeguard-node"
}
endpoint, err := normalizePrimaryURL(primary)
if err != nil {
fmt.Fprintf(os.Stderr, "edgeguard-ctl cluster-join: %v\n", err)
return 1
}
// SAN: gleicher CN + Hostname. IPs hängen wir an wenn das Host-
// Argument eine IP war, damit der lokale Agent-Listener auch
// gegen IP gechecked werden kann.
dnsNames := []string{commonName}
var ips []net.IP
if ip := net.ParseIP(commonName); ip != nil {
ips = append(ips, ip)
// Wenn CN eine IP ist, lassen wir DNSNames leer — RFC 6125
// erlaubt nicht beides als-ob-DNS.
dnsNames = nil
}
keyPEM, csrPEM, err := clustertls.NewPeerKeyAndCSR(commonName, dnsNames, ips)
if err != nil {
fmt.Fprintf(os.Stderr, "edgeguard-ctl cluster-join: gen CSR: %v\n", err)
return 1
}
caCertPEM, peerCertPEM, err := postIssueCert(endpoint, *tokenFlag, csrPEM, *insecure)
if err != nil {
fmt.Fprintf(os.Stderr, "edgeguard-ctl cluster-join: issue-cert: %v\n", err)
return 1
}
if err := os.MkdirAll(*clusterTLSDir, 0o700); err != nil {
fmt.Fprintf(os.Stderr, "edgeguard-ctl cluster-join: mkdir %s: %v\n", *clusterTLSDir, err)
return 1
}
// Schreiben in stabiler Reihenfolge: erst CA (wird vom Peer-Cert-
// Verify gebraucht), dann peer.{crt,key}.
for _, w := range []struct {
name string
mode os.FileMode
data string
}{
{"ca.crt", 0o644, caCertPEM},
{"peer.crt", 0o644, peerCertPEM},
{"peer.key", 0o600, keyPEM},
} {
path := *clusterTLSDir + "/" + w.name
if err := os.WriteFile(path, []byte(w.data), w.mode); err != nil {
fmt.Fprintf(os.Stderr, "edgeguard-ctl cluster-join: write %s: %v\n", path, err)
return 1
}
}
// Phase 3.5: Auto-Register beim Primary. Nutzt das frisch erhaltene
// Peer-Cert via mTLS, damit der Primary uns in ha_nodes mit
// status='joining' anlegt + sein peer_ipv4-Set updated.
if err := autoRegister(endpoint, *clusterTLSDir, commonName); err != nil {
fmt.Fprintf(os.Stderr,
"edgeguard-ctl cluster-join: auto-register failed (Cert-Material liegt aber schon — kannst manuell nachholen): %v\n", err)
// Wir geben hier NICHT-NULL zurück — der Cert-Issue war ja
// erfolgreich. Der Operator kann manuell registrieren oder
// es funktioniert beim Service-Start (Phase 3.2 Heartbeat).
}
fmt.Printf("Cluster-Join erfolgreich.\n")
fmt.Printf(" Primary: %s\n", endpoint)
fmt.Printf(" CN: %s\n", commonName)
fmt.Printf(" Files: %s/{ca.crt,peer.crt,peer.key}\n", *clusterTLSDir)
fmt.Printf("\nNächste Schritte:\n")
fmt.Printf(" 1) sudo systemctl restart edgeguard-api # lädt das neue Cert ins mTLS-Agent-Listener\n")
fmt.Printf(" 2) Auf dem Primary in der Cluster-UI prüfen ob der neue Peer in /cluster/nodes auftaucht\n")
fmt.Printf(" 3) PG-Basebackup + KeyDB-Replica-Setup folgt mit Phase 3.5 (manuell bis dahin)\n")
return 0
}
// autoRegister: POST mTLS an <primary-host>:8443/agent/cluster/peers.
// Note: der mTLS-Agent-Port :8443 ist anders als der Public-Port
// (3443). Wir leiten den Host aus der primary-URL ab und ersetzen
// den Port.
func autoRegister(primary, tlsDir, commonName string) error {
// Primary-URL parse + Port-Override
u, err := url.Parse(primary)
if err != nil {
return err
}
u.Host = u.Hostname() + ":8443"
u.Path = "/agent/cluster/peers"
// Local node-id + body bauen. node-id liegt in /var/lib/edgeguard/
// node-id (vom Heartbeat-Subsystem persistiert); wir lesen direkt
// statt cluster.EnsureNodeID() um den DB-Abhängigkeit-Pfad nicht
// zu öffnen.
nodeID, _ := os.ReadFile("/var/lib/edgeguard/node-id")
hostname, _ := os.Hostname()
body, _ := json.Marshal(map[string]string{
"id": strings.TrimSpace(string(nodeID)),
"name": hostname,
"fqdn": commonName,
"api_url": "https://" + commonName + ":3443",
"version": version,
})
// mTLS-Client mit gerade frisch geschriebenem Material.
pair, err := tls.LoadX509KeyPair(tlsDir+"/peer.crt", tlsDir+"/peer.key")
if err != nil {
return fmt.Errorf("load peer cert: %w", err)
}
caPEM, err := os.ReadFile(tlsDir + "/ca.crt")
if err != nil {
return fmt.Errorf("read ca: %w", err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(caPEM) {
return errors.New("invalid ca.crt")
}
tr := &http.Transport{
TLSClientConfig: &tls.Config{
Certificates: []tls.Certificate{pair},
RootCAs: pool,
MinVersion: tls.VersionTLS13,
// Hostname-Verify: wir checken gegen den CN/SAN des
// Primary-Cert. Wenn der Primary-Cert das nicht hat
// (Self-Signed for IP only), kann der join trotzdem
// erfolgreich sein wenn das CA-Cert validiert.
ServerName: u.Hostname(),
},
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: 10 * time.Second,
}
client := &http.Client{Transport: tr, Timeout: 30 * time.Second}
req, err := http.NewRequest(http.MethodPost, u.String(), bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
}
return nil
}
// normalizePrimaryURL: nimmt "fqdn", "host:port" oder "https://host:port"
// und liefert immer "https://host:port" zurück. Default-Port 3443 (das
// ist der Mgmt-UI-Listener; /cluster/issue-cert läuft dort).
func normalizePrimaryURL(in string) (string, error) {
in = strings.TrimSpace(in)
if in == "" {
return "", errors.New("empty primary fqdn/url")
}
if !strings.HasPrefix(in, "http://") && !strings.HasPrefix(in, "https://") {
in = "https://" + in
}
u, err := url.Parse(in)
if err != nil {
return "", err
}
if u.Hostname() == "" {
return "", errors.New("primary URL has no host")
}
if u.Port() == "" {
u.Host = u.Hostname() + ":3443"
}
u.Path = ""
u.RawQuery = ""
u.Fragment = ""
return u.String(), nil
}
// postIssueCert: POSTet {token, csr} an <primary>/api/v1/cluster/issue-cert.
// `insecure` skippt TLS-Verify damit der Bootstrap auch wenn der Primary
// mit self-signed Cert hört durchgeht — die Sicherheit hängt am HMAC-
// gesigneten Token, nicht am TLS-Layer.
func postIssueCert(primary, token, csr string, insecure bool) (caCert, peerCert string, err error) {
body, _ := json.Marshal(map[string]string{"token": token, "csr": csr})
req, err := http.NewRequest(http.MethodPost,
primary+"/api/v1/cluster/issue-cert", bytes.NewReader(body))
if err != nil {
return "", "", err
}
req.Header.Set("Content-Type", "application/json")
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure, MinVersion: tls.VersionTLS12},
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: 10 * time.Second,
}
client := &http.Client{Transport: tr, Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", "", err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
return "", "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
}
var env struct {
Data struct {
CACert string `json:"ca_cert"`
PeerCert string `json:"peer_cert"`
} `json:"data"`
Error string `json:"error"`
}
if err := json.Unmarshal(raw, &env); err != nil {
return "", "", fmt.Errorf("decode response: %w", err)
}
if env.Error != "" {
return "", "", fmt.Errorf("server: %s", env.Error)
}
if env.Data.CACert == "" || env.Data.PeerCert == "" {
return "", "", errors.New("response missing ca_cert or peer_cert")
}
return env.Data.CACert, env.Data.PeerCert, nil
}

View File

@@ -0,0 +1,62 @@
package main
import (
"flag"
"fmt"
"os"
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/clustertls"
)
// cmdClusterRenewSelf: re-issued das eigene peer.{crt,key} mit der
// lokalen Cluster-CA. Nur sinnvoll auf der Founder/Primary-Box wo die
// CA-Key noch vorhanden ist; auf Joinern fehlt die ca.key (sie haben
// nur die CA-Cert zum Verify) und der Aufruf scheitert.
//
// Usage:
// edgeguard-ctl cluster-renew-self [--cn <fqdn>] [--tls-dir <path>]
//
// Output: schreibt peer.crt + peer.key über das vorhandene peer.{crt,
// key} hinweg. Restart-Hinweis am Ende.
func cmdClusterRenewSelf(args []string) int {
fs := flag.NewFlagSet("cluster-renew-self", flag.ContinueOnError)
cn := fs.String("cn", "", "subject common name (default: aus dem aktuellen peer.crt geerbt)")
tlsDir := fs.String("tls-dir", clustertls.DefaultDir, "where peer.{crt,key} live")
fs.SetOutput(os.Stderr)
if err := fs.Parse(args); err != nil {
return 2
}
store := clustertls.New(*tlsDir)
if !store.HasCA() {
fmt.Fprintln(os.Stderr,
"edgeguard-ctl cluster-renew-self: no local CA found — this command works only on a founder/primary node.")
return 1
}
commonName := *cn
if commonName == "" {
if info, err := store.PeerCertInfo(); err == nil && info.CommonName != "" {
commonName = info.CommonName
}
}
if commonName == "" {
h, _ := os.Hostname()
commonName = h
}
if commonName == "" {
commonName = "edgeguard-node"
}
if err := store.RenewSelfSigned(commonName, []string{commonName}, nil, nil); err != nil {
fmt.Fprintf(os.Stderr, "edgeguard-ctl cluster-renew-self: %v\n", err)
return 1
}
info, _ := store.PeerCertInfo()
fmt.Printf("Cluster-Peer-Cert erneuert.\n")
fmt.Printf(" CN: %s\n", commonName)
fmt.Printf(" Files: %s/peer.{crt,key}\n", *tlsDir)
if info != nil {
fmt.Printf(" Gültig bis: %s (%d Tage)\n", info.NotAfter, info.DaysRemaining)
}
fmt.Printf("\nDamit der Agent-Listener das neue Cert lädt:\n")
fmt.Printf(" sudo systemctl restart edgeguard-api\n")
return 0
}

View File

@@ -28,7 +28,16 @@ Commands:
render-config Regenerate haproxy / nftables configs from PG (--no-reload, --only=)
wg-import [--path <dir>] Import existing /etc/wireguard/*.conf files into the DB
reset-password Generate a one-time token for the /reset-password UI flow
cluster-join Join an existing cluster (Phase 3, not yet implemented)
cluster-join <primary> --token <…>
Provision Cluster-TLS material on this node by
exchanging the join-token at the primary's
/api/v1/cluster/issue-cert endpoint. Writes
ca.crt + peer.{crt,key} into /var/lib/edgeguard/
cluster-tls/. PG-Basebackup + KeyDB replica
setup remain manual until Phase 3.5.
cluster-renew-self Re-issue this node's peer.{crt,key} using the
local cluster CA (founder/single-node only).
1-year validity. Restart edgeguard-api after.
promote Promote this node's PG to primary (Phase 3, not yet implemented)
dump-config Print effective config (Phase 3, not yet implemented)
`
@@ -53,7 +62,11 @@ func main() {
os.Exit(cmdWGImport(os.Args[2:]))
case "reset-password":
os.Exit(cmdResetPassword())
case "cluster-join", "cluster-leave", "promote", "dump-config":
case "cluster-join":
os.Exit(cmdClusterJoin(os.Args[2:]))
case "cluster-renew-self":
os.Exit(cmdClusterRenewSelf(os.Args[2:]))
case "cluster-leave", "promote", "dump-config":
fmt.Fprintf(os.Stderr, "edgeguard-ctl: %q is a Phase-3 stub — not yet implemented\n", os.Args[1])
os.Exit(1)
default:

View File

@@ -15,15 +15,18 @@ import (
"log/slog"
"os"
"strconv"
"syscall"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster"
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/clustertls"
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
"git.netcell-it.de/projekte/edgeguard-native/internal/license"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/acme"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/alerts"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/backup"
backupremote "git.netcell-it.de/projekte/edgeguard-native/internal/services/backup/remote"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/certrenewer"
@@ -53,10 +56,47 @@ const (
// Retention: 14 erfolgreiche Backups (default in backup.Service).
backupTickInterval = 24 * time.Hour
// configHashTickInterval — alle 5 min config_hash neu berechnen
// und in ha_nodes der eigenen Row schreiben. Cluster-UI nutzt
// das fürs Drift-Banner — pro-Mutation-Refresh wäre teuer.
configHashTickInterval = 5 * time.Minute
// staleSweepTickInterval — Phase 3.2: alle 30s prüfen ob Peers
// last_seen länger als staleThreshold nicht gemeldet haben →
// status='offline'. Symmetrisch zum 30s-API-Heartbeat.
staleSweepTickInterval = 30 * time.Second
// staleThreshold — Peer gilt als offline wenn last_seen älter als
// das ist. 4× Heartbeat-Intervall lässt einen verpassten Tick
// (Restart, GC-Pause, kurzer Network-Glitch) zu ohne false positive.
staleThreshold = 2 * time.Minute
// clusterCertCheckInterval — täglicher Check ob CA + peer-cert
// in den nächsten clusterCertWarnDays ablaufen. Bei Hit feuert
// ein Alert (dedupe 12h damit der Operator nicht alle 24h dieselbe
// Warnung sieht).
clusterCertCheckInterval = 24 * time.Hour
// clusterCertWarnDays — Schwelle für die Cert-Expiry-Warnung.
// Operator hat damit min. 30 Tage Vorlauf für `edgeguard-ctl
// cluster-renew-self` oder einen manuellen Re-Join.
clusterCertWarnDays = 30
// clusterCertAutoRenewDays — Schwelle ab der wir automatisch
// neu signieren (nur Founder mit lokaler CA). Wir liegen 2× vor
// der Warn-Schwelle damit ein verpasster Tick + ein verpasster
// Restart-Window noch passen.
clusterCertAutoRenewDays = 60
// diskCheckInterval — stündliche Disk-Usage-Prüfung. Fire-Schwellen
// in runDiskCheck (warning 80%, error 90%). Stündlich ist schnell
// genug damit der Operator vor /var = 100% noch Zeit zum Aufräumen
// hat, ohne Log-Spam zu produzieren (Dedupe 12h pro Severity).
diskCheckInterval = 1 * time.Hour
diskWarnPct = 80.0
diskCriticalPct = 90.0
// auditCleanupInterval — täglicher Cleanup. Audit-Rows älter als
// auditRetentionDays werden gelöscht. Idempotent — wenn nichts da
// ist passiert nichts.
auditCleanupInterval = 24 * time.Hour
auditRetentionDays = 90
)
func main() {
@@ -98,6 +138,7 @@ func main() {
"dir", backupSvc.BackupDir, "keep_n", backup.DefaultKeepN)
alertSvc := alerts.New(pool)
auditRepo := audit.New(pool)
alertDedupe := newDedupe(12 * time.Hour)
if renewer != nil {
@@ -105,13 +146,15 @@ func main() {
}
runLicenseVerify(ctx, licClient, licKeyStore, licRepo, nodeID, alertSvc, alertDedupe)
// Lokale Node-ID für config-hash-refresh. EnsureNodeID liefert
// dieselbe ID die die API hat (gleiches /var/lib/edgeguard/node-id).
// Lokale Node-ID für Heartbeat. EnsureNodeID liefert dieselbe ID
// die die API hat (gleiches /var/lib/edgeguard/node-id).
localID, _ := cluster.EnsureNodeID("")
slog.Info("scheduler: config-hash refresh enabled", "tick", configHashTickInterval, "node_id", localID)
// Initial-Refresh damit /cluster/status nach API+Scheduler-Boot
// nicht 5min auf den ersten Wert wartet.
runConfigHash(ctx, pool, localID)
slog.Info("scheduler: stale-sweeper enabled",
"tick", staleSweepTickInterval, "threshold", staleThreshold, "node_id", localID)
// Initial-Sweep + initial-Heartbeat damit /cluster/status nach
// Scheduler-Boot direkt einen frischen Zustand sieht.
runHeartbeat(ctx, pool, localID, version)
runStaleSweep(ctx, pool)
renewTick := time.NewTicker(renewTickInterval)
defer renewTick.Stop()
@@ -119,8 +162,23 @@ func main() {
defer licTick.Stop()
backupTick := time.NewTicker(backupTickInterval)
defer backupTick.Stop()
hashTick := time.NewTicker(configHashTickInterval)
defer hashTick.Stop()
sweepTick := time.NewTicker(staleSweepTickInterval)
defer sweepTick.Stop()
clusterCertTick := time.NewTicker(clusterCertCheckInterval)
defer clusterCertTick.Stop()
// Initial-Cert-Check direkt beim Start, sonst sieht der Operator
// einen Warning erst nach 24h ab Boot.
runClusterCertExpiryCheck(ctx, alertSvc, alertDedupe)
diskTick := time.NewTicker(diskCheckInterval)
defer diskTick.Stop()
// Initial-Disk-Check: wenn die Box schon bei 95% steht beim
// Scheduler-Boot, wollen wir keine Stunde auf den ersten Alert
// warten.
runDiskCheck(ctx, alertSvc, alertDedupe)
auditTick := time.NewTicker(auditCleanupInterval)
defer auditTick.Stop()
for {
select {
@@ -132,13 +190,227 @@ func main() {
case <-licTick.C:
runLicenseVerify(ctx, licClient, licKeyStore, licRepo, nodeID, alertSvc, alertDedupe)
case <-backupTick.C:
runBackup(ctx, backupSvc, version, alertSvc)
case <-hashTick.C:
runConfigHash(ctx, pool, localID)
runBackup(ctx, backupSvc, version, alertSvc, setupStore)
case <-sweepTick.C:
// Symmetrisches Heartbeat aus dem Scheduler — falls die API
// pausiert/hängt, hält der Scheduler die eigene Row warm.
// Idempotent zur API-Heartbeat-Goroutine.
runHeartbeat(ctx, pool, localID, version)
runStaleSweep(ctx, pool)
case <-clusterCertTick.C:
runClusterCertExpiryCheck(ctx, alertSvc, alertDedupe)
case <-diskTick.C:
runDiskCheck(ctx, alertSvc, alertDedupe)
case <-auditTick.C:
runAuditCleanup(ctx, auditRepo, setupStore)
}
}
}
// runAuditCleanup löscht audit_log-Rows älter als die konfigurierte
// Retention. Operator kann den Wert in den Settings übersteuern; ohne
// Setup-Custom fällt's auf auditRetentionDays-Default zurück.
// Schutz vor unbounded growth — auf einer aktiven Box wird das Log
// nach 1-2 Jahren mehrere GB groß und macht die /logs-Page langsam.
// Best-effort: Fehler werden nur geloggt, der Tick läuft beim nächsten
// Zyklus wieder.
func runAuditCleanup(ctx context.Context, r *audit.Repo, setupStore *setup.Store) {
if r == nil {
return
}
keepDays := auditRetentionDays
if setupStore != nil {
if st, err := setupStore.Load(); err == nil && st != nil && st.AuditRetentionDays > 0 {
keepDays = st.AuditRetentionDays
}
}
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
n, err := r.Cleanup(cctx, keepDays)
if err != nil {
slog.Warn("scheduler: audit cleanup failed",
"keep_days", keepDays, "error", err)
return
}
if n > 0 {
slog.Info("scheduler: audit cleanup",
"deleted", n, "keep_days", keepDays)
}
}
// runDiskCheck prüft die Belegung von / via statfs. Fire-Schwellen:
// - >= 90% → Critical (error). Box ist akut gefährdet — beim
// nächsten Backup-Run oder größeren apt-Update droht "no space
// left" und damit failed services.
// - >= 80% → Warning. Operator hat noch Luft aber sollte aufräumen.
// - < 80% → kein Alert.
//
// Dedupe-Keys pro Severity, damit ein lang-belegtes Filesystem nicht
// jede Stunde feuert (12h pro Stufe). Bei Übergang warning→critical
// gibt's einen frischen Alert weil die Keys verschieden sind.
//
// Fix-Hint im Body: was der Operator als Erstes prüfen soll
// (/var/backups/edgeguard, /var/log/edgeguard, /var/cache/apt).
func runDiskCheck(ctx context.Context, a *alerts.Service, d *dedupe) {
if a == nil || d == nil {
return
}
var fs syscall.Statfs_t
if err := syscall.Statfs("/", &fs); err != nil {
slog.Warn("scheduler: disk-check statfs failed", "error", err)
return
}
total := float64(fs.Blocks) * float64(fs.Bsize)
free := float64(fs.Bavail) * float64(fs.Bsize)
if total <= 0 {
return
}
usedPct := (total - free) * 100 / total
freeGB := free / (1024 * 1024 * 1024)
totalGB := total / (1024 * 1024 * 1024)
var key, title string
var sev alerts.Severity
switch {
case usedPct >= diskCriticalPct:
key = "disk.full.critical"
sev = alerts.SeverityError
title = fmt.Sprintf("Disk kritisch voll: %.0f%%", usedPct)
case usedPct >= diskWarnPct:
key = "disk.full.warning"
sev = alerts.SeverityWarning
title = fmt.Sprintf("Disk-Belegung hoch: %.0f%%", usedPct)
default:
return
}
if !d.shouldFire(key) {
return
}
desc := fmt.Sprintf(
"Wurzel-Filesystem (/) ist zu %.1f%% belegt — noch %.2f GB von %.2f GB frei.\n\n"+
"Häufige Verursacher checken:\n"+
" sudo du -hs /var/backups/edgeguard /var/log/edgeguard /var/cache/apt /var/lib/postgresql\n\n"+
"Backup-Retention ist 14 (default). Manuell aufräumen:\n"+
" ls -lhS /var/backups/edgeguard | head\n"+
" sudo apt-get clean # /var/cache/apt leeren",
usedPct, freeGB, totalGB)
if _, err := a.Fire(ctx, "disk.full", sev, title, desc); err != nil {
slog.Warn("scheduler: disk-check alert fire failed", "error", err)
}
}
// runClusterCertExpiryCheck warnt wenn CA oder peer.crt in <
// clusterCertWarnDays Tagen ablaufen (oder schon abgelaufen sind).
// Dedupe pro Cert-Typ + 12h.
//
// Zusätzlich (Phase 1.1.1): wenn das peer.crt < clusterCertAutoRenewDays
// remaining hat UND eine lokale CA existiert, wird automatisch neu
// signiert. Restart-Hinweis als Info-Alert — wir starten edgeguard-api
// nicht selbst neu, das passiert beim nächsten geplanten Update/Reboot.
func runClusterCertExpiryCheck(ctx context.Context, a *alerts.Service, d *dedupe) {
if a == nil || d == nil {
return
}
store := clustertls.New("")
// Auto-Renew zuerst — danach lesen wir die (eventuell frischen)
// Cert-Infos für den Alert-Check ab.
tryAutoRenew(ctx, store, a, d)
check := func(kind, key string, info *clustertls.CertInfo, err error) {
if err != nil {
return // Cert nicht vorhanden / unleserlich — keine Warnung.
}
if info.DaysRemaining > clusterCertWarnDays {
return
}
if !d.shouldFire(key) {
return
}
sev := alerts.SeverityWarning
title := "Cluster-" + kind + " läuft bald ab"
desc := fmt.Sprintf("%s (CN=%s) läuft in %d Tagen ab (NotAfter=%s).",
kind, info.CommonName, info.DaysRemaining, info.NotAfter.Format(time.RFC3339))
if info.DaysRemaining < 0 {
sev = alerts.SeverityError
title = "Cluster-" + kind + " ist abgelaufen"
desc = fmt.Sprintf("%s (CN=%s) ist seit %d Tagen abgelaufen (NotAfter=%s).",
kind, info.CommonName, -info.DaysRemaining, info.NotAfter.Format(time.RFC3339))
}
desc += "\n\nFix: sudo edgeguard-ctl cluster-renew-self (founder/single-node)\n sudo systemctl restart edgeguard-api"
if _, err := a.Fire(ctx, "cluster.cert.expiring", sev, title, desc); err != nil {
slog.Warn("scheduler: cluster cert alert fire failed", "kind", kind, "error", err)
}
}
if store.HasCA() {
info, err := store.CACertInfo()
check("CA", "cluster.cert.expiring:ca", info, err)
}
if store.HasPeer() {
info, err := store.PeerCertInfo()
check("peer-Cert", "cluster.cert.expiring:peer", info, err)
}
}
// tryAutoRenew: wenn das peer.crt < clusterCertAutoRenewDays Tage
// remaining hat UND wir eine lokale CA haben (= Founder-Node), wird
// automatisch ein frisches peer.{crt,key} signiert. Edgeguard-api
// muss anschließend manuell restartet werden damit der Listener das
// neue Material lädt — wir alarmieren das, restarten aber nicht
// selbst (würde laufende Requests + die Heartbeat-Goroutine killen).
//
// Joiner-Nodes (keine eigene CA) ignorieren wir hier; sie laufen über
// einen anderen Renewal-Pfad (Phase 3.6, Renewal-Token via mTLS).
func tryAutoRenew(ctx context.Context, store *clustertls.Store, a *alerts.Service, d *dedupe) {
if !store.HasPeer() || !store.HasCA() {
return
}
info, err := store.PeerCertInfo()
if err != nil {
return
}
if info.DaysRemaining > clusterCertAutoRenewDays {
return
}
// CN aus dem alten Cert übernehmen — sonst würde ein Hostname-
// Wechsel mitten in der Renewal unbemerkt durchgehen.
cn := info.CommonName
if cn == "" {
cn = "edgeguard-node"
}
if err := store.RenewSelfSigned(cn, []string{cn}, nil, nil); err != nil {
slog.Warn("scheduler: cluster cert auto-renew failed", "error", err)
// Failure-Alert dedupe 12h — Operator soll daran erinnert werden.
if d.shouldFire("cluster.cert.auto_renew.failed") {
_, _ = a.Fire(ctx, "cluster.cert.auto_renew.failed",
alerts.SeverityError,
"Cluster-Peer-Cert Auto-Renew fehlgeschlagen",
"clustertls.RenewSelfSigned: "+err.Error()+
"\n\nFix: sudo edgeguard-ctl cluster-renew-self")
}
return
}
// Success — neuer Cert auf Disk, alter Cert noch im API-Speicher.
// Info-Alert mit Restart-Hinweis. Dedupe 24h damit nicht
// gefloodet wird wenn der Operator nicht restartet.
if d.shouldFire("cluster.cert.auto_renew.ok") {
fresh, _ := store.PeerCertInfo()
until := info.NotAfter.Format(time.RFC3339)
if fresh != nil {
until = fresh.NotAfter.Format(time.RFC3339)
}
_, _ = a.Fire(ctx, "cluster.cert.auto_renewed",
alerts.SeverityInfo,
"Cluster-Peer-Cert automatisch erneuert",
fmt.Sprintf("Neues Peer-Cert auf Disk (CN=%s, gültig bis %s). "+
"Damit edgeguard-api das neue Cert in den mTLS-Listener lädt:\n\n"+
" sudo systemctl restart edgeguard-api\n\n"+
"Bis dahin nutzt der laufende Prozess das alte Cert.", cn, until))
}
slog.Info("scheduler: cluster peer cert auto-renewed", "cn", cn,
"old_days_remaining", info.DaysRemaining)
}
// dedupe verhindert dass derselbe Alert-Key (z.B. "cert.expiring:utm-1.netcell-it.de")
// öfter als alle 12h gefeuert wird. In-memory — Scheduler-Restart
// resettet, was OK ist (Operator soll bei restart wieder einen kennen-
@@ -200,18 +472,38 @@ func runCertExpiryCheck(ctx context.Context, repo *tlscerts.Repo,
}
}
// runConfigHash berechnet den Hash und schreibt ihn in ha_nodes.
// Pool kann nil sein (scheduler-pool-fail beim boot) — dann no-op.
func runConfigHash(ctx context.Context, pool *pgxpoolPool, localID string) {
// runHeartbeat schreibt last_seen + status=online + version + config_hash
// auf die eigene ha_nodes-Row. Pool kann nil sein (scheduler-pool-fail
// beim Boot) — dann no-op. Errors landen im WARN, kein Abort der Schleife.
func runHeartbeat(ctx context.Context, pool *pgxpoolPool, localID, version string) {
if pool == nil || localID == "" {
return
}
hash, err := cluster.RefreshLocalHash(ctx, pool, localID)
if err != nil {
slog.Warn("scheduler: config-hash refresh failed", "error", err)
hbCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := cluster.Heartbeat(hbCtx, pool, localID, version); err != nil {
slog.Warn("scheduler: heartbeat failed", "error", err)
}
}
// runStaleSweep markiert Peers mit last_seen < NOW()-staleThreshold als
// offline. Logged nur wenn Rows betroffen sind (sonst floodet das Log
// mit "0 rows" alle 30s).
func runStaleSweep(ctx context.Context, pool *pgxpoolPool) {
if pool == nil {
return
}
slog.Debug("scheduler: config-hash refreshed", "hash", hash)
swCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
flipped, err := cluster.SweepStaleNodes(swCtx, pool, staleThreshold)
if err != nil {
slog.Warn("scheduler: stale-sweep failed", "error", err)
return
}
if flipped > 0 {
slog.Info("scheduler: marked stale peers offline",
"count", flipped, "threshold", staleThreshold)
}
}
// pgxpoolPool ist ein lokaler Alias damit die Signatur stabil bleibt
@@ -220,7 +512,7 @@ type pgxpoolPool = pgxpool.Pool
// runBackup führt einen scheduled Backup aus + prunet alte. Failures
// loggen wir + alarmieren — verlorene Backups sind kritisch.
func runBackup(ctx context.Context, svc *backup.Service, version string, a *alerts.Service) {
func runBackup(ctx context.Context, svc *backup.Service, version string, a *alerts.Service, setupStore *setup.Store) {
res, err := svc.Run(ctx, backup.KindScheduled, version)
if err != nil {
slog.Warn("scheduler: backup failed", "error", err, "file", res.File)
@@ -235,7 +527,13 @@ func runBackup(ctx context.Context, svc *backup.Service, version string, a *aler
"file", res.File, "size", res.SizeBytes,
"db_bytes", res.DBDumpBytes, "files_bytes", res.FilesBytes,
"sha256", res.SHA256)
if err := svc.Prune(ctx, backup.DefaultKeepN); err != nil {
keepN := backup.DefaultKeepN
if setupStore != nil {
if st, err := setupStore.Load(); err == nil && st != nil && st.BackupRetentionKeep > 0 {
keepN = st.BackupRetentionKeep
}
}
if err := svc.Prune(ctx, keepN); err != nil {
slog.Warn("scheduler: backup prune failed", "error", err)
}
}