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:
@@ -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 {
|
||||
|
||||
296
cmd/edgeguard-ctl/cluster_join.go
Normal file
296
cmd/edgeguard-ctl/cluster_join.go
Normal 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
|
||||
}
|
||||
62
cmd/edgeguard-ctl/cluster_renew.go
Normal file
62
cmd/edgeguard-ctl/cluster_renew.go
Normal 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
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user