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 {