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 {
|
||||
|
||||
Reference in New Issue
Block a user