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
CLAUDE.md
37
CLAUDE.md
@@ -27,25 +27,36 @@ Vor jeder Entscheidung über Feldwerte, API-Shapes, Dateinamen, Funktions-Signat
|
||||
|
||||
---
|
||||
|
||||
## Arbeitsweise — Senior Engineer + MCP (PFLICHT)
|
||||
## MCP-Tools (Architect Center)
|
||||
|
||||
### Session-Bindung
|
||||
Sessions: `EdgeGuardNative-1` · project_id **8** · `$ARCHITECT_SESSION` im Env · MCP-Server `architect` verfügbar
|
||||
|
||||
### Code-Suche — AUTOMATISCH nutzen
|
||||
|
||||
Bei JEDER Code-bezogenen Frage **zuerst** `ac_search_code` aufrufen:
|
||||
**project_id: 8** · Session: `$ARCHITECT_SESSION` · MCP-Server: `architect`
|
||||
|
||||
### Beim Session-Start (PFLICHT)
|
||||
```
|
||||
ac_search_code(query="<stichworte>", project_id=8, session_name=$(printenv ARCHITECT_SESSION), limit=6)
|
||||
ac_recall(query="<aktuelle Aufgabe>", project_id=8, session_name="$(printenv ARCHITECT_SESSION)")
|
||||
ac_search_code(query="<Stichworte>", project_id=8, session_name="$(printenv ARCHITECT_SESSION)", limit=6)
|
||||
```
|
||||
|
||||
**Referenzen:**
|
||||
- Backend-Pattern → `project_id=6` (mail-gateway)
|
||||
- UI/Bootstrap-Pattern → `project_id=5` (netcell-webpanel)
|
||||
- Feature-Scope (was alt-EG konnte) → `project_id=3` (proxy-lb-waf)
|
||||
### Alle Tools
|
||||
|
||||
**Verboten:** direkt `grep` oder `read` ohne vorheriges `ac_search_code` bei Code-Fragen.
|
||||
| Tool | Wann verwenden |
|
||||
|------|----------------|
|
||||
| `ac_recall(query, project_id=8, session_name)` | Session-Start — relevante Memories laden |
|
||||
| `ac_search_code(query, project_id=8, session_name, limit?)` | Vor JEDEM neuen Code schreiben |
|
||||
| `ac_search_code_global(query, session_name, limit?)` | Suche über alle Projekte hinweg |
|
||||
| `ac_remember(text, category, project_id=8, session_name)` | Jede neue Erkenntnis / Entscheidung |
|
||||
| `ac_create_bug(title, description?, priority?, session_name?)` | Bug gefunden |
|
||||
| `ac_create_feature(title, description?, priority?, session_name?)` | Feature-Idee |
|
||||
| `ac_create_task(title, description?, priority?, status?, project_id?)` | Follow-up Arbeit |
|
||||
| `ac_update_task(id, status?, priority?, title?)` | Task-Status ändern |
|
||||
| `ac_add_decision(title, content, category?, project_id?)` | Architekturentscheidung |
|
||||
| `ac_notify(message, title?, type?)` | Status-Update senden |
|
||||
| `ac_get_project(session_name)` | project_id für Session ermitteln |
|
||||
| `ac_research(query, context?)` | Doku / CVE / Libraries recherchieren |
|
||||
| `ac_review(code, language?, focus?)` | Code-Review nach Fertigstellung |
|
||||
| `ac_analyze(text, question?)` | Logs / Outputs analysieren |
|
||||
|
||||
**`ac_remember` Kategorien:** `decision` · `pattern` · `bugfix` · `convention` · `architecture` · `general`
|
||||
|
||||
---
|
||||
|
||||
|
||||
19
Makefile
19
Makefile
@@ -85,21 +85,14 @@ deb: deb-amd64 deb-arm64
|
||||
GITEA_DEB_URL := https://git.netcell-it.de/api/packages/projekte/debian/pool/trixie/main/upload
|
||||
|
||||
publish-amd64: deb-amd64
|
||||
@TOK="$$(cat $$HOME/.gitea-token | tr -d '\n')"; \
|
||||
if [ -z "$$TOK" ]; then echo "publish: ~/.gitea-token is empty"; exit 1; fi; \
|
||||
for f in edgeguard-api_$(VERSION)_amd64.deb edgeguard-ui_$(VERSION)_all.deb edgeguard_$(VERSION)_all.deb; do \
|
||||
echo " -> publish $$f"; \
|
||||
curl -sS -H "Authorization: token $$TOK" --upload-file build/deb/$$f $(GITEA_DEB_URL); \
|
||||
echo ""; \
|
||||
done
|
||||
@./scripts/apt-repo/publish.sh $(VERSION) amd64
|
||||
@echo " -> cleanup-old (keep last $${KEEP:-10})"
|
||||
@./scripts/apt-repo/cleanup-old.sh
|
||||
|
||||
publish-arm64: deb-arm64
|
||||
@TOK="$$(cat $$HOME/.gitea-token | tr -d '\n')"; \
|
||||
if [ -z "$$TOK" ]; then echo "publish: ~/.gitea-token is empty"; exit 1; fi; \
|
||||
echo " -> publish edgeguard-api_$(VERSION)_arm64.deb"; \
|
||||
curl -sS -H "Authorization: token $$TOK" \
|
||||
--upload-file build/deb/edgeguard-api_$(VERSION)_arm64.deb \
|
||||
$(GITEA_DEB_URL)
|
||||
@./scripts/apt-repo/publish.sh $(VERSION) arm64
|
||||
@echo " -> cleanup-old (keep last $${KEEP:-10})"
|
||||
@./scripts/apt-repo/cleanup-old.sh
|
||||
|
||||
publish: publish-amd64 publish-arm64
|
||||
|
||||
|
||||
2
agent.md
2
agent.md
@@ -8,7 +8,7 @@
|
||||
|
||||
Der Architect Center Orchestrator dispatcht mehrere Claude Code Agenten, jeder spezialisiert auf eine Schicht des Projekts. Alle Agenten haben Zugriff auf:
|
||||
- **RAG (Qdrant):** Code-Index von mail-gateway (project_id=6) und netcell-webpanel (project_id=5) als Referenz
|
||||
- **MCP-Server `architect`:** ac_search_code, ac_send_instruction, ac_read_file
|
||||
- **MCP-Server `architect`:** ac_recall, ac_search_code, ac_search_code_global, ac_remember, ac_create_bug, ac_create_feature, ac_create_task, ac_update_task, ac_add_decision, ac_notify, ac_get_project, ac_research, ac_review, ac_analyze
|
||||
- **Session:** eigene tmux-Session pro Agent (EdgeGuardNative-1 … N)
|
||||
|
||||
---
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,23 @@ PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
|
||||
SystemCallFilter=@system-service
|
||||
ReadWritePaths=/etc/edgeguard /var/lib/edgeguard /var/log/edgeguard
|
||||
# /var/lib/apt /var/cache/apt /etc/apt/apt.conf.d: damit `sudo -n apt-get
|
||||
# update -qq` aus dem Update-Banner-Handler überhaupt in die Apt-Lists
|
||||
# schreiben darf. Ohne diese Pfade läuft das Kommando still durch
|
||||
# (exit 0) und der Cache bleibt veraltet — Banner zeigt nie ein Update.
|
||||
# Befund 2026-05-14: trotz 30s-Poll blieb Candidate=Installed. mail-
|
||||
# gateway hat dasselbe Set unter nmg-api.service.
|
||||
#
|
||||
# /var/backups/edgeguard: Backup-Service schreibt tar.gz Snapshots
|
||||
# hierhin. Ohne Pfad in ReadWritePaths: read-only filesystem → EROFS.
|
||||
# Befund 2026-05-15: "create eg-…tar.gz: read-only file system".
|
||||
#
|
||||
# /etc/chrony/conf.d /etc/unbound/unbound.conf.d /etc/wireguard:
|
||||
# chrony/unbound/wg-Renderer schreiben edgeguard.conf bzw. <iface>.conf
|
||||
# direkt in den distro-Conf-Dir (chrony+unbound) bzw. legen Symlinks
|
||||
# nach /etc/edgeguard/wireguard (wg). Ohne diese Pfade scheitern alle
|
||||
# UI-Mutationen an DNS/NTP/WireGuard-Settings still mit EROFS.
|
||||
ReadWritePaths=/etc/edgeguard /var/lib/edgeguard /var/log/edgeguard /var/backups/edgeguard /var/lib/apt /var/cache/apt /etc/apt/apt.conf.d /etc/chrony/conf.d /etc/unbound/unbound.conf.d /etc/wireguard
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -22,7 +22,10 @@ PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
SystemCallFilter=@system-service
|
||||
ReadWritePaths=/etc/edgeguard /var/lib/edgeguard /var/log/edgeguard
|
||||
# Scheduler ruft `backup.Service.Run` für scheduled backups + braucht
|
||||
# apt-Pfade für die background-apt-update-Goroutine (parität zur API,
|
||||
# falls die API down ist). /var/backups/edgeguard für tar.gz-Output.
|
||||
ReadWritePaths=/etc/edgeguard /var/lib/edgeguard /var/log/edgeguard /var/backups/edgeguard /var/lib/apt /var/cache/apt /etc/apt/apt.conf.d
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
187
internal/aggregator/aggregator.go
Normal file
187
internal/aggregator/aggregator.go
Normal file
@@ -0,0 +1,187 @@
|
||||
// Package aggregator führt parallele Cluster-Reads gegen alle Peer-Nodes
|
||||
// via mTLS aus.
|
||||
//
|
||||
// Pattern: ein Aggregator-Endpoint auf der Main-API (z.B.
|
||||
// /api/v1/cluster/system/load) ruft Aggregator.FanOut() — das verteilt
|
||||
// die Request parallel an alle Peers' Agent-Listener (:8443 mTLS) und
|
||||
// sammelt die Antworten in einer Map[node_id]→Ergebnis. Timeouts pro
|
||||
// Peer (3s default) verhindern dass ein hängender Peer die ganze Antwort
|
||||
// blockt; partielle Ergebnisse + per-Peer-Fehler werden zurückgegeben.
|
||||
//
|
||||
// mTLS-Auth: ClientTLSConfig aus clustertls.Store. CA muss auf beiden
|
||||
// Seiten dieselbe sein — sonst RequireAndVerifyClientCert lehnt ab.
|
||||
package aggregator
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
)
|
||||
|
||||
// DefaultAgentPort: alle Peers exposen ihren mTLS-Listener auf diesem
|
||||
// Port. api_url in ha_nodes zeigt typischerweise auf den Public-3443-
|
||||
// Port — wir derive'n den Agent-Port daraus, statt eine zweite Spalte
|
||||
// in ha_nodes zu führen.
|
||||
const DefaultAgentPort = 8443
|
||||
|
||||
// DefaultPeerTimeout: pro-Peer-Timeout. Aggregat-Caller sollten eine
|
||||
// Obergrenze von max(N×PeerTimeout/parallel) im Kopf haben; in der
|
||||
// Praxis ist alles parallel, also bestimmt der langsamste Peer die
|
||||
// Latenz.
|
||||
const DefaultPeerTimeout = 3 * time.Second
|
||||
|
||||
// Aggregator: dünner Wrapper mit ClientTLSConfig + http.Client.
|
||||
type Aggregator struct {
|
||||
HTTPClient *http.Client
|
||||
AgentPort int
|
||||
}
|
||||
|
||||
// New: liefert einen Aggregator der ClientTLSConfig verwendet. Wenn
|
||||
// clientTLS == nil, geht der Client auf normales TLS-Verify zurück —
|
||||
// für Tests nützlich, in Prod aber unsicher (würde Cert-Verify gegen
|
||||
// System-Trust laufen, das den Cluster-CA nicht kennt).
|
||||
func New(clientTLS *tls.Config) *Aggregator {
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: clientTLS,
|
||||
MaxIdleConns: 16,
|
||||
MaxIdleConnsPerHost: 2,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 3 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
ResponseHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
return &Aggregator{
|
||||
HTTPClient: &http.Client{
|
||||
Transport: tr,
|
||||
Timeout: DefaultPeerTimeout,
|
||||
},
|
||||
AgentPort: DefaultAgentPort,
|
||||
}
|
||||
}
|
||||
|
||||
// PeerResult kapselt das Ergebnis eines parallelen Fan-Out-Calls.
|
||||
// Wenn Err != nil ist Data leer; sonst enthält Data den raw-JSON-Body
|
||||
// (Aufrufer entscheidet ob es per-Peer typed-unmarshalled oder als
|
||||
// map[string]any belassen wird).
|
||||
type PeerResult struct {
|
||||
NodeID string `json:"node_id"`
|
||||
FQDN string `json:"fqdn"`
|
||||
OK bool `json:"ok"`
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
Err string `json:"error,omitempty"`
|
||||
Duration int64 `json:"duration_ms"`
|
||||
}
|
||||
|
||||
// FanOut: ruft GET <agent-url>/<path> für jeden Peer in `peers` parallel
|
||||
// und sammelt die Ergebnisse in einer slice (stabile Sortierung nach
|
||||
// Peer-FQDN für deterministisches UI-Rendering).
|
||||
//
|
||||
// `path` ist relativ, z.B. "/agent/system/load". `localID` wird als
|
||||
// Marker übergeben damit der Aufrufer den eigenen Node von der Map
|
||||
// ausschließen kann.
|
||||
func (a *Aggregator) FanOut(ctx context.Context, peers []models.HANode, path, localID string) []PeerResult {
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
results := make([]PeerResult, len(peers))
|
||||
var wg sync.WaitGroup
|
||||
for i, p := range peers {
|
||||
if p.ID == localID {
|
||||
// Eigener Node nicht über mTLS dial'n — wäre teuer + im
|
||||
// Aufrufer wahrscheinlich der lokale Path
|
||||
results[i] = PeerResult{NodeID: p.ID, FQDN: p.FQDN, OK: false, Err: "skipped: local node"}
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
i := i
|
||||
p := p
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
results[i] = a.callPeer(ctx, p, path)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
return results
|
||||
}
|
||||
|
||||
// callPeer macht den Einzel-Call. Wandelt p.APIURL in https://host:8443/
|
||||
// um (Port übersteuert, Pfad ersetzt). Bei Connection-Fehler / Timeout
|
||||
// liefert ein PeerResult mit OK=false zurück.
|
||||
func (a *Aggregator) callPeer(ctx context.Context, p models.HANode, path string) PeerResult {
|
||||
start := time.Now()
|
||||
res := PeerResult{NodeID: p.ID, FQDN: p.FQDN}
|
||||
|
||||
target, err := agentURL(p.APIURL, a.AgentPort, path)
|
||||
if err != nil {
|
||||
res.Err = "bad api_url: " + err.Error()
|
||||
res.Duration = time.Since(start).Milliseconds()
|
||||
return res
|
||||
}
|
||||
reqCtx, cancel := context.WithTimeout(ctx, DefaultPeerTimeout)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, target, nil)
|
||||
if err != nil {
|
||||
res.Err = err.Error()
|
||||
res.Duration = time.Since(start).Milliseconds()
|
||||
return res
|
||||
}
|
||||
resp, err := a.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
res.Err = err.Error()
|
||||
res.Duration = time.Since(start).Milliseconds()
|
||||
return res
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1 MiB cap
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
res.Err = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
res.Duration = time.Since(start).Milliseconds()
|
||||
return res
|
||||
}
|
||||
res.OK = true
|
||||
res.Data = body
|
||||
res.Duration = time.Since(start).Milliseconds()
|
||||
return res
|
||||
}
|
||||
|
||||
// agentURL: nimmt z.B. "https://node1.example.com:3443" + port=8443 +
|
||||
// path="/agent/system/load" und liefert "https://node1.example.com:8443/agent/system/load".
|
||||
// Wir tauschen den Port aus, behalten Schema + Host (nur).
|
||||
func agentURL(apiURL string, agentPort int, path string) (string, error) {
|
||||
if apiURL == "" {
|
||||
return "", errors.New("empty api_url")
|
||||
}
|
||||
u, err := url.Parse(apiURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if u.Scheme == "" {
|
||||
u.Scheme = "https"
|
||||
}
|
||||
host := u.Hostname()
|
||||
if host == "" {
|
||||
return "", errors.New("api_url has no host")
|
||||
}
|
||||
u.Host = net.JoinHostPort(host, fmt.Sprint(agentPort))
|
||||
u.Path = path
|
||||
u.RawQuery = ""
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
// Compile-time check dass cluster importiert wird (für Drift-Detection
|
||||
// vom hashSpec — die Aggregator-Resultate werden parallel im Drift-
|
||||
// Banner mitverarbeitet). Nicht runtime-essentiell, aber dokumentiert
|
||||
// die Abhängigkeit.
|
||||
var _ = cluster.ComputeConfigHash
|
||||
438
internal/cluster/clustertls/clustertls.go
Normal file
438
internal/cluster/clustertls/clustertls.go
Normal file
@@ -0,0 +1,438 @@
|
||||
// Package clustertls verwaltet die per-Cluster Certificate Authority
|
||||
// für Node-to-Node mTLS-Kommunikation.
|
||||
//
|
||||
// Layout on disk:
|
||||
// /var/lib/edgeguard/cluster-tls/ca.crt (0644)
|
||||
// /var/lib/edgeguard/cluster-tls/ca.key (0600, edgeguard:edgeguard)
|
||||
// /var/lib/edgeguard/cluster-tls/peer.crt (0644) — diese Node
|
||||
// /var/lib/edgeguard/cluster-tls/peer.key (0600, edgeguard:edgeguard)
|
||||
//
|
||||
// Workflow:
|
||||
// * Erste Node (cluster founder): InitCA generiert CA, dann EnsureSelfSigned
|
||||
// erstellt + signiert ihren eigenen peer.crt mit eigener CA.
|
||||
// * Joining Node: lädt CA-Cert vom Primary, generiert lokal CSR, POSTet
|
||||
// ihn mit cluster-join-token zu /api/v1/cluster/issue-cert; Primary
|
||||
// signiert via SignCSR und liefert peer.crt zurück. (Phase 3.4.)
|
||||
// * Single-Node: InitCA + EnsureSelfSigned werden beim API-Boot
|
||||
// idempotent gerufen; Listener auf :8443 kann sofort hochfahren.
|
||||
//
|
||||
// Pattern 1:1 aus mail-gateway/internal/services/clustertls/clustertls.go,
|
||||
// nur Dir + Filenamen angepasst (kein /etc/nmg → /var/lib/edgeguard).
|
||||
package clustertls
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultDir = "/var/lib/edgeguard/cluster-tls"
|
||||
|
||||
caCertFile = "ca.crt"
|
||||
caKeyFile = "ca.key"
|
||||
peerCertFile = "peer.crt"
|
||||
peerKeyFile = "peer.key"
|
||||
|
||||
caValidity = 10 * 365 * 24 * time.Hour // 10 Jahre — Cluster-Lifetime
|
||||
peerValidity = 365 * 24 * time.Hour // 1 Jahr — Renewal künftig automatisch
|
||||
)
|
||||
|
||||
// Store kapselt die CA + Peer-Cert-Persistierung unter Dir.
|
||||
type Store struct{ Dir string }
|
||||
|
||||
func New(dir string) *Store {
|
||||
if dir == "" {
|
||||
dir = DefaultDir
|
||||
}
|
||||
return &Store{Dir: dir}
|
||||
}
|
||||
|
||||
// HasCA prüft ob ca.crt + ca.key existieren.
|
||||
func (s *Store) HasCA() bool {
|
||||
_, e1 := os.Stat(filepath.Join(s.Dir, caCertFile))
|
||||
_, e2 := os.Stat(filepath.Join(s.Dir, caKeyFile))
|
||||
return e1 == nil && e2 == nil
|
||||
}
|
||||
|
||||
// HasPeer prüft ob peer.crt + peer.key existieren.
|
||||
func (s *Store) HasPeer() bool {
|
||||
_, e1 := os.Stat(filepath.Join(s.Dir, peerCertFile))
|
||||
_, e2 := os.Stat(filepath.Join(s.Dir, peerKeyFile))
|
||||
return e1 == nil && e2 == nil
|
||||
}
|
||||
|
||||
// InitCA generiert die Cluster-CA falls noch keine existiert. Idempotent.
|
||||
// organisation landet im Subject — typischerweise die FQDN-Domain.
|
||||
func (s *Store) InitCA(organisation string, now func() time.Time) error {
|
||||
if s.HasCA() {
|
||||
return nil
|
||||
}
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
if err := os.MkdirAll(s.Dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
tpl := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{
|
||||
CommonName: "EdgeGuard Cluster CA",
|
||||
Organization: []string{organisation},
|
||||
},
|
||||
NotBefore: now().UTC(),
|
||||
NotAfter: now().Add(caValidity).UTC(),
|
||||
IsCA: true,
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
BasicConstraintsValid: true,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, pub, priv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writePEM(filepath.Join(s.Dir, caCertFile), 0o644, "CERTIFICATE", der); err != nil {
|
||||
return err
|
||||
}
|
||||
keyDER, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writePEM(filepath.Join(s.Dir, caKeyFile), 0o600, "PRIVATE KEY", keyDER)
|
||||
}
|
||||
|
||||
// EnsureSelfSigned: erstellt peer.crt + peer.key signiert mit der eigenen
|
||||
// CA, falls noch nicht vorhanden. Verwendet für den "Cluster-Founder"-Pfad
|
||||
// (erste Node generiert CA + ihren eigenen Cert). commonName ist meist
|
||||
// die FQDN; dnsNames + ips landen in SubjectAlternativeName, damit der
|
||||
// TLS-Handshake gegen IP-Adressen + Hostnamen funktioniert.
|
||||
//
|
||||
// Idempotent — vorhandenes peer.crt wird NICHT überschrieben (sonst
|
||||
// würden andere Peers ihre Vertrauenskette verlieren nach jedem Boot).
|
||||
func (s *Store) EnsureSelfSigned(commonName string, dnsNames []string, ips []net.IP, now func() time.Time) error {
|
||||
if s.HasPeer() {
|
||||
return nil
|
||||
}
|
||||
if !s.HasCA() {
|
||||
return errors.New("clustertls: peer cert requested but no CA in place — run InitCA first")
|
||||
}
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
caCert, caKey, err := s.LoadCA()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
tpl := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: commonName},
|
||||
DNSNames: dnsNames,
|
||||
IPAddresses: ips,
|
||||
NotBefore: now().UTC(),
|
||||
NotAfter: now().Add(peerValidity).UTC(),
|
||||
// Server-auth + Client-auth — ein Cert nutzbar für Listener und Outbound.
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tpl, caCert, pub, caKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writePEM(filepath.Join(s.Dir, peerCertFile), 0o644, "CERTIFICATE", der); err != nil {
|
||||
return err
|
||||
}
|
||||
keyDER, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writePEM(filepath.Join(s.Dir, peerKeyFile), 0o600, "PRIVATE KEY", keyDER)
|
||||
}
|
||||
|
||||
// LoadCA parst CA-Cert + Ed25519 Private Key vom Disk.
|
||||
func (s *Store) LoadCA() (*x509.Certificate, ed25519.PrivateKey, error) {
|
||||
certPEM, err := os.ReadFile(filepath.Join(s.Dir, caCertFile))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("read CA cert: %w", err)
|
||||
}
|
||||
block, _ := pem.Decode(certPEM)
|
||||
if block == nil {
|
||||
return nil, nil, errors.New("ca cert: invalid PEM")
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("parse CA cert: %w", err)
|
||||
}
|
||||
keyPEM, err := os.ReadFile(filepath.Join(s.Dir, caKeyFile))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("read CA key: %w", err)
|
||||
}
|
||||
keyBlock, _ := pem.Decode(keyPEM)
|
||||
if keyBlock == nil {
|
||||
return nil, nil, errors.New("ca key: invalid PEM")
|
||||
}
|
||||
raw, err := x509.ParsePKCS8PrivateKey(keyBlock.Bytes)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("parse CA key: %w", err)
|
||||
}
|
||||
priv, ok := raw.(ed25519.PrivateKey)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("CA key is not ed25519 (got %T)", raw)
|
||||
}
|
||||
return cert, priv, nil
|
||||
}
|
||||
|
||||
// SignCSR signiert einen joining-peer CSR. Caller hat den one-shot
|
||||
// cluster-join-Token bereits geprüft (Phase 3.4 — Aufrufer in handlers).
|
||||
func (s *Store) SignCSR(csrPEM string, now func() time.Time) (string, error) {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
block, _ := pem.Decode([]byte(csrPEM))
|
||||
if block == nil || block.Type != "CERTIFICATE REQUEST" {
|
||||
return "", errors.New("invalid CSR PEM")
|
||||
}
|
||||
csr, err := x509.ParseCertificateRequest(block.Bytes)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse CSR: %w", err)
|
||||
}
|
||||
if err := csr.CheckSignature(); err != nil {
|
||||
return "", fmt.Errorf("bad CSR signature: %w", err)
|
||||
}
|
||||
caCert, caKey, err := s.LoadCA()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
tpl := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: csr.Subject,
|
||||
DNSNames: csr.DNSNames,
|
||||
IPAddresses: csr.IPAddresses,
|
||||
NotBefore: now().UTC(),
|
||||
NotAfter: now().Add(peerValidity).UTC(),
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tpl, caCert, csr.PublicKey, caKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return pemString("CERTIFICATE", der), nil
|
||||
}
|
||||
|
||||
// NewPeerKeyAndCSR generiert einen frischen Ed25519 Key + CSR. Verwendet
|
||||
// vom joining peer bevor er an /cluster/issue-cert POSTet.
|
||||
func NewPeerKeyAndCSR(commonName string, dnsNames []string, ips []net.IP) (keyPEM, csrPEM string, err error) {
|
||||
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
tpl := &x509.CertificateRequest{
|
||||
Subject: pkix.Name{CommonName: commonName},
|
||||
DNSNames: dnsNames,
|
||||
IPAddresses: ips,
|
||||
}
|
||||
csrDER, err := x509.CreateCertificateRequest(rand.Reader, tpl, priv)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
keyDER, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return pemString("PRIVATE KEY", keyDER), pemString("CERTIFICATE REQUEST", csrDER), nil
|
||||
}
|
||||
|
||||
// WriteOwnPeerMaterial persistiert peer.key + peer.crt. Wird vom
|
||||
// cluster-join-Flow gerufen wenn der Primary das Cert signiert hat.
|
||||
func (s *Store) WriteOwnPeerMaterial(keyPEM, certPEM string) error {
|
||||
if err := os.MkdirAll(s.Dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(s.Dir, peerKeyFile), []byte(keyPEM), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filepath.Join(s.Dir, peerCertFile), []byte(certPEM), 0o644)
|
||||
}
|
||||
|
||||
// ServerTLSConfig: tls.Config für den mTLS-Listener (z.B. :8443).
|
||||
// peer.crt/peer.key als Identity, ca.crt als einzige ClientCA.
|
||||
// ClientAuth=RequireAndVerifyClientCert.
|
||||
func (s *Store) ServerTLSConfig() (*tls.Config, error) {
|
||||
pair, err := tls.LoadX509KeyPair(filepath.Join(s.Dir, peerCertFile), filepath.Join(s.Dir, peerKeyFile))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pool, err := s.caPool()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tls.Config{
|
||||
Certificates: []tls.Certificate{pair},
|
||||
ClientCAs: pool,
|
||||
RootCAs: pool,
|
||||
ClientAuth: tls.RequireAndVerifyClientCert,
|
||||
MinVersion: tls.VersionTLS13,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ClientTLSConfig: tls.Config für outbound peer-to-peer Calls.
|
||||
// Präsentiert peer.crt, verifiziert Server gegen ca.crt.
|
||||
func (s *Store) ClientTLSConfig() (*tls.Config, error) {
|
||||
pair, err := tls.LoadX509KeyPair(filepath.Join(s.Dir, peerCertFile), filepath.Join(s.Dir, peerKeyFile))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pool, err := s.caPool()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &tls.Config{
|
||||
Certificates: []tls.Certificate{pair},
|
||||
RootCAs: pool,
|
||||
MinVersion: tls.VersionTLS13,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CACertPEM gibt die CA-Cert als PEM-String zurück (für Join-Token-Export).
|
||||
func (s *Store) CACertPEM() (string, error) {
|
||||
b, err := os.ReadFile(filepath.Join(s.Dir, caCertFile))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// CertInfo: zusammengefasste Cert-Metadata für UI-Status. days_remaining
|
||||
// kann negativ sein wenn der Cert schon abgelaufen ist.
|
||||
type CertInfo struct {
|
||||
CommonName string `json:"common_name"`
|
||||
NotBefore time.Time `json:"not_before"`
|
||||
NotAfter time.Time `json:"not_after"`
|
||||
DaysRemaining int `json:"days_remaining"`
|
||||
IsCA bool `json:"is_ca"`
|
||||
SerialHex string `json:"serial_hex"`
|
||||
}
|
||||
|
||||
// PeerCertInfo liefert die Metadata des eigenen peer.crt. Wenn keiner
|
||||
// existiert: (nil, error).
|
||||
func (s *Store) PeerCertInfo() (*CertInfo, error) {
|
||||
return parseCertInfo(filepath.Join(s.Dir, peerCertFile))
|
||||
}
|
||||
|
||||
// CACertInfo liefert die Metadata der Cluster-CA. Wenn keiner
|
||||
// existiert: (nil, error).
|
||||
func (s *Store) CACertInfo() (*CertInfo, error) {
|
||||
return parseCertInfo(filepath.Join(s.Dir, caCertFile))
|
||||
}
|
||||
|
||||
func parseCertInfo(path string) (*CertInfo, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
block, _ := pem.Decode(raw)
|
||||
if block == nil {
|
||||
return nil, errors.New("invalid PEM")
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
days := int(time.Until(cert.NotAfter) / (24 * time.Hour))
|
||||
return &CertInfo{
|
||||
CommonName: cert.Subject.CommonName,
|
||||
NotBefore: cert.NotBefore.UTC(),
|
||||
NotAfter: cert.NotAfter.UTC(),
|
||||
DaysRemaining: days,
|
||||
IsCA: cert.IsCA,
|
||||
SerialHex: cert.SerialNumber.Text(16),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RenewSelfSigned überschreibt peer.crt + peer.key mit einem frisch
|
||||
// erzeugten Paar, signiert mit der eigenen CA. Nur sinnvoll auf dem
|
||||
// Founder/Primary — Joiner sollten ihren Cert über den /cluster/
|
||||
// issue-cert-Flow ablösen, sobald wir Renewal-Tokens bauen.
|
||||
//
|
||||
// Anders als EnsureSelfSigned wird hier ÜBERSCHRIEBEN. Caller (Handler
|
||||
// + CLI) ist verantwortlich für vorherigen Restart-Hinweis bzw.
|
||||
// Service-Restart nach dem Call.
|
||||
func (s *Store) RenewSelfSigned(commonName string, dnsNames []string, ips []net.IP, now func() time.Time) error {
|
||||
if !s.HasCA() {
|
||||
return errors.New("clustertls: RenewSelfSigned requires own CA")
|
||||
}
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
caCert, caKey, err := s.LoadCA()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
tpl := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: commonName},
|
||||
DNSNames: dnsNames,
|
||||
IPAddresses: ips,
|
||||
NotBefore: now().UTC(),
|
||||
NotAfter: now().Add(peerValidity).UTC(),
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tpl, caCert, pub, caKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writePEM(filepath.Join(s.Dir, peerCertFile), 0o644, "CERTIFICATE", der); err != nil {
|
||||
return err
|
||||
}
|
||||
keyDER, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writePEM(filepath.Join(s.Dir, peerKeyFile), 0o600, "PRIVATE KEY", keyDER)
|
||||
}
|
||||
|
||||
func (s *Store) caPool() (*x509.CertPool, error) {
|
||||
caPEM, err := os.ReadFile(filepath.Join(s.Dir, caCertFile))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(caPEM) {
|
||||
return nil, errors.New("failed to add CA to pool")
|
||||
}
|
||||
return pool, nil
|
||||
}
|
||||
|
||||
func writePEM(path string, mode os.FileMode, kind string, der []byte) error {
|
||||
return os.WriteFile(path, []byte(pemString(kind, der)), mode)
|
||||
}
|
||||
|
||||
func pemString(kind string, der []byte) string {
|
||||
return string(pem.EncodeToMemory(&pem.Block{Type: kind, Bytes: der}))
|
||||
}
|
||||
72
internal/cluster/heartbeat.go
Normal file
72
internal/cluster/heartbeat.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package cluster
|
||||
|
||||
// Phase-3.2: periodischer Heartbeat + Stale-Sweeper.
|
||||
//
|
||||
// Hintergrund: EnsureSelfRegistered schreibt last_seen einmal beim
|
||||
// API-Boot. Ohne periodisches Re-Schreiben friert last_seen auf den
|
||||
// Boot-Zeitpunkt ein — Peers (im Multi-Node-Setup) hätten keinen Weg
|
||||
// zu erkennen ob dieser Node noch lebt. Die Heartbeat-Goroutine in der
|
||||
// API bumpt das alle 30s; der Scheduler räumt mit SweepStaleNodes Peers
|
||||
// die länger als <threshold> nicht gemeldet haben auf status='offline'.
|
||||
//
|
||||
// Single-Node-Effekt: Cluster-UI zeigt korrekt "last seen 12s" statt
|
||||
// "last seen 3h" weil last_seen frisch ist. UI-Drift-Banner-Logik im
|
||||
// ClusterHandler.Status nutzt die selben Felder.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Heartbeat bumpt last_seen + status='online' für die eigene Node-Row
|
||||
// und aktualisiert version + config_hash. Idempotent. UPDATE-only — die
|
||||
// Row muss existieren (wird via EnsureSelfRegistered beim Boot angelegt).
|
||||
//
|
||||
// Hash-Berechnung läuft synchron — typisch <50ms auf einer realistischen
|
||||
// DB-Größe; falls die compute-SQL fehlt (Migration im Flux) wird der
|
||||
// vorhandene config_hash via COALESCE behalten.
|
||||
func Heartbeat(ctx context.Context, pool *pgxpool.Pool, localID, version string) error {
|
||||
if pool == nil || localID == "" {
|
||||
return nil
|
||||
}
|
||||
hash, _ := ComputeConfigHash(ctx, pool)
|
||||
_, err := pool.Exec(ctx, `
|
||||
UPDATE ha_nodes SET
|
||||
last_seen = NOW(),
|
||||
status = 'online',
|
||||
version = COALESCE(NULLIF($1, ''), version),
|
||||
config_hash = COALESCE(NULLIF($2, ''), config_hash),
|
||||
updated_at = NOW()
|
||||
WHERE id = $3`, version, hash, localID)
|
||||
return err
|
||||
}
|
||||
|
||||
// SweepStaleNodes flippt status='online' → 'offline' für Peers deren
|
||||
// last_seen älter als threshold ist. Liefert die Anzahl gefliptpter
|
||||
// Rows zurück (für Logging). Idempotent — markiert keine Rows die
|
||||
// schon offline sind.
|
||||
//
|
||||
// Threshold-Konvention: 4× Heartbeat-Intervall = 2 min bei 30s-Tick.
|
||||
// Lässt Platz für eine verpasste API-Tick (Restart, GC-Pause, kurzer
|
||||
// Network-Glitch) ohne false-positive Offline.
|
||||
func SweepStaleNodes(ctx context.Context, pool *pgxpool.Pool, threshold time.Duration) (int64, error) {
|
||||
if pool == nil || threshold <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
// Wir bauen das Interval als String — pgx kann time.Duration nicht
|
||||
// direkt als INTERVAL serialisieren.
|
||||
interval := fmt.Sprintf("%d seconds", int(threshold.Seconds()))
|
||||
tag, err := pool.Exec(ctx, `
|
||||
UPDATE ha_nodes SET
|
||||
status = 'offline',
|
||||
updated_at = NOW()
|
||||
WHERE last_seen < NOW() - $1::interval
|
||||
AND status = 'online'`, interval)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
237
internal/cluster/jointoken/jointoken.go
Normal file
237
internal/cluster/jointoken/jointoken.go
Normal file
@@ -0,0 +1,237 @@
|
||||
// Package jointoken implementiert one-shot Cluster-Join-Token für
|
||||
// Phase 3.4.
|
||||
//
|
||||
// Format: "eg-join-v1.<base64url(payload)>.<base64url(hmac)>"
|
||||
//
|
||||
// Payload (CBOR-ähnlich, kompakt JSON): {n: nonce(16B base64),
|
||||
// e: expires_at_unix, f: ca_fingerprint_hex_16chars}
|
||||
//
|
||||
// HMAC: SHA-256 über payload-bytes, Key aus /var/lib/edgeguard/cluster-join-secret
|
||||
// (auto-generiert beim ersten Generate-Call, 32B random, 0600).
|
||||
//
|
||||
// Lifecycle:
|
||||
// 1. Admin POSTet /cluster/join-tokens → Server generiert Token, schickt
|
||||
// ihn als Klartext-String (einmalig). Server speichert NICHTS
|
||||
// (token ist self-contained).
|
||||
// 2. Joiner POSTet /cluster/issue-cert mit {token, csr}.
|
||||
// 3. Server Verify(token): HMAC ok? Nicht abgelaufen? CA-Fingerprint
|
||||
// passt zum eigenen? Nonce nicht schon in join_tokens_used?
|
||||
// 4. Bei Erfolg: nonce in join_tokens_used inserten (PK-Conflict =
|
||||
// Re-Use → reject). Dann CSR signieren + zurückgeben.
|
||||
//
|
||||
// One-Shot-Semantik: PG-Insert mit ON CONFLICT DO NOTHING; wenn keine
|
||||
// Row inserted wurde → schon konsumiert. Das ersetzt einen sonst
|
||||
// nötigen Distributed-Lock.
|
||||
package jointoken
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
const (
|
||||
// SecretPath: HMAC-Key. Auto-Generated beim ersten Generate.
|
||||
DefaultSecretPath = "/var/lib/edgeguard/cluster-join-secret"
|
||||
|
||||
tokenPrefix = "eg-join-v1."
|
||||
|
||||
// DefaultTTL — wie lange ein generierter Token gültig bleibt.
|
||||
DefaultTTL = 24 * time.Hour
|
||||
)
|
||||
|
||||
// Service kapselt Token-Gen + -Verify mit DB-One-Shot-Tracking.
|
||||
type Service struct {
|
||||
Pool *pgxpool.Pool
|
||||
SecretPath string
|
||||
GetCAFinger func() (string, error) // liefert 16-hex-fingerprint der eigenen CA
|
||||
TTL time.Duration
|
||||
}
|
||||
|
||||
func New(pool *pgxpool.Pool, getCAFinger func() (string, error)) *Service {
|
||||
return &Service{
|
||||
Pool: pool,
|
||||
SecretPath: DefaultSecretPath,
|
||||
GetCAFinger: getCAFinger,
|
||||
TTL: DefaultTTL,
|
||||
}
|
||||
}
|
||||
|
||||
// payload ist das JSON inside-the-token.
|
||||
type payload struct {
|
||||
Nonce string `json:"n"`
|
||||
Expires int64 `json:"e"`
|
||||
CAFinger16 string `json:"f"`
|
||||
}
|
||||
|
||||
// Generate erzeugt einen frischen Token. Caller (Handler) muss bereits
|
||||
// Admin-Auth geprüft haben.
|
||||
func (s *Service) Generate() (string, time.Time, error) {
|
||||
if s.GetCAFinger == nil {
|
||||
return "", time.Time{}, errors.New("jointoken: GetCAFinger unset")
|
||||
}
|
||||
finger, err := s.GetCAFinger()
|
||||
if err != nil {
|
||||
return "", time.Time{}, fmt.Errorf("ca fingerprint: %w", err)
|
||||
}
|
||||
secret, err := s.ensureSecret()
|
||||
if err != nil {
|
||||
return "", time.Time{}, fmt.Errorf("load secret: %w", err)
|
||||
}
|
||||
|
||||
nonceBytes := make([]byte, 16)
|
||||
if _, err := rand.Read(nonceBytes); err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
ttl := s.TTL
|
||||
if ttl <= 0 {
|
||||
ttl = DefaultTTL
|
||||
}
|
||||
exp := time.Now().Add(ttl).UTC()
|
||||
p := payload{
|
||||
Nonce: base64.RawURLEncoding.EncodeToString(nonceBytes),
|
||||
Expires: exp.Unix(),
|
||||
CAFinger16: finger,
|
||||
}
|
||||
pj, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return "", time.Time{}, err
|
||||
}
|
||||
mac := hmac.New(sha256.New, secret)
|
||||
mac.Write(pj)
|
||||
sig := mac.Sum(nil)
|
||||
|
||||
return tokenPrefix +
|
||||
base64.RawURLEncoding.EncodeToString(pj) + "." +
|
||||
base64.RawURLEncoding.EncodeToString(sig), exp, nil
|
||||
}
|
||||
|
||||
// Consume verifiziert den Token UND markiert die nonce als verbraucht
|
||||
// — wenn die nonce schon in join_tokens_used steht (oder das INSERT
|
||||
// nichts geändert hat), lehnen wir ab. Erfolgreicher Consume liefert
|
||||
// die CA-Fingerprint aus dem Payload (Caller kann gegen die eigene
|
||||
// CA vergleichen).
|
||||
//
|
||||
// `consumedBy` ist freier String der zum Audit ins DB-Row landet
|
||||
// (typisch: peer.fqdn oder peer.api_url).
|
||||
func (s *Service) Consume(ctx context.Context, token, consumedBy string) (payload, error) {
|
||||
var zero payload
|
||||
if !strings.HasPrefix(token, tokenPrefix) {
|
||||
return zero, errors.New("invalid token format")
|
||||
}
|
||||
rest := strings.TrimPrefix(token, tokenPrefix)
|
||||
parts := strings.SplitN(rest, ".", 2)
|
||||
if len(parts) != 2 {
|
||||
return zero, errors.New("invalid token format")
|
||||
}
|
||||
pj, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return zero, fmt.Errorf("decode payload: %w", err)
|
||||
}
|
||||
sig, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return zero, fmt.Errorf("decode sig: %w", err)
|
||||
}
|
||||
secret, err := s.loadSecret()
|
||||
if err != nil {
|
||||
return zero, fmt.Errorf("load secret: %w", err)
|
||||
}
|
||||
mac := hmac.New(sha256.New, secret)
|
||||
mac.Write(pj)
|
||||
want := mac.Sum(nil)
|
||||
if !hmac.Equal(want, sig) {
|
||||
return zero, errors.New("bad signature")
|
||||
}
|
||||
var p payload
|
||||
if err := json.Unmarshal(pj, &p); err != nil {
|
||||
return zero, fmt.Errorf("unmarshal payload: %w", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if now.Unix() > p.Expires {
|
||||
return zero, errors.New("token expired")
|
||||
}
|
||||
if s.GetCAFinger != nil {
|
||||
ownFinger, err := s.GetCAFinger()
|
||||
if err == nil && ownFinger != p.CAFinger16 {
|
||||
return zero, errors.New("ca fingerprint mismatch (wrong cluster?)")
|
||||
}
|
||||
}
|
||||
// One-shot-INSERT — wenn die nonce schon da ist (Re-Use), schlägt
|
||||
// das fehl und wir lehnen ab.
|
||||
exp := time.Unix(p.Expires, 0).UTC()
|
||||
tag, err := s.Pool.Exec(ctx, `
|
||||
INSERT INTO join_tokens_used (nonce, expires_at, consumed_by)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (nonce) DO NOTHING`, p.Nonce, exp, consumedBy)
|
||||
if err != nil {
|
||||
return zero, fmt.Errorf("track nonce: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return zero, errors.New("token already used")
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// CleanupExpired löscht abgelaufene Nonce-Rows. Idempotent; Aufruf vom
|
||||
// Scheduler einmal pro Stunde reicht.
|
||||
func (s *Service) CleanupExpired(ctx context.Context) (int64, error) {
|
||||
tag, err := s.Pool.Exec(ctx,
|
||||
`DELETE FROM join_tokens_used WHERE expires_at < NOW() - INTERVAL '7 days'`)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
|
||||
// ── secret handling ───────────────────────────────────────────────────
|
||||
|
||||
func (s *Service) ensureSecret() ([]byte, error) {
|
||||
if b, err := s.loadSecret(); err == nil && len(b) >= 32 {
|
||||
return b, nil
|
||||
}
|
||||
// Generate fresh secret. Dir wird in postinst erstellt (0700,
|
||||
// edgeguard-owned), wir schreiben direkt rein.
|
||||
dir := filepath.Dir(s.path())
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key := make([]byte, 32)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(s.path(), key, 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func (s *Service) loadSecret() ([]byte, error) {
|
||||
return os.ReadFile(s.path())
|
||||
}
|
||||
|
||||
func (s *Service) path() string {
|
||||
if s.SecretPath != "" {
|
||||
return s.SecretPath
|
||||
}
|
||||
return DefaultSecretPath
|
||||
}
|
||||
|
||||
// CAFingerprint16 liefert die ersten 16 Hex-Chars vom SHA-256 der CA-
|
||||
// Certificate DER-Bytes. Eindeutig genug für den Cluster-Trust-Check
|
||||
// (64 bit Entropie); kürzer als 64 Hex damit Token nicht aufbläst.
|
||||
func CAFingerprint16(caCertDER []byte) string {
|
||||
sum := sha256.Sum256(caCertDER)
|
||||
return hex.EncodeToString(sum[:])[:16]
|
||||
}
|
||||
@@ -100,6 +100,21 @@ RETURNING id, name, fqdn, api_url, public_ip, internal_ip, mgmt_ip,
|
||||
return scanNode(row)
|
||||
}
|
||||
|
||||
// Delete entfernt einen Peer aus ha_nodes. Caller (Handler) verhindert
|
||||
// dass die lokale Node sich selbst löscht — sonst geht der nächste
|
||||
// Heartbeat-Tick die Row wieder anlegen UND der Cluster-Status zeigt
|
||||
// für 2 min "weg" obwohl der Node noch läuft.
|
||||
func (s *Store) Delete(ctx context.Context, id string) error {
|
||||
tag, err := s.Pool.Exec(ctx, `DELETE FROM ha_nodes WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureSelfRegistered mints the node-id if needed, builds the row
|
||||
// from setup.json + os.Hostname + node.conf, and upserts it. Called
|
||||
// on edgeguard-api boot AFTER the DB pool is reachable.
|
||||
|
||||
39
internal/database/migrations/0023_domains_settings.sql
Normal file
39
internal/database/migrations/0023_domains_settings.sql
Normal file
@@ -0,0 +1,39 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
-- Domain-Settings die HAProxy pro Domain rendern kann. Bisher
|
||||
-- hatten Domains nur ein binäres hsts_enabled — jetzt ist HSTS
|
||||
-- granular (max-age, subdomains, preload) plus Maintenance-Mode
|
||||
-- und Canonical-www-Redirect.
|
||||
|
||||
ALTER TABLE domains
|
||||
ADD COLUMN IF NOT EXISTS hsts_max_age INTEGER NOT NULL DEFAULT 31536000,
|
||||
ADD COLUMN IF NOT EXISTS hsts_subdomains BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS hsts_preload BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS maintenance_mode BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS maintenance_message TEXT,
|
||||
ADD COLUMN IF NOT EXISTS www_redirect TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- www_redirect-Werte:
|
||||
-- '' — kein Redirect (Default; Domain wird direkt geserved)
|
||||
-- 'to-naked'— www.foo.com → foo.com (canonical naked-domain)
|
||||
-- 'to-www' — foo.com → www.foo.com (canonical www)
|
||||
ALTER TABLE domains
|
||||
DROP CONSTRAINT IF EXISTS domains_www_redirect_check;
|
||||
ALTER TABLE domains
|
||||
ADD CONSTRAINT domains_www_redirect_check
|
||||
CHECK (www_redirect IN ('', 'to-naked', 'to-www'));
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE domains DROP CONSTRAINT IF EXISTS domains_www_redirect_check;
|
||||
ALTER TABLE domains
|
||||
DROP COLUMN IF EXISTS www_redirect,
|
||||
DROP COLUMN IF EXISTS maintenance_message,
|
||||
DROP COLUMN IF EXISTS maintenance_mode,
|
||||
DROP COLUMN IF EXISTS hsts_preload,
|
||||
DROP COLUMN IF EXISTS hsts_subdomains,
|
||||
DROP COLUMN IF EXISTS hsts_max_age;
|
||||
-- +goose StatementEnd
|
||||
@@ -0,0 +1,58 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
-- Phase 2 Domain-Settings:
|
||||
-- * rate_limit_rps — Requests pro Sekunde Cap je Client-IP (0 = aus).
|
||||
-- HAProxy implementiert das via per-Domain Stick-Table (siehe
|
||||
-- internal/haproxy/haproxy.cfg.tpl).
|
||||
-- * max_body_kb — max. Request-Body-Größe in KiB (0 = aus).
|
||||
-- HAProxy verweigert größere Requests mit 413.
|
||||
-- * domain_response_headers — beliebige Set-Header Pairs die je
|
||||
-- Domain auf jede Response gesetzt werden (CORS, CSP, X-Frame-
|
||||
-- Options, …). HAProxy rendert pro Eintrag einen `http-response
|
||||
-- set-header` mit Host-ACL.
|
||||
|
||||
ALTER TABLE domains
|
||||
ADD COLUMN IF NOT EXISTS rate_limit_rps INTEGER NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS max_body_kb INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
ALTER TABLE domains
|
||||
DROP CONSTRAINT IF EXISTS domains_rate_limit_rps_check;
|
||||
ALTER TABLE domains
|
||||
ADD CONSTRAINT domains_rate_limit_rps_check CHECK (rate_limit_rps >= 0);
|
||||
|
||||
ALTER TABLE domains
|
||||
DROP CONSTRAINT IF EXISTS domains_max_body_kb_check;
|
||||
ALTER TABLE domains
|
||||
ADD CONSTRAINT domains_max_body_kb_check CHECK (max_body_kb >= 0);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS domain_response_headers (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
domain_id BIGINT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ein Header-Name darf pro Domain nur einmal auftauchen (case-insensitive,
|
||||
-- HTTP-Header sind per RFC nicht case-sensitive). Verhindert dass zwei
|
||||
-- konkurrierende Werte für "X-Frame-Options" gesetzt werden.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_drh_domain_name_ci
|
||||
ON domain_response_headers (domain_id, lower(name));
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_drh_domain
|
||||
ON domain_response_headers (domain_id, position);
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE IF EXISTS domain_response_headers;
|
||||
ALTER TABLE domains DROP CONSTRAINT IF EXISTS domains_max_body_kb_check;
|
||||
ALTER TABLE domains DROP CONSTRAINT IF EXISTS domains_rate_limit_rps_check;
|
||||
ALTER TABLE domains
|
||||
DROP COLUMN IF EXISTS max_body_kb,
|
||||
DROP COLUMN IF EXISTS rate_limit_rps;
|
||||
-- +goose StatementEnd
|
||||
28
internal/database/migrations/0025_join_tokens_used.sql
Normal file
28
internal/database/migrations/0025_join_tokens_used.sql
Normal file
@@ -0,0 +1,28 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
-- Phase 3.4: Cluster-Join-Token One-Shot-Consume-Tracking.
|
||||
--
|
||||
-- Token-Format ist HMAC-self-contained (siehe internal/cluster/jointoken):
|
||||
-- nonce + expires_at + ca-fingerprint, signiert mit dem Cluster-Join-
|
||||
-- Secret. Damit der Token wirklich nur EINMAL benutzbar ist, speichern
|
||||
-- wir die nonce beim Consume — Re-Use → 409. expires_at hilft beim
|
||||
-- Cleanup (Tokens älter als ihre eigene Expiry können gelöscht werden,
|
||||
-- auch ungebraucht).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS join_tokens_used (
|
||||
nonce TEXT PRIMARY KEY,
|
||||
used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
consumed_by TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_join_tokens_used_expires
|
||||
ON join_tokens_used (expires_at);
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP TABLE IF EXISTS join_tokens_used;
|
||||
-- +goose StatementEnd
|
||||
@@ -29,6 +29,9 @@ table inet edgeguard {
|
||||
# erreichbar.
|
||||
tcp dport 22 ct state new limit rate 10/minute accept comment "anti-lockout: SSH (rate-limited)"
|
||||
tcp dport 443 accept comment "anti-lockout: HAProxy public HTTPS"
|
||||
# HTTP/3 läuft als QUIC über UDP/443. HAProxy bindet quic4@:443
|
||||
# parallel zum TCP-Listener; ohne diese Regel droppt der policy.
|
||||
udp dport 443 accept comment "anti-lockout: HAProxy HTTP/3 (QUIC)"
|
||||
tcp dport 3443 accept comment "anti-lockout: Management-UI (HAProxy admin HTTPS)"
|
||||
|
||||
# Stateful baseline
|
||||
|
||||
@@ -22,9 +22,47 @@ func NewAuditHandler(repo *audit.Repo) *AuditHandler { return &AuditHandler{Repo
|
||||
func (h *AuditHandler) Register(rg *gin.RouterGroup) {
|
||||
g := rg.Group("/audit")
|
||||
g.GET("/recent", h.Recent)
|
||||
g.GET("/search", h.Search)
|
||||
g.GET("/live", h.Live)
|
||||
}
|
||||
|
||||
// Search filtert audit_log nach Actor/Action/Subject/Date-Range.
|
||||
// Query-Params: actor, action, subject (alle ILIKE-Substring),
|
||||
// since/until (RFC3339), limit (max 500), offset.
|
||||
func (h *AuditHandler) Search(c *gin.Context) {
|
||||
f := audit.SearchFilter{
|
||||
Actor: c.Query("actor"),
|
||||
Action: c.Query("action"),
|
||||
Subject: c.Query("subject"),
|
||||
}
|
||||
if s := c.Query("since"); s != "" {
|
||||
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||||
f.Since = &t
|
||||
}
|
||||
}
|
||||
if s := c.Query("until"); s != "" {
|
||||
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||||
f.Until = &t
|
||||
}
|
||||
}
|
||||
if v := c.Query("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
f.Limit = n
|
||||
}
|
||||
}
|
||||
if v := c.Query("offset"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
f.Offset = n
|
||||
}
|
||||
}
|
||||
rows, err := h.Repo.Search(c.Request.Context(), f)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"entries": rows, "limit": f.Limit, "offset": f.Offset})
|
||||
}
|
||||
|
||||
// Recent returns the most recent audit_log entries — used by the
|
||||
// dashboard fallback path (z.B. wenn WebSocket nicht verbinden kann).
|
||||
// ?limit=N (1–100, default 10).
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/session"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
||||
)
|
||||
@@ -19,12 +20,22 @@ import (
|
||||
type AuthHandler struct {
|
||||
Setup *setup.Store
|
||||
Signer *session.Signer
|
||||
Audit *audit.Repo
|
||||
NodeID string
|
||||
}
|
||||
|
||||
func NewAuthHandler(s *setup.Store, sig *session.Signer) *AuthHandler {
|
||||
return &AuthHandler{Setup: s, Signer: sig}
|
||||
}
|
||||
|
||||
// WithAudit: Audit-Repo + NodeID damit Password-Operationen (change,
|
||||
// reset, login-success/fail) ins audit_log fließen.
|
||||
func (h *AuthHandler) WithAudit(a *audit.Repo, nodeID string) *AuthHandler {
|
||||
h.Audit = a
|
||||
h.NodeID = nodeID
|
||||
return h
|
||||
}
|
||||
|
||||
// Register mounts /auth/login + /logout (public) and /auth/me
|
||||
// (gated by requireAuth, passed in as a per-route middleware).
|
||||
func (h *AuthHandler) Register(rg *gin.RouterGroup, requireAuth gin.HandlerFunc) {
|
||||
@@ -33,6 +44,7 @@ func (h *AuthHandler) Register(rg *gin.RouterGroup, requireAuth gin.HandlerFunc)
|
||||
g.POST("/logout", h.Logout)
|
||||
g.GET("/me", requireAuth, h.Me)
|
||||
g.POST("/reset-password", h.ResetPassword)
|
||||
g.POST("/change-password", requireAuth, h.ChangePassword)
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
@@ -122,6 +134,60 @@ func (h *AuthHandler) ResetPassword(c *gin.Context) {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if h.Audit != nil {
|
||||
// ResetPassword: keine Session, deshalb "self-reset" als Actor
|
||||
// damit der Audit-Trail zeigt dass es kein admin-mediated Reset war.
|
||||
_ = h.Audit.Log(c.Request.Context(), "self-reset", "auth.password.reset",
|
||||
"", gin.H{"remote": c.ClientIP()}, h.NodeID)
|
||||
}
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
type changePasswordRequest struct {
|
||||
CurrentPassword string `json:"current_password" binding:"required"`
|
||||
NewPassword string `json:"new_password" binding:"required,min=12"`
|
||||
}
|
||||
|
||||
// ChangePassword: authenticated User wechselt sein eigenes Passwort.
|
||||
// Anders als ResetPassword (CLI-Token-Flow für vergessenes Passwort)
|
||||
// braucht das hier das current_password als Confirmation — verhindert
|
||||
// dass eine kompromittierte Session den Account übernimmt ohne dass
|
||||
// das alte Passwort bekannt ist.
|
||||
func (h *AuthHandler) ChangePassword(c *gin.Context) {
|
||||
var req changePasswordRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
st, err := h.Setup.Load()
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
if st == nil || !st.Completed {
|
||||
response.Err(c, http.StatusServiceUnavailable, errors.New("setup_required"))
|
||||
return
|
||||
}
|
||||
// Authorisierte Session ist nicht automatisch der Admin (Phase 4
|
||||
// admin_users-Tabelle könnte mehrere Rollen haben). v1: aktuell
|
||||
// nur der eine Admin-User; trotzdem prüfen wir das current_password
|
||||
// gegen die persistierte Hash.
|
||||
if !st.VerifyAdminPassword(req.CurrentPassword) {
|
||||
response.Unauthorized(c, errors.New("invalid_current_password"))
|
||||
return
|
||||
}
|
||||
if err := h.Setup.SetAdminPassword(req.NewPassword); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "auth.password.change",
|
||||
st.AdminEmail, gin.H{"actor": actorOf(c)}, h.NodeID)
|
||||
}
|
||||
// Neue Session ausstellen — alte Cookie zeigt auf ein Token das
|
||||
// noch gültig ist; das ist OK für UX (kein erzwungener Logout),
|
||||
// sicherheitsbewusster: clearSession + force re-login. Wir
|
||||
// halten's hier ruhig.
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
|
||||
@@ -2,32 +2,149 @@ package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/aggregator"
|
||||
"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/cluster/jointoken"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
)
|
||||
|
||||
// ClusterHandler exposes cluster-state endpoints. v1 ist read-only;
|
||||
// /status liefert eine strukturierte UI-Sicht (local + peers + health),
|
||||
// /nodes bleibt als simpler list-endpoint für Tools/Scripts.
|
||||
// ClusterHandler exposes cluster-state endpoints. /status ist die
|
||||
// strukturierte UI-Sicht (local + peers + health), /nodes ist die
|
||||
// simple list, /system/load fan-outet via mTLS-Aggregator zu allen
|
||||
// Peers und liefert pro Node die /proc-Metriken.
|
||||
//
|
||||
// Aggregator kann nil sein (clustertls nicht initialisiert) — dann
|
||||
// liefert /cluster/system/load nur den lokalen Wert.
|
||||
type ClusterHandler struct {
|
||||
Store *cluster.Store
|
||||
LocalID string
|
||||
Store *cluster.Store
|
||||
LocalID string
|
||||
Aggregator *aggregator.Aggregator
|
||||
|
||||
// TLSStore + Tokens: optional, gesetzt bei Phase 3.4. Erlauben das
|
||||
// Generieren von Join-Tokens und das Issue-Cert für joining Peers.
|
||||
TLSStore *clustertls.Store
|
||||
Tokens *jointoken.Service
|
||||
|
||||
// PeerReloader: optional, gesetzt bei Phase 3.5. Nach Auto-Register
|
||||
// triggert das den firewall-Render damit peer_ipv4 frisch ist.
|
||||
PeerReloader PeerReloader
|
||||
}
|
||||
|
||||
func NewClusterHandler(store *cluster.Store, localID string) *ClusterHandler {
|
||||
return &ClusterHandler{Store: store, LocalID: localID}
|
||||
}
|
||||
|
||||
// WithAggregator: optionale Aggregator-Konfiguration. Nur wenn vorhanden
|
||||
// wird /cluster/system/load die Peers via mTLS abklappern.
|
||||
func (h *ClusterHandler) WithAggregator(a *aggregator.Aggregator) *ClusterHandler {
|
||||
h.Aggregator = a
|
||||
return h
|
||||
}
|
||||
|
||||
// WithJoinFlow: Cluster-CA + Join-Token-Service. Nur wenn beide gesetzt
|
||||
// sind exposen wir /cluster/join-tokens (admin) + /cluster/issue-cert (public).
|
||||
func (h *ClusterHandler) WithJoinFlow(store *clustertls.Store, tokens *jointoken.Service) *ClusterHandler {
|
||||
h.TLSStore = store
|
||||
h.Tokens = tokens
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *ClusterHandler) Register(rg *gin.RouterGroup) {
|
||||
g := rg.Group("/cluster")
|
||||
g.GET("/nodes", h.ListNodes)
|
||||
g.GET("/status", h.Status)
|
||||
g.GET("/system/load", h.SystemLoad)
|
||||
g.DELETE("/nodes/:id", h.DeleteNode)
|
||||
if h.TLSStore != nil {
|
||||
g.GET("/cert-status", h.CertStatus)
|
||||
g.POST("/renew-self", h.RenewSelf)
|
||||
}
|
||||
if h.TLSStore != nil && h.Tokens != nil {
|
||||
g.POST("/join-tokens", h.GenerateJoinToken)
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteNode entfernt einen Peer aus ha_nodes. Verweigert für die
|
||||
// lokale Node (LocalID) — die kannst du nicht via UI löschen, sonst
|
||||
// kommt der nächste Heartbeat-Tick die Row wieder anlegen oder
|
||||
// die Cluster-Page wird inkonsistent.
|
||||
//
|
||||
// Nach erfolgreichem Delete triggert der PeerReloader (falls gesetzt)
|
||||
// einen Firewall-Render — peer_ipv4-Set verliert die IP, der entfernte
|
||||
// Peer kann nicht mehr auf :8443/:16379 connecten.
|
||||
func (h *ClusterHandler) DeleteNode(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
response.BadRequest(c, simpleError("missing id"))
|
||||
return
|
||||
}
|
||||
if id == h.LocalID {
|
||||
response.BadRequest(c, simpleError("cannot remove the local node — would auto-recreate on next heartbeat"))
|
||||
return
|
||||
}
|
||||
if err := h.Store.Delete(c.Request.Context(), id); err != nil {
|
||||
if err == cluster.ErrNotFound {
|
||||
response.NotFound(c, err)
|
||||
return
|
||||
}
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
if h.PeerReloader != nil {
|
||||
go func() {
|
||||
rctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := h.PeerReloader(rctx); err != nil {
|
||||
slog.Warn("cluster: firewall render after peer delete failed", "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
slog.Info("cluster: peer removed", "id", id, "actor", actorOf(c))
|
||||
response.NoContent(c)
|
||||
}
|
||||
|
||||
// RegisterPublic mountet die public (unauth) Endpoints — joining Peers
|
||||
// haben noch keine Session/Cert, deshalb läuft /issue-cert vor der
|
||||
// requireAuth-Middleware. Aufrufer muss diesen Group auf /api/v1 setzen
|
||||
// (NICHT auf authed).
|
||||
func (h *ClusterHandler) RegisterPublic(rg *gin.RouterGroup) {
|
||||
if h.TLSStore == nil || h.Tokens == nil {
|
||||
return
|
||||
}
|
||||
g := rg.Group("/cluster")
|
||||
g.POST("/issue-cert", h.IssueCert)
|
||||
}
|
||||
|
||||
// RegisterAgent mountet die Peer-only-Endpoints auf dem mTLS-Agent-
|
||||
// Listener. Auth läuft über das Peer-Cert (RequireAndVerifyClientCert
|
||||
// im ServerTLSConfig); der CN des Cert ist die FQDN des Peers.
|
||||
//
|
||||
// /agent/cluster/peers — joining Peer trägt sich nach erfolgreichem
|
||||
// issue-cert hier ein, damit der Primary ihn in ha_nodes hat (mit
|
||||
// status='joining') und der nächste Firewall-Render-Lauf seine IP
|
||||
// ins peer_ipv4-Set aufnimmt.
|
||||
func (h *ClusterHandler) RegisterAgent(rg *gin.RouterGroup) {
|
||||
g := rg.Group("/agent/cluster")
|
||||
g.POST("/peers", h.AgentRegisterPeer)
|
||||
}
|
||||
|
||||
// PeerReloader: optionale Funktion die nach einem Auto-Register
|
||||
// Firewall + ggfs. andere Configs regeneriert (damit peer_ipv4-Set
|
||||
// frisch ist). Wird vom main.go gesetzt.
|
||||
type PeerReloader func(ctx context.Context) error
|
||||
|
||||
// WithPeerReloader: nach jedem AgentRegisterPeer-Aufruf gefeuert.
|
||||
func (h *ClusterHandler) WithPeerReloader(r PeerReloader) *ClusterHandler {
|
||||
h.PeerReloader = r
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *ClusterHandler) ListNodes(c *gin.Context) {
|
||||
@@ -116,3 +233,292 @@ func (h *ClusterHandler) Status(c *gin.Context) {
|
||||
}
|
||||
response.OK(c, out)
|
||||
}
|
||||
|
||||
// SystemLoad aggregiert /proc-Metriken aller Peers via mTLS-Aggregator.
|
||||
// Liefert ein Array { node_id, fqdn, ok, data, error, duration_ms }.
|
||||
// Lokaler Node wird IMMER eingefügt (direkter Call statt mTLS-Roundtrip).
|
||||
func (h *ClusterHandler) SystemLoad(c *gin.Context) {
|
||||
all, err := h.Store.List(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
results := make([]aggregator.PeerResult, 0, len(all))
|
||||
// Lokalen Node selbst befragen: wir rufen die Resources-Funktion
|
||||
// inline statt einen mTLS-Loopback aufzubauen — auch wenn der
|
||||
// Agent-Listener läuft, ist ein direkter Call billiger.
|
||||
for _, n := range all {
|
||||
if n.ID == h.LocalID {
|
||||
local := localSystemLoad()
|
||||
raw, _ := json.Marshal(local)
|
||||
results = append(results, aggregator.PeerResult{
|
||||
NodeID: n.ID,
|
||||
FQDN: n.FQDN,
|
||||
OK: true,
|
||||
Data: raw,
|
||||
Duration: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
if h.Aggregator != nil {
|
||||
peers := make([]models.HANode, 0, len(all))
|
||||
for _, n := range all {
|
||||
if n.ID == h.LocalID {
|
||||
continue
|
||||
}
|
||||
peers = append(peers, n)
|
||||
}
|
||||
// /agent/system/resources auf dem Peer-Agent-Listener (:8443 mTLS).
|
||||
fan := h.Aggregator.FanOut(c.Request.Context(), peers, "/agent/system/resources", h.LocalID)
|
||||
results = append(results, fan...)
|
||||
}
|
||||
response.OK(c, gin.H{"nodes": results})
|
||||
}
|
||||
|
||||
// localSystemLoad ruft die selben Werte wie /system/resources, aber
|
||||
// als bare struct (kein gin.Context). Damit liefert SystemLoad pro Node
|
||||
// dasselbe Format wie der Agent-Endpoint.
|
||||
func localSystemLoad() any {
|
||||
// SystemHandler.Resources nutzt ein internes `resources` struct.
|
||||
// Wir duplizieren die /proc-Reads nicht — der Agent-Listener mountet
|
||||
// denselben Handler und liefert die JSON-Struktur. Für den lokalen
|
||||
// Path liefern wir das Snapshot über computeLocalSystemResources.
|
||||
return computeLocalSystemResources()
|
||||
}
|
||||
|
||||
// ── Phase 3.4: Cluster-Join Token Flow ────────────────────────────────
|
||||
|
||||
// GenerateJoinToken — Admin generiert einen one-shot Bootstrap-Token
|
||||
// für einen neuen Peer. Token wird NUR EINMAL zurückgegeben; Server
|
||||
// speichert keinen Klartext, beim Re-Use blockt der nonce-Tracker.
|
||||
func (h *ClusterHandler) GenerateJoinToken(c *gin.Context) {
|
||||
token, exp, err := h.Tokens.Generate()
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
// Wir liefern auch die primary-fqdn + ca-fingerprint mit, damit
|
||||
// das UI den Join-Befehl als kompletten curl/CLI-String anzeigen
|
||||
// kann.
|
||||
caCert, _, err := h.TLSStore.LoadCA()
|
||||
caFP := ""
|
||||
if err == nil && caCert != nil {
|
||||
caFP = jointoken.CAFingerprint16(caCert.Raw)
|
||||
}
|
||||
response.OK(c, gin.H{
|
||||
"token": token,
|
||||
"expires_at": exp.UTC().Format(time.RFC3339),
|
||||
"ca_fingerprint": caFP,
|
||||
})
|
||||
}
|
||||
|
||||
// IssueCert — Joining Peer POSTet seinen CSR + den Token. Wir verifizieren
|
||||
// + konsumieren den Token, signieren den CSR mit unserer Cluster-CA und
|
||||
// liefern {ca_cert, peer_cert} zurück. PUBLIC Endpoint — keine Session-
|
||||
// Auth nötig (der Joiner hat noch keine).
|
||||
type issueCertRequest struct {
|
||||
Token string `json:"token"`
|
||||
CSR string `json:"csr"`
|
||||
}
|
||||
type issueCertResponse struct {
|
||||
CACert string `json:"ca_cert"`
|
||||
PeerCert string `json:"peer_cert"`
|
||||
}
|
||||
|
||||
func (h *ClusterHandler) IssueCert(c *gin.Context) {
|
||||
var req issueCertRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if req.Token == "" || req.CSR == "" {
|
||||
response.BadRequest(c, errInvalidJoinRequest)
|
||||
return
|
||||
}
|
||||
// consumedBy → Remote-IP. Audit-Trail wenn jemand Tokens stiehlt
|
||||
// und vom falschen Host einlöst.
|
||||
consumedBy := c.ClientIP()
|
||||
if _, err := h.Tokens.Consume(c.Request.Context(), req.Token, consumedBy); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
// CSR signieren.
|
||||
peerCert, err := h.TLSStore.SignCSR(req.CSR, nil)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
caPEM, err := h.TLSStore.CACertPEM()
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, issueCertResponse{
|
||||
CACert: caPEM,
|
||||
PeerCert: peerCert,
|
||||
})
|
||||
}
|
||||
|
||||
var errInvalidJoinRequest = simpleError("missing token or csr")
|
||||
|
||||
type simpleError string
|
||||
|
||||
func (e simpleError) Error() string { return string(e) }
|
||||
|
||||
// ── Cluster-Cert-Status + Renewal ─────────────────────────────────────
|
||||
|
||||
// CertStatus liefert Metadata zu CA + Peer-Cert (Common Name, Expiry,
|
||||
// days_remaining). UI nutzt das für Expiry-Warnungen.
|
||||
func (h *ClusterHandler) CertStatus(c *gin.Context) {
|
||||
out := gin.H{
|
||||
"has_ca": h.TLSStore.HasCA(),
|
||||
"has_peer": h.TLSStore.HasPeer(),
|
||||
}
|
||||
if h.TLSStore.HasCA() {
|
||||
if info, err := h.TLSStore.CACertInfo(); err == nil {
|
||||
out["ca"] = info
|
||||
}
|
||||
}
|
||||
if h.TLSStore.HasPeer() {
|
||||
if info, err := h.TLSStore.PeerCertInfo(); err == nil {
|
||||
out["peer"] = info
|
||||
}
|
||||
}
|
||||
response.OK(c, out)
|
||||
}
|
||||
|
||||
// RenewSelf re-signed das eigene peer.crt mit der eigenen CA. Nur
|
||||
// sinnvoll auf einer Founder-Box; Joiner haben keine eigene CA.
|
||||
//
|
||||
// Nach Renew muss edgeguard-api restartet werden damit der Agent-
|
||||
// Listener das neue Cert in seinen TLS-Config-Snapshot lädt — wir
|
||||
// triggern das NICHT automatisch (würde die HTTP-Response abreißen);
|
||||
// stattdessen liefern wir einen Hinweis im Response.
|
||||
func (h *ClusterHandler) RenewSelf(c *gin.Context) {
|
||||
if !h.TLSStore.HasCA() {
|
||||
response.BadRequest(c, simpleError("no local CA — joiners cannot self-renew"))
|
||||
return
|
||||
}
|
||||
// Common-Name aus dem existierenden Peer-Cert übernehmen damit der
|
||||
// Cert weiterhin auf die aktuelle FQDN passt.
|
||||
cn := "edgeguard-node"
|
||||
if info, err := h.TLSStore.PeerCertInfo(); err == nil && info.CommonName != "" {
|
||||
cn = info.CommonName
|
||||
}
|
||||
if err := h.TLSStore.RenewSelfSigned(cn, []string{cn}, nil, nil); err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
info, err := h.TLSStore.PeerCertInfo()
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{
|
||||
"peer": info,
|
||||
"restart_hint": "systemctl restart edgeguard-api",
|
||||
})
|
||||
}
|
||||
|
||||
// ── Phase 3.5: Auto-Register beim Cluster-Join ────────────────────────
|
||||
|
||||
// registerPeerRequest: vom Joiner via mTLS-POST an /agent/cluster/peers.
|
||||
// CN des Client-Cert authentifiziert den Peer. Wir nehmen nur die Felder
|
||||
// die wir wirklich brauchen — sonst kann ein joining Peer beliebige
|
||||
// ha_nodes-Felder überschreiben.
|
||||
type registerPeerRequest struct {
|
||||
ID string `json:"id"` // Joiner's eigene node-id
|
||||
Name string `json:"name"` // hostname
|
||||
FQDN string `json:"fqdn"` // sollte mit Client-Cert-CN matchen
|
||||
APIURL string `json:"api_url"` // https://<fqdn>
|
||||
PublicIP string `json:"public_ip"` // optional
|
||||
InternalIP string `json:"internal_ip"` // mTLS-Listener-IP (für peer_ipv4-Set)
|
||||
MgmtIP string `json:"mgmt_ip"` // optional
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
// AgentRegisterPeer: vom Joiner nach issue-cert via mTLS aufgerufen.
|
||||
// Validiert dass der Client-Cert-CN zur fqdn passt (verhindert Cross-
|
||||
// Peer-Hijack) und upsertet die Row in ha_nodes mit status='joining'.
|
||||
// Phase 3.2 Heartbeat wird die Status auf 'online' ändern sobald der
|
||||
// Joiner seinen eigenen Heartbeat-Tick startet.
|
||||
func (h *ClusterHandler) AgentRegisterPeer(c *gin.Context) {
|
||||
var req registerPeerRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if req.ID == "" || req.FQDN == "" {
|
||||
response.BadRequest(c, simpleError("id + fqdn required"))
|
||||
return
|
||||
}
|
||||
// Cert-CN-Check: TLS-Layer hat den Cert bereits gegen unsere CA
|
||||
// verifiziert; jetzt prüfen wir dass der CN zur claimed FQDN passt.
|
||||
// Sonst könnte ein peer1.example.com Cert nutzen um peer2.example.com
|
||||
// in ha_nodes zu schreiben.
|
||||
if c.Request.TLS == nil || len(c.Request.TLS.PeerCertificates) == 0 {
|
||||
response.Forbidden(c, simpleError("no client cert presented"))
|
||||
return
|
||||
}
|
||||
cn := c.Request.TLS.PeerCertificates[0].Subject.CommonName
|
||||
if cn != req.FQDN {
|
||||
slog.Warn("cluster: peer cert CN does not match registration FQDN",
|
||||
"cn", cn, "fqdn", req.FQDN)
|
||||
response.Forbidden(c, simpleError("cert CN does not match fqdn"))
|
||||
return
|
||||
}
|
||||
|
||||
// Wir bauen ein models.HANode zusammen + nutzen den existierenden
|
||||
// UpsertSelf. (UpsertSelf nimmt eine HANode für einen registrierenden
|
||||
// Node, hier ist der „Self" der joining-Peer auf dieser Primary-Seite.
|
||||
// Der Name passt nicht 100% semantisch, aber das SQL ist exakt das was
|
||||
// wir brauchen.)
|
||||
n := models.HANode{
|
||||
ID: req.ID,
|
||||
Name: req.Name,
|
||||
FQDN: req.FQDN,
|
||||
APIURL: req.APIURL,
|
||||
Role: "peer",
|
||||
Status: "joining",
|
||||
}
|
||||
if req.PublicIP != "" {
|
||||
v := req.PublicIP
|
||||
n.PublicIP = &v
|
||||
}
|
||||
if req.InternalIP != "" {
|
||||
v := req.InternalIP
|
||||
n.InternalIP = &v
|
||||
}
|
||||
if req.MgmtIP != "" {
|
||||
v := req.MgmtIP
|
||||
n.MgmtIP = &v
|
||||
}
|
||||
if req.Version != "" {
|
||||
v := req.Version
|
||||
n.Version = &v
|
||||
}
|
||||
out, err := h.Store.UpsertSelf(c.Request.Context(), n)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Firewall-Reload damit peer_ipv4-Set die neue IP aufnimmt. Best-
|
||||
// effort: Fehler loggen, Response weiter durchreichen — der Peer
|
||||
// hat seine Identity erfolgreich registriert, Operator kann manuell
|
||||
// nachrendern.
|
||||
if h.PeerReloader != nil {
|
||||
go func() {
|
||||
rctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := h.PeerReloader(rctx); err != nil {
|
||||
slog.Warn("cluster: PeerReloader failed after AgentRegisterPeer", "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
slog.Info("cluster: peer registered via mTLS",
|
||||
"id", out.ID, "fqdn", out.FQDN, "role", out.Role, "status", out.Status,
|
||||
"client_cn", cn, "remote", c.ClientIP())
|
||||
response.OK(c, out)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
|
||||
"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/routingrules"
|
||||
)
|
||||
@@ -18,6 +19,7 @@ import (
|
||||
type DomainsHandler struct {
|
||||
Repo *domains.Repo
|
||||
Routing *routingrules.Repo
|
||||
Headers *domainheaders.Repo
|
||||
Audit *audit.Repo
|
||||
NodeID string
|
||||
|
||||
@@ -29,8 +31,8 @@ type DomainsHandler struct {
|
||||
Reloader func(ctx context.Context) error
|
||||
}
|
||||
|
||||
func NewDomainsHandler(repo *domains.Repo, routing *routingrules.Repo, a *audit.Repo, nodeID string, reloader func(context.Context) error) *DomainsHandler {
|
||||
return &DomainsHandler{Repo: repo, Routing: routing, Audit: a, NodeID: nodeID, Reloader: reloader}
|
||||
func NewDomainsHandler(repo *domains.Repo, routing *routingrules.Repo, headers *domainheaders.Repo, a *audit.Repo, nodeID string, reloader func(context.Context) error) *DomainsHandler {
|
||||
return &DomainsHandler{Repo: repo, Routing: routing, Headers: headers, Audit: a, NodeID: nodeID, Reloader: reloader}
|
||||
}
|
||||
|
||||
func (h *DomainsHandler) reload(ctx context.Context, op string) {
|
||||
@@ -50,6 +52,11 @@ func (h *DomainsHandler) Register(rg *gin.RouterGroup) {
|
||||
g.PUT("/:id", h.Update)
|
||||
g.DELETE("/:id", h.Delete)
|
||||
g.GET("/:id/routing-rules", h.ListRoutingRules)
|
||||
|
||||
g.GET("/:id/headers", h.ListHeaders)
|
||||
g.POST("/:id/headers", h.CreateHeader)
|
||||
g.PUT("/:id/headers/:hid", h.UpdateHeader)
|
||||
g.DELETE("/:id/headers/:hid", h.DeleteHeader)
|
||||
}
|
||||
|
||||
func (h *DomainsHandler) List(c *gin.Context) {
|
||||
@@ -161,6 +168,127 @@ func parseID(c *gin.Context) (int64, bool) {
|
||||
return id, true
|
||||
}
|
||||
|
||||
// ── Response-Header CRUD ──────────────────────────────────────────────
|
||||
//
|
||||
// Header werden über `http-response set-header` von HAProxy gesetzt
|
||||
// (siehe internal/haproxy/haproxy.cfg.tpl). Name ist case-insensitive
|
||||
// unique pro Domain — Doppel-Eintrag erkennt PG selbst via Unique-Index
|
||||
// und liefert 409 zurück.
|
||||
|
||||
func (h *DomainsHandler) ListHeaders(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
out, err := h.Headers.ListForDomain(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"headers": out})
|
||||
}
|
||||
|
||||
func (h *DomainsHandler) CreateHeader(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req models.DomainResponseHeader
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
req.DomainID = id
|
||||
if !validHeaderName(req.Name) {
|
||||
response.BadRequest(c, errors.New("invalid header name"))
|
||||
return
|
||||
}
|
||||
out, err := h.Headers.Create(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "domain.header.create", out.Name, out, h.NodeID)
|
||||
response.Created(c, out); h.reload(c.Request.Context(), "header.create")
|
||||
}
|
||||
|
||||
func (h *DomainsHandler) UpdateHeader(c *gin.Context) {
|
||||
_, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
hid, err := strconv.ParseInt(c.Param("hid"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, errors.New("invalid header id"))
|
||||
return
|
||||
}
|
||||
var req models.DomainResponseHeader
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if !validHeaderName(req.Name) {
|
||||
response.BadRequest(c, errors.New("invalid header name"))
|
||||
return
|
||||
}
|
||||
out, err := h.Headers.Update(c.Request.Context(), hid, req)
|
||||
if err != nil {
|
||||
if errors.Is(err, domainheaders.ErrNotFound) {
|
||||
response.NotFound(c, err)
|
||||
return
|
||||
}
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "domain.header.update", out.Name, out, h.NodeID)
|
||||
response.OK(c, out); h.reload(c.Request.Context(), "header.update")
|
||||
}
|
||||
|
||||
func (h *DomainsHandler) DeleteHeader(c *gin.Context) {
|
||||
_, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
hid, err := strconv.ParseInt(c.Param("hid"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, errors.New("invalid header id"))
|
||||
return
|
||||
}
|
||||
if err := h.Headers.Delete(c.Request.Context(), hid); err != nil {
|
||||
if errors.Is(err, domainheaders.ErrNotFound) {
|
||||
response.NotFound(c, err)
|
||||
return
|
||||
}
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "domain.header.delete",
|
||||
strconv.FormatInt(hid, 10), gin.H{"id": hid}, h.NodeID)
|
||||
response.NoContent(c); h.reload(c.Request.Context(), "header.delete")
|
||||
}
|
||||
|
||||
// validHeaderName: RFC 7230 token chars. Wir engen das auf eine
|
||||
// vernünftige Untermenge ein (a-z A-Z 0-9 + `-`), damit niemand
|
||||
// versehentlich Newlines oder Quotes in den HAProxy-Renderer schiebt
|
||||
// und die Config-Datei sprengt.
|
||||
func validHeaderName(name string) bool {
|
||||
if name == "" || len(name) > 100 {
|
||||
return false
|
||||
}
|
||||
for _, r := range name {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z',
|
||||
r >= 'A' && r <= 'Z',
|
||||
r >= '0' && r <= '9',
|
||||
r == '-':
|
||||
// ok
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func actorOf(c *gin.Context) string {
|
||||
if t := CurrentToken(c); t != nil {
|
||||
return t.Actor
|
||||
|
||||
@@ -3,7 +3,10 @@ package handlers
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -37,6 +40,7 @@ func (h *NTPHandler) Register(rg *gin.RouterGroup) {
|
||||
g := rg.Group("/ntp")
|
||||
g.GET("/settings", h.GetSettings)
|
||||
g.PUT("/settings", h.UpdateSettings)
|
||||
g.GET("/status", h.Status)
|
||||
|
||||
p := g.Group("/pools")
|
||||
p.GET("", h.ListPools)
|
||||
@@ -46,6 +50,74 @@ func (h *NTPHandler) Register(rg *gin.RouterGroup) {
|
||||
p.DELETE("/:id", h.DeletePool)
|
||||
}
|
||||
|
||||
// Status liefert den aktuellen chrony-Tracking-Status via `chronyc tracking`.
|
||||
// Output-Felder: synced (bool), reference (Quell-Server), stratum (int),
|
||||
// offset_ms (float), freq_ppm (float), rms_offset_ms (float), error (string
|
||||
// wenn chronyc nicht verfügbar oder System-Takt nicht sync'd).
|
||||
func (h *NTPHandler) Status(c *gin.Context) {
|
||||
out, err := exec.Command("chronyc", "tracking").Output()
|
||||
if err != nil {
|
||||
response.OK(c, gin.H{
|
||||
"synced": false,
|
||||
"error": "chronyc nicht verfügbar: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
resp := parseChronyTracking(string(out))
|
||||
response.OK(c, resp)
|
||||
}
|
||||
|
||||
type chronyStatus struct {
|
||||
Synced bool `json:"synced"`
|
||||
Reference string `json:"reference"`
|
||||
Stratum int `json:"stratum"`
|
||||
OffsetMs float64 `json:"offset_ms"`
|
||||
FreqPPM float64 `json:"freq_ppm"`
|
||||
RMSOffsetMs float64 `json:"rms_offset_ms"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func parseChronyTracking(out string) chronyStatus {
|
||||
s := chronyStatus{}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
key, val, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
val = strings.TrimSpace(val)
|
||||
switch key {
|
||||
case "Reference ID":
|
||||
// "Reference ID : A29FC801 (time.cloudflare.com)"
|
||||
if i := strings.Index(val, "("); i >= 0 {
|
||||
s.Reference = strings.Trim(val[i:], "()")
|
||||
} else {
|
||||
s.Reference = val
|
||||
}
|
||||
s.Synced = val != "00000000 ()"
|
||||
case "Stratum":
|
||||
fmt.Sscanf(val, "%d", &s.Stratum)
|
||||
if s.Stratum > 0 && s.Stratum < 16 {
|
||||
s.Synced = true
|
||||
}
|
||||
case "System time":
|
||||
// "0.000012345 seconds fast of NTP time"
|
||||
var v float64
|
||||
fmt.Sscanf(val, "%f", &v)
|
||||
s.OffsetMs = v * 1000
|
||||
case "Frequency":
|
||||
// "-12.345 ppm slow" or "+12.345 ppm fast"
|
||||
fmt.Sscanf(val, "%f", &s.FreqPPM)
|
||||
case "RMS offset":
|
||||
var v float64
|
||||
fmt.Sscanf(val, "%f", &v)
|
||||
s.RMSOffsetMs = v * 1000
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (h *NTPHandler) GetSettings(c *gin.Context) {
|
||||
s, err := h.Repo.GetSettings(c.Request.Context())
|
||||
if err != nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
||||
)
|
||||
|
||||
@@ -11,22 +12,40 @@ import (
|
||||
// are mounted before SetupGate so they remain reachable while the API
|
||||
// is in setup mode.
|
||||
type SetupHandler struct {
|
||||
Store *setup.Store
|
||||
Store *setup.Store
|
||||
Audit *audit.Repo
|
||||
NodeID string
|
||||
}
|
||||
|
||||
func NewSetupHandler(store *setup.Store) *SetupHandler {
|
||||
return &SetupHandler{Store: store}
|
||||
}
|
||||
|
||||
// WithAudit injiziert Audit-Repo + Node-ID damit Mutationen (contact-emails)
|
||||
// in audit_log landen. Optional — wenn Audit nil bleibt, läuft die
|
||||
// Mutation, aber ohne Log-Eintrag.
|
||||
func (h *SetupHandler) WithAudit(a *audit.Repo, nodeID string) *SetupHandler {
|
||||
h.Audit = a
|
||||
h.NodeID = nodeID
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *SetupHandler) Register(rg *gin.RouterGroup) {
|
||||
g := rg.Group("/setup")
|
||||
g.GET("/status", h.Status)
|
||||
g.POST("/complete", h.Complete)
|
||||
}
|
||||
|
||||
// RegisterAuthed mountet die Endpoints die nach abgeschlossenem Setup
|
||||
// den Admin-Modus brauchen — Aufrufer hat requireAuth schon dran.
|
||||
func (h *SetupHandler) RegisterAuthed(rg *gin.RouterGroup) {
|
||||
g := rg.Group("/setup")
|
||||
g.POST("/contact-emails", h.SetContactEmails)
|
||||
}
|
||||
|
||||
// Status returns just the public bits of the setup state: whether
|
||||
// it's done and (if so) the configured admin_email + fqdn. Never
|
||||
// exposes the password hash.
|
||||
// it's done and (if so) the configured admin_email + acme_email +
|
||||
// fqdn. Never exposes the password hash.
|
||||
func (h *SetupHandler) Status(c *gin.Context) {
|
||||
st, err := h.Store.Load()
|
||||
if err != nil {
|
||||
@@ -36,10 +55,45 @@ func (h *SetupHandler) Status(c *gin.Context) {
|
||||
response.OK(c, gin.H{
|
||||
"completed": st.Completed,
|
||||
"admin_email": st.AdminEmail,
|
||||
"acme_email": st.ACMEEmail,
|
||||
"fqdn": st.FQDN,
|
||||
})
|
||||
}
|
||||
|
||||
// SetContactEmails: Admin-only Update der zwei E-Mail-Felder.
|
||||
// Sessions bleiben aktiv (Cookie referenziert den alten Actor); auf
|
||||
// nächstem Login zählt der neue Wert.
|
||||
func (h *SetupHandler) SetContactEmails(c *gin.Context) {
|
||||
var req struct {
|
||||
AdminEmail string `json:"admin_email" binding:"required,email"`
|
||||
ACMEEmail string `json:"acme_email" binding:"required,email"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if err := h.Store.SetContactEmails(req.AdminEmail, req.ACMEEmail); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
st, err := h.Store.Load()
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "setup.contact_emails",
|
||||
st.AdminEmail, gin.H{
|
||||
"admin_email": st.AdminEmail,
|
||||
"acme_email": st.ACMEEmail,
|
||||
}, h.NodeID)
|
||||
}
|
||||
response.OK(c, gin.H{
|
||||
"admin_email": st.AdminEmail,
|
||||
"acme_email": st.ACMEEmail,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *SetupHandler) Complete(c *gin.Context) {
|
||||
var req setup.Request
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
@@ -16,20 +15,55 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||
aptsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/apt"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
||||
)
|
||||
|
||||
// SystemHandler covers /system/health, /system/package-versions and
|
||||
// /system/upgrade. Wired from day 1 because the management UI carries
|
||||
// an update banner that polls package-versions.
|
||||
// SystemHandler covers /system/health, /system/package-versions,
|
||||
// /system/upgrade, /system/auto-update + /system/maintenance.
|
||||
//
|
||||
// Setup + HAProxyReloader sind optional — werden für /maintenance
|
||||
// gebraucht. Wenn nil sind die Maintenance-Endpoints noch verfügbar
|
||||
// aber returnen 503 (Setup nicht abgeschlossen).
|
||||
type SystemHandler struct {
|
||||
Version string
|
||||
Version string
|
||||
Setup *setup.Store
|
||||
HAProxyReloader func(stdcontext.Context) error
|
||||
Audit *audit.Repo
|
||||
NodeID string
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewSystemHandler(version string) *SystemHandler {
|
||||
return &SystemHandler{Version: version}
|
||||
}
|
||||
|
||||
// WithMaintenance: setup + reloader injection. Aufrufer in main.go
|
||||
// nach DB-Pool-Open.
|
||||
func (h *SystemHandler) WithMaintenance(setupStore *setup.Store, reloader func(stdcontext.Context) error) *SystemHandler {
|
||||
h.Setup = setupStore
|
||||
h.HAProxyReloader = reloader
|
||||
return h
|
||||
}
|
||||
|
||||
// WithAudit: Audit-Repo + NodeID damit Toggle-Aktionen (Maintenance,
|
||||
// Auto-Update) ins audit_log fließen.
|
||||
func (h *SystemHandler) WithAudit(a *audit.Repo, nodeID string) *SystemHandler {
|
||||
h.Audit = a
|
||||
h.NodeID = nodeID
|
||||
return h
|
||||
}
|
||||
|
||||
// WithDB: Pool für DB-Size + andere PG-introspection-Endpoints.
|
||||
func (h *SystemHandler) WithDB(pool *pgxpool.Pool) *SystemHandler {
|
||||
h.Pool = pool
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *SystemHandler) Register(rg *gin.RouterGroup) {
|
||||
g := rg.Group("/system")
|
||||
g.GET("/health", h.Health)
|
||||
@@ -38,6 +72,32 @@ func (h *SystemHandler) Register(rg *gin.RouterGroup) {
|
||||
g.GET("/interfaces", h.Interfaces)
|
||||
g.GET("/services", h.Services)
|
||||
g.GET("/resources", h.Resources)
|
||||
g.GET("/auto-update", h.AutoUpdate)
|
||||
g.POST("/auto-update", h.ToggleAutoUpdate)
|
||||
g.GET("/maintenance", h.Maintenance)
|
||||
g.POST("/maintenance", h.ToggleMaintenance)
|
||||
g.GET("/backup-retention", h.BackupRetention)
|
||||
g.POST("/backup-retention", h.SetBackupRetention)
|
||||
g.GET("/audit-retention", h.AuditRetention)
|
||||
g.POST("/audit-retention", h.SetAuditRetention)
|
||||
g.GET("/db-size", h.DBSize)
|
||||
g.POST("/haproxy-reload", h.HAProxyReload)
|
||||
g.POST("/render-configs", h.RenderConfigs)
|
||||
g.POST("/service-restart", h.ServiceRestart)
|
||||
g.GET("/upgrade-status", h.UpgradeStatus)
|
||||
}
|
||||
|
||||
// RegisterAgent mountet die read-only System-Endpoints auf der mTLS-
|
||||
// Agent-Engine (Port :8443). Der Cluster-Aggregator auf der Main-API
|
||||
// ruft diese Endpoints parallel auf allen Peers ab und kompiliert das
|
||||
// Ergebnis für /cluster/system/load.
|
||||
//
|
||||
// Bewusst KEINE Mutations + KEIN /package-versions (würde apt-get update
|
||||
// auf jedem Peer triggern), KEIN /upgrade.
|
||||
func (h *SystemHandler) RegisterAgent(rg *gin.RouterGroup) {
|
||||
g := rg.Group("/agent/system")
|
||||
g.GET("/health", h.Health)
|
||||
g.GET("/resources", h.Resources)
|
||||
}
|
||||
|
||||
// servicesToCheck is the curated list shown on the dashboard
|
||||
@@ -141,6 +201,14 @@ type resources struct {
|
||||
// Resources reads /proc + statfs for the box-level metrics card.
|
||||
// All best-effort — missing files just leave the field at zero.
|
||||
func (h *SystemHandler) Resources(c *gin.Context) {
|
||||
response.OK(c, computeLocalSystemResources())
|
||||
}
|
||||
|
||||
// computeLocalSystemResources extrahiert die /proc + statfs reads aus
|
||||
// dem Handler damit der Cluster-Aggregator denselben Snapshot ohne
|
||||
// gin.Context erstellen kann. Best-effort: fehlende Quellen lassen
|
||||
// die jeweiligen Felder einfach auf 0.
|
||||
func computeLocalSystemResources() resources {
|
||||
r := resources{}
|
||||
if data, err := os.ReadFile("/proc/loadavg"); err == nil {
|
||||
f := strings.Fields(string(data))
|
||||
@@ -195,7 +263,383 @@ func (h *SystemHandler) Resources(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
response.OK(c, r)
|
||||
return r
|
||||
}
|
||||
|
||||
// Maintenance liefert den aktuellen Whole-Box-Maintenance-Status.
|
||||
// Wenn Setup nicht initialisiert: enabled=false.
|
||||
func (h *SystemHandler) Maintenance(c *gin.Context) {
|
||||
if h.Setup == nil {
|
||||
response.OK(c, gin.H{"enabled": false, "message": ""})
|
||||
return
|
||||
}
|
||||
st, err := h.Setup.Load()
|
||||
if err != nil || st == nil {
|
||||
response.OK(c, gin.H{"enabled": false, "message": ""})
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{
|
||||
"enabled": st.MaintenanceMode,
|
||||
"message": st.MaintenanceMessage,
|
||||
})
|
||||
}
|
||||
|
||||
// ToggleMaintenance schaltet whole-box maintenance an/aus.
|
||||
// Persistiert in setup-State + triggert HAProxy-Render damit das
|
||||
// 503-Block aktiv wird (oder verschwindet).
|
||||
func (h *SystemHandler) ToggleMaintenance(c *gin.Context) {
|
||||
if h.Setup == nil {
|
||||
response.Err(c, http.StatusServiceUnavailable,
|
||||
simpleErr("setup not initialised"))
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if err := h.Setup.SetMaintenanceMode(req.Enabled, req.Message); err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
if h.HAProxyReloader != nil {
|
||||
ctx, cancel := stdcontext.WithTimeout(c.Request.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := h.HAProxyReloader(ctx); err != nil {
|
||||
slog.Warn("system: haproxy reload after maintenance toggle failed", "error", err)
|
||||
}
|
||||
}
|
||||
if h.Audit != nil {
|
||||
action := "system.maintenance.off"
|
||||
if req.Enabled {
|
||||
action = "system.maintenance.on"
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), action,
|
||||
"", gin.H{"enabled": req.Enabled, "message": req.Message}, h.NodeID)
|
||||
}
|
||||
response.OK(c, gin.H{"enabled": req.Enabled, "message": req.Message})
|
||||
}
|
||||
|
||||
type simpleErr string
|
||||
|
||||
func (e simpleErr) Error() string { return string(e) }
|
||||
|
||||
// BackupRetention liefert keep_n. 0 = Default (backup.DefaultKeepN).
|
||||
func (h *SystemHandler) BackupRetention(c *gin.Context) {
|
||||
keep := 0
|
||||
if h.Setup != nil {
|
||||
if st, err := h.Setup.Load(); err == nil && st != nil {
|
||||
keep = st.BackupRetentionKeep
|
||||
}
|
||||
}
|
||||
response.OK(c, gin.H{"keep": keep, "default": 14})
|
||||
}
|
||||
|
||||
// SetBackupRetention persistiert die Operator-gewählte Retention.
|
||||
// keep=0 → wieder Default, keep=1..365 → custom.
|
||||
func (h *SystemHandler) SetBackupRetention(c *gin.Context) {
|
||||
if h.Setup == nil {
|
||||
response.Err(c, http.StatusServiceUnavailable, simpleErr("setup not initialised"))
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Keep int `json:"keep"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if err := h.Setup.SetBackupRetention(req.Keep); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "system.backup_retention",
|
||||
"", gin.H{"keep": req.Keep}, h.NodeID)
|
||||
}
|
||||
response.OK(c, gin.H{"keep": req.Keep})
|
||||
}
|
||||
|
||||
// AuditRetention liefert die konfigurierte Retention in Tagen.
|
||||
// 0 = Default (90 — siehe scheduler/main.go).
|
||||
func (h *SystemHandler) AuditRetention(c *gin.Context) {
|
||||
days := 0
|
||||
if h.Setup != nil {
|
||||
if st, err := h.Setup.Load(); err == nil && st != nil {
|
||||
days = st.AuditRetentionDays
|
||||
}
|
||||
}
|
||||
response.OK(c, gin.H{"days": days, "default": 90})
|
||||
}
|
||||
|
||||
// SetAuditRetention setzt Audit-Retention in Tagen. 0..3650.
|
||||
func (h *SystemHandler) SetAuditRetention(c *gin.Context) {
|
||||
if h.Setup == nil {
|
||||
response.Err(c, http.StatusServiceUnavailable, simpleErr("setup not initialised"))
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Days int `json:"days"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if err := h.Setup.SetAuditRetention(req.Days); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "system.audit_retention",
|
||||
"", gin.H{"days": req.Days}, h.NodeID)
|
||||
}
|
||||
response.OK(c, gin.H{"days": req.Days})
|
||||
}
|
||||
|
||||
// DBSize liefert pg_database_size + Top-N Tabellen-Größen für Capacity-
|
||||
// Planning. Operator sieht so welche Tabellen Disk fressen (typisch
|
||||
// audit_log + firewall_log → siehe Retention-Settings).
|
||||
type dbSizeTable struct {
|
||||
Name string `json:"name"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
HumanSz string `json:"human_size"`
|
||||
}
|
||||
type dbSizeResponse struct {
|
||||
TotalBytes int64 `json:"total_bytes"`
|
||||
HumanTotal string `json:"human_total"`
|
||||
Tables []dbSizeTable `json:"top_tables"`
|
||||
}
|
||||
|
||||
func (h *SystemHandler) DBSize(c *gin.Context) {
|
||||
if h.Pool == nil {
|
||||
response.Err(c, http.StatusServiceUnavailable, simpleErr("db pool unavailable"))
|
||||
return
|
||||
}
|
||||
ctx, cancel := stdcontext.WithTimeout(c.Request.Context(), 3*time.Second)
|
||||
defer cancel()
|
||||
var total int64
|
||||
var totalH string
|
||||
if err := h.Pool.QueryRow(ctx, `
|
||||
SELECT pg_database_size(current_database()),
|
||||
pg_size_pretty(pg_database_size(current_database()))`).Scan(&total, &totalH); err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
// Top 10 user-tables nach total_relation_size (inkl. Indizes + TOAST).
|
||||
rows, err := h.Pool.Query(ctx, `
|
||||
SELECT c.relname,
|
||||
pg_total_relation_size(c.oid),
|
||||
pg_size_pretty(pg_total_relation_size(c.oid))
|
||||
FROM pg_class c
|
||||
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE c.relkind = 'r' AND n.nspname = 'public'
|
||||
ORDER BY pg_total_relation_size(c.oid) DESC
|
||||
LIMIT 10`)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
out := dbSizeResponse{TotalBytes: total, HumanTotal: totalH, Tables: []dbSizeTable{}}
|
||||
for rows.Next() {
|
||||
var t dbSizeTable
|
||||
if err := rows.Scan(&t.Name, &t.Bytes, &t.HumanSz); err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
out.Tables = append(out.Tables, t)
|
||||
}
|
||||
response.OK(c, out)
|
||||
}
|
||||
|
||||
// HAProxyReload zwingt ein systemctl reload haproxy.service — nützlich
|
||||
// wenn der Operator manuell in /etc/edgeguard/tls/ geschrieben hat
|
||||
// (z. B. eigenes PEM per SSH kopiert) und HAProxy das neue Cert sehen
|
||||
// soll, ohne eine UI-Mutation zu triggern die das automatisch täte.
|
||||
func (h *SystemHandler) HAProxyReload(c *gin.Context) {
|
||||
out, err := exec.Command("sudo", "-n", "/usr/bin/systemctl", "reload", "haproxy.service").CombinedOutput()
|
||||
if err != nil {
|
||||
response.Err(c, http.StatusInternalServerError, simpleErr(strings.TrimSpace(string(out))+": "+err.Error()))
|
||||
return
|
||||
}
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "system.haproxy_reload",
|
||||
"", gin.H{}, h.NodeID)
|
||||
}
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// restartAllowlist sind die Dienste die der Operator über die UI neu
|
||||
// starten darf. edgeguard-api selbst ist bewusst ausgeschlossen (würde
|
||||
// die eigene HTTP-Response killen). postgresql ebenfalls (Datenpfad).
|
||||
var restartAllowlist = map[string]bool{
|
||||
"haproxy": true,
|
||||
"squid": true,
|
||||
"unbound": true,
|
||||
"chrony": true,
|
||||
"nftables": true,
|
||||
"wireguard": true, // wireguard als Metadienst; einzelne wg-Ifaces über wg-quick@<name>
|
||||
"edgeguard-scheduler": true,
|
||||
}
|
||||
|
||||
// ServiceRestart startet einen Dienst aus der Allowlist via
|
||||
// `systemctl restart`. Gibt 400 zurück wenn der Dienst nicht auf der
|
||||
// Allowlist steht, 500 wenn systemctl fehlschlägt.
|
||||
func (h *SystemHandler) ServiceRestart(c *gin.Context) {
|
||||
var req struct {
|
||||
Service string `json:"service" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Err(c, http.StatusBadRequest, simpleErr("service required"))
|
||||
return
|
||||
}
|
||||
svc := strings.TrimSpace(req.Service)
|
||||
if !restartAllowlist[svc] {
|
||||
response.Err(c, http.StatusBadRequest, simpleErr("service not in allowlist: "+svc))
|
||||
return
|
||||
}
|
||||
unitName := svc + ".service"
|
||||
out, err := exec.Command("sudo", "-n", "/usr/bin/systemctl", "restart", unitName).CombinedOutput()
|
||||
if err != nil {
|
||||
response.Err(c, http.StatusInternalServerError, simpleErr(strings.TrimSpace(string(out))+": "+err.Error()))
|
||||
return
|
||||
}
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "system.service_restart",
|
||||
svc, gin.H{"service": svc}, h.NodeID)
|
||||
}
|
||||
response.OK(c, gin.H{"ok": true, "service": svc})
|
||||
}
|
||||
|
||||
// RenderConfigs erzwingt ein Re-Render aller Service-Configs aus dem
|
||||
// aktuellen DB-State. Wenn der HAProxyReloader gesetzt ist, läuft der
|
||||
// (rendert haproxy.cfg + reload). Praktisch wenn ein Operator denkt
|
||||
// dass die generierte Config nicht mehr mit der DB übereinstimmt
|
||||
// (Drift, Manual-Edit, etc.).
|
||||
//
|
||||
// v1 macht NUR haproxy — weitere Renderer (firewall, dns, ntp, wg,
|
||||
// squid) sind per Handler an die jeweiligen Mutations-Endpoints
|
||||
// gekoppelt; für die fehlt aktuell ein generisches "render all".
|
||||
func (h *SystemHandler) RenderConfigs(c *gin.Context) {
|
||||
if h.HAProxyReloader == nil {
|
||||
response.Err(c, http.StatusServiceUnavailable, simpleErr("renderer not wired"))
|
||||
return
|
||||
}
|
||||
ctx, cancel := stdcontext.WithTimeout(c.Request.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := h.HAProxyReloader(ctx); err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "system.render_configs",
|
||||
"", gin.H{}, h.NodeID)
|
||||
}
|
||||
response.OK(c, gin.H{"ok": true, "rendered": []string{"haproxy"}})
|
||||
}
|
||||
|
||||
// UpgradeStatus liefert den Status des letzten Self-Upgrade-Versuchs.
|
||||
// Reads systemctl show + journalctl der edgeguard-upgrade.service
|
||||
// transient unit. Hilft beim Debuggen wenn der Update-Banner nach
|
||||
// einem Click nicht weg geht — typische Ursachen (apt-Resolver-fail,
|
||||
// dpkg-broken) sind direkt im Log sichtbar.
|
||||
type upgradeStatusResponse struct {
|
||||
State string `json:"state"` // inactive / activating / failed / ...
|
||||
Result string `json:"result"` // success / exit-code / ...
|
||||
ExecMainPID int `json:"exec_main_pid"` // 0 wenn nie gelaufen
|
||||
ExitCode int `json:"exit_code"` // exit-status des letzten Laufs
|
||||
StartedAt string `json:"started_at"` // RFC3339 oder leer
|
||||
FinishedAt string `json:"finished_at"` // RFC3339 oder leer
|
||||
Log []string `json:"log"` // letzte N Zeilen aus journalctl
|
||||
}
|
||||
|
||||
func (h *SystemHandler) UpgradeStatus(c *gin.Context) {
|
||||
out := upgradeStatusResponse{Log: []string{}}
|
||||
|
||||
// systemctl show liefert key=value pairs für die transient unit.
|
||||
// Wenn die Unit nie existiert hat → leeres Output / inactive.
|
||||
if data, err := exec.Command("systemctl", "show", "edgeguard-upgrade.service",
|
||||
"--no-page",
|
||||
"-p", "ActiveState",
|
||||
"-p", "Result",
|
||||
"-p", "ExecMainPID",
|
||||
"-p", "ExecMainStatus",
|
||||
"-p", "ExecMainStartTimestamp",
|
||||
"-p", "ExecMainExitTimestamp",
|
||||
).CombinedOutput(); err == nil {
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
kv := strings.SplitN(strings.TrimSpace(line), "=", 2)
|
||||
if len(kv) != 2 {
|
||||
continue
|
||||
}
|
||||
switch kv[0] {
|
||||
case "ActiveState":
|
||||
out.State = kv[1]
|
||||
case "Result":
|
||||
out.Result = kv[1]
|
||||
case "ExecMainPID":
|
||||
out.ExecMainPID, _ = strconv.Atoi(kv[1])
|
||||
case "ExecMainStatus":
|
||||
out.ExitCode, _ = strconv.Atoi(kv[1])
|
||||
case "ExecMainStartTimestamp":
|
||||
if t, err := time.Parse("Mon 2006-01-02 15:04:05 MST", kv[1]); err == nil {
|
||||
out.StartedAt = t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
case "ExecMainExitTimestamp":
|
||||
if t, err := time.Parse("Mon 2006-01-02 15:04:05 MST", kv[1]); err == nil {
|
||||
out.FinishedAt = t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Letzte 200 Zeilen Journal — reicht für apt-output + Stack-Traces.
|
||||
if data, err := exec.Command("journalctl",
|
||||
"-u", "edgeguard-upgrade.service",
|
||||
"--no-pager", "-n", "200", "-o", "cat",
|
||||
).CombinedOutput(); err == nil {
|
||||
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
|
||||
// Leere "no entries"-Antwort als leeres Log zurückgeben.
|
||||
if !(len(lines) == 1 && (lines[0] == "" || strings.HasPrefix(lines[0], "-- No entries"))) {
|
||||
out.Log = lines
|
||||
}
|
||||
}
|
||||
|
||||
response.OK(c, out)
|
||||
}
|
||||
|
||||
// AutoUpdate liefert den aktuellen Status (Conf-File existiert?).
|
||||
func (h *SystemHandler) AutoUpdate(c *gin.Context) {
|
||||
response.OK(c, gin.H{"enabled": aptsvc.AutoUpdateEnabled()})
|
||||
}
|
||||
|
||||
// ToggleAutoUpdate schaltet automatische Updates an/aus. Schreibt
|
||||
// /etc/apt/apt.conf.d/52edgeguard-auto-updates (sudo tee) bzw.
|
||||
// entfernt es (sudo rm). Idempotent.
|
||||
func (h *SystemHandler) ToggleAutoUpdate(c *gin.Context) {
|
||||
var req struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if err := aptsvc.SetAutoUpdate(req.Enabled); err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
if h.Audit != nil {
|
||||
action := "system.auto_update.off"
|
||||
if req.Enabled {
|
||||
action = "system.auto_update.on"
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), action,
|
||||
"", gin.H{"enabled": req.Enabled}, h.NodeID)
|
||||
}
|
||||
response.OK(c, gin.H{"enabled": req.Enabled})
|
||||
}
|
||||
|
||||
func (h *SystemHandler) Health(c *gin.Context) {
|
||||
@@ -207,29 +651,16 @@ func (h *SystemHandler) Health(c *gin.Context) {
|
||||
|
||||
// PackageVersions reports installed and available versions for the
|
||||
// edgeguard-* APT packages. Called by the UI's update banner — it
|
||||
// polls every few minutes and lights up when available > installed.
|
||||
// polls every 30s and lights up when available > installed.
|
||||
//
|
||||
// `apt-get update -qq` is fired first (best-effort, no error if it
|
||||
// fails — we'd still return the cached candidate). Then `apt-cache
|
||||
// policy` is parsed for each package.
|
||||
// Implementierung delegiert an internal/services/apt: dort sitzt der
|
||||
// 5-min-Throttle für apt-get update, der LC_ALL=C-Fix für deutsche
|
||||
// Locales und das Background-Refresh-Timer. `?force=1` (UI-Button
|
||||
// „Jetzt prüfen") bypassed den Throttle, damit nach einem `make publish`
|
||||
// nicht 5 min auf das nächste Tick gewartet werden muss.
|
||||
func (h *SystemHandler) PackageVersions(c *gin.Context) {
|
||||
// API läuft als edgeguard-User; ohne sudo schreibt apt-get update
|
||||
// nicht in /var/lib/apt/lists und der candidate bleibt veraltet.
|
||||
// Sudoers-Eintrag in postinst whitelisted exakt diese Zeile.
|
||||
_ = exec.Command("sudo", "-n", "/usr/bin/apt-get", "update", "-qq").Run()
|
||||
|
||||
out := map[string]string{}
|
||||
for _, pkg := range []string{"edgeguard-api", "edgeguard-ui", "edgeguard"} {
|
||||
raw, err := exec.Command("apt-cache", "policy", pkg).CombinedOutput()
|
||||
if err != nil {
|
||||
out[pkg+"_installed"] = ""
|
||||
out[pkg+"_available"] = ""
|
||||
continue
|
||||
}
|
||||
installed, candidate := parseAptPolicy(string(raw))
|
||||
out[pkg+"_installed"] = installed
|
||||
out[pkg+"_available"] = candidate
|
||||
}
|
||||
force := c.Query("force") == "1" || c.Query("force") == "true"
|
||||
out := aptsvc.PackageVersions(c.Request.Context(), force)
|
||||
response.OK(c, out)
|
||||
}
|
||||
|
||||
@@ -248,16 +679,42 @@ func (h *SystemHandler) Upgrade(c *gin.Context) {
|
||||
// /var/lib/edgeguard ist edgeguard-owned + persistent + von
|
||||
// beiden Namespaces aus zugänglich.
|
||||
const scriptPath = "/var/lib/edgeguard/upgrade.sh"
|
||||
// Retry-Logik gegen Gitea-Packages.gz-Race: nach einem frischen
|
||||
// Publish kann der Packages-Index für ein paar Sekunden inkonsistent
|
||||
// sein (z. B. Meta uploaded, api/ui noch nicht in der regenerierten
|
||||
// Index-Datei) → apt-resolver-fail mit "no choices are installable".
|
||||
// Drei Versuche mit 15s/30s Backoff geben Gitea Zeit den Index
|
||||
// nachzuziehen. Befund 2026-05-17.
|
||||
const script = `#!/bin/bash
|
||||
set -e
|
||||
sleep 2
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
echo "[upgrade] dpkg --configure -a"
|
||||
dpkg --configure -a || true
|
||||
echo "[upgrade] apt-get update"
|
||||
apt-get update -qq
|
||||
echo "[upgrade] apt-get install -y edgeguard-api edgeguard-ui edgeguard"
|
||||
apt-get install -y -qq -o Dpkg::Options::=--force-confold edgeguard-api edgeguard-ui edgeguard
|
||||
|
||||
retry_apt() {
|
||||
local attempt=0
|
||||
local max=3
|
||||
local wait_for=15
|
||||
while [ $attempt -lt $max ]; do
|
||||
attempt=$((attempt + 1))
|
||||
echo "[upgrade] attempt $attempt/$max: apt-get update + install"
|
||||
apt-get update -qq || true
|
||||
if apt-get install -y -qq -o Dpkg::Options::=--force-confold \
|
||||
edgeguard-api edgeguard-ui edgeguard; then
|
||||
return 0
|
||||
fi
|
||||
if [ $attempt -lt $max ]; then
|
||||
echo "[upgrade] failed (likely Packages-index race), waiting ${wait_for}s before retry"
|
||||
sleep $wait_for
|
||||
wait_for=$((wait_for * 2))
|
||||
fi
|
||||
done
|
||||
echo "[upgrade] all $max attempts failed"
|
||||
return 1
|
||||
}
|
||||
|
||||
retry_apt
|
||||
echo "[upgrade] complete"
|
||||
rm -f /var/lib/edgeguard/upgrade.sh
|
||||
`
|
||||
@@ -401,27 +858,3 @@ func flagsToList(f net.Flags) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// parseAptPolicy extracts "Installed: x" and "Candidate: y" from
|
||||
// apt-cache policy output. Both can be "(none)"; we normalise that to
|
||||
// empty string.
|
||||
var aptPolicyLine = regexp.MustCompile(`^\s+(Installed|Candidate):\s+(.+)\s*$`)
|
||||
|
||||
func parseAptPolicy(out string) (installed, candidate string) {
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
m := aptPolicyLine.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
val := m[2]
|
||||
if val == "(none)" {
|
||||
val = ""
|
||||
}
|
||||
switch m[1] {
|
||||
case "Installed":
|
||||
installed = val
|
||||
case "Candidate":
|
||||
candidate = val
|
||||
}
|
||||
}
|
||||
return installed, candidate
|
||||
}
|
||||
|
||||
@@ -10,6 +10,11 @@ global
|
||||
daemon
|
||||
ssl-default-bind-options ssl-min-ver TLSv1.2 no-tls-tickets
|
||||
ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
|
||||
# HAProxy 3.0.x in Trixie ist mit USE_QUIC=1 + USE_QUIC_OPENSSL_COMPAT=1
|
||||
# gebaut; die System-OpenSSL kann QUIC aber nur über den Kompat-Layer.
|
||||
# Ohne dieses Flag verweigert HAProxy `bind quic4@…` mit "this SSL
|
||||
# library does not support the QUIC protocol".
|
||||
limited-quic
|
||||
|
||||
defaults
|
||||
log global
|
||||
@@ -50,8 +55,14 @@ frontend public_http
|
||||
# Admin-Panel fällt. mailgateway/enconf-Pattern.
|
||||
frontend public_https
|
||||
bind :443 ssl crt /etc/edgeguard/tls/ alpn h2,http/1.1
|
||||
# HTTP/3 via QUIC (UDP/443). HAProxy 3.0.x ist mit USE_QUIC=1
|
||||
# gebaut; Browser fallen via Alt-Svc-Header (siehe unten) für
|
||||
# Folge-Requests auf h3 zurück.
|
||||
bind quic4@:443 ssl crt /etc/edgeguard/tls/ alpn h3
|
||||
|
||||
http-response set-header Strict-Transport-Security "max-age=31536000"
|
||||
# Alt-Svc: signalisiert dass h3 auf demselben Port verfügbar ist.
|
||||
# ma=86400 = Browser darf den Hinweis 24h cachen.
|
||||
http-response set-header Alt-Svc "h3=\":443\"; ma=86400"
|
||||
|
||||
# Client-IP-Weiterleitung an Backends. `option forwardfor` (defaults)
|
||||
# setzt X-Forwarded-For; wir ergänzen Proto + RealIP damit Apps
|
||||
@@ -60,7 +71,47 @@ frontend public_https
|
||||
http-request set-header X-Forwarded-Proto https
|
||||
http-request set-header X-Real-IP %[src]
|
||||
|
||||
{{- if .GlobalMaintenance}}
|
||||
# Whole-Box-Maintenance — Settings → Maintenance-Mode aktiv. Dieser
|
||||
# Block kommt VOR allen per-Domain ACLs und blockt JEDEN Customer-
|
||||
# Request. Mgmt-UI auf :3443 (mgmt_https) ist davon nicht betroffen.
|
||||
http-request return status 503 content-type "text/plain; charset=utf-8" string "{{.GlobalMaintenanceMessage}}"
|
||||
{{- end}}
|
||||
|
||||
{{- range $d := .Domains}}
|
||||
{{- if $d.RedirectFromHost}}
|
||||
# www-Redirect: {{$d.RedirectFromHost}} → {{$d.Name}}
|
||||
http-request redirect prefix https://{{$d.Name}} code 301 if { hdr(host) -i {{$d.RedirectFromHost}} }
|
||||
{{- end}}
|
||||
{{- if $d.MaintenanceMode}}
|
||||
# Wartungs-Modus für {{$d.Name}} — alle Requests werden mit 503 beantwortet.
|
||||
http-request return status 503 content-type "text/plain; charset=utf-8" string "{{$d.MaintMessage}}" if { hdr(host) -i {{$d.Name}} }
|
||||
{{- end}}
|
||||
{{- if $d.MaxBodyBytes}}
|
||||
# Body-Size-Cap für {{$d.Name}}: {{$d.MaxBodyBytes}} Bytes.
|
||||
# Nur Content-Length wird geprüft — Chunked-Bodies wären erst nach
|
||||
# http-buffer-request abgreifbar, das wollen wir auf Frontend-Level
|
||||
# nicht aktivieren (verbraucht RAM pro Connection).
|
||||
http-request deny deny_status 413 if { hdr(host) -i {{$d.Name}} } { req.hdr_val(content-length) -m int gt {{$d.MaxBodyBytes}} }
|
||||
{{- end}}
|
||||
{{- if $d.RateLimitThreshold}}
|
||||
# Rate-Limit für {{$d.Name}}: {{$d.RateLimitRPS}} req/s pro Client-IP
|
||||
# (Schwelle = rps × 10s-Fenster = {{$d.RateLimitThreshold}} hits).
|
||||
http-request track-sc0 src table rl_{{$d.ID}} if { hdr(host) -i {{$d.Name}} }
|
||||
http-request deny deny_status 429 if { hdr(host) -i {{$d.Name}} } { sc_http_req_rate(0) gt {{$d.RateLimitThreshold}} }
|
||||
{{- end}}
|
||||
{{- if $d.HSTSHeader}}
|
||||
# del + set damit ein vom Backend gesetztes HSTS (z. B. nginx mit
|
||||
# eigener add_header-Direktive) garantiert von unserer Policy
|
||||
# überschrieben wird — sonst tauchen 2 Strict-Transport-Security
|
||||
# Header in der Response auf.
|
||||
http-response del-header Strict-Transport-Security if { hdr(host) -i {{$d.Name}} }
|
||||
http-response set-header Strict-Transport-Security "{{$d.HSTSHeader}}" if { hdr(host) -i {{$d.Name}} }
|
||||
{{- end}}
|
||||
{{- range $h := $d.ResponseHeaders}}
|
||||
http-response del-header {{$h.Name}} if { hdr(host) -i {{$d.Name}} }
|
||||
http-response set-header {{$h.Name}} "{{$h.Value}}" if { hdr(host) -i {{$d.Name}} }
|
||||
{{- end}}
|
||||
{{- range $r := $d.Routes}}
|
||||
use_backend eg_backend_{{$r.BackendID}} if { hdr(host) -i {{$d.Name}} } { path_beg {{$r.PathPrefix}} }
|
||||
{{- end}}
|
||||
@@ -99,6 +150,20 @@ backend api_backend
|
||||
timeout tunnel 1h
|
||||
server api1 127.0.0.1:9443 check
|
||||
|
||||
{{- /* Per-Domain Rate-Limit Stick-Tables.
|
||||
Eigenes Backend pro Domain damit verschiedene Schwellen nicht
|
||||
miteinander interferieren; HAProxy 3.0 erlaubt mehrere
|
||||
stick-table-Backends die per Frontend mit track-sc0 angesteuert
|
||||
werden. expire 10s hält den Speicher knapp. */ -}}
|
||||
{{- range $d := .Domains}}
|
||||
{{- if $d.RateLimitThreshold}}
|
||||
|
||||
backend rl_{{$d.ID}}
|
||||
# Rate-Limit-Counter für {{$d.Name}}; gefüllt von public_https.
|
||||
stick-table type ip size 100k expire 10s store http_req_rate(10s)
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
|
||||
{{- range $b := .Backends}}
|
||||
|
||||
backend eg_backend_{{$b.ID}}
|
||||
|
||||
@@ -20,8 +20,10 @@ import (
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/backends"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/backendservers"
|
||||
"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/routingrules"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
||||
)
|
||||
|
||||
//go:embed haproxy.cfg.tpl
|
||||
@@ -61,6 +63,12 @@ type Generator struct {
|
||||
BackendsRepo *backends.Repo
|
||||
ServersRepo *backendservers.Repo
|
||||
RoutingRepo *routingrules.Repo
|
||||
HeadersRepo *domainheaders.Repo
|
||||
|
||||
// SetupStore (optional): wenn gesetzt, lesen wir Whole-Box-
|
||||
// Maintenance-Status hieraus und reichen ihn als View.GlobalMaintenance
|
||||
// ans Template weiter.
|
||||
SetupStore *setup.Store
|
||||
|
||||
OutputPath string
|
||||
SkipReload bool
|
||||
@@ -73,6 +81,8 @@ func New(pool *pgxpool.Pool) *Generator {
|
||||
BackendsRepo: backends.New(pool),
|
||||
ServersRepo: backendservers.New(pool),
|
||||
RoutingRepo: routingrules.New(pool),
|
||||
HeadersRepo: domainheaders.New(pool),
|
||||
SetupStore: setup.NewStore(setup.DefaultDir),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,11 +120,53 @@ func (g *Generator) Render(ctx context.Context) error {
|
||||
type View struct {
|
||||
Domains []DomainView
|
||||
Backends []BackendView
|
||||
|
||||
// GlobalMaintenance: wenn true emittiert public_https einen
|
||||
// 503-Block ganz am Anfang (vor allen anderen ACLs), der
|
||||
// alle Customer-Domains gleichzeitig stilllegt. mgmt_https
|
||||
// (:3443) bleibt unverändert. Whole-Box-Mode für
|
||||
// OS-Upgrades / Wartungsfenster.
|
||||
GlobalMaintenance bool
|
||||
GlobalMaintenanceMessage string
|
||||
}
|
||||
|
||||
type DomainView struct {
|
||||
models.Domain
|
||||
Routes []RouteView
|
||||
|
||||
// HSTSHeader: fertige Header-Value `max-age=…; includeSubDomains; preload`,
|
||||
// nur gesetzt wenn HSTSEnabled — Template muss nichts mehr zusammenbauen.
|
||||
HSTSHeader string
|
||||
|
||||
// MaintMessage: aus MaintenanceMessage übernommen, leer → Default.
|
||||
// Bereits HAProxy-safe (kein " enthalten).
|
||||
MaintMessage string
|
||||
|
||||
// RedirectFromHost: aliasing-Host der nach Name umgeleitet werden soll.
|
||||
// Nur gesetzt wenn WWWRedirect != "":
|
||||
// to-naked → Name="example.com" → "www.example.com"
|
||||
// to-www → Name="www.example.com" → "example.com" (strip www.-Prefix)
|
||||
RedirectFromHost string
|
||||
|
||||
// ResponseHeaders: Custom-Headers die HAProxy auf jede Response für
|
||||
// diese Domain setzt. Werte sind bereits HAProxy-safe escaped
|
||||
// (Quotes → ', Newlines → Space).
|
||||
ResponseHeaders []ResponseHeaderView
|
||||
|
||||
// MaxBodyBytes: Content-Length-Cap in Bytes (max_body_kb * 1024).
|
||||
// 0 = aus, kein Rendering.
|
||||
MaxBodyBytes int
|
||||
|
||||
// RateLimitThreshold: requests-pro-10s über die Stick-Table.
|
||||
// 0 = aus, kein Rendering. = RateLimitRPS * 10.
|
||||
RateLimitThreshold int
|
||||
}
|
||||
|
||||
// ResponseHeaderView: pre-escaped Name/Value damit das Template
|
||||
// nicht escapen muss.
|
||||
type ResponseHeaderView struct {
|
||||
Name string
|
||||
Value string
|
||||
}
|
||||
|
||||
type RouteView struct {
|
||||
@@ -144,6 +196,17 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list routing rules: %w", err)
|
||||
}
|
||||
headers, err := g.HeadersRepo.ListAll(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list domain headers: %w", err)
|
||||
}
|
||||
headersByDomain := map[int64][]ResponseHeaderView{}
|
||||
for _, h := range headers {
|
||||
headersByDomain[h.DomainID] = append(headersByDomain[h.DomainID], ResponseHeaderView{
|
||||
Name: h.Name,
|
||||
Value: sanitizeHeaderValue(h.Value),
|
||||
})
|
||||
}
|
||||
|
||||
rulesByDomain := map[int64][]RouteView{}
|
||||
for _, r := range rules {
|
||||
@@ -180,11 +243,110 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) {
|
||||
if !d.Active {
|
||||
continue
|
||||
}
|
||||
domViews = append(domViews, DomainView{
|
||||
Domain: d,
|
||||
Routes: rulesByDomain[d.ID],
|
||||
})
|
||||
dv := DomainView{
|
||||
Domain: d,
|
||||
Routes: rulesByDomain[d.ID],
|
||||
HSTSHeader: buildHSTSHeader(d),
|
||||
MaintMessage: buildMaintMessage(d),
|
||||
RedirectFromHost: buildRedirectFromHost(d),
|
||||
ResponseHeaders: headersByDomain[d.ID],
|
||||
}
|
||||
if d.MaxBodyKB > 0 {
|
||||
dv.MaxBodyBytes = d.MaxBodyKB * 1024
|
||||
}
|
||||
if d.RateLimitRPS > 0 {
|
||||
// Stick-Table store-window ist 10s (siehe Template); deshalb
|
||||
// ist die Threshold-Größe rps * 10.
|
||||
dv.RateLimitThreshold = d.RateLimitRPS * 10
|
||||
}
|
||||
domViews = append(domViews, dv)
|
||||
}
|
||||
|
||||
return &View{Domains: domViews, Backends: activeBackends}, nil
|
||||
v := &View{Domains: domViews, Backends: activeBackends}
|
||||
if g.SetupStore != nil {
|
||||
if st, err := g.SetupStore.Load(); err == nil && st != nil {
|
||||
v.GlobalMaintenance = st.MaintenanceMode
|
||||
v.GlobalMaintenanceMessage = sanitizeHeaderValue(st.MaintenanceMessage)
|
||||
if v.GlobalMaintenanceMessage == "" && v.GlobalMaintenance {
|
||||
v.GlobalMaintenanceMessage = "EdgeGuard maintenance in progress."
|
||||
}
|
||||
}
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// buildHSTSHeader baut den fertigen Strict-Transport-Security Wert.
|
||||
// Leer wenn HSTS für die Domain aus ist — das Template prüft dann.
|
||||
func buildHSTSHeader(d models.Domain) string {
|
||||
if !d.HSTSEnabled {
|
||||
return ""
|
||||
}
|
||||
maxAge := d.HSTSMaxAge
|
||||
if maxAge <= 0 {
|
||||
maxAge = 31536000
|
||||
}
|
||||
out := fmt.Sprintf("max-age=%d", maxAge)
|
||||
if d.HSTSSubdomains {
|
||||
out += "; includeSubDomains"
|
||||
}
|
||||
if d.HSTSPreload {
|
||||
out += "; preload"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildMaintMessage liefert die Wartungs-Meldung HAProxy-safe (ohne ").
|
||||
// Default-Text wenn nichts gesetzt ist.
|
||||
func buildMaintMessage(d models.Domain) string {
|
||||
if !d.MaintenanceMode {
|
||||
return ""
|
||||
}
|
||||
msg := ""
|
||||
if d.MaintenanceMessage != nil {
|
||||
msg = *d.MaintenanceMessage
|
||||
}
|
||||
msg = strings.TrimSpace(msg)
|
||||
if msg == "" {
|
||||
msg = "Service temporarily unavailable for maintenance."
|
||||
}
|
||||
// HAProxy kennt kein Escape innerhalb von "…". Lieber " durch ' tauschen
|
||||
// und Newlines flatten, damit der Template-Output garantiert parst.
|
||||
msg = strings.ReplaceAll(msg, `"`, "'")
|
||||
msg = strings.ReplaceAll(msg, "\n", " ")
|
||||
msg = strings.ReplaceAll(msg, "\r", " ")
|
||||
return msg
|
||||
}
|
||||
|
||||
// sanitizeHeaderValue macht den Wert HAProxy-safe für `set-header "…"`.
|
||||
// HAProxy versteht innerhalb einer "…"-Sequenz keine Escapes, deshalb
|
||||
// werden Quotes durch ' ersetzt und CR/LF entfernt (sonst sprengt ein
|
||||
// böser Wert die Config oder ermöglicht Header-Injection).
|
||||
func sanitizeHeaderValue(v string) string {
|
||||
v = strings.ReplaceAll(v, `"`, "'")
|
||||
v = strings.ReplaceAll(v, "\n", " ")
|
||||
v = strings.ReplaceAll(v, "\r", " ")
|
||||
return v
|
||||
}
|
||||
|
||||
// buildRedirectFromHost gibt den Alias-Host zurück der nach Name umgeleitet
|
||||
// werden soll, oder "" wenn kein Redirect konfiguriert ist.
|
||||
func buildRedirectFromHost(d models.Domain) string {
|
||||
switch d.WWWRedirect {
|
||||
case "to-naked":
|
||||
// Name ist die nackte Form → wir leiten www.Name → Name um.
|
||||
if strings.HasPrefix(strings.ToLower(d.Name), "www.") {
|
||||
// User-Fehlkonfiguration — Name beginnt schon mit www. Skip.
|
||||
return ""
|
||||
}
|
||||
return "www." + d.Name
|
||||
case "to-www":
|
||||
// Name ist die www-Form → wir leiten Name-ohne-www → Name um.
|
||||
lower := strings.ToLower(d.Name)
|
||||
if !strings.HasPrefix(lower, "www.") {
|
||||
return ""
|
||||
}
|
||||
return d.Name[4:]
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,13 @@ func TestRender_BaselineHasFrontendsAndApiBackend(t *testing.T) {
|
||||
"backend api_backend",
|
||||
"server api1 127.0.0.1:9443 check",
|
||||
"bind :443 ssl crt /etc/edgeguard/tls/",
|
||||
// HTTP/3 (QUIC) zusätzlich zum h2/http1.1-Listener.
|
||||
"bind quic4@:443 ssl crt /etc/edgeguard/tls/ alpn h3",
|
||||
// limited-quic global muss gesetzt sein, sonst weigert sich
|
||||
// HAProxy 3.0 das quic4-bind anzunehmen (OpenSSL-Kompat-Layer).
|
||||
"limited-quic",
|
||||
// Alt-Svc damit Browser auf h3 upgraden.
|
||||
`Alt-Svc "h3=\":443\"; ma=86400"`,
|
||||
"path_beg /.well-known/acme-challenge/",
|
||||
"http-request redirect scheme https",
|
||||
// Client-IP-Weiterleitung an Backends — XFF kommt aus
|
||||
@@ -54,6 +61,326 @@ func TestRender_BaselineHasFrontendsAndApiBackend(t *testing.T) {
|
||||
t.Errorf("missing %q in baseline output:\n%s", w, out)
|
||||
}
|
||||
}
|
||||
// Globales HSTS auf public_https darf NICHT mehr drin sein —
|
||||
// das wird jetzt pro Domain via ACL gesetzt (siehe HSTS-Test).
|
||||
// mgmt_https hat aber weiterhin ein globales HSTS.
|
||||
publicIdx := strings.Index(out, "frontend public_https")
|
||||
mgmtIdx := strings.Index(out, "frontend mgmt_https")
|
||||
if publicIdx < 0 || mgmtIdx < 0 || publicIdx >= mgmtIdx {
|
||||
t.Fatalf("frontend ordering unexpected:\n%s", out)
|
||||
}
|
||||
publicBlock := out[publicIdx:mgmtIdx]
|
||||
if strings.Contains(publicBlock, "set-header Strict-Transport-Security") {
|
||||
t.Errorf("public_https soll KEIN globales HSTS mehr enthalten (pro-Domain ACL):\n%s", publicBlock)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_HSTSPerDomain(t *testing.T) {
|
||||
v := View{
|
||||
Domains: []DomainView{
|
||||
{
|
||||
Domain: models.Domain{
|
||||
ID: 1, Name: "a.example.com", Active: true,
|
||||
HSTSEnabled: true, HSTSMaxAge: 63072000,
|
||||
HSTSSubdomains: true, HSTSPreload: true,
|
||||
},
|
||||
HSTSHeader: "max-age=63072000; includeSubDomains; preload",
|
||||
},
|
||||
{
|
||||
Domain: models.Domain{
|
||||
ID: 2, Name: "b.example.com", Active: true,
|
||||
HSTSEnabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
out := renderView(t, v)
|
||||
for _, w := range []string{
|
||||
// Erst löschen (gegen Backend-set HSTS), dann setzen.
|
||||
`http-response del-header Strict-Transport-Security if { hdr(host) -i a.example.com }`,
|
||||
`http-response set-header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" if { hdr(host) -i a.example.com }`,
|
||||
} {
|
||||
if !strings.Contains(out, w) {
|
||||
t.Errorf("missing %q in per-domain HSTS output:\n%s", w, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "Strict-Transport-Security \"\" if { hdr(host) -i b.example.com }") ||
|
||||
strings.Contains(out, "if { hdr(host) -i b.example.com }") && strings.Contains(out, "Strict-Transport-Security") &&
|
||||
strings.Contains(out, "b.example.com") && strings.Count(out, "Strict-Transport-Security") > 2 {
|
||||
// HSTS soll für Domain ohne HSTSEnabled gar nicht erst gerendert werden.
|
||||
// (mgmt_https hat noch eins, plus die eine Zeile von a.example.com → 2 Vorkommen erwartet.)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_MaintenanceModeBlocksWith503(t *testing.T) {
|
||||
msg := `Wartung — bitte später wiederkommen.`
|
||||
v := View{
|
||||
Domains: []DomainView{
|
||||
{
|
||||
Domain: models.Domain{
|
||||
ID: 1, Name: "down.example.com", Active: true,
|
||||
MaintenanceMode: true, MaintenanceMessage: &msg,
|
||||
},
|
||||
MaintMessage: msg,
|
||||
},
|
||||
},
|
||||
}
|
||||
out := renderView(t, v)
|
||||
want := `http-request return status 503 content-type "text/plain; charset=utf-8" string "Wartung — bitte später wiederkommen." if { hdr(host) -i down.example.com }`
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("missing maintenance-503 line:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_WWWRedirectToNaked(t *testing.T) {
|
||||
v := View{
|
||||
Domains: []DomainView{
|
||||
{
|
||||
Domain: models.Domain{
|
||||
ID: 1, Name: "example.com", Active: true,
|
||||
WWWRedirect: "to-naked",
|
||||
},
|
||||
RedirectFromHost: "www.example.com",
|
||||
},
|
||||
},
|
||||
}
|
||||
out := renderView(t, v)
|
||||
want := `http-request redirect prefix https://example.com code 301 if { hdr(host) -i www.example.com }`
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("missing www→naked redirect line:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_WWWRedirectToWWW(t *testing.T) {
|
||||
v := View{
|
||||
Domains: []DomainView{
|
||||
{
|
||||
Domain: models.Domain{
|
||||
ID: 1, Name: "www.example.com", Active: true,
|
||||
WWWRedirect: "to-www",
|
||||
},
|
||||
RedirectFromHost: "example.com",
|
||||
},
|
||||
},
|
||||
}
|
||||
out := renderView(t, v)
|
||||
want := `http-request redirect prefix https://www.example.com code 301 if { hdr(host) -i example.com }`
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("missing naked→www redirect line:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHSTSHeader(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
d models.Domain
|
||||
want string
|
||||
}{
|
||||
{"disabled", models.Domain{HSTSEnabled: false}, ""},
|
||||
{"defaults", models.Domain{HSTSEnabled: true}, "max-age=31536000"},
|
||||
{"explicit", models.Domain{HSTSEnabled: true, HSTSMaxAge: 7200}, "max-age=7200"},
|
||||
{"sub", models.Domain{HSTSEnabled: true, HSTSMaxAge: 60, HSTSSubdomains: true}, "max-age=60; includeSubDomains"},
|
||||
{"sub+preload", models.Domain{HSTSEnabled: true, HSTSMaxAge: 60, HSTSSubdomains: true, HSTSPreload: true}, "max-age=60; includeSubDomains; preload"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := buildHSTSHeader(tc.d); got != tc.want {
|
||||
t.Errorf("buildHSTSHeader: got %q want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMaintMessage(t *testing.T) {
|
||||
msg := `He said "hi"` + "\n" + `and left`
|
||||
d := models.Domain{MaintenanceMode: true, MaintenanceMessage: &msg}
|
||||
got := buildMaintMessage(d)
|
||||
// Quotes durch ' ersetzt, Newline → Space.
|
||||
want := `He said 'hi' and left`
|
||||
if got != want {
|
||||
t.Errorf("buildMaintMessage: got %q want %q", got, want)
|
||||
}
|
||||
if buildMaintMessage(models.Domain{MaintenanceMode: false}) != "" {
|
||||
t.Errorf("buildMaintMessage should be empty when MaintenanceMode is off")
|
||||
}
|
||||
if got := buildMaintMessage(models.Domain{MaintenanceMode: true}); got == "" {
|
||||
t.Errorf("buildMaintMessage should emit fallback text when no message set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_GlobalMaintenanceBlocksAllCustomerTraffic(t *testing.T) {
|
||||
v := View{
|
||||
GlobalMaintenance: true,
|
||||
GlobalMaintenanceMessage: "Wartung läuft.",
|
||||
Domains: []DomainView{
|
||||
{Domain: models.Domain{ID: 1, Name: "site.example.com", Active: true}},
|
||||
},
|
||||
}
|
||||
out := renderView(t, v)
|
||||
want := `http-request return status 503 content-type "text/plain; charset=utf-8" string "Wartung läuft."`
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("missing global-maintenance 503 block:\n%s", out)
|
||||
}
|
||||
// Block muss VOR den use_backend-Zeilen IM SELBEN public_https-
|
||||
// Frontend stehen (sonst ineffektiv — HAProxy execut'ed in-order,
|
||||
// return-actions terminieren die Chain). public_http hat ein
|
||||
// frühes use_backend api_backend für ACME — das hier nicht
|
||||
// matchen.
|
||||
pubIdx := strings.Index(out, "frontend public_https")
|
||||
mgmtIdxStart := strings.Index(out, "frontend mgmt_https")
|
||||
if pubIdx < 0 || mgmtIdxStart < 0 {
|
||||
t.Fatalf("frontends not found in output:\n%s", out)
|
||||
}
|
||||
publicBlock := out[pubIdx:mgmtIdxStart]
|
||||
idxBlock := strings.Index(publicBlock, want)
|
||||
idxUseBackend := strings.Index(publicBlock, "use_backend")
|
||||
if idxBlock < 0 || (idxUseBackend > 0 && idxBlock > idxUseBackend) {
|
||||
t.Errorf("global-maintenance block must precede use_backend in public_https\n block at %d, use_backend at %d", idxBlock, idxUseBackend)
|
||||
}
|
||||
// mgmt_https darf NICHT betroffen sein.
|
||||
mgmtIdx := strings.Index(out, "frontend mgmt_https")
|
||||
if mgmtIdx > 0 {
|
||||
mgmtBlock := out[mgmtIdx:]
|
||||
if strings.Contains(mgmtBlock, want) {
|
||||
t.Errorf("mgmt_https must NOT contain global-maintenance block:\n%s", mgmtBlock)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_GlobalMaintenanceOff_NoBlock(t *testing.T) {
|
||||
v := View{
|
||||
Domains: []DomainView{
|
||||
{Domain: models.Domain{ID: 1, Name: "site.example.com", Active: true}},
|
||||
},
|
||||
}
|
||||
out := renderView(t, v)
|
||||
if strings.Contains(out, "Whole-Box-Maintenance") {
|
||||
t.Errorf("global-maintenance comment should not be rendered when off:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_RateLimitEmitsStickTableAndDeny(t *testing.T) {
|
||||
v := View{
|
||||
Domains: []DomainView{
|
||||
{
|
||||
Domain: models.Domain{
|
||||
ID: 7, Name: "api.example.com", Active: true,
|
||||
RateLimitRPS: 50,
|
||||
},
|
||||
RateLimitThreshold: 500, // 50 rps × 10s
|
||||
},
|
||||
},
|
||||
}
|
||||
out := renderView(t, v)
|
||||
for _, w := range []string{
|
||||
"backend rl_7",
|
||||
"stick-table type ip size 100k expire 10s store http_req_rate(10s)",
|
||||
`http-request track-sc0 src table rl_7 if { hdr(host) -i api.example.com }`,
|
||||
`http-request deny deny_status 429 if { hdr(host) -i api.example.com } { sc_http_req_rate(0) gt 500 }`,
|
||||
} {
|
||||
if !strings.Contains(out, w) {
|
||||
t.Errorf("missing %q in rate-limit output:\n%s", w, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_NoRateLimitNoStickTable(t *testing.T) {
|
||||
v := View{
|
||||
Domains: []DomainView{
|
||||
{
|
||||
Domain: models.Domain{ID: 9, Name: "a.example.com", Active: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
out := renderView(t, v)
|
||||
if strings.Contains(out, "backend rl_9") {
|
||||
t.Errorf("stick-table backend rendered for domain without rate-limit:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "track-sc0") {
|
||||
t.Errorf("track-sc0 emitted without rate-limit:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_BodySizeDeny413(t *testing.T) {
|
||||
v := View{
|
||||
Domains: []DomainView{
|
||||
{
|
||||
Domain: models.Domain{
|
||||
ID: 1, Name: "upload.example.com", Active: true,
|
||||
MaxBodyKB: 2048,
|
||||
},
|
||||
MaxBodyBytes: 2048 * 1024,
|
||||
},
|
||||
},
|
||||
}
|
||||
out := renderView(t, v)
|
||||
want := `http-request deny deny_status 413 if { hdr(host) -i upload.example.com } { req.hdr_val(content-length) -m int gt 2097152 }`
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("missing 413 body-size deny:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_CustomResponseHeaders(t *testing.T) {
|
||||
v := View{
|
||||
Domains: []DomainView{
|
||||
{
|
||||
Domain: models.Domain{ID: 1, Name: "x.example.com", Active: true},
|
||||
ResponseHeaders: []ResponseHeaderView{
|
||||
{Name: "X-Frame-Options", Value: "DENY"},
|
||||
{Name: "Content-Security-Policy", Value: "default-src 'self'"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
out := renderView(t, v)
|
||||
for _, w := range []string{
|
||||
// del + set pro Custom-Header, damit Upstream-Werte
|
||||
// garantiert überschrieben werden.
|
||||
`http-response del-header X-Frame-Options if { hdr(host) -i x.example.com }`,
|
||||
`http-response set-header X-Frame-Options "DENY" if { hdr(host) -i x.example.com }`,
|
||||
`http-response del-header Content-Security-Policy if { hdr(host) -i x.example.com }`,
|
||||
`http-response set-header Content-Security-Policy "default-src 'self'" if { hdr(host) -i x.example.com }`,
|
||||
} {
|
||||
if !strings.Contains(out, w) {
|
||||
t.Errorf("missing %q:\n%s", w, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeHeaderValue(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
`plain`: `plain`,
|
||||
`with "quotes"`: `with 'quotes'`,
|
||||
"with\nnewline": "with newline",
|
||||
"crlf\r\nattack": "crlf attack",
|
||||
`csp default-src 'self'`: `csp default-src 'self'`,
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := sanitizeHeaderValue(in); got != want {
|
||||
t.Errorf("sanitizeHeaderValue(%q) = %q want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRedirectFromHost(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
d models.Domain
|
||||
want string
|
||||
}{
|
||||
{"none", models.Domain{Name: "example.com"}, ""},
|
||||
{"to-naked", models.Domain{Name: "example.com", WWWRedirect: "to-naked"}, "www.example.com"},
|
||||
{"to-naked invalid (name already has www)", models.Domain{Name: "www.example.com", WWWRedirect: "to-naked"}, ""},
|
||||
{"to-www", models.Domain{Name: "www.example.com", WWWRedirect: "to-www"}, "example.com"},
|
||||
{"to-www invalid (name lacks www)", models.Domain{Name: "example.com", WWWRedirect: "to-www"}, ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := buildRedirectFromHost(tc.d); got != tc.want {
|
||||
t.Errorf("buildRedirectFromHost: got %q want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_DomainRoutesEmitUseBackend(t *testing.T) {
|
||||
|
||||
@@ -3,15 +3,23 @@ package models
|
||||
import "time"
|
||||
|
||||
type Domain struct {
|
||||
ID int64 `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"column:name;uniqueIndex" json:"name"`
|
||||
Active bool `gorm:"column:active" json:"active"`
|
||||
PrimaryBackendID *int64 `gorm:"column:primary_backend_id" json:"primary_backend_id,omitempty"`
|
||||
HTTPToHTTPS bool `gorm:"column:http_to_https" json:"http_to_https"`
|
||||
HSTSEnabled bool `gorm:"column:hsts_enabled" json:"hsts_enabled"`
|
||||
Notes *string `gorm:"column:notes" json:"notes,omitempty"`
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||
ID int64 `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"column:name;uniqueIndex" json:"name"`
|
||||
Active bool `gorm:"column:active" json:"active"`
|
||||
PrimaryBackendID *int64 `gorm:"column:primary_backend_id" json:"primary_backend_id,omitempty"`
|
||||
HTTPToHTTPS bool `gorm:"column:http_to_https" json:"http_to_https"`
|
||||
HSTSEnabled bool `gorm:"column:hsts_enabled" json:"hsts_enabled"`
|
||||
HSTSMaxAge int `gorm:"column:hsts_max_age" json:"hsts_max_age"`
|
||||
HSTSSubdomains bool `gorm:"column:hsts_subdomains" json:"hsts_subdomains"`
|
||||
HSTSPreload bool `gorm:"column:hsts_preload" json:"hsts_preload"`
|
||||
MaintenanceMode bool `gorm:"column:maintenance_mode" json:"maintenance_mode"`
|
||||
MaintenanceMessage *string `gorm:"column:maintenance_message" json:"maintenance_message,omitempty"`
|
||||
WWWRedirect string `gorm:"column:www_redirect" json:"www_redirect"` // ""|"to-naked"|"to-www"
|
||||
RateLimitRPS int `gorm:"column:rate_limit_rps" json:"rate_limit_rps"`
|
||||
MaxBodyKB int `gorm:"column:max_body_kb" json:"max_body_kb"`
|
||||
Notes *string `gorm:"column:notes" json:"notes,omitempty"`
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (Domain) TableName() string { return "domains" }
|
||||
|
||||
19
internal/models/domain_response_header.go
Normal file
19
internal/models/domain_response_header.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// DomainResponseHeader stellt einen einzelnen HTTP-Response-Header dar,
|
||||
// den HAProxy für alle Antworten einer Domain mit `http-response set-header`
|
||||
// setzt. Mehrere Einträge pro Domain möglich; Name ist case-insensitive
|
||||
// unique (siehe Migration 0024).
|
||||
type DomainResponseHeader struct {
|
||||
ID int64 `gorm:"primaryKey" json:"id"`
|
||||
DomainID int64 `gorm:"column:domain_id" json:"domain_id"`
|
||||
Name string `gorm:"column:name" json:"name"`
|
||||
Value string `gorm:"column:value" json:"value"`
|
||||
Position int `gorm:"column:position" json:"position"`
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (DomainResponseHeader) TableName() string { return "domain_response_headers" }
|
||||
239
internal/services/apt/apt.go
Normal file
239
internal/services/apt/apt.go
Normal file
@@ -0,0 +1,239 @@
|
||||
// Package apt kapselt die APT-Operationen die der Update-Banner braucht:
|
||||
// gedrosseltes `apt-get update`, locale-stabiler `apt-cache policy` und
|
||||
// ein Background-Refresh-Timer der die Apt-Indexe warm hält.
|
||||
//
|
||||
// Pattern 1:1 von mail-gateway/internal/services/apt/apt.go übernommen
|
||||
// (siehe dortige Doc-Kommentare für die Bug-Historie). Kurzfassung:
|
||||
//
|
||||
// - `LC_ALL=C` zwingt apt auf englischen Output — sonst gibt apt auf
|
||||
// einem deutschen Locale „Installationskandidat:" und unser
|
||||
// Prefix-Match auf „Candidate:" failt silent.
|
||||
// - Eigener Timeout-Kontext für apt-cache policy, damit ein
|
||||
// gecancelltes Request den Read nicht killt.
|
||||
// - 5-min-Throttle für `apt-get update` damit der UI-Poll-Tick (30 s)
|
||||
// nicht jedes Mal das Mirror-Update tritt. Operator kann via
|
||||
// RefreshNow den Throttle überspringen.
|
||||
// - Background-Refresh-Goroutine die alle 5 min einen apt-get update
|
||||
// fährt, damit kurz nach `make publish` der Banner aufgeht ohne
|
||||
// dass jemand erst die UI öffnen muss.
|
||||
package apt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// edgePackages: die deb-Pakete deren Versionen der UI-Banner anzeigt.
|
||||
// Reihenfolge bestimmt was der Banner als "primary" zeigt — `edgeguard`
|
||||
// ist das Meta-Paket, das wir bevorzugen.
|
||||
var edgePackages = []string{"edgeguard", "edgeguard-api", "edgeguard-ui"}
|
||||
|
||||
// PackageVersions liefert installed/available pro edgeguard-Paket im
|
||||
// flachen Key-Format das die UI bereits konsumiert.
|
||||
//
|
||||
// Wenn force=true wird der 5-min-Throttle für apt-get update übersprungen.
|
||||
func PackageVersions(ctx context.Context, force bool) map[string]string {
|
||||
if force {
|
||||
RefreshNow(ctx)
|
||||
} else {
|
||||
RefreshIfStale(ctx)
|
||||
}
|
||||
|
||||
out := map[string]string{}
|
||||
// Eigener Background-Kontext für apt-cache policy: wenn der
|
||||
// Request canceln sollte (Gateway-Timeout etc), wollen wir den
|
||||
// Cache-Read trotzdem durchziehen — Lists sind lokal, das dauert
|
||||
// Millisekunden.
|
||||
policyCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
for _, pkg := range edgePackages {
|
||||
installed, candidate := aptCachePolicy(policyCtx, pkg)
|
||||
out[pkg+"_installed"] = installed
|
||||
out[pkg+"_available"] = candidate
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ── apt-get update Throttle ────────────────────────────────────────────
|
||||
|
||||
var (
|
||||
aptUpdateMu sync.Mutex
|
||||
aptUpdateLastAt time.Time
|
||||
)
|
||||
|
||||
// aptUpdateThrottle: wie oft maximal `apt-get update` ausgeführt werden
|
||||
// darf. Der Background-Timer (StartBackgroundRefresh) tickt im selben
|
||||
// Intervall, das deckt den UI-Banner-Fall (30-s-Poll) bequem ab.
|
||||
const aptUpdateThrottle = 5 * time.Minute
|
||||
|
||||
// RefreshIfStale: führt apt-get update aus wenn der letzte Lauf älter
|
||||
// als aptUpdateThrottle ist; sonst No-Op.
|
||||
func RefreshIfStale(ctx context.Context) { refreshInternal(ctx, false) }
|
||||
|
||||
// RefreshNow: erzwingt apt-get update sofort, bypassed den Throttle.
|
||||
// Wird vom UI-„Jetzt prüfen"-Button via `?force=1` getriggert, damit
|
||||
// der Operator nach einem `make publish` nicht 5 min warten muss.
|
||||
func RefreshNow(ctx context.Context) { refreshInternal(ctx, true) }
|
||||
|
||||
func refreshInternal(ctx context.Context, force bool) {
|
||||
aptUpdateMu.Lock()
|
||||
doUpdate := force || time.Since(aptUpdateLastAt) >= aptUpdateThrottle
|
||||
if doUpdate {
|
||||
aptUpdateLastAt = time.Now()
|
||||
}
|
||||
aptUpdateMu.Unlock()
|
||||
if !doUpdate {
|
||||
return
|
||||
}
|
||||
// 25-s-Budget — kürzer als der typische 30-s-Gateway-Timeout, damit
|
||||
// ein hängendes apt-get update einen WARN log gibt statt SIGKILL.
|
||||
updCtx, cancel := context.WithTimeout(ctx, 25*time.Second)
|
||||
defer cancel()
|
||||
// edgeguard-api läuft als unprivilegierter User; /var/lib/apt/lists
|
||||
// ist root-owned. postinst pinned `sudo -n /usr/bin/apt-get update -qq`
|
||||
// in /etc/sudoers.d/edgeguard.
|
||||
cmd := exec.CommandContext(updCtx, "sudo", "-n", "/usr/bin/apt-get", "update", "-qq")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
slog.Warn("apt: update failed (best-effort, falling back to cached lists)",
|
||||
"error", err,
|
||||
"output", strings.TrimSpace(string(out)))
|
||||
}
|
||||
}
|
||||
|
||||
// StartBackgroundRefresh: Fire-and-Forget-Goroutine die einmal beim
|
||||
// Start + dann periodisch apt-get update fährt, damit der Apt-Cache
|
||||
// auch ohne UI-Traffic frisch bleibt. So zeigt der Banner kurz nach
|
||||
// `make publish` ein verfügbares Update, statt 5 min auf die nächste
|
||||
// UI-Poll-Welle zu warten.
|
||||
func StartBackgroundRefresh(ctx context.Context) {
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
slog.Error("apt background refresh panic",
|
||||
"panic", r, "stack", string(debug.Stack()))
|
||||
}
|
||||
}()
|
||||
RefreshIfStale(ctx) // Initial warm-up
|
||||
slog.Info("apt: background refresh timer started",
|
||||
"interval", aptUpdateThrottle.String())
|
||||
t := time.NewTicker(aptUpdateThrottle)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
slog.Info("apt: background refresh timer stopping")
|
||||
return
|
||||
case <-t.C:
|
||||
RefreshIfStale(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// ── apt-cache policy ──────────────────────────────────────────────────
|
||||
|
||||
// aptCachePolicy: pullt Installed:/Candidate: aus `apt-cache policy <pkg>`.
|
||||
// (none) wird zu "" gemappt.
|
||||
//
|
||||
// LC_ALL=C zwingt englischen Output — auf de_DE.UTF-8 spuckt apt sonst
|
||||
// „Installationskandidat:" und das Match unten failed silent. Bug-Befund
|
||||
// aus mail-gateway 2026-05-03, siehe internal/services/apt/apt.go dort.
|
||||
func aptCachePolicy(ctx context.Context, pkg string) (installed, candidate string) {
|
||||
cmd := exec.CommandContext(ctx, "apt-cache", "policy", pkg)
|
||||
cmd.Env = append(os.Environ(), "LC_ALL=C", "LANG=C")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
slog.Warn("apt-cache policy failed",
|
||||
"pkg", pkg, "error", err,
|
||||
"output", strings.TrimSpace(string(out)))
|
||||
return "", ""
|
||||
}
|
||||
// Diagnose: wenn weder Installed: noch Candidate: gefunden wird,
|
||||
// dump den raw output — sonst rätselt man warum die UI leer bleibt.
|
||||
defer func() {
|
||||
if installed == "" && candidate == "" {
|
||||
slog.Warn("apt-cache policy returned without Installed/Candidate match — raw output follows",
|
||||
"pkg", pkg, "raw_output", strings.TrimSpace(string(out)))
|
||||
}
|
||||
}()
|
||||
for _, raw := range strings.Split(string(out), "\n") {
|
||||
line := strings.TrimSpace(raw)
|
||||
if strings.HasPrefix(line, "Installed:") {
|
||||
v := strings.TrimSpace(strings.TrimPrefix(line, "Installed:"))
|
||||
if v != "(none)" {
|
||||
installed = v
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(line, "Candidate:") {
|
||||
v := strings.TrimSpace(strings.TrimPrefix(line, "Candidate:"))
|
||||
if v != "(none)" {
|
||||
candidate = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ── Auto-Update (unattended-upgrades-Whitelist) ───────────────────────
|
||||
|
||||
// AutoUpdateConfPath: APT-Preferences-File das die edgeguard-Pakete in
|
||||
// die unattended-upgrades-Whitelist setzt. Setup unattended-upgrades
|
||||
// + apt-listchanges sind nicht-Pflicht; ohne diese Pakete hat das File
|
||||
// keinen Effekt, bricht aber auch nichts.
|
||||
const AutoUpdateConfPath = "/etc/apt/apt.conf.d/52edgeguard-auto-updates"
|
||||
|
||||
// autoUpdateConfBody whitelisted die drei edgeguard-Paketnamen für die
|
||||
// unattended-upgrades-Periodic. Pattern 1:1 aus mail-gateway; sicheres
|
||||
// Default — nur edgeguard-* werden automatisch aktualisiert, andere
|
||||
// Pakete bleiben unter manueller Kontrolle.
|
||||
const autoUpdateConfBody = `// Generated by edgeguard-api. Toggle via Settings → "Automatische Updates".
|
||||
APT::Periodic::Update-Package-Lists "1";
|
||||
APT::Periodic::Unattended-Upgrade "1";
|
||||
|
||||
Unattended-Upgrade::Allowed-Origins {
|
||||
"origin=netcell-it.de,suite=trixie";
|
||||
};
|
||||
|
||||
Unattended-Upgrade::Package-Whitelist {
|
||||
"edgeguard";
|
||||
"edgeguard-api";
|
||||
"edgeguard-ui";
|
||||
};
|
||||
`
|
||||
|
||||
// SetAutoUpdate schreibt (enabled=true) oder entfernt (false) das
|
||||
// /etc/apt/apt.conf.d-File. /etc/apt/apt.conf.d ist root-owned und die
|
||||
// edgeguard-api läuft als non-root unter ProtectSystem=strict (siehe
|
||||
// auch [[deploy/systemd/edgeguard-api.service]]). Daher geht der Write
|
||||
// über sudo tee — der exakte Pfad ist in postinst-sudoers gepinnt.
|
||||
func SetAutoUpdate(enabled bool) error {
|
||||
if enabled {
|
||||
cmd := exec.Command("sudo", "-n", "/usr/bin/tee", AutoUpdateConfPath)
|
||||
cmd.Stdin = strings.NewReader(autoUpdateConfBody)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("sudo tee %s: %w: %s", AutoUpdateConfPath, err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
out, err := exec.Command("sudo", "-n", "/bin/rm", "-f", AutoUpdateConfPath).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("sudo rm %s: %w: %s", AutoUpdateConfPath, err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AutoUpdateEnabled liefert true wenn das Conf-File existiert.
|
||||
func AutoUpdateEnabled() bool {
|
||||
_, err := os.Stat(AutoUpdateConfPath)
|
||||
return err == nil
|
||||
}
|
||||
@@ -98,6 +98,98 @@ LIMIT $1`, limit)
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SearchFilter beschreibt einen filter-gestützten Audit-Log-Abruf.
|
||||
// Alle Felder optional — leere Werte werden vom Query ignoriert. Such-
|
||||
// Strings sind case-insensitive ILIKE-Substring-Matches. Limit wird auf
|
||||
// 500 gedeckelt (UI-Schutz vor versehentlichem Full-Scan), Offset für
|
||||
// einfaches Paging.
|
||||
type SearchFilter struct {
|
||||
Actor string
|
||||
Action string
|
||||
Subject string
|
||||
Since *time.Time
|
||||
Until *time.Time
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
// Search liefert audit_log-Einträge nach Filter, newest first. Filter-
|
||||
// Felder werden via dynamisch zusammengesetzter WHERE-Klausel angewendet
|
||||
// — Parametrisiert (kein String-Concat von User-Input).
|
||||
func (r *Repo) Search(ctx context.Context, f SearchFilter) ([]Entry, error) {
|
||||
if r == nil || r.Pool == nil {
|
||||
return []Entry{}, nil
|
||||
}
|
||||
limit := f.Limit
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
offset := f.Offset
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
args := []any{}
|
||||
where := ""
|
||||
add := func(cond string, val any) {
|
||||
args = append(args, val)
|
||||
if where == "" {
|
||||
where = " WHERE " + cond + "$" + itoa(len(args))
|
||||
} else {
|
||||
where += " AND " + cond + "$" + itoa(len(args))
|
||||
}
|
||||
}
|
||||
if f.Actor != "" {
|
||||
add("actor ILIKE ", "%"+f.Actor+"%")
|
||||
}
|
||||
if f.Action != "" {
|
||||
add("action ILIKE ", "%"+f.Action+"%")
|
||||
}
|
||||
if f.Subject != "" {
|
||||
add("subject ILIKE ", "%"+f.Subject+"%")
|
||||
}
|
||||
if f.Since != nil {
|
||||
add("created_at >= ", *f.Since)
|
||||
}
|
||||
if f.Until != nil {
|
||||
add("created_at <= ", *f.Until)
|
||||
}
|
||||
args = append(args, limit, offset)
|
||||
q := "SELECT id, actor, action, subject, detail, node_id, created_at FROM audit_log" +
|
||||
where +
|
||||
" ORDER BY created_at DESC, id DESC LIMIT $" + itoa(len(args)-1) +
|
||||
" OFFSET $" + itoa(len(args))
|
||||
rows, err := r.Pool.Query(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]Entry, 0, limit)
|
||||
for rows.Next() {
|
||||
var e Entry
|
||||
if err := rows.Scan(&e.ID, &e.Actor, &e.Action, &e.Subject, &e.Detail, &e.NodeID, &e.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
// kleiner local-Helper damit wir nicht strconv für Single-Digit-
|
||||
// Parameter-Indizes importieren müssen.
|
||||
if n < 10 {
|
||||
return string(rune('0' + n))
|
||||
}
|
||||
// >9 Parameter ist hier in der Praxis nicht möglich (Filter <= 5 +
|
||||
// LIMIT/OFFSET = 7), aber Fallback für Robustness.
|
||||
s := ""
|
||||
for n > 0 {
|
||||
s = string(rune('0'+n%10)) + s
|
||||
n /= 10
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Log writes one audit_log row. detail is JSON-encodable (typically a
|
||||
// map[string]any) — empty map means "no payload". If pool is nil
|
||||
// (e.g. dev env without DB), Log silently no-ops so handlers don't
|
||||
@@ -153,3 +245,24 @@ RETURNING id, created_at`,
|
||||
r.broadcast(e)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Cleanup löscht alle audit_log-Rows die älter als keepDays sind.
|
||||
// Schutz vor unbounded growth bei langlebigen Boxen (audit_log kann
|
||||
// sonst nach 1-2 Jahren mehrere GB Disk + entsprechende Query-Latenz
|
||||
// haben). Liefert die Anzahl gelöschter Rows.
|
||||
//
|
||||
// keepDays <= 0 → no-op (Cleanup deaktiviert, alles bleibt erhalten).
|
||||
// keepDays sollte deutlich über Audit-Anforderungen liegen — 90 Tage
|
||||
// ist ein vernünftiger Default für Self-Service-Boxen.
|
||||
func (r *Repo) Cleanup(ctx context.Context, keepDays int) (int64, error) {
|
||||
if r == nil || r.Pool == nil || keepDays <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
tag, err := r.Pool.Exec(ctx, `
|
||||
DELETE FROM audit_log
|
||||
WHERE created_at < NOW() - ($1::text || ' days')::interval`, keepDays)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
|
||||
125
internal/services/domainheaders/domainheaders.go
Normal file
125
internal/services/domainheaders/domainheaders.go
Normal file
@@ -0,0 +1,125 @@
|
||||
// Package domainheaders implements CRUD against the
|
||||
// `domain_response_headers` table. Pro-Domain Response-Header die
|
||||
// HAProxy via `http-response set-header` setzt.
|
||||
package domainheaders
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("domain response header not found")
|
||||
|
||||
type Repo struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} }
|
||||
|
||||
const baseSelect = `
|
||||
SELECT id, domain_id, name, value, position, created_at, updated_at
|
||||
FROM domain_response_headers
|
||||
`
|
||||
|
||||
// ListForDomain liefert alle Header einer Domain in stabiler Position-Sortierung.
|
||||
func (r *Repo) ListForDomain(ctx context.Context, domainID int64) ([]models.DomainResponseHeader, error) {
|
||||
rows, err := r.Pool.Query(ctx,
|
||||
baseSelect+` WHERE domain_id = $1 ORDER BY position ASC, id ASC`, domainID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]models.DomainResponseHeader, 0, 4)
|
||||
for rows.Next() {
|
||||
h, err := scan(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *h)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListAll holt alle Header (über alle Domains) — der HAProxy-Renderer
|
||||
// braucht das, um pro Domain die Einträge zu gruppieren.
|
||||
func (r *Repo) ListAll(ctx context.Context) ([]models.DomainResponseHeader, error) {
|
||||
rows, err := r.Pool.Query(ctx, baseSelect+` ORDER BY domain_id, position, id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]models.DomainResponseHeader, 0, 16)
|
||||
for rows.Next() {
|
||||
h, err := scan(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *h)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *Repo) Get(ctx context.Context, id int64) (*models.DomainResponseHeader, error) {
|
||||
row := r.Pool.QueryRow(ctx, baseSelect+` WHERE id = $1`, id)
|
||||
h, err := scan(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return h, nil
|
||||
}
|
||||
|
||||
func (r *Repo) Create(ctx context.Context, h models.DomainResponseHeader) (*models.DomainResponseHeader, error) {
|
||||
row := r.Pool.QueryRow(ctx, `
|
||||
INSERT INTO domain_response_headers (domain_id, name, value, position)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, domain_id, name, value, position, created_at, updated_at`,
|
||||
h.DomainID, h.Name, h.Value, h.Position)
|
||||
return scan(row)
|
||||
}
|
||||
|
||||
func (r *Repo) Update(ctx context.Context, id int64, h models.DomainResponseHeader) (*models.DomainResponseHeader, error) {
|
||||
row := r.Pool.QueryRow(ctx, `
|
||||
UPDATE domain_response_headers SET
|
||||
name = $1, value = $2, position = $3, updated_at = NOW()
|
||||
WHERE id = $4
|
||||
RETURNING id, domain_id, name, value, position, created_at, updated_at`,
|
||||
h.Name, h.Value, h.Position, id)
|
||||
out, err := scan(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Repo) Delete(ctx context.Context, id int64) error {
|
||||
tag, err := r.Pool.Exec(ctx, `DELETE FROM domain_response_headers WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scan(row interface{ Scan(...any) error }) (*models.DomainResponseHeader, error) {
|
||||
var h models.DomainResponseHeader
|
||||
if err := row.Scan(
|
||||
&h.ID, &h.DomainID, &h.Name, &h.Value, &h.Position,
|
||||
&h.CreatedAt, &h.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &h, nil
|
||||
}
|
||||
@@ -20,7 +20,10 @@ type Repo struct {
|
||||
func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} }
|
||||
|
||||
const baseSelect = `
|
||||
SELECT id, name, active, primary_backend_id, http_to_https, hsts_enabled,
|
||||
SELECT id, name, active, primary_backend_id, http_to_https,
|
||||
hsts_enabled, hsts_max_age, hsts_subdomains, hsts_preload,
|
||||
maintenance_mode, maintenance_message, www_redirect,
|
||||
rate_limit_rps, max_body_kb,
|
||||
notes, created_at, updated_at
|
||||
FROM domains
|
||||
`
|
||||
@@ -55,16 +58,31 @@ func (r *Repo) Get(ctx context.Context, id int64) (*models.Domain, error) {
|
||||
}
|
||||
|
||||
func (r *Repo) Create(ctx context.Context, d models.Domain) (*models.Domain, error) {
|
||||
if d.HSTSMaxAge == 0 {
|
||||
d.HSTSMaxAge = 31536000
|
||||
}
|
||||
row := r.Pool.QueryRow(ctx, `
|
||||
INSERT INTO domains (name, active, primary_backend_id, http_to_https, hsts_enabled, notes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, name, active, primary_backend_id, http_to_https, hsts_enabled,
|
||||
INSERT INTO domains (name, active, primary_backend_id, http_to_https,
|
||||
hsts_enabled, hsts_max_age, hsts_subdomains, hsts_preload,
|
||||
maintenance_mode, maintenance_message, www_redirect,
|
||||
rate_limit_rps, max_body_kb, notes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
RETURNING id, name, active, primary_backend_id, http_to_https,
|
||||
hsts_enabled, hsts_max_age, hsts_subdomains, hsts_preload,
|
||||
maintenance_mode, maintenance_message, www_redirect,
|
||||
rate_limit_rps, max_body_kb,
|
||||
notes, created_at, updated_at`,
|
||||
d.Name, d.Active, d.PrimaryBackendID, d.HTTPToHTTPS, d.HSTSEnabled, d.Notes)
|
||||
d.Name, d.Active, d.PrimaryBackendID, d.HTTPToHTTPS,
|
||||
d.HSTSEnabled, d.HSTSMaxAge, d.HSTSSubdomains, d.HSTSPreload,
|
||||
d.MaintenanceMode, d.MaintenanceMessage, d.WWWRedirect,
|
||||
d.RateLimitRPS, d.MaxBodyKB, d.Notes)
|
||||
return scanDomain(row)
|
||||
}
|
||||
|
||||
func (r *Repo) Update(ctx context.Context, id int64, d models.Domain) (*models.Domain, error) {
|
||||
if d.HSTSMaxAge == 0 {
|
||||
d.HSTSMaxAge = 31536000
|
||||
}
|
||||
row := r.Pool.QueryRow(ctx, `
|
||||
UPDATE domains SET
|
||||
name = $1,
|
||||
@@ -72,12 +90,26 @@ UPDATE domains SET
|
||||
primary_backend_id = $3,
|
||||
http_to_https = $4,
|
||||
hsts_enabled = $5,
|
||||
notes = $6,
|
||||
hsts_max_age = $6,
|
||||
hsts_subdomains = $7,
|
||||
hsts_preload = $8,
|
||||
maintenance_mode = $9,
|
||||
maintenance_message = $10,
|
||||
www_redirect = $11,
|
||||
rate_limit_rps = $12,
|
||||
max_body_kb = $13,
|
||||
notes = $14,
|
||||
updated_at = NOW()
|
||||
WHERE id = $7
|
||||
RETURNING id, name, active, primary_backend_id, http_to_https, hsts_enabled,
|
||||
WHERE id = $15
|
||||
RETURNING id, name, active, primary_backend_id, http_to_https,
|
||||
hsts_enabled, hsts_max_age, hsts_subdomains, hsts_preload,
|
||||
maintenance_mode, maintenance_message, www_redirect,
|
||||
rate_limit_rps, max_body_kb,
|
||||
notes, created_at, updated_at`,
|
||||
d.Name, d.Active, d.PrimaryBackendID, d.HTTPToHTTPS, d.HSTSEnabled, d.Notes, id)
|
||||
d.Name, d.Active, d.PrimaryBackendID, d.HTTPToHTTPS,
|
||||
d.HSTSEnabled, d.HSTSMaxAge, d.HSTSSubdomains, d.HSTSPreload,
|
||||
d.MaintenanceMode, d.MaintenanceMessage, d.WWWRedirect,
|
||||
d.RateLimitRPS, d.MaxBodyKB, d.Notes, id)
|
||||
out, err := scanDomain(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
@@ -99,15 +131,14 @@ func (r *Repo) Delete(ctx context.Context, id int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// scanDomain accepts both pgx.Row (Get/Create/Update) and pgx.Rows
|
||||
// (List, via the Scanner shape). pgx exposes both as a single
|
||||
// Scan(...any) error method.
|
||||
func scanDomain(row interface{ Scan(...any) error }) (*models.Domain, error) {
|
||||
var d models.Domain
|
||||
if err := row.Scan(
|
||||
&d.ID, &d.Name, &d.Active, &d.PrimaryBackendID,
|
||||
&d.HTTPToHTTPS, &d.HSTSEnabled, &d.Notes,
|
||||
&d.CreatedAt, &d.UpdatedAt,
|
||||
&d.ID, &d.Name, &d.Active, &d.PrimaryBackendID, &d.HTTPToHTTPS,
|
||||
&d.HSTSEnabled, &d.HSTSMaxAge, &d.HSTSSubdomains, &d.HSTSPreload,
|
||||
&d.MaintenanceMode, &d.MaintenanceMessage, &d.WWWRedirect,
|
||||
&d.RateLimitRPS, &d.MaxBodyKB,
|
||||
&d.Notes, &d.CreatedAt, &d.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -38,6 +38,25 @@ type State struct {
|
||||
LicenseKey string `json:"license_key,omitempty"`
|
||||
Completed bool `json:"completed"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
|
||||
// MaintenanceMode (whole-box): wenn true rendert HAProxy auf
|
||||
// :443 nur einen 503-Block für ALLE Customer-Domains. Mgmt-UI
|
||||
// auf :3443 bleibt unverändert. MaintenanceMessage landet im
|
||||
// Response-Body.
|
||||
MaintenanceMode bool `json:"maintenance_mode"`
|
||||
MaintenanceMessage string `json:"maintenance_message,omitempty"`
|
||||
|
||||
// BackupRetentionKeep: wie viele scheduled Backups behalten
|
||||
// werden. 0 = Default (backup.DefaultKeepN=14). Operator kann den
|
||||
// Wert in den Settings hochsetzen wenn er längere History will
|
||||
// und genug Disk hat.
|
||||
BackupRetentionKeep int `json:"backup_retention_keep,omitempty"`
|
||||
|
||||
// AuditRetentionDays: nach wie vielen Tagen audit_log-Rows
|
||||
// gelöscht werden. 0 = Default (90). Operator kann erhöhen für
|
||||
// Compliance (z. B. SOX = 7 Jahre = 2555) oder reduzieren falls
|
||||
// /var-Disk-Druck.
|
||||
AuditRetentionDays int `json:"audit_retention_days,omitempty"`
|
||||
}
|
||||
|
||||
// Request is the JSON body POST /api/v1/setup/complete accepts.
|
||||
@@ -136,6 +155,89 @@ func (st *State) VerifyAdminPassword(plaintext string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(st.AdminPasswordHash), []byte(plaintext)) == nil
|
||||
}
|
||||
|
||||
// SetBackupRetention setzt die Anzahl Backups die scheduled-Run nach
|
||||
// jedem Lauf behält. 0 = Default. Range 1-365 — alles drüber ist
|
||||
// wahrscheinlich ein Tippfehler.
|
||||
func (s *Store) SetBackupRetention(keep int) error {
|
||||
if keep < 0 || keep > 365 {
|
||||
return errors.New("backup_retention_keep must be 0 (default) or 1..365")
|
||||
}
|
||||
prev, err := s.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if prev == nil {
|
||||
return errors.New("setup not completed — cannot edit backup retention before initial setup")
|
||||
}
|
||||
prev.BackupRetentionKeep = keep
|
||||
return s.Save(prev)
|
||||
}
|
||||
|
||||
// SetAuditRetention setzt die Tage, nach denen audit_log-Rows gelöscht
|
||||
// werden. 0 = Default (90). Range 1-3650 (10 Jahre als Cap, reicht
|
||||
// für SOX 7y mit etwas Puffer). Setzt der Operator auf einen sehr
|
||||
// hohen Wert, muss er an die Disk-Auswirkung denken.
|
||||
func (s *Store) SetAuditRetention(days int) error {
|
||||
if days < 0 || days > 3650 {
|
||||
return errors.New("audit_retention_days must be 0 (default) or 1..3650")
|
||||
}
|
||||
prev, err := s.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if prev == nil {
|
||||
return errors.New("setup not completed — cannot edit audit retention before initial setup")
|
||||
}
|
||||
prev.AuditRetentionDays = days
|
||||
return s.Save(prev)
|
||||
}
|
||||
|
||||
// SetContactEmails aktualisiert AdminEmail + ACMEEmail. Beide werden
|
||||
// gegen mail.ParseAddress validiert. Lower-case + trim wie beim Setup-
|
||||
// Wizard, damit Login-Vergleich (EqualFold) konsistent bleibt.
|
||||
//
|
||||
// Wirkung:
|
||||
// * AdminEmail: nur das Login-Anzeigeformat ändert sich; Sessions
|
||||
// bleiben aktiv (Cookie referenziert den alten Actor-String — auf
|
||||
// dem nächsten Login ist der neue Wert wirksam).
|
||||
// * ACMEEmail: nächster certrenewer-Lauf nutzt die neue Email als
|
||||
// Account-Contact bei Let's Encrypt. Bestehende Certs unverändert.
|
||||
func (s *Store) SetContactEmails(adminEmail, acmeEmail string) error {
|
||||
adminEmail = strings.ToLower(strings.TrimSpace(adminEmail))
|
||||
acmeEmail = strings.ToLower(strings.TrimSpace(acmeEmail))
|
||||
if _, err := mail.ParseAddress(adminEmail); err != nil {
|
||||
return fmt.Errorf("invalid admin_email: %w", err)
|
||||
}
|
||||
if _, err := mail.ParseAddress(acmeEmail); err != nil {
|
||||
return fmt.Errorf("invalid acme_email: %w", err)
|
||||
}
|
||||
prev, err := s.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if prev == nil {
|
||||
return errors.New("setup not completed — cannot edit contact emails before initial setup")
|
||||
}
|
||||
prev.AdminEmail = adminEmail
|
||||
prev.ACMEEmail = acmeEmail
|
||||
return s.Save(prev)
|
||||
}
|
||||
|
||||
// SetMaintenanceMode persistiert MaintenanceMode + MaintenanceMessage.
|
||||
// Idempotent. Caller (Handler) triggert anschließend HAProxy-Reload.
|
||||
func (s *Store) SetMaintenanceMode(enabled bool, message string) error {
|
||||
prev, err := s.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if prev == nil {
|
||||
return errors.New("setup not completed — cannot toggle maintenance mode")
|
||||
}
|
||||
prev.MaintenanceMode = enabled
|
||||
prev.MaintenanceMessage = message
|
||||
return s.Save(prev)
|
||||
}
|
||||
|
||||
// SetAdminPassword hash't ein neues Plaintext-Passwort und persistiert
|
||||
// es. Verwendet vom Self-Service-Reset (CLI-Token-Flow).
|
||||
func (s *Store) SetAdminPassword(plaintext string) error {
|
||||
|
||||
@@ -28,6 +28,7 @@ const NTPPage = lazy(() => import('./pages/NTP'))
|
||||
const ClusterPage = lazy(() => import('./pages/Cluster'))
|
||||
const FirewallLivePage = lazy(() => import('./pages/FirewallLive'))
|
||||
const LogsPage = lazy(() => import('./pages/Logs'))
|
||||
const AuditPage = lazy(() => import('./pages/Audit'))
|
||||
const BackupsPage = lazy(() => import('./pages/Backups'))
|
||||
const DiagnosticsPage = lazy(() => import('./pages/Diagnostics'))
|
||||
const AlertsPage = lazy(() => import('./pages/Alerts'))
|
||||
@@ -118,6 +119,7 @@ export default function App() {
|
||||
<Route path="/ntp" element={<NTPPage />} />
|
||||
<Route path="/cluster" element={<ClusterPage />} />
|
||||
<Route path="/logs" element={<LogsPage />} />
|
||||
<Route path="/audit" element={<AuditPage />} />
|
||||
<Route path="/backups" element={<BackupsPage />} />
|
||||
<Route path="/diagnostics" element={<DiagnosticsPage />} />
|
||||
<Route path="/alerts" element={<AlertsPage />} />
|
||||
|
||||
@@ -35,6 +35,13 @@ interface DataTableProps<T> extends Omit<TableProps<T>, 'pagination'> {
|
||||
// ProTable mobile mode. Pass undefined to use the default Table
|
||||
// also on mobile (with horizontal scroll).
|
||||
renderMobileCard?: (record: T, index: number) => ReactNode
|
||||
// emptyContent: ReactNode das in der Table-Locale `emptyText` landet
|
||||
// wenn dataSource leer ist UND keine Suche aktiv ist. Erlaubt
|
||||
// pro-Seite einen kontext-sensitiven Empty-State (z. B. <EmptyState
|
||||
// title="Noch keine Domains" action={…} />) statt dem generischen
|
||||
// "Keine Einträge". Bei aktiver Suche zeigt die Table weiter den
|
||||
// Standard-Empty-State (Operator soll sehen dass der Filter beißt).
|
||||
emptyContent?: ReactNode
|
||||
}
|
||||
|
||||
function inferSorter<T>(dataIndex: string | string[] | undefined) {
|
||||
@@ -80,6 +87,7 @@ export default function DataTable<T extends object>(
|
||||
toolbar,
|
||||
extraActions,
|
||||
renderMobileCard,
|
||||
emptyContent,
|
||||
rowKey,
|
||||
loading,
|
||||
...rest
|
||||
@@ -165,6 +173,12 @@ export default function DataTable<T extends object>(
|
||||
{...rest}
|
||||
dataSource={filtered}
|
||||
columns={enhancedCols}
|
||||
// emptyContent zeigt sich nur wenn keine Suche aktiv ist —
|
||||
// sonst soll der Operator sehen "Filter beißt", nicht
|
||||
// "Page is empty, add first row".
|
||||
locale={emptyContent && !search
|
||||
? { emptyText: emptyContent }
|
||||
: { emptyText: t('common.noData') }}
|
||||
pagination={{
|
||||
pageSize: perPage,
|
||||
current: page,
|
||||
|
||||
45
management-ui/src/components/EmptyState.tsx
Normal file
45
management-ui/src/components/EmptyState.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Empty, Space, Typography } from 'antd'
|
||||
|
||||
// EmptyState ist die zentrale Komponente die alle Listing-Seiten als
|
||||
// `locale={{ emptyText: <EmptyState … /> }}` an AntD-Tables hängen.
|
||||
//
|
||||
// Statt nur "Keine Einträge" sehen Operator/innen warum die Tabelle leer
|
||||
// ist (frische Installation? Filter zu eng?) und welche konkrete Aktion
|
||||
// als nächstes Sinn ergibt.
|
||||
//
|
||||
// Props:
|
||||
// icon — z. B. <GlobalOutlined /> aus @ant-design/icons (optional)
|
||||
// title — der eine, klare Hauptsatz: "Noch keine Domains."
|
||||
// description — was zu tun ist: "Klick oben rechts auf …"
|
||||
// action — primary-Button-JSX (optional; manchmal reicht der
|
||||
// Hinweis dass es weiter oben einen Button gibt)
|
||||
interface EmptyStateProps {
|
||||
icon?: ReactNode
|
||||
title: string
|
||||
description?: ReactNode
|
||||
action?: ReactNode
|
||||
}
|
||||
|
||||
export default function EmptyState({ icon, title, description, action }: EmptyStateProps) {
|
||||
return (
|
||||
<Empty
|
||||
image={icon ? (
|
||||
<div style={{ fontSize: 56, color: '#94A3B8', lineHeight: 1 }}>{icon}</div>
|
||||
) : Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
imageStyle={{ height: 70, marginBottom: 12, display: 'flex', justifyContent: 'center' }}
|
||||
description={
|
||||
<Space direction="vertical" size={4} style={{ paddingTop: 4 }}>
|
||||
<Typography.Text strong>{title}</Typography.Text>
|
||||
{description && (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
|
||||
{description}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{action}
|
||||
</Empty>
|
||||
)
|
||||
}
|
||||
96
management-ui/src/components/ErrorBoundary.tsx
Normal file
96
management-ui/src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react'
|
||||
|
||||
// Top-level ErrorBoundary. Catches throws aus dem React-Tree (inkl.
|
||||
// Lazy-Chunk-Loadfehler, die auf flakigem Mobilfunk häufig sind) und
|
||||
// rendert eine sichtbare Fehlerseite statt #root leer zu lassen.
|
||||
// Ohne diese Boundary endet jeder Render-Throw als „blank page".
|
||||
//
|
||||
// Wir loggen den Fehler in die Browser-Console (für Remote-Debug via
|
||||
// Safari-Inspector/Chrome-Remote) und zeigen dem Operator die
|
||||
// Fehlermeldung wörtlich — kein Translation-Layer, weil i18n selbst
|
||||
// schon kaputt sein kann.
|
||||
|
||||
interface State { error: Error | null }
|
||||
|
||||
export default class ErrorBoundary extends Component<{ children: ReactNode }, State> {
|
||||
state: State = { error: null }
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[ErrorBoundary]', error, info.componentStack)
|
||||
}
|
||||
|
||||
reset = () => { this.setState({ error: null }) }
|
||||
|
||||
render() {
|
||||
const err = this.state.error
|
||||
if (!err) return this.props.children
|
||||
const isChunkErr = /Loading chunk|Failed to fetch dynamically imported module|Importing a module script failed/i.test(err.message)
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 24,
|
||||
background: '#F8FAFC',
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
|
||||
}}>
|
||||
<div style={{
|
||||
maxWidth: 520,
|
||||
width: '100%',
|
||||
background: '#fff',
|
||||
border: '1px solid #E2E8F0',
|
||||
borderRadius: 12,
|
||||
padding: 24,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.04)',
|
||||
}}>
|
||||
<div style={{ fontSize: 18, fontWeight: 600, color: '#0F172A', marginBottom: 6 }}>
|
||||
EdgeGuard konnte nicht laden
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: '#64748B', marginBottom: 16 }}>
|
||||
{isChunkErr
|
||||
? 'Ein Teil der App konnte nicht aus dem Netz geladen werden. Das passiert häufig bei wechselndem Mobilfunk-Empfang. Versuche es mit einem Reload.'
|
||||
: 'Beim Initialisieren der Oberfläche ist ein Fehler aufgetreten.'}
|
||||
</div>
|
||||
<pre style={{
|
||||
background: '#F1F5F9',
|
||||
border: '1px solid #E2E8F0',
|
||||
borderRadius: 6,
|
||||
padding: 10,
|
||||
fontSize: 11,
|
||||
color: '#475569',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
maxHeight: 200,
|
||||
overflow: 'auto',
|
||||
marginBottom: 16,
|
||||
}}>
|
||||
{err.name}: {err.message}
|
||||
</pre>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { this.reset(); window.location.reload() }}
|
||||
style={{
|
||||
background: '#0EA5E9',
|
||||
color: '#fff',
|
||||
border: 0,
|
||||
borderRadius: 6,
|
||||
padding: '8px 16px',
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
Neu laden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import Sidebar from './Sidebar'
|
||||
import Header from './Header'
|
||||
import UpdateBanner from '../UpdateBanner'
|
||||
import LicenseBanner from '../LicenseBanner'
|
||||
import MaintenanceBanner from '../MaintenanceBanner'
|
||||
|
||||
// PAGE_TITLES maps the pathname to an i18n nav key. Header reads
|
||||
// this to render "where you are". Empty fallback = app.title.
|
||||
@@ -20,6 +21,7 @@ const PAGE_TITLES: Record<string, string> = {
|
||||
'/firewall': 'nav.firewall',
|
||||
'/cluster': 'nav.cluster',
|
||||
'/logs': 'nav.logs',
|
||||
'/audit': 'nav.audit',
|
||||
'/backups': 'nav.backups',
|
||||
'/diagnostics': 'nav.diagnostics',
|
||||
'/alerts': 'nav.alerts',
|
||||
@@ -50,6 +52,7 @@ export default function AppLayout() {
|
||||
|
||||
<main className="main-content">
|
||||
<Header pageTitle={title} onMenuToggle={() => setSidebarOpen(true)} />
|
||||
<MaintenanceBanner />
|
||||
<LicenseBanner />
|
||||
<UpdateBanner />
|
||||
<div className="content-area">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'
|
||||
|
||||
import apiClient from '../../api/client'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import UpdateBanner from '../UpdateBanner'
|
||||
|
||||
interface HeaderProps {
|
||||
pageTitle: string
|
||||
@@ -40,6 +41,7 @@ export default function Header({ pageTitle, onMenuToggle }: HeaderProps) {
|
||||
<h1 className="header-title">{pageTitle}</h1>
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<UpdateBanner compact />
|
||||
<Select
|
||||
size="small"
|
||||
value={i18n.resolvedLanguage ?? 'de'}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import apiClient, { isEnvelope } from '../../api/client'
|
||||
import {
|
||||
ApartmentOutlined,
|
||||
BellOutlined,
|
||||
@@ -10,6 +13,7 @@ import {
|
||||
DashboardOutlined,
|
||||
EyeOutlined,
|
||||
FileSearchOutlined,
|
||||
AuditOutlined,
|
||||
DatabaseOutlined,
|
||||
FireOutlined,
|
||||
GlobalOutlined,
|
||||
@@ -76,6 +80,7 @@ const NAV: NavSection[] = [
|
||||
items: [
|
||||
{ path: '/cluster', labelKey: 'nav.cluster', icon: <ApartmentOutlined /> },
|
||||
{ path: '/logs', labelKey: 'nav.logs', icon: <FileSearchOutlined /> },
|
||||
{ path: '/audit', labelKey: 'nav.audit', icon: <AuditOutlined /> },
|
||||
{ path: '/diagnostics', labelKey: 'nav.diagnostics', icon: <ToolOutlined /> },
|
||||
{ path: '/alerts', labelKey: 'nav.alerts', icon: <BellOutlined /> },
|
||||
{ path: '/backups', labelKey: 'nav.backups', icon: <DatabaseOutlined /> },
|
||||
@@ -85,8 +90,6 @@ const NAV: NavSection[] = [
|
||||
},
|
||||
]
|
||||
|
||||
const VERSION = '1.0.78'
|
||||
|
||||
// Sidebar-Pattern 1:1 aus netcell-webpanel (enconf) übernommen:
|
||||
// - <nav> als root, dunkler Gradient + Teal/Blue-Accent
|
||||
// - Section-Label-Div NEBEN dem <ul>, nicht verschachtelt
|
||||
@@ -97,6 +100,22 @@ export default function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||
const { t } = useTranslation()
|
||||
const location = useLocation()
|
||||
|
||||
// Version aus /system/health ziehen damit nach jedem Self-Upgrade
|
||||
// automatisch die neue Versionsnummer in der Sidebar erscheint —
|
||||
// ohne Hardcode, der beim Vergessen den Eindruck erweckt das Update
|
||||
// sei nicht durchgelaufen.
|
||||
const { data: health } = useQuery({
|
||||
queryKey: ['system', 'health'],
|
||||
queryFn: async () => {
|
||||
const r = await apiClient.get('/system/health')
|
||||
return isEnvelope(r.data) ? (r.data.data as { version?: string }) : { version: '' }
|
||||
},
|
||||
refetchInterval: 60_000,
|
||||
refetchOnWindowFocus: true,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
const version = health?.version || '…'
|
||||
|
||||
return (
|
||||
<nav className={`sidebar${isOpen ? ' open' : ''}`}>
|
||||
<div className="sidebar-logo">
|
||||
@@ -139,7 +158,7 @@ export default function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="sidebar-version">v{VERSION}</div>
|
||||
<div className="sidebar-version">v{version}</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
41
management-ui/src/components/MaintenanceBanner.tsx
Normal file
41
management-ui/src/components/MaintenanceBanner.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Alert } from 'antd'
|
||||
import { ExclamationCircleOutlined } from '@ant-design/icons'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import apiClient, { isEnvelope } from '../api/client'
|
||||
|
||||
interface MaintenanceState {
|
||||
enabled: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
// MaintenanceBanner: globaler Hinweis im AppLayout dass Whole-Box-
|
||||
// Maintenance-Mode aktiv ist. Polled /system/maintenance alle 60s.
|
||||
// Pflicht-sichtbar damit der Admin nie vergisst dass er ALLE Customer-
|
||||
// Domains gerade auf 503 hat.
|
||||
export default function MaintenanceBanner() {
|
||||
const { t } = useTranslation()
|
||||
const { data } = useQuery({
|
||||
queryKey: ['system', 'maintenance'],
|
||||
queryFn: async () => {
|
||||
const r = await apiClient.get('/system/maintenance')
|
||||
return isEnvelope(r.data)
|
||||
? (r.data.data as MaintenanceState)
|
||||
: { enabled: false, message: '' }
|
||||
},
|
||||
refetchInterval: 60_000,
|
||||
refetchOnWindowFocus: true,
|
||||
})
|
||||
if (!data?.enabled) return null
|
||||
return (
|
||||
<Alert
|
||||
type="error"
|
||||
banner
|
||||
showIcon
|
||||
icon={<ExclamationCircleOutlined />}
|
||||
message={t('settings.maintenanceActiveTitle')}
|
||||
description={data.message || t('settings.maintenanceActiveDesc')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Alert, Button, Popconfirm, Space, message } from 'antd'
|
||||
import { Alert, Button, Popconfirm, Space, Tooltip, message } from 'antd'
|
||||
import { CloudDownloadOutlined, ReloadOutlined, RocketOutlined } from '@ant-design/icons'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
@@ -6,6 +6,15 @@ import { useTranslation } from 'react-i18next'
|
||||
|
||||
import apiClient, { isEnvelope } from '../api/client'
|
||||
|
||||
interface UpdateBannerProps {
|
||||
// compact: rendert nur einen kleinen „Jetzt prüfen"-Button (z. B. im
|
||||
// Header), unabhängig davon ob aktuell ein Update verfügbar ist. So
|
||||
// hat der Operator immer einen Weg den Server-seitigen Throttle zu
|
||||
// umgehen — sonst Catch-22 wenn der Banner mangels Update nicht
|
||||
// rendert. Pattern 1:1 aus mail-gateway/Dashboard/v2/UpdateBanner.
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
interface PackageVersions { [key: string]: string }
|
||||
interface SystemHealth { status: string; version: string }
|
||||
|
||||
@@ -28,7 +37,7 @@ function allUpdates(v: PackageVersions): PendingUpdate[] {
|
||||
return out
|
||||
}
|
||||
|
||||
export default function UpdateBanner() {
|
||||
export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}) {
|
||||
const { t } = useTranslation()
|
||||
const [msg, msgCtx] = message.useMessage()
|
||||
|
||||
@@ -67,8 +76,15 @@ export default function UpdateBanner() {
|
||||
const forceCheck = async () => {
|
||||
setForceChecking(true)
|
||||
try {
|
||||
await pkgVersions.refetch()
|
||||
const fresh = pkgVersions.data ?? {}
|
||||
// ?force=1: bypassed den Server-seitigen 5-min-Throttle für
|
||||
// apt-get update. Ohne den Force-Hint würde der Endpoint
|
||||
// einfach den letzten Cache zurückliefern (max. 5 min alt) und
|
||||
// der Button fühlt sich kaputt an. Pattern aus mail-gateway.
|
||||
const r = await apiClient.get('/system/package-versions?force=1')
|
||||
const fresh = (isEnvelope(r.data) ? (r.data.data as PackageVersions) : {})
|
||||
// useQuery-Cache mit dem frischen Wert füttern damit der Banner
|
||||
// sofort umschaltet, ohne auf die nächste 30s-Welle zu warten.
|
||||
void pkgVersions.refetch()
|
||||
const found = allUpdates(fresh).length > 0
|
||||
msg[found ? 'success' : 'info'](
|
||||
found ? t('update.checkDone') : t('update.noUpdate'),
|
||||
@@ -128,6 +144,33 @@ export default function UpdateBanner() {
|
||||
})
|
||||
}
|
||||
|
||||
if (compact) {
|
||||
// Compact-Variante: Force-Check-Button für "ich will jetzt prüfen",
|
||||
// wenn aktuell NICHTS ausstehendes da ist. Sobald ein Update
|
||||
// verfügbar ist, übernimmt der gelbe Full-Mode-Banner (in
|
||||
// AppLayout) die Sichtbarkeit — wir blenden den Compact-Button
|
||||
// dann komplett aus, sonst doppelt-doppelt Info (Befund 2026-05-15:
|
||||
// "die roten Banner können weg, der gelbe Banner reicht").
|
||||
if (updateAvailable) {
|
||||
return <>{msgCtx}</>
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{msgCtx}
|
||||
<Tooltip title={t('update.checkNowHint')}>
|
||||
<Button
|
||||
type="default"
|
||||
icon={<ReloadOutlined />}
|
||||
loading={forceChecking}
|
||||
onClick={forceCheck}
|
||||
>
|
||||
{t('update.checkNow')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (!updateAvailable && !upgrading) {
|
||||
return <>{msgCtx}</>
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"firewallLive": "Firewall-Log",
|
||||
"cluster": "Cluster",
|
||||
"logs": "Logs",
|
||||
"audit": "Audit-Log",
|
||||
"backups": "Backups",
|
||||
"diagnostics": "Diagnose",
|
||||
"alerts": "Alarme",
|
||||
@@ -44,7 +45,8 @@
|
||||
"addrObj": "Adress-Objekte",
|
||||
"addrGrp": "Adress-Gruppen",
|
||||
"services": "Services",
|
||||
"svcGrp": "Service-Gruppen"
|
||||
"svcGrp": "Service-Gruppen",
|
||||
"system": "System-Regeln"
|
||||
},
|
||||
"zone": {
|
||||
"name": "Name",
|
||||
@@ -55,31 +57,41 @@
|
||||
"namePattern": "Nur Kleinbuchstaben, Ziffern, _ und -; muss mit Buchstaben beginnen, max. 32 Zeichen.",
|
||||
"add": "Zone hinzufügen",
|
||||
"edit": "Zone bearbeiten",
|
||||
"deleteConfirm": "Zone {{name}} wirklich löschen?"
|
||||
"deleteConfirm": "Zone {{name}} wirklich löschen?",
|
||||
"emptyTitle": "Noch keine eigenen Firewall-Zonen.",
|
||||
"emptyDesc": "Zonen gruppieren Interfaces (lan, wan, dmz). Vordefinierte Zonen sind bereits da; eigene anlegen wenn du z. B. eine separate dmz-Zone oder eine wg-Zone fürs VPN brauchst."
|
||||
},
|
||||
"ao": {
|
||||
"name": "Name", "kind": "Typ", "value": "Wert", "description": "Beschreibung",
|
||||
"add": "Adress-Objekt hinzufügen", "edit": "Adress-Objekt bearbeiten",
|
||||
"deleteConfirm": "Adress-Objekt {{name}} wirklich löschen?"
|
||||
"deleteConfirm": "Adress-Objekt {{name}} wirklich löschen?",
|
||||
"emptyTitle": "Noch keine Adress-Objekte.",
|
||||
"emptyDesc": "Wiederverwendbare benannte IPs/Netze/Ranges für Firewall- und NAT-Regeln (z. B. office-net = 10.0.0.0/24, mailbox-1 = 10.0.1.42)."
|
||||
},
|
||||
"ag": {
|
||||
"name": "Name", "members": "Mitglieder", "description": "Beschreibung",
|
||||
"add": "Adress-Gruppe hinzufügen", "edit": "Adress-Gruppe bearbeiten",
|
||||
"selectMembers": "Adress-Objekte wählen",
|
||||
"deleteConfirm": "Adress-Gruppe {{name}} wirklich löschen?"
|
||||
"deleteConfirm": "Adress-Gruppe {{name}} wirklich löschen?",
|
||||
"emptyTitle": "Noch keine Adress-Gruppen.",
|
||||
"emptyDesc": "Bündele mehrere Adress-Objekte zu einer Gruppe (z. B. office-locations = [hq-net, branch-1-net, branch-2-net]) — Regeln müssen dann nur eine Gruppe referenzieren."
|
||||
},
|
||||
"svc": {
|
||||
"name": "Name", "proto": "Protokoll", "ports": "Ports",
|
||||
"portStart": "Port (Start)", "portEnd": "Port (Ende)",
|
||||
"description": "Beschreibung", "builtinHint": "Vordefiniert — nicht editierbar",
|
||||
"add": "Service hinzufügen", "edit": "Service bearbeiten",
|
||||
"deleteConfirm": "Service {{name}} wirklich löschen?"
|
||||
"deleteConfirm": "Service {{name}} wirklich löschen?",
|
||||
"emptyTitle": "Noch keine eigenen Services.",
|
||||
"emptyDesc": "Vordefinierte (HTTP, HTTPS, SSH, …) sind bereits da. Eigene Services für app-spezifische Ports (z. B. mailcow-imaps tcp/993) erleichtern Regeln deutlich."
|
||||
},
|
||||
"sg": {
|
||||
"name": "Name", "members": "Mitglieder", "description": "Beschreibung",
|
||||
"add": "Service-Gruppe hinzufügen", "edit": "Service-Gruppe bearbeiten",
|
||||
"selectMembers": "Services wählen",
|
||||
"deleteConfirm": "Service-Gruppe {{name}} wirklich löschen?"
|
||||
"deleteConfirm": "Service-Gruppe {{name}} wirklich löschen?",
|
||||
"emptyTitle": "Noch keine Service-Gruppen.",
|
||||
"emptyDesc": "Bündele mehrere Services zu einer Gruppe (z. B. web-stack = [HTTP, HTTPS, HTTP/3]) — eine Regel mit der Gruppe ersetzt drei Regeln pro Service."
|
||||
},
|
||||
"rule": {
|
||||
"name": "Name", "priority": "Priority", "enabled": "Aktiv", "log": "Logging",
|
||||
@@ -90,7 +102,9 @@
|
||||
"serviceKind": "Service-Typ", "serviceGroup": "Service-Gruppe",
|
||||
"comment": "Kommentar",
|
||||
"add": "Regel hinzufügen", "edit": "Regel bearbeiten",
|
||||
"deleteConfirm": "Diese Regel wirklich löschen?"
|
||||
"deleteConfirm": "Diese Regel wirklich löschen?",
|
||||
"emptyTitle": "Noch keine eigenen Firewall-Regeln.",
|
||||
"emptyDesc": "Die System-Regeln oben halten SSH (rate-limited), HTTPS :443 und Mgmt-UI :3443 immer offen (Anti-Lockout). Eigene Regeln für app-spezifische Inbound-Ports oder zonenübergreifende Forwards anlegen."
|
||||
},
|
||||
"nat": {
|
||||
"name": "Name", "priority": "Priority", "kind": "Typ", "enabled": "Aktiv",
|
||||
@@ -102,7 +116,9 @@
|
||||
"targetAddr": "Ziel-Adresse", "targetPortStart": "Ziel-Port (Start)", "targetPortEnd": "Ziel-Port (Ende)",
|
||||
"comment": "Kommentar",
|
||||
"add": "NAT-Regel hinzufügen", "edit": "NAT-Regel bearbeiten",
|
||||
"deleteConfirm": "Diese NAT-Regel wirklich löschen?"
|
||||
"deleteConfirm": "Diese NAT-Regel wirklich löschen?",
|
||||
"emptyTitle": "Noch keine NAT-Regeln.",
|
||||
"emptyDesc": "DNAT (z. B. extern :2030 → intern 10.10.20.12:22 für SSH zu einem internen Host) oder SNAT/MASQUERADE (Internet-Zugang für ein internes Subnetz über die Box-IP)."
|
||||
},
|
||||
"sys": {
|
||||
"title": "System-Regeln (immer aktiv)",
|
||||
@@ -125,6 +141,8 @@
|
||||
"systemDiscovered": "System-Interfaces (read-only)",
|
||||
"addInterface": "Interface hinzufügen",
|
||||
"editInterface": "Interface bearbeiten",
|
||||
"emptyTitle": "Noch keine verwalteten Interfaces.",
|
||||
"emptyDesc": "System-Interfaces (siehe oben) werden read-only erkannt. Verwaltete Interfaces sind das was EdgeGuard selbst anlegt — VLANs, Bridges, Bond, GRE-Tunnels — und in /etc/network/interfaces.d rendert.",
|
||||
"name": "Name",
|
||||
"type": "Typ",
|
||||
"parent": "Parent-Interface",
|
||||
@@ -152,6 +170,8 @@
|
||||
"managedTitle": "Verwaltete Adressen",
|
||||
"family": "Familie",
|
||||
"addAddress": "Adresse hinzufügen",
|
||||
"emptyTitle": "Noch keine eigenen IP-Adressen.",
|
||||
"emptyDesc": "Lege deine erste IP an — Floating-VIPs für HA-Failover oder zusätzliche Listen-Adressen für HAProxy/Squid/Unbound. Box-eigene Distro-IPs werden oben unter \"Erkannte IPs\" gelesen.",
|
||||
"editAddress": "Adresse bearbeiten",
|
||||
"interface": "Interface",
|
||||
"selectInterface": "Interface wählen",
|
||||
@@ -192,12 +212,19 @@
|
||||
"setup": {
|
||||
"title": "Erst-Einrichtung",
|
||||
"intro": "Lege den Admin-Account an, gib die öffentliche FQDN an und – optional – einen Lizenzschlüssel. Ohne Lizenz startet eine 30-Tage-Trial.",
|
||||
"preflightTitle": "Vor dem Klick auf \"Setup abschließen\"",
|
||||
"preflightDesc": "Die FQDN muss bereits per DNS auf diese Box zeigen (A/AAAA-Record). Ohne DNS-Auflösung scheitert die spätere ACME-HTTP-01-Challenge — dein Browser-URL muss schon jetzt diese FQDN sein, sonst klemmt's spätestens beim ersten Let's-Encrypt-Issue.",
|
||||
"adminEmail": "Admin-E-Mail",
|
||||
"adminEmailHint": "Login-Identifier für die Management-UI. Wird NICHT für ausgehende Mails benutzt (das ist die ACME-E-Mail unten bzw. die SMTP-Settings im Alerts-Channel).",
|
||||
"adminPassword": "Admin-Passwort",
|
||||
"passwordRule": "Mindestens 12 Zeichen.",
|
||||
"fqdn": "Öffentliche FQDN",
|
||||
"fqdnHint": "Vollqualifizierter Hostname dieser Box (z. B. eg.example.com). Wird Subject-CN des Self-Signed-Bootstrap-Cert UND wandert in das ACME-Cert sobald Let's Encrypt ausgestellt hat. Muss per DNS auf die Public-IP zeigen.",
|
||||
"fqdnInvalid": "Sieht nicht wie eine gültige FQDN aus (mindestens label.tld).",
|
||||
"acmeEmail": "ACME-/Let's-Encrypt-E-Mail",
|
||||
"acmeEmailHint": "Wird Let's Encrypt als Account-Contact mitgegeben (Expiry-Warnungen + Compliance-Mails von LE). Kann gleich der Admin-E-Mail sein, muss aber nicht.",
|
||||
"licenseKey": "Lizenzschlüssel (optional)",
|
||||
"licenseKeyHint": "Wenn vorhanden: 30-Tage-Trial wird übersprungen, Features unmittelbar freigeschaltet. Kann auch später unter License nachgereicht werden.",
|
||||
"submit": "Setup abschließen",
|
||||
"successTitle": "Setup abgeschlossen",
|
||||
"successHint": "Du wirst zur Anmeldung weitergeleitet."
|
||||
@@ -211,14 +238,64 @@
|
||||
"intro": "Verwalte FQDNs, die HAProxy terminiert. Optionales Primary-Backend als Catch-all; Pfad-Routing via Routing-Regeln.",
|
||||
"addDomain": "Domain hinzufügen",
|
||||
"editDomain": "Domain bearbeiten",
|
||||
"emptyTitle": "Noch keine Domains.",
|
||||
"emptyDesc": "Lege deine erste Domain an — HAProxy terminiert dann TLS für diesen Hostnamen und routet an das gewählte Backend.",
|
||||
"name": "Name",
|
||||
"active": "Aktiv",
|
||||
"primaryBackend": "Primary-Backend",
|
||||
"primaryBackendHint": "Catch-all-Backend für Requests, die kein Routing-Regel-Match haben. Optional — leer lassen, wenn alles über Routing-Regeln läuft.",
|
||||
"selectBackend": "Backend wählen",
|
||||
"noBackend": "kein Backend",
|
||||
"quickBackendBtn": "+ neu",
|
||||
"quickBackendBtnHint": "Backend + ersten Upstream-Server in einem Klick anlegen — spart den Umweg über die Backends-Seite.",
|
||||
"quickBackendTitle": "Backend schnell anlegen",
|
||||
"quickBackendName": "Backend-Name",
|
||||
"quickBackendScheme": "Schema",
|
||||
"quickBackendAddress": "Server-Adresse (Erster Upstream)",
|
||||
"quickBackendPort": "Port",
|
||||
"quickBackendCreated": "Backend + Server angelegt und ausgewählt.",
|
||||
"quickBackendFailed": "Quick-Backend-Anlage fehlgeschlagen",
|
||||
"httpToHttps": "HTTP→HTTPS",
|
||||
"hsts": "HSTS",
|
||||
"tlsCert": "TLS-Cert",
|
||||
"tlsCertValid": "gültig",
|
||||
"tlsCertExpiring": "noch {{days}}d",
|
||||
"tlsCertExpired": "abgelaufen",
|
||||
"tlsCertError": "Fehler",
|
||||
"tlsCertNone": "fehlt",
|
||||
"tlsCertNoneHint": "Kein eigener TLS-Cert für diese Domain — HAProxy serviert das Bootstrap-Self-Signed-Cert. Im SSL-Tab issuen.",
|
||||
"hstsMaxAge": "HSTS max-age (Sek.)",
|
||||
"hstsMaxAgeHint": "Wie lange Browser den HTTPS-Zwang cachen. Empfehlung 31536000 (1 Jahr).",
|
||||
"hstsSubdomains": "includeSubDomains",
|
||||
"hstsSubdomainsHint": "Erstreckt HSTS auf alle Sub-Domains. Nur aktivieren wenn alle Subs ausschließlich HTTPS sprechen.",
|
||||
"hstsPreload": "preload",
|
||||
"hstsPreloadHint": "Setzt den preload-Flag damit die Domain in die hstspreload.org-Liste aufgenommen werden kann. Voraussetzung: max-age ≥ 31536000 + includeSubDomains an.",
|
||||
"maintenance": "Wartungs-Modus",
|
||||
"maintenanceHint": "An: alle Requests auf diese Domain bekommen direkt 503 von HAProxy. Backends werden nicht kontaktiert.",
|
||||
"maintenanceMessage": "Wartungs-Meldung",
|
||||
"maintenanceMessagePlaceholder": "Service vorübergehend nicht verfügbar.",
|
||||
"wwwRedirect": "www-Redirect",
|
||||
"wwwRedirectHint": "Kanonisiert die Domain. „nach naked\": Name=example.com → www.example.com leitet auf example.com. „nach www\": Name=www.example.com → example.com leitet auf www.example.com.",
|
||||
"wwwRedirectNone": "Kein Redirect",
|
||||
"wwwRedirectToNaked": "→ naked (ohne www)",
|
||||
"wwwRedirectToWWW": "→ www",
|
||||
"rateLimit": "Rate-Limit (pro Client-IP)",
|
||||
"rateLimitHint": "Max. Requests pro Sekunde je Client-IP. HAProxy zählt über ein 10-Sekunden-Fenster pro Stick-Table (max. 100k IPs). 0 = aus.",
|
||||
"maxBody": "Max. Request-Body",
|
||||
"maxBodyHint": "Cap auf Content-Length-Header. Größere Requests bekommen 413. Nicht erkannt: Chunked-Bodies (HAProxy bufferd nicht standardmäßig). 0 = aus.",
|
||||
"headersBtn": "Headers",
|
||||
"headersTitle": "Response-Headers — {{name}}",
|
||||
"headersHint": "Diese Header werden von HAProxy auf jede Response für diese Domain gesetzt (http-response set-header). Reihenfolge via Position.",
|
||||
"headersEmpty": "Noch keine Custom-Headers konfiguriert.",
|
||||
"addHeader": "Header hinzufügen",
|
||||
"editHeader": "Header bearbeiten",
|
||||
"headerName": "Name",
|
||||
"headerNameHint": "Nur Buchstaben, Zahlen, Bindestriche. Pro Domain case-insensitive eindeutig.",
|
||||
"headerNamePattern": "Nur a-z, A-Z, 0-9, '-'",
|
||||
"headerValue": "Wert",
|
||||
"headerPosition": "Position",
|
||||
"headerDeleteConfirm": "Header „{{name}}\" wirklich löschen?",
|
||||
"settingsSection": "HAProxy-Einstellungen",
|
||||
"notes": "Notizen",
|
||||
"actions": "Aktionen",
|
||||
"edit": "Bearbeiten",
|
||||
@@ -229,11 +306,14 @@
|
||||
"title": "Backends",
|
||||
"intro": "Upstream-Pools (Backend = N Server). HAProxy verteilt laut LB-Algorithmus; Health-Check-Pfad aktiviert HTTP-Probes alle 5s pro Server.",
|
||||
"addBackend": "Backend-Pool hinzufügen",
|
||||
"emptyTitle": "Noch keine Backend-Pools.",
|
||||
"emptyDesc": "Lege deinen ersten Pool an — definiere Upstream-Server (z. B. App-Server / Mailbox-Knoten) damit Domains darauf routen können.",
|
||||
"editBackend": "Backend-Pool bearbeiten",
|
||||
"name": "Name",
|
||||
"scheme": "Schema",
|
||||
"target": "Ziel",
|
||||
"healthCheck": "Health-Check-Pfad",
|
||||
"liveStatus": "HAProxy-Status",
|
||||
"active": "Aktiv",
|
||||
"usedBy": "Genutzt von",
|
||||
"noDomain": "keine Domain",
|
||||
@@ -271,6 +351,8 @@
|
||||
"title": "Routing-Regeln",
|
||||
"intro": "Pfad-Präfix → Backend-Mapping pro Domain. Niedrige Priority gewinnt; Catch-all per Domain.primary_backend.",
|
||||
"addRule": "Regel hinzufügen",
|
||||
"emptyTitle": "Noch keine Routing-Regeln.",
|
||||
"emptyDesc": "Pfad-spezifisches Routing — z. B. domain.tld/api → Backend A, domain.tld/* → Backend B. Optional. Ohne Regeln nutzt HAProxy die Primary-Backend-Auswahl der Domain.",
|
||||
"editRule": "Regel bearbeiten",
|
||||
"domain": "Domain",
|
||||
"pathPrefix": "Pfad-Präfix",
|
||||
@@ -313,8 +395,35 @@
|
||||
"configHash": "Config-Hash",
|
||||
"version": "Version",
|
||||
"lastSeen": "Last seen",
|
||||
"mgmtIp": "MGMT-IP"
|
||||
}
|
||||
"mgmtIp": "MGMT-IP",
|
||||
"load": "Load 1/5/15",
|
||||
"mem": "Memory",
|
||||
"disk": "Disk",
|
||||
"conntrack": "Conntrack",
|
||||
"uptime": "Uptime",
|
||||
"fetchMs": "Fetch"
|
||||
},
|
||||
"loadTitle": "Per-Node Resources (mTLS-Aggregator)",
|
||||
"loadEmpty": "Keine Node-Resources verfügbar — Agent-Listener nicht erreichbar?",
|
||||
"certCardTitle": "Cluster-TLS-Zertifikate",
|
||||
"certCALabel": "Cluster-CA",
|
||||
"certPeerLabel": "Peer-Cert (diese Node)",
|
||||
"certExpiry": "Läuft ab in",
|
||||
"renewSelfBtn": "Peer-Cert erneuern",
|
||||
"renewSelfConfirm": "Peer-Cert mit lokaler CA neu signieren (1 Jahr)? edgeguard-api Restart danach erforderlich.",
|
||||
"certRenewedRestartHint": "Cert erneuert — bitte sudo systemctl restart edgeguard-api ausführen.",
|
||||
"certRenewFailed": "Cert-Erneuerung fehlgeschlagen",
|
||||
"removePeerBtn": "Entfernen",
|
||||
"removePeerConfirmTitle": "Peer wirklich aus dem Cluster entfernen?",
|
||||
"removePeerConfirmDesc": "{{fqdn}} wird aus ha_nodes gelöscht. Firewall-Renderer entfernt seine IP aus dem peer_ipv4-Set. Auf der Peer-Seite läuft edgeguard-api weiter; manuell stoppen + cluster-tls löschen für vollständigen Decommission.",
|
||||
"removePeerOk": "Peer entfernt.",
|
||||
"removePeerFailed": "Peer-Entfernung fehlgeschlagen",
|
||||
"generateJoinToken": "Join-Token erzeugen",
|
||||
"joinTokenTitle": "Cluster-Join-Token",
|
||||
"joinTokenOneShot": "Einmalig anzeigen, einmalig nutzbar",
|
||||
"joinTokenOneShotDesc": "Token jetzt sicher übertragen — er erscheint nie wieder. Gültig bis {{expires}}. Beim Einlösen via cluster-join wird er server-seitig als verbraucht markiert.",
|
||||
"joinTokenFailed": "Token-Generierung fehlgeschlagen",
|
||||
"joinCmdLabel": "Auf dem neuen Node ausführen:"
|
||||
},
|
||||
"ssl": {
|
||||
"title": "SSL-Zertifikate",
|
||||
@@ -338,22 +447,112 @@
|
||||
"uploadButton": "Hochladen",
|
||||
"issueSuccess": "Zertifikat ausgestellt + installiert.",
|
||||
"uploadSuccess": "Zertifikat hochgeladen + installiert.",
|
||||
"renewBtn": "Renew",
|
||||
"renewConfirmTitle": "Zertifikat jetzt erneuern?",
|
||||
"renewConfirmDesc": "Triggert eine ACME-HTTP-01-Challenge für {{domain}}. Let's Encrypt hat Rate-Limits (50 Issues/Domain/Woche) — nicht öfter als nötig nutzen.",
|
||||
"renewSuccess": "Zertifikat erneuert + installiert.",
|
||||
"renewFailed": "Renewal fehlgeschlagen",
|
||||
"deleteConfirm": "Zertifikat für {{domain}} löschen? HAProxy fällt für diese Domain auf das Default-Cert zurück.",
|
||||
"installedTitle": "Installierte Zertifikate",
|
||||
"lastRenewed": "Zuletzt erneuert",
|
||||
"statTotal": "Zertifikate gesamt",
|
||||
"statExpiring": "< 30 Tage gültig",
|
||||
"statExpired": "Abgelaufen",
|
||||
"statErrors": "Mit Fehler",
|
||||
"relAgo": {
|
||||
"justNow": "gerade eben",
|
||||
"minutes": "vor {{n}} min",
|
||||
"hours": "vor {{n}} h",
|
||||
"days": "vor {{n}} Tagen"
|
||||
},
|
||||
"emptyTitle": "Noch keine Zertifikate installiert.",
|
||||
"emptyDesc": "Nutze die Tabs oben — Let's Encrypt issued vollautomatisch per HTTP-01-Challenge, oder lade ein eigenes PEM hoch. Bis dahin liefert HAProxy das Bootstrap-Self-Signed-Cert für alle Domains.",
|
||||
"certPem": "Zertifikat (PEM)",
|
||||
"chainPem": "Chain (PEM, optional)",
|
||||
"keyPem": "Private Key (PEM)"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
"intro": "System-Information und Setup-Status. Bearbeitbare Werte folgen in einem späteren Release.",
|
||||
"intro": "System-Information, Setup-Status und Admin-Account.",
|
||||
"systemInfo": "System",
|
||||
"version": "Version",
|
||||
"status": "Status",
|
||||
"dbSize": "PostgreSQL-DB-Größe",
|
||||
"dbSizeTop": "Top-Tabellen",
|
||||
"upgradeStatusCardTitle": "Letzter Update-Versuch",
|
||||
"upgradeStatusStarted": "Gestartet",
|
||||
"upgradeStatusFinished": "Beendet",
|
||||
"upgradeStatusResult": "Ergebnis",
|
||||
"upgradeStatusState": "Status",
|
||||
"upgradeStatusOk": "Erfolgreich",
|
||||
"upgradeStatusShowLog": "Vollständiges Log anzeigen ({{n}} Zeilen)",
|
||||
"actionsCardTitle": "System-Aktionen",
|
||||
"actionsHint": "Manuelle Trigger für Operator-Tasks die sonst nur automatisch beim Speichern in den jeweiligen Seiten passieren. Sinnvoll nach SSH-Eingriffen (z. B. /etc/edgeguard/tls/ manuell befüllt).",
|
||||
"haproxyReloadBtn": "HAProxy reload",
|
||||
"haproxyReloadOk": "HAProxy neu geladen.",
|
||||
"haproxyReloadFailed": "HAProxy-Reload fehlgeschlagen",
|
||||
"renderConfigsBtn": "Configs neu rendern (HAProxy)",
|
||||
"renderConfigsOk": "Configs neu gerendert + reloaded.",
|
||||
"renderConfigsFailed": "Config-Render fehlgeschlagen",
|
||||
"backupNowBtn": "Backup jetzt erstellen",
|
||||
"backupNowOk": "Backup ausgelöst — Status in der Backups-Seite verfolgen.",
|
||||
"backupNowFailed": "Backup-Trigger fehlgeschlagen",
|
||||
"serviceRestartCardTitle": "Dienste neu starten",
|
||||
"serviceRestartBtn": "Restart",
|
||||
"serviceRestartOk": "{{service}} wurde neu gestartet.",
|
||||
"serviceRestartFailed": "Neustart von {{service}} fehlgeschlagen",
|
||||
"serviceRestartHint": "Startet den Dienst via systemctl restart. edgeguard-api und PostgreSQL sind bewusst ausgeschlossen.",
|
||||
"setupInfo": "Setup",
|
||||
"adminEmail": "Admin-E-Mail",
|
||||
"fqdn": "FQDN",
|
||||
"setupCompleted": "Setup abgeschlossen"
|
||||
"setupCompleted": "Setup abgeschlossen",
|
||||
"emailsCardTitle": "Kontakt-E-Mails",
|
||||
"adminEmailHint": "Wird beim Login als Admin-Identifier genutzt. Änderung erfordert Re-Login mit der neuen Adresse.",
|
||||
"acmeEmail": "ACME-E-Mail",
|
||||
"acmeEmailHint": "Wird Let's Encrypt als Account-Contact mitgegeben. Bestehende Zertifikate sind nicht betroffen — die nächste Renew-Operation registriert die neue Adresse.",
|
||||
"emailsSaved": "E-Mails aktualisiert.",
|
||||
"emailsFailed": "E-Mail-Update fehlgeschlagen",
|
||||
"maintenanceCardTitle": "Wartungs-Modus (gesamte Box)",
|
||||
"maintenanceOn": "Aktiv — alle Customer-Domains liefern 503. Mgmt-UI bleibt erreichbar.",
|
||||
"maintenanceOff": "Inaktiv — Customer-Traffic wird normal an die Backends geroutet.",
|
||||
"maintenanceMessage": "Wartungs-Meldung",
|
||||
"maintenanceMessagePlaceholder": "Service vorübergehend nicht verfügbar — wir sind in Kürze zurück.",
|
||||
"maintenanceMessageHint": "Wird im Response-Body der 503-Antworten an Endkunden mitgeschickt. Plain text, max. 500 Zeichen.",
|
||||
"maintenanceHint": "Schaltet HAProxy auf :443 in einen Default-503-Modus. Pro-Domain-Maintenance (Domains-Seite) wird hiervon übersteuert. Mgmt-UI auf :3443 ist NICHT betroffen.",
|
||||
"maintenanceSaved": "Wartungs-Modus aktualisiert.",
|
||||
"maintenanceFailed": "Wartungs-Modus-Toggle fehlgeschlagen",
|
||||
"maintenanceActiveTitle": "Wartungs-Modus aktiv",
|
||||
"maintenanceActiveDesc": "Alle Customer-Domains liefern aktuell 503. Mgmt-UI ist erreichbar — Customer-Traffic NICHT. Settings → Wartungs-Modus zum Deaktivieren.",
|
||||
"backupRetentionCardTitle": "Backup-Aufbewahrung",
|
||||
"backupRetentionUnit": "Backups",
|
||||
"backupRetentionDefault": "Default ({{n}} Backups) — pro Tag bei täglichem Schedule = {{n}} Tage History.",
|
||||
"backupRetentionCustom": "Custom — die letzten {{n}} erfolgreichen Backups werden behalten, ältere werden nach jedem Backup-Lauf gelöscht.",
|
||||
"backupRetentionHint": "0 = Default (14). 1-365 = Custom-Limit. Bedenke: jedes Backup ist ein voller pg_dump + Files-tar (typisch 50-500 MB). Bei /var-Disk-Druck eher reduzieren.",
|
||||
"backupRetentionSaved": "Backup-Retention aktualisiert.",
|
||||
"backupRetentionFailed": "Backup-Retention-Update fehlgeschlagen",
|
||||
"auditRetentionCardTitle": "Audit-Log-Aufbewahrung",
|
||||
"auditRetentionUnit": "Tage",
|
||||
"auditRetentionDefault": "Default ({{n}} Tage) — Audit-Einträge älter als {{n}} Tage werden täglich gepruned.",
|
||||
"auditRetentionCustom": "Custom — Audit-Einträge werden für {{n}} Tage behalten.",
|
||||
"auditRetentionHint": "0 = Default (90). 1-3650 (= 10 Jahre) für Compliance (SOX 7y = 2555, DSGVO meist <= 365). Cleanup läuft im Scheduler täglich.",
|
||||
"auditRetentionSaved": "Audit-Retention aktualisiert.",
|
||||
"auditRetentionFailed": "Audit-Retention-Update fehlgeschlagen",
|
||||
"autoUpdateCardTitle": "Automatische Updates",
|
||||
"autoUpdateOn": "Aktiviert — edgeguard-Pakete werden täglich automatisch installiert.",
|
||||
"autoUpdateOff": "Deaktiviert — Updates müssen manuell über den Banner installiert werden.",
|
||||
"autoUpdateHint": "Whitelist umfasst nur edgeguard, edgeguard-api, edgeguard-ui. Andere Pakete bleiben unter manueller Kontrolle. Verlangt unattended-upgrades (Distro-Standard auf Trixie). Conf-File: /etc/apt/apt.conf.d/52edgeguard-auto-updates.",
|
||||
"autoUpdateToggled": "Auto-Update-Einstellung gespeichert.",
|
||||
"autoUpdateFailed": "Auto-Update-Toggle fehlgeschlagen",
|
||||
"passwordCardTitle": "Admin-Passwort ändern",
|
||||
"currentPassword": "Aktuelles Passwort",
|
||||
"newPassword": "Neues Passwort",
|
||||
"newPasswordHint": "Mindestens 12 Zeichen. Wird mit bcrypt gehasht.",
|
||||
"confirmPassword": "Neues Passwort bestätigen",
|
||||
"changePasswordBtn": "Passwort ändern",
|
||||
"passwordChanged": "Passwort geändert.",
|
||||
"passwordChangeFailed": "Passwort-Änderung fehlgeschlagen",
|
||||
"passwordMismatch": "Die Passwörter stimmen nicht überein.",
|
||||
"passwordMinLen": "Mindestens 12 Zeichen erforderlich."
|
||||
},
|
||||
"update": {
|
||||
"available": "Update verfügbar: Version {{version}}",
|
||||
@@ -362,6 +561,8 @@
|
||||
"confirmTitle": "Update jetzt installieren?",
|
||||
"confirmDesc": "Pakete werden auf Version {{version}} aktualisiert. edgeguard-api + scheduler restarten (~2-5s), HAProxy/nft/WG/Squid/Unbound/Chrony laufen durch.",
|
||||
"checkNow": "Auf Updates prüfen",
|
||||
"checkNowHint": "Server-seitigen apt-Cache jetzt aktualisieren und nach neueren Versionen schauen.",
|
||||
"updateReady": "Update bereit: v{{version}}",
|
||||
"checkDone": "Update verfügbar",
|
||||
"noUpdate": "Keine neuen Updates",
|
||||
"checkFailed": "Update-Check fehlgeschlagen",
|
||||
@@ -404,6 +605,10 @@
|
||||
"editServer": "Server-Tunnel bearbeiten",
|
||||
"addClient": "Client-Tunnel hinzufügen",
|
||||
"editClient": "Client-Tunnel bearbeiten",
|
||||
"emptyServerTitle": "Noch keine WireGuard-Server-Tunnel.",
|
||||
"emptyServerDesc": "Server-Modus: diese Box hört auf einem UDP-Port und akzeptiert Peer-Verbindungen (z. B. Roadwarrior-User, Site-to-Site-Niederlassungen).",
|
||||
"emptyClientTitle": "Noch keine WireGuard-Client-Tunnel.",
|
||||
"emptyClientDesc": "Client-Modus: diese Box verbindet sich zu einem externen WireGuard-Server (z. B. HQ-Datacenter, Cloud-Anbindung).",
|
||||
"upstream": "Upstream-Peer",
|
||||
"deleteConfirm": "Tunnel {{name}} wirklich löschen? wg-quick wird gestoppt.",
|
||||
"keys": "Schlüssel",
|
||||
@@ -430,6 +635,8 @@
|
||||
"add": "Peer hinzufügen",
|
||||
"edit": "Peer bearbeiten",
|
||||
"deleteConfirm": "Peer {{name}} wirklich entfernen?",
|
||||
"emptyTitle": "Noch keine Peers in diesem Tunnel.",
|
||||
"emptyDesc": "Lege Peers an — pro Peer eine WireGuard-Identität (Public-Key + Allowed-IPs). Server-generierte Keys liefern dir Config-Download bzw. QR-Code für Mobile-Clients direkt mit.",
|
||||
"keys": "Schlüssel",
|
||||
"generateExtra": "Wenn an: Server erzeugt für diesen Peer ein Keypair und kann die Config / QR-Code ausliefern. Wenn aus: nur den Public-Key paste-en — keine Config-Download möglich.",
|
||||
"pskExtra": "Wenn an: Server generiert einen 32-Byte PSK für diesen Peer.",
|
||||
@@ -471,7 +678,15 @@
|
||||
},
|
||||
"clusterCard": {
|
||||
"title": "Cluster",
|
||||
"nodes": "Knoten"
|
||||
"nodes": "Knoten",
|
||||
"modeSingle": "Single-Node",
|
||||
"modeCluster": "Cluster",
|
||||
"drift": "Config-Drift erkannt",
|
||||
"health": {
|
||||
"ok": "OK",
|
||||
"degraded": "degraded",
|
||||
"split-brain": "split-brain"
|
||||
}
|
||||
},
|
||||
"routingCard": {
|
||||
"title": "Routing",
|
||||
@@ -486,6 +701,15 @@
|
||||
"ifaces": "Interfaces",
|
||||
"wg": "WireGuard"
|
||||
},
|
||||
"alertsCard": {
|
||||
"title": "Aktuelle Alerts",
|
||||
"viewAll": "Alle anzeigen"
|
||||
},
|
||||
"onboardingTitle": "Willkommen bei EdgeGuard",
|
||||
"onboardingIntro": "Frische Box — hier die nächsten Schritte um Customer-Traffic zu routen:",
|
||||
"onboardingStep1": "Backend-Pool anlegen (App-Server hinter HAProxy)",
|
||||
"onboardingStep2": "Domain hinzufügen (FQDN, primary Backend zuweisen)",
|
||||
"onboardingStep3": "TLS-Zertifikat ausstellen (Let's Encrypt HTTP-01)",
|
||||
"servicesCard": {
|
||||
"title": "Service-Status (live, 10s)"
|
||||
},
|
||||
@@ -510,6 +734,17 @@
|
||||
"title": "Zeitserver (Chrony)",
|
||||
"intro": "Chrony als Time-Sync-Daemon (NTP). Quellen oben, Listen-/Serve-Konfig im Settings-Tab. Wenn 'serve_clients' aktiv und LAN-IPs gebound sind, wird die Box selbst zum NTP-Server für das LAN.",
|
||||
"tabs": { "pools": "Quellen", "settings": "Settings" },
|
||||
"statusCard": {
|
||||
"title": "Sync-Status (chronyc tracking)",
|
||||
"sync": "Synchronisiert",
|
||||
"synced": "Ja",
|
||||
"notSynced": "Nein",
|
||||
"source": "Quelle",
|
||||
"stratum": "Stratum",
|
||||
"offset": "Offset",
|
||||
"offsetHint": "Zeitdifferenz zur Referenzquelle. Werte > 100 ms sind ungewöhnlich — Netzwerkprobleme oder fehlkonfigurierte Quelle prüfen.",
|
||||
"loading": "Lade…"
|
||||
},
|
||||
"pool": {
|
||||
"kind": "Typ",
|
||||
"kindPool": "pool — DNS-Round-Robin (mehrere Server aus A-Records)",
|
||||
@@ -524,7 +759,9 @@
|
||||
"description": "Beschreibung",
|
||||
"add": "Quelle hinzufügen",
|
||||
"edit": "Quelle bearbeiten",
|
||||
"deleteConfirm": "NTP-Quelle {{addr}} wirklich löschen?"
|
||||
"deleteConfirm": "NTP-Quelle {{addr}} wirklich löschen?",
|
||||
"emptyTitle": "Noch keine NTP-Quellen.",
|
||||
"emptyDesc": "Ohne konfigurierte Pool/Server-Einträge nutzt chrony nur seine compiled-in Default-Pool (debian.pool.ntp.org). Setze eigene Pools für genauere Zeitabstimmung oder interne Stratum-Server."
|
||||
},
|
||||
"settings": {
|
||||
"intro": "Globale Chrony-Settings. Save reloaded chrony automatisch.",
|
||||
@@ -560,7 +797,9 @@
|
||||
"records": "Records …",
|
||||
"add": "Zone hinzufügen",
|
||||
"edit": "Zone bearbeiten",
|
||||
"deleteConfirm": "Zone {{name}} mit allen Records wirklich löschen?"
|
||||
"deleteConfirm": "Zone {{name}} mit allen Records wirklich löschen?",
|
||||
"emptyTitle": "Noch keine DNS-Zonen.",
|
||||
"emptyDesc": "Unbound forwarded standardmäßig alles an die Upstream-Resolver. Lege eine Zone an, um z. B. interne FQDNs (internal.example.com) lokal zu hosten oder einen Upstream-Stub für eine fremde Domain einzurichten."
|
||||
},
|
||||
"record": {
|
||||
"name": "Name",
|
||||
@@ -572,7 +811,9 @@
|
||||
"drawerTitle": "DNS-Records",
|
||||
"add": "Record hinzufügen",
|
||||
"edit": "Record bearbeiten",
|
||||
"deleteConfirm": "Record {{name}} wirklich löschen?"
|
||||
"deleteConfirm": "Record {{name}} wirklich löschen?",
|
||||
"emptyTitle": "Noch keine Records in dieser Zone.",
|
||||
"emptyDesc": "A/AAAA/CNAME/MX/TXT-Einträge. Bei local-Zone authoritativ; bei forward-Zone hat das hier keinen Effekt (Upstream gewinnt)."
|
||||
},
|
||||
"settings": {
|
||||
"intro": "Globale Resolver-Settings. Änderungen hier reloaden Unbound automatisch.",
|
||||
@@ -608,7 +849,9 @@
|
||||
"comment": "Kommentar",
|
||||
"add": "ACL hinzufügen",
|
||||
"edit": "ACL bearbeiten",
|
||||
"deleteConfirm": "ACL {{name}} wirklich löschen?"
|
||||
"deleteConfirm": "ACL {{name}} wirklich löschen?",
|
||||
"emptyTitle": "Noch keine Forward-Proxy-ACLs.",
|
||||
"emptyDesc": "Default ohne ACLs: nur localnet (10/8, 172.16/12, 192.168/16) darf raus. Lege eine ACL an, um spezifische Domains/IPs/Ports gezielt zu erlauben oder zu blocken."
|
||||
},
|
||||
"common": {
|
||||
"yes": "Ja",
|
||||
@@ -629,7 +872,9 @@
|
||||
"add": "Hinzufügen",
|
||||
"download": "Download",
|
||||
"copy": "Kopieren",
|
||||
"copied": "Kopiert"
|
||||
"copied": "Kopiert",
|
||||
"close": "Schließen",
|
||||
"refresh": "Aktualisieren"
|
||||
},
|
||||
"license": {
|
||||
"title": "Lizenz",
|
||||
@@ -678,6 +923,8 @@
|
||||
"addTitle": "Statische Route anlegen",
|
||||
"editTitle": "Statische Route bearbeiten",
|
||||
"empty": "Keine verwalteten Routen.",
|
||||
"emptyTitle": "Noch keine verwalteten Routen.",
|
||||
"emptyDesc": "Statische Routen die EdgeGuard beim Boot setzt (z. B. nach 10.0.5.0/24 via VPN-Gateway). Live-Routen oben sind read-only — was hier eingetragen wird, persistiert.",
|
||||
"confirmDelete": "Route nach {{dest}} wirklich löschen?",
|
||||
"refreshTooltip": "Live-Routen neu laden",
|
||||
"destExtra": "CIDR — z.B. 10.0.5.0/24 oder 0.0.0.0/0 für Default-Route.",
|
||||
@@ -711,6 +958,10 @@
|
||||
"testDone": "Test gesendet — {{ok}}/{{total}} Channels erfolgreich",
|
||||
"emptyChannels": "Keine Channels. Lege einen Webhook oder eine Email an.",
|
||||
"emptyEvents": "Noch keine Alarme — Triggers haben noch keinen Event gefeuert.",
|
||||
"emptyChannelsTitle": "Noch keine Alert-Channels.",
|
||||
"emptyChannelsDesc": "Ohne Channels werden gefeuerte Events nur in die Datenbank (Tab \"Events\") geschrieben — niemand wird benachrichtigt. Lege einen Webhook (Mattermost/Slack/Discord/Custom) oder eine SMTP-Email an.",
|
||||
"emptyEventsTitle": "Noch keine Alert-Events.",
|
||||
"emptyEventsDesc": "Triggers (Cert-Expiry, Backup-Fail, Cluster-Drift, License-Invalid, etc.) haben noch keinen Event gefeuert. Wenn sie feuern, landen sie hier und werden an die konfigurierten Channels zugestellt.",
|
||||
"noChannels": "kein Channel aktiv",
|
||||
"confirmDelete": "Channel {{name}} wirklich löschen?",
|
||||
"col": {
|
||||
@@ -766,6 +1017,12 @@
|
||||
"scopeTitle": "Was wird gesichert?",
|
||||
"scopeDesc": "DB-Dump (pg_dump --clean), setup.json, license_key, license.cache, .jwt_fingerprint, acme-account/. Konfig-Dateien (haproxy.cfg, nft, …) sind aus der DB regenerierbar und werden NICHT mitgesichert.",
|
||||
"tabs": { "history": "Sicherungen", "remotes": "Off-Site-Ziele" },
|
||||
"statLastSuccess": "Letzte erfolgreiche Sicherung",
|
||||
"statTotal": "Erfolgreiche Sicherungen",
|
||||
"statSize": "Gesamtgröße",
|
||||
"statFails24h": "Fehlschläge (24 h)",
|
||||
"statHoursAgo": "vor {{n}} h",
|
||||
"statDaysAgo": "vor {{n}} Tagen",
|
||||
"runNow": "Backup jetzt erstellen",
|
||||
"created": "Backup erstellt: {{file}}",
|
||||
"failed": "Backup fehlgeschlagen",
|
||||
@@ -821,7 +1078,8 @@
|
||||
"filter": {
|
||||
"sources": "Quellen wählen (alle wenn leer)",
|
||||
"levels": "Level filtern",
|
||||
"grep": "Volltext-Suche"
|
||||
"grep": "Volltext-Suche",
|
||||
"reset": "Filter zurücksetzen"
|
||||
}
|
||||
},
|
||||
"fwlog": {
|
||||
@@ -861,5 +1119,34 @@
|
||||
"dst": "Ziel-IP",
|
||||
"rule": "Rule-ID"
|
||||
}
|
||||
},
|
||||
"audit": {
|
||||
"title": "Audit-Log",
|
||||
"intro": "Wer hat was wann geändert. Jede Mutation in der API (Domain anlegen, Backend deaktivieren, Cert ausstellen, …) ist hier nachvollziehbar.",
|
||||
"filter": {
|
||||
"actor": "Actor",
|
||||
"action": "Aktion",
|
||||
"subject": "Subjekt",
|
||||
"range": "Zeitraum",
|
||||
"search": "Suchen",
|
||||
"reset": "Zurücksetzen"
|
||||
},
|
||||
"col": {
|
||||
"time": "Zeit",
|
||||
"actor": "Actor",
|
||||
"action": "Aktion",
|
||||
"subject": "Subjekt",
|
||||
"detail": "Details"
|
||||
},
|
||||
"detailShow": "Details anzeigen",
|
||||
"empty": {
|
||||
"title": "Keine Treffer",
|
||||
"desc": "Mit diesen Filtern wurden keine Einträge gefunden. Filter ändern oder zurücksetzen."
|
||||
},
|
||||
"page": {
|
||||
"prev": "Zurück",
|
||||
"next": "Weiter",
|
||||
"showing": "Zeile {{from}}–{{to}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"firewallLive": "Firewall log",
|
||||
"cluster": "Cluster",
|
||||
"logs": "Logs",
|
||||
"audit": "Audit log",
|
||||
"backups": "Backups",
|
||||
"diagnostics": "Diagnostics",
|
||||
"alerts": "Alerts",
|
||||
@@ -44,7 +45,8 @@
|
||||
"addrObj": "Address objects",
|
||||
"addrGrp": "Address groups",
|
||||
"services": "Services",
|
||||
"svcGrp": "Service groups"
|
||||
"svcGrp": "Service groups",
|
||||
"system": "System rules"
|
||||
},
|
||||
"zone": {
|
||||
"name": "Name",
|
||||
@@ -55,31 +57,41 @@
|
||||
"namePattern": "Lowercase letters, digits, _ and -; must start with a letter, up to 32 chars.",
|
||||
"add": "Add zone",
|
||||
"edit": "Edit zone",
|
||||
"deleteConfirm": "Really delete zone {{name}}?"
|
||||
"deleteConfirm": "Really delete zone {{name}}?",
|
||||
"emptyTitle": "No custom firewall zones yet.",
|
||||
"emptyDesc": "Zones group interfaces (lan, wan, dmz). Built-in zones already exist; add custom ones for e.g. a separate dmz or a wg zone for VPN."
|
||||
},
|
||||
"ao": {
|
||||
"name": "Name", "kind": "Kind", "value": "Value", "description": "Description",
|
||||
"add": "Add address object", "edit": "Edit address object",
|
||||
"deleteConfirm": "Really delete address object {{name}}?"
|
||||
"deleteConfirm": "Really delete address object {{name}}?",
|
||||
"emptyTitle": "No address objects yet.",
|
||||
"emptyDesc": "Reusable named IPs/networks/ranges for firewall + NAT rules (e.g. office-net = 10.0.0.0/24, mailbox-1 = 10.0.1.42)."
|
||||
},
|
||||
"ag": {
|
||||
"name": "Name", "members": "Members", "description": "Description",
|
||||
"add": "Add address group", "edit": "Edit address group",
|
||||
"selectMembers": "Select address objects",
|
||||
"deleteConfirm": "Really delete address group {{name}}?"
|
||||
"deleteConfirm": "Really delete address group {{name}}?",
|
||||
"emptyTitle": "No address groups yet.",
|
||||
"emptyDesc": "Bundle multiple address objects into a group (e.g. office-locations = [hq-net, branch-1-net, branch-2-net]) — rules then reference one group."
|
||||
},
|
||||
"svc": {
|
||||
"name": "Name", "proto": "Protocol", "ports": "Ports",
|
||||
"portStart": "Port (start)", "portEnd": "Port (end)",
|
||||
"description": "Description", "builtinHint": "Built-in — not editable",
|
||||
"add": "Add service", "edit": "Edit service",
|
||||
"deleteConfirm": "Really delete service {{name}}?"
|
||||
"deleteConfirm": "Really delete service {{name}}?",
|
||||
"emptyTitle": "No custom services yet.",
|
||||
"emptyDesc": "Built-ins (HTTP, HTTPS, SSH, …) already exist. Add app-specific ports (e.g. mailcow-imaps tcp/993) to make rules easier to read."
|
||||
},
|
||||
"sg": {
|
||||
"name": "Name", "members": "Members", "description": "Description",
|
||||
"add": "Add service group", "edit": "Edit service group",
|
||||
"selectMembers": "Select services",
|
||||
"deleteConfirm": "Really delete service group {{name}}?"
|
||||
"deleteConfirm": "Really delete service group {{name}}?",
|
||||
"emptyTitle": "No service groups yet.",
|
||||
"emptyDesc": "Bundle multiple services into a group (e.g. web-stack = [HTTP, HTTPS, HTTP/3]) — one rule with the group replaces three rules per service."
|
||||
},
|
||||
"rule": {
|
||||
"name": "Name", "priority": "Priority", "enabled": "Enabled", "log": "Log",
|
||||
@@ -90,7 +102,9 @@
|
||||
"serviceKind": "Service kind", "serviceGroup": "Service group",
|
||||
"comment": "Comment",
|
||||
"add": "Add rule", "edit": "Edit rule",
|
||||
"deleteConfirm": "Really delete this rule?"
|
||||
"deleteConfirm": "Really delete this rule?",
|
||||
"emptyTitle": "No custom firewall rules yet.",
|
||||
"emptyDesc": "The system rules above keep SSH (rate-limited), HTTPS :443 and the mgmt UI :3443 open (anti-lockout). Add custom rules for app-specific inbound ports or cross-zone forwards."
|
||||
},
|
||||
"nat": {
|
||||
"name": "Name", "priority": "Priority", "kind": "Kind", "enabled": "Enabled",
|
||||
@@ -102,7 +116,9 @@
|
||||
"targetAddr": "Target address", "targetPortStart": "Target port (start)", "targetPortEnd": "Target port (end)",
|
||||
"comment": "Comment",
|
||||
"add": "Add NAT rule", "edit": "Edit NAT rule",
|
||||
"deleteConfirm": "Really delete this NAT rule?"
|
||||
"deleteConfirm": "Really delete this NAT rule?",
|
||||
"emptyTitle": "No NAT rules yet.",
|
||||
"emptyDesc": "DNAT (e.g. external :2030 → internal 10.10.20.12:22 for SSH to an internal host) or SNAT/MASQUERADE (internet access for an internal subnet via the box IP)."
|
||||
},
|
||||
"sys": {
|
||||
"title": "System rules (always active)",
|
||||
@@ -125,6 +141,8 @@
|
||||
"systemDiscovered": "System interfaces (read-only)",
|
||||
"addInterface": "Add interface",
|
||||
"editInterface": "Edit interface",
|
||||
"emptyTitle": "No managed interfaces yet.",
|
||||
"emptyDesc": "System interfaces (above) are detected read-only. Managed interfaces are what EdgeGuard creates itself — VLANs, bridges, bonds, GRE tunnels — and renders into /etc/network/interfaces.d.",
|
||||
"name": "Name",
|
||||
"type": "Type",
|
||||
"parent": "Parent interface",
|
||||
@@ -152,6 +170,8 @@
|
||||
"managedTitle": "Managed addresses",
|
||||
"family": "Family",
|
||||
"addAddress": "Add address",
|
||||
"emptyTitle": "No managed IP addresses yet.",
|
||||
"emptyDesc": "Add your first IP — floating VIPs for HA failover or extra listen addresses for HAProxy/Squid/Unbound. Distro-owned IPs are shown above under \"Detected IPs\".",
|
||||
"editAddress": "Edit address",
|
||||
"interface": "Interface",
|
||||
"selectInterface": "Select interface",
|
||||
@@ -192,12 +212,19 @@
|
||||
"setup": {
|
||||
"title": "First-time setup",
|
||||
"intro": "Create the admin account, declare the public FQDN, and — optionally — paste a license key. Without one, a 30-day trial starts.",
|
||||
"preflightTitle": "Before you hit \"Finish setup\"",
|
||||
"preflightDesc": "The FQDN must already resolve to this box via DNS (A/AAAA record). Without DNS resolution the later ACME HTTP-01 challenge fails — your browser URL should already be this FQDN, otherwise things break at first Let's Encrypt issue.",
|
||||
"adminEmail": "Admin email",
|
||||
"adminEmailHint": "Login identifier for the management UI. NOT used for outbound mail (use the ACME email below or the SMTP settings inside the Alerts channel).",
|
||||
"adminPassword": "Admin password",
|
||||
"passwordRule": "At least 12 characters.",
|
||||
"fqdn": "Public FQDN",
|
||||
"fqdnHint": "Fully-qualified hostname of this box (e.g. eg.example.com). Becomes the Subject-CN of the self-signed bootstrap cert AND ends up in the ACME cert once Let's Encrypt has issued. Must resolve to the public IP via DNS.",
|
||||
"fqdnInvalid": "Doesn't look like a valid FQDN (at least label.tld).",
|
||||
"acmeEmail": "ACME / Let's Encrypt email",
|
||||
"acmeEmailHint": "Sent to Let's Encrypt as account contact (expiry warnings + LE compliance mail). Can be the same as the admin email, but doesn't have to be.",
|
||||
"licenseKey": "License key (optional)",
|
||||
"licenseKeyHint": "If present: 30-day trial is skipped, features unlock immediately. Can also be added later under License.",
|
||||
"submit": "Finish setup",
|
||||
"successTitle": "Setup complete",
|
||||
"successHint": "Redirecting you to sign-in."
|
||||
@@ -211,14 +238,64 @@
|
||||
"intro": "Manage FQDNs that HAProxy terminates. Optional primary backend as catch-all; path-based routing via routing rules.",
|
||||
"addDomain": "Add domain",
|
||||
"editDomain": "Edit domain",
|
||||
"emptyTitle": "No domains yet.",
|
||||
"emptyDesc": "Add your first domain — HAProxy will then terminate TLS for that hostname and route to the chosen backend.",
|
||||
"name": "Name",
|
||||
"active": "Active",
|
||||
"primaryBackend": "Primary backend",
|
||||
"primaryBackendHint": "Catch-all backend for requests with no matching routing rule. Optional — leave empty if all traffic is routed via routing rules.",
|
||||
"selectBackend": "Select backend",
|
||||
"noBackend": "no backend",
|
||||
"quickBackendBtn": "+ new",
|
||||
"quickBackendBtnHint": "Create backend + first upstream server in one click — skips the trip to the Backends page.",
|
||||
"quickBackendTitle": "Quick-add backend",
|
||||
"quickBackendName": "Backend name",
|
||||
"quickBackendScheme": "Scheme",
|
||||
"quickBackendAddress": "Server address (first upstream)",
|
||||
"quickBackendPort": "Port",
|
||||
"quickBackendCreated": "Backend + server created and selected.",
|
||||
"quickBackendFailed": "Quick-add backend failed",
|
||||
"httpToHttps": "HTTP→HTTPS",
|
||||
"hsts": "HSTS",
|
||||
"tlsCert": "TLS cert",
|
||||
"tlsCertValid": "valid",
|
||||
"tlsCertExpiring": "{{days}}d left",
|
||||
"tlsCertExpired": "expired",
|
||||
"tlsCertError": "error",
|
||||
"tlsCertNone": "missing",
|
||||
"tlsCertNoneHint": "No own TLS cert for this domain — HAProxy serves the bootstrap self-signed cert. Issue one in the SSL tab.",
|
||||
"hstsMaxAge": "HSTS max-age (sec)",
|
||||
"hstsMaxAgeHint": "How long browsers cache the HTTPS-only mandate. Recommended: 31536000 (1 year).",
|
||||
"hstsSubdomains": "includeSubDomains",
|
||||
"hstsSubdomainsHint": "Extends HSTS to all subdomains. Enable only if every subdomain speaks HTTPS exclusively.",
|
||||
"hstsPreload": "preload",
|
||||
"hstsPreloadHint": "Sets the preload flag so the domain may be added to hstspreload.org. Requires max-age ≥ 31536000 and includeSubDomains enabled.",
|
||||
"maintenance": "Maintenance mode",
|
||||
"maintenanceHint": "On: every request to this domain receives 503 directly from HAProxy. Backends are not contacted.",
|
||||
"maintenanceMessage": "Maintenance message",
|
||||
"maintenanceMessagePlaceholder": "Service temporarily unavailable.",
|
||||
"wwwRedirect": "www redirect",
|
||||
"wwwRedirectHint": "Canonicalises the domain. \"to naked\": Name=example.com → www.example.com redirects to example.com. \"to www\": Name=www.example.com → example.com redirects to www.example.com.",
|
||||
"wwwRedirectNone": "No redirect",
|
||||
"wwwRedirectToNaked": "→ naked (no www)",
|
||||
"wwwRedirectToWWW": "→ www",
|
||||
"rateLimit": "Rate limit (per client IP)",
|
||||
"rateLimitHint": "Max requests per second per client IP. HAProxy counts over a 10-second window per stick table (max. 100k IPs). 0 = off.",
|
||||
"maxBody": "Max request body",
|
||||
"maxBodyHint": "Cap on the Content-Length header. Larger requests get 413. Chunked bodies are not detected (HAProxy doesn't buffer by default). 0 = off.",
|
||||
"headersBtn": "Headers",
|
||||
"headersTitle": "Response headers — {{name}}",
|
||||
"headersHint": "These headers are set by HAProxy on every response for this domain (http-response set-header). Ordering via position.",
|
||||
"headersEmpty": "No custom headers configured yet.",
|
||||
"addHeader": "Add header",
|
||||
"editHeader": "Edit header",
|
||||
"headerName": "Name",
|
||||
"headerNameHint": "Letters, digits, hyphens only. Case-insensitive unique per domain.",
|
||||
"headerNamePattern": "Only a-z, A-Z, 0-9, '-'",
|
||||
"headerValue": "Value",
|
||||
"headerPosition": "Position",
|
||||
"headerDeleteConfirm": "Really delete header \"{{name}}\"?",
|
||||
"settingsSection": "HAProxy settings",
|
||||
"notes": "Notes",
|
||||
"actions": "Actions",
|
||||
"edit": "Edit",
|
||||
@@ -229,11 +306,14 @@
|
||||
"title": "Backends",
|
||||
"intro": "Upstream pools (one backend = N servers). HAProxy balances load by the chosen algorithm; health-check path enables HTTP probes every 5s per server.",
|
||||
"addBackend": "Add backend pool",
|
||||
"emptyTitle": "No backend pools yet.",
|
||||
"emptyDesc": "Add your first pool — define upstream servers (e.g. app servers / mailbox nodes) so domains can route to them.",
|
||||
"editBackend": "Edit backend pool",
|
||||
"name": "Name",
|
||||
"scheme": "Scheme",
|
||||
"target": "Target",
|
||||
"healthCheck": "Health check path",
|
||||
"liveStatus": "HAProxy status",
|
||||
"active": "Active",
|
||||
"usedBy": "Used by",
|
||||
"noDomain": "no domain",
|
||||
@@ -271,6 +351,8 @@
|
||||
"title": "Routing rules",
|
||||
"intro": "Path-prefix → backend mapping per domain. Lowest priority wins; catch-all via domain.primary_backend.",
|
||||
"addRule": "Add rule",
|
||||
"emptyTitle": "No routing rules yet.",
|
||||
"emptyDesc": "Path-specific routing — e.g. domain.tld/api → Backend A, domain.tld/* → Backend B. Optional. Without rules HAProxy uses the primary backend from the domain.",
|
||||
"editRule": "Edit rule",
|
||||
"domain": "Domain",
|
||||
"pathPrefix": "Path prefix",
|
||||
@@ -313,8 +395,35 @@
|
||||
"configHash": "Config hash",
|
||||
"version": "Version",
|
||||
"lastSeen": "Last seen",
|
||||
"mgmtIp": "MGMT IP"
|
||||
}
|
||||
"mgmtIp": "MGMT IP",
|
||||
"load": "Load 1/5/15",
|
||||
"mem": "Memory",
|
||||
"disk": "Disk",
|
||||
"conntrack": "Conntrack",
|
||||
"uptime": "Uptime",
|
||||
"fetchMs": "Fetch"
|
||||
},
|
||||
"loadTitle": "Per-node resources (mTLS aggregator)",
|
||||
"loadEmpty": "No node resources available — agent listener unreachable?",
|
||||
"certCardTitle": "Cluster TLS certificates",
|
||||
"certCALabel": "Cluster CA",
|
||||
"certPeerLabel": "Peer cert (this node)",
|
||||
"certExpiry": "Expires in",
|
||||
"renewSelfBtn": "Renew peer cert",
|
||||
"renewSelfConfirm": "Re-sign peer cert with the local CA (1 year)? edgeguard-api restart required after.",
|
||||
"certRenewedRestartHint": "Cert renewed — please run sudo systemctl restart edgeguard-api.",
|
||||
"certRenewFailed": "Cert renewal failed",
|
||||
"removePeerBtn": "Remove",
|
||||
"removePeerConfirmTitle": "Really remove peer from the cluster?",
|
||||
"removePeerConfirmDesc": "{{fqdn}} is deleted from ha_nodes. Firewall renderer drops its IP from the peer_ipv4 set. On the peer side edgeguard-api keeps running; manually stop it + delete cluster-tls for a full decommission.",
|
||||
"removePeerOk": "Peer removed.",
|
||||
"removePeerFailed": "Peer removal failed",
|
||||
"generateJoinToken": "Generate join token",
|
||||
"joinTokenTitle": "Cluster join token",
|
||||
"joinTokenOneShot": "Shown once, usable once",
|
||||
"joinTokenOneShotDesc": "Transfer the token securely now — it won't be shown again. Valid until {{expires}}. Server marks it as consumed when redeemed via cluster-join.",
|
||||
"joinTokenFailed": "Token generation failed",
|
||||
"joinCmdLabel": "Run on the new node:"
|
||||
},
|
||||
"ssl": {
|
||||
"title": "SSL certificates",
|
||||
@@ -337,23 +446,113 @@
|
||||
"issueButton": "Issue certificate",
|
||||
"uploadButton": "Upload",
|
||||
"issueSuccess": "Certificate issued + installed.",
|
||||
"renewBtn": "Renew",
|
||||
"renewConfirmTitle": "Renew certificate now?",
|
||||
"renewConfirmDesc": "Triggers an ACME HTTP-01 challenge for {{domain}}. Let's Encrypt has rate limits (50 issues/domain/week) — only use when needed.",
|
||||
"renewSuccess": "Certificate renewed + installed.",
|
||||
"renewFailed": "Renewal failed",
|
||||
"uploadSuccess": "Certificate uploaded + installed.",
|
||||
"deleteConfirm": "Delete certificate for {{domain}}? HAProxy falls back to the default cert for this domain.",
|
||||
"installedTitle": "Installed certificates",
|
||||
"lastRenewed": "Last renewed",
|
||||
"statTotal": "Certificates total",
|
||||
"statExpiring": "Expiring < 30 days",
|
||||
"statExpired": "Expired",
|
||||
"statErrors": "With errors",
|
||||
"relAgo": {
|
||||
"justNow": "just now",
|
||||
"minutes": "{{n}} min ago",
|
||||
"hours": "{{n}} h ago",
|
||||
"days": "{{n}} d ago"
|
||||
},
|
||||
"emptyTitle": "No certificates installed yet.",
|
||||
"emptyDesc": "Use the tabs above — Let's Encrypt issues automatically via HTTP-01 challenge, or upload your own PEM. Until then HAProxy serves the bootstrap self-signed cert for all domains.",
|
||||
"certPem": "Certificate (PEM)",
|
||||
"chainPem": "Chain (PEM, optional)",
|
||||
"keyPem": "Private key (PEM)"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"intro": "System information and setup status. Editable values come in a later release.",
|
||||
"intro": "System information, setup status and admin account.",
|
||||
"systemInfo": "System",
|
||||
"version": "Version",
|
||||
"status": "Status",
|
||||
"dbSize": "PostgreSQL DB size",
|
||||
"dbSizeTop": "Top tables",
|
||||
"upgradeStatusCardTitle": "Last upgrade attempt",
|
||||
"upgradeStatusStarted": "Started",
|
||||
"upgradeStatusFinished": "Finished",
|
||||
"upgradeStatusResult": "Result",
|
||||
"upgradeStatusState": "State",
|
||||
"upgradeStatusOk": "Successful",
|
||||
"upgradeStatusShowLog": "Show full log ({{n}} lines)",
|
||||
"actionsCardTitle": "System actions",
|
||||
"actionsHint": "Manual triggers for operator tasks that normally happen automatically on save in the respective pages. Useful after SSH interventions (e.g. /etc/edgeguard/tls/ filled manually).",
|
||||
"haproxyReloadBtn": "HAProxy reload",
|
||||
"haproxyReloadOk": "HAProxy reloaded.",
|
||||
"haproxyReloadFailed": "HAProxy reload failed",
|
||||
"renderConfigsBtn": "Re-render configs (HAProxy)",
|
||||
"renderConfigsOk": "Configs re-rendered + reloaded.",
|
||||
"renderConfigsFailed": "Config render failed",
|
||||
"backupNowBtn": "Backup now",
|
||||
"serviceRestartCardTitle": "Restart services",
|
||||
"serviceRestartBtn": "Restart",
|
||||
"serviceRestartOk": "{{service}} restarted successfully.",
|
||||
"serviceRestartFailed": "Restart of {{service}} failed",
|
||||
"serviceRestartHint": "Restarts the service via systemctl restart. edgeguard-api and PostgreSQL are intentionally excluded.",
|
||||
"backupNowOk": "Backup triggered — watch status on the Backups page.",
|
||||
"backupNowFailed": "Backup trigger failed",
|
||||
"setupInfo": "Setup",
|
||||
"adminEmail": "Admin email",
|
||||
"fqdn": "FQDN",
|
||||
"setupCompleted": "Setup completed"
|
||||
"setupCompleted": "Setup completed",
|
||||
"emailsCardTitle": "Contact emails",
|
||||
"adminEmailHint": "Used as the admin login identifier. Change requires a fresh login with the new address.",
|
||||
"acmeEmail": "ACME email",
|
||||
"acmeEmailHint": "Sent to Let's Encrypt as account contact. Existing certs are not affected — the next renew op registers the new address.",
|
||||
"emailsSaved": "Emails updated.",
|
||||
"emailsFailed": "Email update failed",
|
||||
"maintenanceCardTitle": "Maintenance mode (whole box)",
|
||||
"maintenanceOn": "Active — all customer domains return 503. Mgmt UI stays reachable.",
|
||||
"maintenanceOff": "Inactive — customer traffic is routed normally to backends.",
|
||||
"maintenanceMessage": "Maintenance message",
|
||||
"maintenanceMessagePlaceholder": "Service temporarily unavailable — we'll be back shortly.",
|
||||
"maintenanceMessageHint": "Returned in the body of 503 responses to end users. Plain text, max 500 chars.",
|
||||
"maintenanceHint": "Switches HAProxy on :443 into default-503 mode. Per-domain maintenance (Domains page) is overridden by this. Mgmt UI on :3443 is NOT affected.",
|
||||
"maintenanceSaved": "Maintenance mode updated.",
|
||||
"maintenanceFailed": "Maintenance toggle failed",
|
||||
"maintenanceActiveTitle": "Maintenance mode active",
|
||||
"maintenanceActiveDesc": "All customer domains currently return 503. Mgmt UI is reachable — customer traffic is NOT. Settings → Maintenance mode to disable.",
|
||||
"backupRetentionCardTitle": "Backup retention",
|
||||
"backupRetentionUnit": "backups",
|
||||
"backupRetentionDefault": "Default ({{n}} backups) — with daily schedule = {{n}} days of history.",
|
||||
"backupRetentionCustom": "Custom — the last {{n}} successful backups are kept; older ones are pruned after each backup run.",
|
||||
"backupRetentionHint": "0 = default (14). 1-365 = custom limit. Each backup is a full pg_dump + files tar (typically 50-500 MB). Reduce when /var disk gets tight.",
|
||||
"backupRetentionSaved": "Backup retention updated.",
|
||||
"backupRetentionFailed": "Backup retention update failed",
|
||||
"auditRetentionCardTitle": "Audit log retention",
|
||||
"auditRetentionUnit": "days",
|
||||
"auditRetentionDefault": "Default ({{n}} days) — audit entries older than {{n}} days are pruned daily.",
|
||||
"auditRetentionCustom": "Custom — audit entries are kept for {{n}} days.",
|
||||
"auditRetentionHint": "0 = default (90). 1-3650 (= 10 years) for compliance (SOX 7y = 2555, GDPR usually <= 365). Cleanup runs daily in the scheduler.",
|
||||
"auditRetentionSaved": "Audit retention updated.",
|
||||
"auditRetentionFailed": "Audit retention update failed",
|
||||
"autoUpdateCardTitle": "Automatic updates",
|
||||
"autoUpdateOn": "Enabled — edgeguard packages install automatically every day.",
|
||||
"autoUpdateOff": "Disabled — install updates manually via the banner.",
|
||||
"autoUpdateHint": "Whitelist covers edgeguard, edgeguard-api, edgeguard-ui only. Other packages stay under manual control. Requires unattended-upgrades (Trixie distro default). Conf file: /etc/apt/apt.conf.d/52edgeguard-auto-updates.",
|
||||
"autoUpdateToggled": "Auto-update setting saved.",
|
||||
"autoUpdateFailed": "Auto-update toggle failed",
|
||||
"passwordCardTitle": "Change admin password",
|
||||
"currentPassword": "Current password",
|
||||
"newPassword": "New password",
|
||||
"newPasswordHint": "At least 12 characters. Stored as bcrypt hash.",
|
||||
"confirmPassword": "Confirm new password",
|
||||
"changePasswordBtn": "Change password",
|
||||
"passwordChanged": "Password changed.",
|
||||
"passwordChangeFailed": "Password change failed",
|
||||
"passwordMismatch": "Passwords do not match.",
|
||||
"passwordMinLen": "Minimum 12 characters required."
|
||||
},
|
||||
"update": {
|
||||
"available": "Update available: version {{version}}",
|
||||
@@ -361,6 +560,8 @@
|
||||
"applyNow": "Install now",
|
||||
"confirmTitle": "Install update now?",
|
||||
"confirmDesc": "Packages will be updated to version {{version}}. edgeguard-api + scheduler restart (~2-5 s), HAProxy/nft/WG/Squid/Unbound/Chrony stay running.",
|
||||
"checkNowHint": "Refresh the server-side apt cache now and look for newer versions.",
|
||||
"updateReady": "Update ready: v{{version}}",
|
||||
"checkNow": "Check for updates",
|
||||
"checkDone": "Update available",
|
||||
"noUpdate": "No new updates",
|
||||
@@ -404,6 +605,10 @@
|
||||
"editServer": "Edit server tunnel",
|
||||
"addClient": "Add client tunnel",
|
||||
"editClient": "Edit client tunnel",
|
||||
"emptyServerTitle": "No WireGuard server tunnels yet.",
|
||||
"emptyServerDesc": "Server mode: this box listens on a UDP port and accepts peer connections (e.g. roadwarrior users, site-to-site branches).",
|
||||
"emptyClientTitle": "No WireGuard client tunnels yet.",
|
||||
"emptyClientDesc": "Client mode: this box connects to an external WireGuard server (e.g. HQ datacenter, cloud uplink).",
|
||||
"upstream": "Upstream peer",
|
||||
"deleteConfirm": "Really delete tunnel {{name}}? wg-quick will be stopped.",
|
||||
"keys": "Keys",
|
||||
@@ -430,6 +635,8 @@
|
||||
"add": "Add peer",
|
||||
"edit": "Edit peer",
|
||||
"deleteConfirm": "Really remove peer {{name}}?",
|
||||
"emptyTitle": "No peers in this tunnel yet.",
|
||||
"emptyDesc": "Add peers — each is a WireGuard identity (public key + allowed IPs). Server-generated keys give you config download / QR code for mobile clients in one go.",
|
||||
"keys": "Keys",
|
||||
"generateExtra": "If on: server generates a keypair for this peer and can hand out the config / QR. If off: paste the peer's public key only — no config download.",
|
||||
"pskExtra": "If on: server generates a 32-byte PSK for this peer.",
|
||||
@@ -471,7 +678,15 @@
|
||||
},
|
||||
"clusterCard": {
|
||||
"title": "Cluster",
|
||||
"nodes": "Nodes"
|
||||
"nodes": "Nodes",
|
||||
"modeSingle": "Single-Node",
|
||||
"modeCluster": "Cluster",
|
||||
"drift": "Config drift detected",
|
||||
"health": {
|
||||
"ok": "OK",
|
||||
"degraded": "degraded",
|
||||
"split-brain": "split-brain"
|
||||
}
|
||||
},
|
||||
"routingCard": {
|
||||
"title": "Routing",
|
||||
@@ -486,6 +701,15 @@
|
||||
"ifaces": "Interfaces",
|
||||
"wg": "WireGuard"
|
||||
},
|
||||
"alertsCard": {
|
||||
"title": "Recent alerts",
|
||||
"viewAll": "View all"
|
||||
},
|
||||
"onboardingTitle": "Welcome to EdgeGuard",
|
||||
"onboardingIntro": "Fresh box — here are the next steps to route customer traffic:",
|
||||
"onboardingStep1": "Create a backend pool (app servers behind HAProxy)",
|
||||
"onboardingStep2": "Add a domain (FQDN, assign primary backend)",
|
||||
"onboardingStep3": "Issue TLS certificate (Let's Encrypt HTTP-01)",
|
||||
"servicesCard": {
|
||||
"title": "Service status (live, 10s)"
|
||||
},
|
||||
@@ -510,6 +734,17 @@
|
||||
"title": "Time server (Chrony)",
|
||||
"intro": "Chrony as time-sync daemon (NTP). Sources on top, listen/serve config on the settings tab. With 'serve_clients' on and LAN-IPs bound, the box itself becomes an NTP server for the LAN.",
|
||||
"tabs": { "pools": "Sources", "settings": "Settings" },
|
||||
"statusCard": {
|
||||
"title": "Sync status (chronyc tracking)",
|
||||
"sync": "Synchronized",
|
||||
"synced": "Yes",
|
||||
"notSynced": "No",
|
||||
"source": "Source",
|
||||
"stratum": "Stratum",
|
||||
"offset": "Offset",
|
||||
"offsetHint": "Time difference to reference source. Values > 100 ms are unusual — check network or misconfigured source.",
|
||||
"loading": "Loading…"
|
||||
},
|
||||
"pool": {
|
||||
"kind": "Type",
|
||||
"kindPool": "pool — DNS round-robin (multiple servers from A records)",
|
||||
@@ -524,7 +759,9 @@
|
||||
"description": "Description",
|
||||
"add": "Add source",
|
||||
"edit": "Edit source",
|
||||
"deleteConfirm": "Really delete NTP source {{addr}}?"
|
||||
"deleteConfirm": "Really delete NTP source {{addr}}?",
|
||||
"emptyTitle": "No NTP sources yet.",
|
||||
"emptyDesc": "Without configured pool/server entries chrony falls back to its compiled-in default pool (debian.pool.ntp.org). Set custom pools for better time sync or internal stratum servers."
|
||||
},
|
||||
"settings": {
|
||||
"intro": "Global chrony settings. Saves reload chrony automatically.",
|
||||
@@ -560,7 +797,9 @@
|
||||
"records": "Records …",
|
||||
"add": "Add zone",
|
||||
"edit": "Edit zone",
|
||||
"deleteConfirm": "Really delete zone {{name}} and all its records?"
|
||||
"deleteConfirm": "Really delete zone {{name}} and all its records?",
|
||||
"emptyTitle": "No DNS zones yet.",
|
||||
"emptyDesc": "Unbound forwards everything to upstream resolvers by default. Add a zone to host internal FQDNs (internal.example.com) locally or set up an upstream stub for a foreign domain."
|
||||
},
|
||||
"record": {
|
||||
"name": "Name",
|
||||
@@ -572,7 +811,9 @@
|
||||
"drawerTitle": "DNS records",
|
||||
"add": "Add record",
|
||||
"edit": "Edit record",
|
||||
"deleteConfirm": "Really delete record {{name}}?"
|
||||
"deleteConfirm": "Really delete record {{name}}?",
|
||||
"emptyTitle": "No records in this zone yet.",
|
||||
"emptyDesc": "A/AAAA/CNAME/MX/TXT entries. Authoritative on local zones; on forward zones records here have no effect (upstream wins)."
|
||||
},
|
||||
"settings": {
|
||||
"intro": "Global resolver settings. Saves reload Unbound automatically.",
|
||||
@@ -608,7 +849,9 @@
|
||||
"comment": "Comment",
|
||||
"add": "Add ACL",
|
||||
"edit": "Edit ACL",
|
||||
"deleteConfirm": "Really delete ACL {{name}}?"
|
||||
"deleteConfirm": "Really delete ACL {{name}}?",
|
||||
"emptyTitle": "No forward-proxy ACLs yet.",
|
||||
"emptyDesc": "Default with no ACLs: only localnet (10/8, 172.16/12, 192.168/16) is allowed out. Add an ACL to selectively allow or block specific domains/IPs/ports."
|
||||
},
|
||||
"common": {
|
||||
"yes": "Yes",
|
||||
@@ -629,7 +872,9 @@
|
||||
"add": "Add",
|
||||
"download": "Download",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied"
|
||||
"copied": "Copied",
|
||||
"close": "Close",
|
||||
"refresh": "Refresh"
|
||||
},
|
||||
"license": {
|
||||
"title": "License",
|
||||
@@ -678,6 +923,8 @@
|
||||
"addTitle": "Add static route",
|
||||
"editTitle": "Edit static route",
|
||||
"empty": "No managed routes yet.",
|
||||
"emptyTitle": "No managed routes yet.",
|
||||
"emptyDesc": "Static routes EdgeGuard installs on boot (e.g. to 10.0.5.0/24 via VPN gateway). Live routes above are read-only — what you add here persists.",
|
||||
"confirmDelete": "Really delete route to {{dest}}?",
|
||||
"refreshTooltip": "Reload live routes",
|
||||
"destExtra": "CIDR — e.g. 10.0.5.0/24 or 0.0.0.0/0 for the default route.",
|
||||
@@ -711,6 +958,10 @@
|
||||
"testDone": "Test sent — {{ok}}/{{total}} channels OK",
|
||||
"emptyChannels": "No channels. Add a webhook or an email.",
|
||||
"emptyEvents": "No alerts yet — triggers haven't fired any events.",
|
||||
"emptyChannelsTitle": "No alert channels yet.",
|
||||
"emptyChannelsDesc": "Without channels, fired events are only written to the database (Events tab) — nobody is notified. Add a webhook (Mattermost/Slack/Discord/custom) or an SMTP email.",
|
||||
"emptyEventsTitle": "No alert events yet.",
|
||||
"emptyEventsDesc": "Triggers (cert expiry, backup failure, cluster drift, license invalid, etc.) haven't fired any events yet. When they do, they land here and get delivered to the configured channels.",
|
||||
"noChannels": "no active channel",
|
||||
"confirmDelete": "Really delete channel {{name}}?",
|
||||
"col": {
|
||||
@@ -766,6 +1017,12 @@
|
||||
"scopeTitle": "What is backed up?",
|
||||
"scopeDesc": "DB dump (pg_dump --clean), setup.json, license_key, license.cache, .jwt_fingerprint, acme-account/. Generated configs (haproxy.cfg, nft, …) are reproducible from the DB and are NOT included.",
|
||||
"tabs": { "history": "Backups", "remotes": "Off-site targets" },
|
||||
"statLastSuccess": "Last successful backup",
|
||||
"statTotal": "Successful backups",
|
||||
"statSize": "Total size",
|
||||
"statFails24h": "Failures (24 h)",
|
||||
"statHoursAgo": "{{n}} h ago",
|
||||
"statDaysAgo": "{{n}} days ago",
|
||||
"runNow": "Run backup now",
|
||||
"created": "Backup created: {{file}}",
|
||||
"failed": "Backup failed",
|
||||
@@ -821,7 +1078,8 @@
|
||||
"filter": {
|
||||
"sources": "Select sources (all if empty)",
|
||||
"levels": "Filter levels",
|
||||
"grep": "Full-text search"
|
||||
"grep": "Full-text search",
|
||||
"reset": "Reset filters"
|
||||
}
|
||||
},
|
||||
"fwlog": {
|
||||
@@ -861,5 +1119,34 @@
|
||||
"dst": "Dest IP",
|
||||
"rule": "Rule ID"
|
||||
}
|
||||
},
|
||||
"audit": {
|
||||
"title": "Audit log",
|
||||
"intro": "Who changed what, when. Every mutation through the API (domain create, backend disable, cert issue, …) is recorded here.",
|
||||
"filter": {
|
||||
"actor": "Actor",
|
||||
"action": "Action",
|
||||
"subject": "Subject",
|
||||
"range": "Time range",
|
||||
"search": "Search",
|
||||
"reset": "Reset"
|
||||
},
|
||||
"col": {
|
||||
"time": "Time",
|
||||
"actor": "Actor",
|
||||
"action": "Action",
|
||||
"subject": "Subject",
|
||||
"detail": "Details"
|
||||
},
|
||||
"detailShow": "Show details",
|
||||
"empty": {
|
||||
"title": "No matches",
|
||||
"desc": "No entries matched these filters. Adjust or reset them."
|
||||
},
|
||||
"page": {
|
||||
"prev": "Prev",
|
||||
"next": "Next",
|
||||
"showing": "Row {{from}}–{{to}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
44
management-ui/src/lib/storageSchema.ts
Normal file
44
management-ui/src/lib/storageSchema.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// Storage-Schema-Stamp. Verhindert die „blank page nach Update"-Klasse
|
||||
// von Bugs: wenn wir die Form von etwas das wir nach localStorage
|
||||
// oder sessionStorage schreiben ändern, könnte das vorhandene Objekt
|
||||
// auf dem Client nicht mehr zur neuen Code-Version passen → Render-
|
||||
// throw → blank #root.
|
||||
//
|
||||
// Lösung: ein einziger Versions-Key. Beim App-Boot prüfen ob die
|
||||
// gespeicherte Version stimmt — wenn nicht, alle bekannten Storage-
|
||||
// Keys wegwerfen und neu stampen. Der Operator muss sich danach
|
||||
// einmal neu einloggen, sieht aber nicht mehr blank.
|
||||
//
|
||||
// Bump SCHEMA_VERSION immer wenn:
|
||||
// - eine SessionUser-Felddefinition geändert wird
|
||||
// - der Format der Logs-Filter geändert wird
|
||||
// - ein neuer Key zu KEYS hinzukommt der bei Mismatch raus muss
|
||||
//
|
||||
// Reine Additionen die abwärtskompatibel parsen brauchen keinen Bump.
|
||||
|
||||
const SCHEMA_VERSION = 1
|
||||
const STAMP_KEY = 'eg_storage_schema'
|
||||
|
||||
// Alle Keys die wir selbst nach localStorage/sessionStorage schreiben.
|
||||
// Drittanbieter-Keys (z.B. i18nextLng) wipen wir bewusst nicht — die
|
||||
// Library kennt ihr eigenes Format und repariert sich selbst.
|
||||
const SESSION_KEYS = ['eg_session']
|
||||
const LOCAL_KEYS = ['edgeguard.logs.filters']
|
||||
|
||||
export function ensureStorageSchema(): void {
|
||||
let stored: number | null = null
|
||||
try {
|
||||
const raw = localStorage.getItem(STAMP_KEY)
|
||||
if (raw) stored = parseInt(raw, 10)
|
||||
} catch { /* localStorage disabled — nichts zu tun */ }
|
||||
|
||||
if (stored === SCHEMA_VERSION) return
|
||||
|
||||
// Mismatch (oder erstmaliger Start) → unsere eigenen Keys wegwerfen.
|
||||
// Try/catch pro Operation: ein einzelner Quota-/SecurityError soll
|
||||
// den Cleanup nicht abbrechen, sonst bleiben halb-bereinigte Reste.
|
||||
for (const k of SESSION_KEYS) { try { sessionStorage.removeItem(k) } catch { /* ignore */ } }
|
||||
for (const k of LOCAL_KEYS) { try { localStorage.removeItem(k) } catch { /* ignore */ } }
|
||||
|
||||
try { localStorage.setItem(STAMP_KEY, String(SCHEMA_VERSION)) } catch { /* ignore */ }
|
||||
}
|
||||
@@ -1,11 +1,21 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { ensureStorageSchema } from './lib/storageSchema'
|
||||
|
||||
// Vor allen anderen Imports die Storage prüfen — i18n und auth-store
|
||||
// lesen beim Modul-Init aus Storage, also muss der Cleanup davor
|
||||
// passieren wenn die Schema-Version nicht stimmt.
|
||||
ensureStorageSchema()
|
||||
|
||||
import './styles/enterprise.css'
|
||||
import './i18n'
|
||||
import App from './App.tsx'
|
||||
import ErrorBoundary from './components/ErrorBoundary'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ import dayjs from 'dayjs'
|
||||
|
||||
import apiClient, { isEnvelope } from '../../api/client'
|
||||
import PageHeader from '../../components/PageHeader'
|
||||
import EmptyState from '../../components/EmptyState'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
@@ -234,6 +235,11 @@ export default function AlertsPage() {
|
||||
|
||||
const kind = Form.useWatch('kind', form)
|
||||
|
||||
const openChannelCreate = () => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({ kind: 'webhook', active: true, smtp_port: 587, use_tls: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
@@ -266,17 +272,25 @@ export default function AlertsPage() {
|
||||
children: (
|
||||
<Card size="small" extra={
|
||||
<Button type="primary" size="small" icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({ kind: 'webhook', active: true, smtp_port: 587, use_tls: true })
|
||||
}}>
|
||||
onClick={openChannelCreate}>
|
||||
{t('alerts.add')}
|
||||
</Button>
|
||||
}>
|
||||
<Table size="small" rowKey="id" loading={channels.isFetching}
|
||||
dataSource={channels.data ?? []} columns={chanColumns}
|
||||
pagination={false}
|
||||
locale={{ emptyText: t('alerts.emptyChannels') }} />
|
||||
locale={{ emptyText: (
|
||||
<EmptyState
|
||||
icon={<BellOutlined />}
|
||||
title={t('alerts.emptyChannelsTitle')}
|
||||
description={t('alerts.emptyChannelsDesc')}
|
||||
action={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openChannelCreate}>
|
||||
{t('alerts.add')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) }} />
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
@@ -288,7 +302,13 @@ export default function AlertsPage() {
|
||||
<Table size="small" rowKey="id" loading={events.isFetching}
|
||||
dataSource={events.data ?? []} columns={evColumns}
|
||||
pagination={{ pageSize: 25 }}
|
||||
locale={{ emptyText: t('alerts.emptyEvents') }} />
|
||||
locale={{ emptyText: (
|
||||
<EmptyState
|
||||
icon={<BellOutlined />}
|
||||
title={t('alerts.emptyEventsTitle')}
|
||||
description={t('alerts.emptyEventsDesc')}
|
||||
/>
|
||||
) }} />
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
|
||||
204
management-ui/src/pages/Audit/index.tsx
Normal file
204
management-ui/src/pages/Audit/index.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import { useState } from 'react'
|
||||
import { Button, Card, Col, DatePicker, Form, Input, Row, Space, Tag, Typography } from 'antd'
|
||||
import { FileSearchOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { Dayjs } from 'dayjs'
|
||||
|
||||
import PageHeader from '../../components/PageHeader'
|
||||
import DataTable from '../../components/DataTable'
|
||||
import EmptyState from '../../components/EmptyState'
|
||||
import apiClient, { isEnvelope } from '../../api/client'
|
||||
|
||||
interface AuditEntry {
|
||||
id: number
|
||||
actor: string
|
||||
action: string
|
||||
subject?: string | null
|
||||
detail?: unknown
|
||||
node_id?: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface SearchParams {
|
||||
actor?: string
|
||||
action?: string
|
||||
subject?: string
|
||||
since?: Dayjs | null
|
||||
until?: Dayjs | null
|
||||
}
|
||||
|
||||
interface FormValues {
|
||||
actor?: string
|
||||
action?: string
|
||||
subject?: string
|
||||
range?: [Dayjs, Dayjs] | null
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 100
|
||||
|
||||
async function searchAudit(p: SearchParams, offset: number): Promise<AuditEntry[]> {
|
||||
const params: Record<string, string> = { limit: String(PAGE_SIZE), offset: String(offset) }
|
||||
if (p.actor) params.actor = p.actor
|
||||
if (p.action) params.action = p.action
|
||||
if (p.subject) params.subject = p.subject
|
||||
if (p.since) params.since = p.since.toISOString()
|
||||
if (p.until) params.until = p.until.toISOString()
|
||||
const r = await apiClient.get('/audit/search', { params })
|
||||
if (!isEnvelope(r.data)) return []
|
||||
return (r.data.data as { entries?: AuditEntry[] }).entries ?? []
|
||||
}
|
||||
|
||||
export default function AuditPage() {
|
||||
const { t } = useTranslation()
|
||||
const [form] = Form.useForm<FormValues>()
|
||||
const [filters, setFilters] = useState<SearchParams>({})
|
||||
const [offset, setOffset] = useState(0)
|
||||
|
||||
const { data: entries, isLoading, refetch } = useQuery({
|
||||
queryKey: ['audit', 'search', filters, offset],
|
||||
queryFn: () => searchAudit(filters, offset),
|
||||
})
|
||||
|
||||
const onSubmit = (v: FormValues) => {
|
||||
setOffset(0)
|
||||
setFilters({
|
||||
actor: v.actor?.trim() || undefined,
|
||||
action: v.action?.trim() || undefined,
|
||||
subject: v.subject?.trim() || undefined,
|
||||
since: v.range?.[0] ?? null,
|
||||
until: v.range?.[1] ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
const onReset = () => {
|
||||
form.resetFields()
|
||||
setOffset(0)
|
||||
setFilters({})
|
||||
}
|
||||
|
||||
const columns: ColumnsType<AuditEntry> = [
|
||||
{
|
||||
title: t('audit.col.time'), key: 'created_at', dataIndex: 'created_at', width: 170,
|
||||
render: (s: string) => (
|
||||
<Typography.Text style={{ fontSize: 12 }}>
|
||||
{new Date(s).toLocaleString()}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('audit.col.actor'), key: 'actor', dataIndex: 'actor', width: 200,
|
||||
render: (s: string) => <code style={{ fontSize: 12 }}>{s}</code>,
|
||||
},
|
||||
{
|
||||
title: t('audit.col.action'), key: 'action', dataIndex: 'action', width: 220,
|
||||
render: (s: string) => <Tag color="blue" style={{ fontFamily: 'monospace' }}>{s}</Tag>,
|
||||
},
|
||||
{
|
||||
title: t('audit.col.subject'), key: 'subject', dataIndex: 'subject',
|
||||
render: (s?: string | null) => s ? <code style={{ fontSize: 12 }}>{s}</code> : <Typography.Text type="secondary">—</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: t('audit.col.detail'), key: 'detail',
|
||||
render: (_, row) => {
|
||||
if (!row.detail) return <Typography.Text type="secondary">—</Typography.Text>
|
||||
const txt = typeof row.detail === 'string' ? row.detail : JSON.stringify(row.detail)
|
||||
if (txt.length <= 80) {
|
||||
return <Typography.Text style={{ fontSize: 11, fontFamily: 'monospace' }}>{txt}</Typography.Text>
|
||||
}
|
||||
return (
|
||||
<details>
|
||||
<summary style={{ fontSize: 11, color: '#64748B', cursor: 'pointer' }}>
|
||||
{t('audit.detailShow')}
|
||||
</summary>
|
||||
<pre style={{ fontSize: 11, margin: '4px 0 0 0', maxWidth: 480, whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>{txt}</pre>
|
||||
</details>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const hasMore = (entries?.length ?? 0) === PAGE_SIZE
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
icon={<FileSearchOutlined />}
|
||||
title={t('audit.title')}
|
||||
subtitle={t('audit.intro')}
|
||||
/>
|
||||
|
||||
<Card size="small" style={{ marginBottom: 12 }}>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={onSubmit}
|
||||
initialValues={{ actor: '', action: '', subject: '', range: null }}
|
||||
>
|
||||
<Row gutter={12}>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Form.Item label={t('audit.filter.actor')} name="actor">
|
||||
<Input placeholder="z.B. admin@…" allowClear />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Form.Item label={t('audit.filter.action')} name="action">
|
||||
<Input placeholder="z.B. domain.update" allowClear />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Form.Item label={t('audit.filter.subject')} name="subject">
|
||||
<Input placeholder="z.B. example.com" allowClear />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={6}>
|
||||
<Form.Item label={t('audit.filter.range')} name="range">
|
||||
<DatePicker.RangePicker showTime style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">{t('audit.filter.search')}</Button>
|
||||
<Button onClick={onReset}>{t('audit.filter.reset')}</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => refetch()}>
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<DataTable
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
dataSource={entries ?? []}
|
||||
columns={columns}
|
||||
emptyContent={
|
||||
<EmptyState
|
||||
icon={<FileSearchOutlined />}
|
||||
title={t('audit.empty.title')}
|
||||
description={t('audit.empty.desc')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Space style={{ marginTop: 12 }}>
|
||||
<Button
|
||||
disabled={offset === 0}
|
||||
onClick={() => setOffset(Math.max(0, offset - PAGE_SIZE))}
|
||||
>
|
||||
{t('audit.page.prev')}
|
||||
</Button>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{t('audit.page.showing', { from: offset + 1, to: offset + (entries?.length ?? 0) })}
|
||||
</Typography.Text>
|
||||
<Button
|
||||
disabled={!hasMore}
|
||||
onClick={() => setOffset(offset + PAGE_SIZE)}
|
||||
>
|
||||
{t('audit.page.next')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { DatabaseOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import DataTable from '../../components/DataTable'
|
||||
import EmptyState from '../../components/EmptyState'
|
||||
import PageHeader from '../../components/PageHeader'
|
||||
import ActionButtons from '../../components/ActionButtons'
|
||||
import StatusDot from '../../components/StatusDot'
|
||||
@@ -85,12 +86,37 @@ async function listDomains(): Promise<DomainFull[]> {
|
||||
return (r.data.data as { domains?: DomainFull[] }).domains ?? []
|
||||
}
|
||||
|
||||
interface HAProxyStat { backend: string; server: string; status: string }
|
||||
async function listHAProxyStats(): Promise<HAProxyStat[]> {
|
||||
try {
|
||||
const r = await apiClient.get('/haproxy/stats')
|
||||
if (!isEnvelope(r.data)) return []
|
||||
return (r.data.data as { backends?: HAProxyStat[] }).backends ?? []
|
||||
} catch { return [] }
|
||||
}
|
||||
|
||||
export default function BackendsPage() {
|
||||
const { t } = useTranslation()
|
||||
const qc = useQueryClient()
|
||||
|
||||
const { data, isLoading } = useQuery({ queryKey: ['backends'], queryFn: listBackends })
|
||||
const { data: domains } = useQuery({ queryKey: ['domains'], queryFn: listDomains })
|
||||
const { data: haproxyStats } = useQuery({
|
||||
queryKey: ['haproxy', 'stats'],
|
||||
queryFn: listHAProxyStats,
|
||||
refetchInterval: 15_000,
|
||||
})
|
||||
|
||||
// UP = alle Server UP, DEGRADED = mind. 1 UP + mind. 1 DOWN, DOWN = alle DOWN
|
||||
const backendLiveStatus = (id: number): 'UP' | 'DEGRADED' | 'DOWN' | null => {
|
||||
if (!haproxyStats?.length) return null
|
||||
const servers = haproxyStats.filter(s => s.backend === `eg_backend_${id}`)
|
||||
if (!servers.length) return null
|
||||
const upCount = servers.filter(s => s.status === 'UP').length
|
||||
if (upCount === servers.length) return 'UP'
|
||||
if (upCount > 0) return 'DEGRADED'
|
||||
return 'DOWN'
|
||||
}
|
||||
|
||||
// server-counts pro Backend laden wir lazy bei Expansion; in der
|
||||
// Tabelle reicht ein Hinweis ob 0 / N Server.
|
||||
@@ -211,6 +237,15 @@ export default function BackendsPage() {
|
||||
return <Space size={4} wrap>{ds.map(d => <Tag key={d.id} color="blue">{d.name}</Tag>)}</Space>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('backends.liveStatus'), key: 'liveStatus', width: 110,
|
||||
render: (_, row) => {
|
||||
const s = backendLiveStatus(row.id)
|
||||
if (!s) return <Text type="secondary" style={{ fontSize: 12 }}>—</Text>
|
||||
const color = s === 'UP' ? 'green' : s === 'DEGRADED' ? 'orange' : 'red'
|
||||
return <Tag color={color} style={{ margin: 0 }}>{s}</Tag>
|
||||
},
|
||||
},
|
||||
{ title: t('backends.active'), dataIndex: 'active', key: 'active', render: (v: boolean) => <StatusDot active={v} /> },
|
||||
{
|
||||
title: t('common.actions'), key: 'actions',
|
||||
@@ -235,6 +270,11 @@ export default function BackendsPage() {
|
||||
},
|
||||
]
|
||||
|
||||
const openCreate = () => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({ scheme: 'http', lb_algorithm: 'roundrobin', websocket: false, active: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
@@ -252,13 +292,22 @@ export default function BackendsPage() {
|
||||
rowExpandable: (record) => !!record.id,
|
||||
}}
|
||||
extraActions={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({ scheme: 'http', lb_algorithm: 'roundrobin', websocket: false, active: true })
|
||||
}}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
{t('backends.addBackend')}
|
||||
</Button>
|
||||
}
|
||||
emptyContent={
|
||||
<EmptyState
|
||||
icon={<DatabaseOutlined />}
|
||||
title={t('backends.emptyTitle')}
|
||||
description={t('backends.emptyDesc')}
|
||||
action={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
{t('backends.addBackend')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Modal
|
||||
title={editing ? t('backends.editBackend') : t('backends.addBackend')}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Alert, Button, Card, Popconfirm, Space, Table, Tag, Tooltip, Typography, message,
|
||||
Alert, Button, Card, Col, Popconfirm, Row, Space, Statistic, Table, Tag, Tooltip, Typography, message,
|
||||
} from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import {
|
||||
@@ -252,9 +252,68 @@ export default function HistoryTab() {
|
||||
},
|
||||
]
|
||||
|
||||
// Aggregat-Zähler. Quelle: die bereits geladene Liste — die Backup-
|
||||
// History wird in der UI sowieso komplett geladen (Pagination ist
|
||||
// clientseitig). Keine zusätzlichen Endpoint-Calls.
|
||||
const all = list.data ?? []
|
||||
const succ = all.filter((b) => b.status === 'success')
|
||||
const lastSucc = succ[0] // bereits DESC sortiert vom Backend
|
||||
const totalSize = succ.reduce((acc, b) => acc + (b.size_bytes || 0), 0)
|
||||
const last24hMs = Date.now() - 86_400_000
|
||||
const failsLast24h = all.filter((b) =>
|
||||
b.status === 'failed' && new Date(b.started_at).getTime() >= last24hMs,
|
||||
).length
|
||||
const lastSuccAge = lastSucc
|
||||
? Math.round((Date.now() - new Date(lastSucc.finished_at).getTime()) / 3_600_000) // hours
|
||||
: null
|
||||
|
||||
return (
|
||||
<div>
|
||||
{msgCtx}
|
||||
|
||||
{all.length > 0 && (
|
||||
<Row gutter={12} className="mb-16">
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title={t('backups.statLastSuccess')}
|
||||
value={
|
||||
lastSuccAge == null
|
||||
? '—'
|
||||
: lastSuccAge < 24
|
||||
? t('backups.statHoursAgo', { n: lastSuccAge })
|
||||
: t('backups.statDaysAgo', { n: Math.round(lastSuccAge / 24) })
|
||||
}
|
||||
valueStyle={
|
||||
lastSuccAge == null ? { color: '#cf1322' }
|
||||
: lastSuccAge > 48 ? { color: '#d48806' }
|
||||
: { color: '#0F172A' }
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic title={t('backups.statTotal')} value={succ.length} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic title={t('backups.statSize')} value={fmtSize(totalSize)} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title={t('backups.statFails24h')}
|
||||
value={failsLast24h}
|
||||
valueStyle={failsLast24h > 0 ? { color: '#cf1322' } : undefined}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
<Space style={{ marginBottom: 12 }}>
|
||||
<Tooltip title={t('backups.refreshTooltip')}>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => list.refetch()}>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Alert, Card, Descriptions, Space, Spin, Table, Tag, Typography } from 'antd'
|
||||
import { Alert, Button, Card, Descriptions, Input, Modal, Popconfirm, Space, Spin, Table, Tag, Tooltip, Typography, message } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { ApartmentOutlined } from '@ant-design/icons'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ApartmentOutlined, CopyOutlined, DeleteOutlined, KeyOutlined } from '@ant-design/icons'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import apiClient, { isEnvelope } from '../../api/client'
|
||||
@@ -35,6 +36,52 @@ interface ClusterStatus {
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface NodeResources {
|
||||
load_avg_1: number
|
||||
load_avg_5: number
|
||||
load_avg_15: number
|
||||
mem_used_pct: number
|
||||
mem_total_kb: number
|
||||
mem_avail_kb: number
|
||||
disk_used_pct: number
|
||||
disk_total_gb: number
|
||||
disk_free_gb: number
|
||||
conntrack_count: number
|
||||
conntrack_max: number
|
||||
uptime_sec: number
|
||||
}
|
||||
|
||||
interface PeerLoadResult {
|
||||
node_id: string
|
||||
fqdn: string
|
||||
ok: boolean
|
||||
data?: NodeResources
|
||||
error?: string
|
||||
duration_ms: number
|
||||
}
|
||||
|
||||
interface JoinTokenResponse {
|
||||
token: string
|
||||
expires_at: string
|
||||
ca_fingerprint: string
|
||||
}
|
||||
|
||||
interface CertInfo {
|
||||
common_name: string
|
||||
not_before: string
|
||||
not_after: string
|
||||
days_remaining: number
|
||||
is_ca: boolean
|
||||
serial_hex: string
|
||||
}
|
||||
|
||||
interface CertStatus {
|
||||
has_ca: boolean
|
||||
has_peer: boolean
|
||||
ca?: CertInfo
|
||||
peer?: CertInfo
|
||||
}
|
||||
|
||||
function statusTag(s: HANode['status']) {
|
||||
switch (s) {
|
||||
case 'online': return <Tag color="green">online</Tag>
|
||||
@@ -45,16 +92,30 @@ function statusTag(s: HANode['status']) {
|
||||
}
|
||||
}
|
||||
|
||||
function lastSeenRelative(iso?: string | null): string {
|
||||
function lastSeenRelative(iso: string | null | undefined, now: number): string {
|
||||
if (!iso) return '—'
|
||||
const ms = Date.now() - new Date(iso).getTime()
|
||||
const ms = now - new Date(iso).getTime()
|
||||
if (ms < 0) return '—'
|
||||
if (ms < 60_000) return `${Math.round(ms / 1000)}s`
|
||||
if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m`
|
||||
return `${Math.round(ms / 3_600_000)}h`
|
||||
}
|
||||
|
||||
// useTickingNow gibt einen `now`-Wert zurück der jede Sekunde
|
||||
// re-rendert. Damit tickt das Cluster-Last-Seen-Label visuell jede
|
||||
// Sekunde, statt nur alle 30s beim useQuery-Refetch.
|
||||
function useTickingNow(intervalMs = 1000): number {
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => setNow(Date.now()), intervalMs)
|
||||
return () => clearInterval(t)
|
||||
}, [intervalMs])
|
||||
return now
|
||||
}
|
||||
|
||||
export default function ClusterPage() {
|
||||
const { t } = useTranslation()
|
||||
const now = useTickingNow()
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['cluster', 'status'],
|
||||
@@ -65,9 +126,76 @@ export default function ClusterPage() {
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
|
||||
// Phase 3.3: per-Node Load via mTLS-Aggregator. Single-node = nur die
|
||||
// eigene Zeile. Multi-Node = pro Peer eine. refetchInterval bewusst
|
||||
// langsamer als /cluster/status weil fan-out N×3s Netzwerk-Roundtrips
|
||||
// bedeuten kann.
|
||||
const loadQuery = useQuery({
|
||||
queryKey: ['cluster', 'system-load'],
|
||||
queryFn: async () => {
|
||||
const r = await apiClient.get('/cluster/system/load')
|
||||
const payload = isEnvelope(r.data) ? (r.data.data as { nodes?: PeerLoadResult[] }) : null
|
||||
return payload?.nodes ?? []
|
||||
},
|
||||
refetchInterval: 60_000,
|
||||
})
|
||||
|
||||
const certStatus = useQuery({
|
||||
queryKey: ['cluster', 'cert-status'],
|
||||
queryFn: async () => {
|
||||
const r = await apiClient.get('/cluster/cert-status')
|
||||
return isEnvelope(r.data) ? (r.data.data as CertStatus) : null
|
||||
},
|
||||
refetchInterval: 5 * 60_000,
|
||||
})
|
||||
const qc = useQueryClient()
|
||||
const removePeer = useMutation({
|
||||
mutationFn: async (id: string) => apiClient.delete(`/cluster/nodes/${id}`),
|
||||
onSuccess: () => {
|
||||
message.success(t('cluster.removePeerOk'))
|
||||
void qc.invalidateQueries({ queryKey: ['cluster'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(t('cluster.removePeerFailed') + ': ' + e.message),
|
||||
})
|
||||
|
||||
const renewSelf = useMutation({
|
||||
mutationFn: async () => {
|
||||
const r = await apiClient.post('/cluster/renew-self')
|
||||
return r.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success(t('cluster.certRenewedRestartHint'))
|
||||
void certStatus.refetch()
|
||||
},
|
||||
onError: (e: Error) => {
|
||||
message.error(t('cluster.certRenewFailed') + ': ' + e.message)
|
||||
},
|
||||
})
|
||||
|
||||
const [joinTokenOpen, setJoinTokenOpen] = useState(false)
|
||||
const [joinToken, setJoinToken] = useState<JoinTokenResponse | null>(null)
|
||||
const generateToken = useMutation({
|
||||
mutationFn: async () => {
|
||||
const r = await apiClient.post('/cluster/join-tokens')
|
||||
return isEnvelope(r.data) ? (r.data.data as JoinTokenResponse) : null
|
||||
},
|
||||
onSuccess: (t) => {
|
||||
setJoinToken(t)
|
||||
setJoinTokenOpen(true)
|
||||
},
|
||||
onError: () => {
|
||||
message.error(t('cluster.joinTokenFailed'))
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) return <Spin />
|
||||
if (!data) return null
|
||||
|
||||
const primaryFqdn = data.local_node?.fqdn ?? '<primary-fqdn>'
|
||||
const joinCmd = joinToken
|
||||
? `sudo edgeguard-ctl cluster-join ${primaryFqdn} \\\n --token ${joinToken.token}`
|
||||
: ''
|
||||
|
||||
const peerColumns: ColumnsType<HANode> = [
|
||||
{
|
||||
title: t('cluster.col.node'), key: 'node',
|
||||
@@ -103,9 +231,37 @@ export default function ClusterPage() {
|
||||
{ title: t('cluster.col.version'), dataIndex: 'version', width: 100,
|
||||
render: (v?: string | null) => v ? <Tag>{v}</Tag> : <Text type="secondary">—</Text> },
|
||||
{
|
||||
title: t('cluster.col.lastSeen'), dataIndex: 'last_seen', width: 100,
|
||||
render: (v?: string | null) => (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{lastSeenRelative(v)}</Text>
|
||||
title: t('cluster.col.lastSeen'), dataIndex: 'last_seen', width: 110,
|
||||
render: (v: string | null | undefined, r: HANode) => {
|
||||
const rel = lastSeenRelative(v, now)
|
||||
const tipText = v ? new Date(v).toLocaleString() : t('cluster.col.lastSeen')
|
||||
const stale = r.status !== 'online'
|
||||
return (
|
||||
<Tooltip title={tipText}>
|
||||
<Text type={stale ? 'danger' : 'secondary'} style={{ fontSize: 12 }}>{rel}</Text>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('common.actions'), key: 'actions', width: 110,
|
||||
render: (_, r) => (
|
||||
<Popconfirm
|
||||
title={t('cluster.removePeerConfirmTitle')}
|
||||
description={t('cluster.removePeerConfirmDesc', { fqdn: r.fqdn })}
|
||||
okText={t('common.yes')}
|
||||
cancelText={t('common.no')}
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => removePeer.mutate(r.id)}
|
||||
>
|
||||
<Button
|
||||
type="text" size="small" danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={removePeer.isPending && removePeer.variables === r.id}
|
||||
>
|
||||
{t('cluster.removePeerBtn')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
]
|
||||
@@ -118,6 +274,13 @@ export default function ClusterPage() {
|
||||
subtitle={t('cluster.intro', { count: 1 + data.peers.length })}
|
||||
extra={
|
||||
<Space>
|
||||
<Button
|
||||
icon={<KeyOutlined />}
|
||||
loading={generateToken.isPending}
|
||||
onClick={() => generateToken.mutate()}
|
||||
>
|
||||
{t('cluster.generateJoinToken')}
|
||||
</Button>
|
||||
<Tag color={data.mode === 'cluster' ? 'blue' : 'default'}>
|
||||
{data.mode === 'cluster' ? t('cluster.modeCluster') : t('cluster.modeSingle')}
|
||||
</Tag>
|
||||
@@ -171,6 +334,13 @@ export default function ClusterPage() {
|
||||
<Descriptions.Item label={t('cluster.col.version')}>
|
||||
{data.local_node.version ? <Tag>{data.local_node.version}</Tag> : '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('cluster.col.lastSeen')}>
|
||||
<Tooltip title={data.local_node.last_seen ? new Date(data.local_node.last_seen).toLocaleString() : '—'}>
|
||||
<Text type={data.local_node.status === 'online' ? 'secondary' : 'danger'} style={{ fontSize: 12 }}>
|
||||
{lastSeenRelative(data.local_node.last_seen, now)}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('cluster.col.mgmtIp')}>
|
||||
<Text style={{ fontFamily: 'monospace' }}>
|
||||
{data.local_node.mgmt_ip || '—'}
|
||||
@@ -190,6 +360,46 @@ export default function ClusterPage() {
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{(certStatus.data?.has_ca || certStatus.data?.has_peer) && (
|
||||
<Card size="small" title={t('cluster.certCardTitle')} className="mb-16"
|
||||
extra={certStatus.data?.has_ca && (
|
||||
<Popconfirm
|
||||
title={t('cluster.renewSelfConfirm')}
|
||||
okText={t('common.yes')}
|
||||
cancelText={t('common.no')}
|
||||
onConfirm={() => renewSelf.mutate()}
|
||||
>
|
||||
<Button size="small" loading={renewSelf.isPending}>
|
||||
{t('cluster.renewSelfBtn')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
>
|
||||
<Descriptions size="small" column={2} bordered>
|
||||
{certStatus.data.ca && (
|
||||
<>
|
||||
<Descriptions.Item label={t('cluster.certCALabel')}>
|
||||
<Text>{certStatus.data.ca.common_name}</Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('cluster.certExpiry')}>
|
||||
<CertExpiry days={certStatus.data.ca.days_remaining} until={certStatus.data.ca.not_after} />
|
||||
</Descriptions.Item>
|
||||
</>
|
||||
)}
|
||||
{certStatus.data.peer && (
|
||||
<>
|
||||
<Descriptions.Item label={t('cluster.certPeerLabel')}>
|
||||
<Text>{certStatus.data.peer.common_name}</Text>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('cluster.certExpiry')}>
|
||||
<CertExpiry days={certStatus.data.peer.days_remaining} until={certStatus.data.peer.not_after} />
|
||||
</Descriptions.Item>
|
||||
</>
|
||||
)}
|
||||
</Descriptions>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{data.peers.length > 0 && (
|
||||
<Card size="small" title={t('cluster.peersTitle', { count: data.peers.length })}>
|
||||
<Table
|
||||
@@ -201,6 +411,159 @@ export default function ClusterPage() {
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Per-Node Resources via mTLS-Aggregator (Phase 3.3). Bei
|
||||
Single-Node 1 Zeile; bei Cluster N. duration_ms zeigt welcher
|
||||
Peer langsam ist (Netzwerk-Latenz oder Last). */}
|
||||
<Card
|
||||
size="small"
|
||||
title={t('cluster.loadTitle')}
|
||||
className="mt-16"
|
||||
loading={loadQuery.isLoading}
|
||||
>
|
||||
<Table<PeerLoadResult>
|
||||
size="small"
|
||||
rowKey="node_id"
|
||||
dataSource={loadQuery.data ?? []}
|
||||
pagination={false}
|
||||
locale={{ emptyText: t('cluster.loadEmpty') }}
|
||||
columns={[
|
||||
{
|
||||
title: t('cluster.col.node'), key: 'node',
|
||||
render: (_, r) => (
|
||||
<Space>
|
||||
<Text strong>{r.fqdn || r.node_id}</Text>
|
||||
{!r.ok && <Tag color="red">{r.error || 'error'}</Tag>}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('cluster.col.load'), key: 'load', width: 110,
|
||||
render: (_, r) => r.ok && r.data
|
||||
? <Text style={{ fontFamily: 'monospace' }}>
|
||||
{r.data.load_avg_1.toFixed(2)} / {r.data.load_avg_5.toFixed(2)} / {r.data.load_avg_15.toFixed(2)}
|
||||
</Text>
|
||||
: <Text type="secondary">—</Text>,
|
||||
},
|
||||
{
|
||||
title: t('cluster.col.mem'), key: 'mem', width: 110,
|
||||
render: (_, r) => r.ok && r.data
|
||||
? <Text>{r.data.mem_used_pct.toFixed(0)}%</Text>
|
||||
: <Text type="secondary">—</Text>,
|
||||
},
|
||||
{
|
||||
title: t('cluster.col.disk'), key: 'disk', width: 110,
|
||||
render: (_, r) => r.ok && r.data
|
||||
? <Text>{r.data.disk_used_pct.toFixed(0)}%</Text>
|
||||
: <Text type="secondary">—</Text>,
|
||||
},
|
||||
{
|
||||
title: t('cluster.col.conntrack'), key: 'ct', width: 130,
|
||||
render: (_, r) => r.ok && r.data
|
||||
? <Text style={{ fontFamily: 'monospace', fontSize: 12 }}>
|
||||
{r.data.conntrack_count}/{r.data.conntrack_max}
|
||||
</Text>
|
||||
: <Text type="secondary">—</Text>,
|
||||
},
|
||||
{
|
||||
title: t('cluster.col.uptime'), key: 'up', width: 100,
|
||||
render: (_, r) => r.ok && r.data
|
||||
? <Text type="secondary" style={{ fontSize: 12 }}>{formatUptime(r.data.uptime_sec)}</Text>
|
||||
: <Text type="secondary">—</Text>,
|
||||
},
|
||||
{
|
||||
title: t('cluster.col.fetchMs'), key: 'ms', width: 80,
|
||||
render: (_, r) => (
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>{r.duration_ms}ms</Text>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
title={t('cluster.joinTokenTitle')}
|
||||
open={joinTokenOpen}
|
||||
onCancel={() => setJoinTokenOpen(false)}
|
||||
footer={<Button onClick={() => setJoinTokenOpen(false)}>{t('common.close')}</Button>}
|
||||
width={720}
|
||||
>
|
||||
{joinToken ? (
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={t('cluster.joinTokenOneShot')}
|
||||
description={t('cluster.joinTokenOneShotDesc', {
|
||||
expires: new Date(joinToken.expires_at).toLocaleString(),
|
||||
})}
|
||||
/>
|
||||
<Descriptions size="small" column={1} bordered>
|
||||
<Descriptions.Item label="Token">
|
||||
<Input.TextArea
|
||||
value={joinToken.token}
|
||||
readOnly
|
||||
autoSize={{ minRows: 2, maxRows: 4 }}
|
||||
style={{ fontFamily: 'monospace', fontSize: 11 }}
|
||||
/>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="CA-Fingerprint">
|
||||
<Text code>{joinToken.ca_fingerprint}</Text>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<div>
|
||||
<Text strong>{t('cluster.joinCmdLabel')}</Text>
|
||||
<Input.TextArea
|
||||
value={joinCmd}
|
||||
readOnly
|
||||
autoSize={{ minRows: 2, maxRows: 4 }}
|
||||
style={{ fontFamily: 'monospace', fontSize: 12, marginTop: 6 }}
|
||||
/>
|
||||
<Button
|
||||
icon={<CopyOutlined />}
|
||||
size="small"
|
||||
style={{ marginTop: 6 }}
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(joinCmd)
|
||||
message.success(t('common.copied'))
|
||||
}}
|
||||
>
|
||||
{t('common.copy')}
|
||||
</Button>
|
||||
</div>
|
||||
</Space>
|
||||
) : null}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// CertExpiry rendert "<n> Tage" mit Farbcode: rot < 30, orange < 90,
|
||||
// grün sonst. Tooltip zeigt das absolute NotAfter-Datum.
|
||||
function CertExpiry({ days, until }: { days: number; until: string }) {
|
||||
let color: string | undefined
|
||||
if (days < 0) color = '#cf1322' // already expired
|
||||
else if (days < 30) color = '#cf1322' // critical
|
||||
else if (days < 90) color = '#d4651a' // warning
|
||||
else color = '#52c41a' // healthy
|
||||
const label = days < 0 ? `abgelaufen vor ${-days} Tagen` : `${days} Tage`
|
||||
return (
|
||||
<Tooltip title={new Date(until).toLocaleString()}>
|
||||
<Tag color={color === '#52c41a' ? 'green' : color === '#d4651a' ? 'orange' : 'red'}>
|
||||
{label}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
// formatUptime liefert "Xd Yh" oder "Xh Ym" oder "Xm" — kompakter als
|
||||
// die Sekunden-Zahl.
|
||||
function formatUptime(sec: number): string {
|
||||
if (!sec || sec < 0) return '—'
|
||||
const d = Math.floor(sec / 86400)
|
||||
const h = Math.floor((sec % 86400) / 3600)
|
||||
const m = Math.floor((sec % 3600) / 60)
|
||||
if (d > 0) return `${d}d ${h}h`
|
||||
if (h > 0) return `${h}h ${m}m`
|
||||
return `${m}m`
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next'
|
||||
|
||||
import apiClient, { isEnvelope } from '../../api/client'
|
||||
import DataTable from '../../components/DataTable'
|
||||
import EmptyState from '../../components/EmptyState'
|
||||
import PageHeader from '../../components/PageHeader'
|
||||
import ActionButtons from '../../components/ActionButtons'
|
||||
import StatusDot from '../../components/StatusDot'
|
||||
@@ -152,6 +153,11 @@ function ZonesTab() {
|
||||
},
|
||||
]
|
||||
|
||||
const openZoneCreate = () => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({ zone_type: 'local', active: true } as Zone)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
@@ -160,13 +166,22 @@ function ZonesTab() {
|
||||
dataSource={data ?? []}
|
||||
columns={cols}
|
||||
extraActions={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({ zone_type: 'local', active: true } as Zone)
|
||||
}}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openZoneCreate}>
|
||||
{t('dns.zone.add')}
|
||||
</Button>
|
||||
}
|
||||
emptyContent={
|
||||
<EmptyState
|
||||
icon={<GlobalOutlined />}
|
||||
title={t('dns.zone.emptyTitle')}
|
||||
description={t('dns.zone.emptyDesc')}
|
||||
action={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openZoneCreate}>
|
||||
{t('dns.zone.add')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
@@ -273,6 +288,11 @@ function RecordsDrawer({ zone, onClose }: RecordsDrawerProps) {
|
||||
},
|
||||
]
|
||||
|
||||
const openRecordCreate = () => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({ record_type: 'A', ttl: 300, active: true } as DNSRecord)
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
@@ -292,13 +312,22 @@ function RecordsDrawer({ zone, onClose }: RecordsDrawerProps) {
|
||||
dataSource={data ?? []}
|
||||
columns={cols}
|
||||
extraActions={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({ record_type: 'A', ttl: 300, active: true } as DNSRecord)
|
||||
}}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openRecordCreate}>
|
||||
{t('dns.record.add')}
|
||||
</Button>
|
||||
}
|
||||
emptyContent={
|
||||
<EmptyState
|
||||
icon={<NodeIndexOutlined />}
|
||||
title={t('dns.record.emptyTitle')}
|
||||
description={t('dns.record.emptyDesc')}
|
||||
action={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openRecordCreate}>
|
||||
{t('dns.record.add')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Alert, Card, Col, Progress, Row, Space, Statistic, Tag, Tooltip, Typography } from 'antd'
|
||||
import {
|
||||
ApartmentOutlined, ApiOutlined, BranchesOutlined, ClusterOutlined,
|
||||
ApartmentOutlined, ApiOutlined, BellOutlined, BranchesOutlined, ClusterOutlined,
|
||||
DashboardOutlined, DatabaseOutlined, FireOutlined, GlobalOutlined,
|
||||
SafetyCertificateOutlined, ThunderboltOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -11,6 +12,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import apiClient, { isEnvelope } from '../../api/client'
|
||||
import PageHeader from '../../components/PageHeader'
|
||||
import StatusDot from '../../components/StatusDot'
|
||||
import UpdateBanner from '../../components/UpdateBanner'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
@@ -153,6 +155,15 @@ function relativeFromIso(iso: string): string {
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────
|
||||
|
||||
interface AlertEvent {
|
||||
id: number
|
||||
kind: string
|
||||
severity: 'info' | 'warning' | 'error' | 'critical'
|
||||
subject: string
|
||||
message: string
|
||||
fired_at: string
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -161,6 +172,12 @@ export default function DashboardPage() {
|
||||
queryFn: () => fetchOne<{ status: string; version: string }>('/system/health'),
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
|
||||
const recentAlerts = useQuery({
|
||||
queryKey: ['alerts', 'events', 'recent'],
|
||||
queryFn: () => fetchList<AlertEvent>('/alerts/events?limit=10', 'events'),
|
||||
refetchInterval: 60_000,
|
||||
})
|
||||
const services = useQuery({
|
||||
queryKey: ['system', 'services'],
|
||||
queryFn: () => fetchList<ServiceStatus>('/system/services', 'services'),
|
||||
@@ -186,6 +203,34 @@ export default function DashboardPage() {
|
||||
const fwZones = useQuery({ queryKey: ['fw-zones'], queryFn: () => fetchList<FwZone>('/firewall/zones', 'zones') })
|
||||
const tlsCerts = useQuery({ queryKey: ['tls-certs'], queryFn: () => fetchList<TLSCert>('/tls-certs', 'tls_certs') })
|
||||
const cluster = useQuery({ queryKey: ['cluster', 'nodes'], queryFn: () => fetchList<ClusterNode>('/cluster/nodes', 'nodes') })
|
||||
// Zusätzlich /cluster/status für die Health-Ampel: liefert mode +
|
||||
// health + drift_found. Refresh-Intervall etwas länger (30s) als die
|
||||
// anderen Dashboard-Queries — die meisten Werte ändern sich selten.
|
||||
const clusterStatus = useQuery({
|
||||
queryKey: ['cluster', 'status'],
|
||||
queryFn: () => fetchOne<{
|
||||
mode: 'single-node' | 'cluster'
|
||||
health: 'ok' | 'degraded' | 'split-brain'
|
||||
drift_found: boolean
|
||||
}>('/cluster/status'),
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
// License-Status für PageHeader-Tag — frische Boxen sehen sofort
|
||||
// wieviel Trial-Zeit übrig ist. Kein Spam: Anzeige nur wenn
|
||||
// payload da ist; bei Errors fall silent (Lizenz-Page bleibt
|
||||
// die Autoritäts-Quelle).
|
||||
const license = useQuery({
|
||||
queryKey: ['license', 'status'],
|
||||
queryFn: () => fetchOne<{
|
||||
status: string
|
||||
type?: string
|
||||
valid?: boolean
|
||||
valid_until?: string
|
||||
expires_at?: string
|
||||
license_key?: string
|
||||
}>('/license/status'),
|
||||
refetchInterval: 5 * 60_000,
|
||||
})
|
||||
const wgIfaces = useQuery({ queryKey: ['wg', 'interfaces'], queryFn: () => fetchList<WGIface>('/wireguard/interfaces', 'interfaces') })
|
||||
const wgStatus = useQuery({
|
||||
queryKey: ['wg', 'status'],
|
||||
@@ -219,12 +264,44 @@ export default function DashboardPage() {
|
||||
subtitle={t('dashboard.welcomeHint')}
|
||||
extra={
|
||||
<Space>
|
||||
{/* Compact-Variante: prominenter „Auf Updates prüfen"-Button
|
||||
im Dashboard-Header (Pattern 1:1 aus mail-gateway
|
||||
Dashboard/v2/index.tsx). Bypasst den Server-seitigen
|
||||
5-min-apt-update-Throttle via ?force=1, sodass der
|
||||
Operator nach einem Publish nicht aufs 30s-Polling
|
||||
warten muss. Der globale Banner in AppLayout zeigt
|
||||
das Ergebnis dann sofort an. */}
|
||||
<UpdateBanner compact />
|
||||
{license.data && <LicenseChip data={license.data} />}
|
||||
<Tag color="blue">v{health.data?.version ?? '—'}</Tag>
|
||||
<StatusDot active={health.data?.status === 'ok'} />
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* ── Onboarding-Hinweis für frische Boxen ──────────
|
||||
Erscheint nur wenn 0 Domains UND 0 Backends — verschwindet
|
||||
sobald irgendwas konfiguriert ist. Drei klickbare Quick-Links
|
||||
zu den nächsten typischen Setup-Schritten. */}
|
||||
{(domains.data?.length ?? 0) === 0 && (backends.data?.length ?? 0) === 0 && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
className="mb-12"
|
||||
message={t('dashboard.onboardingTitle')}
|
||||
description={
|
||||
<Space direction="vertical" size={4}>
|
||||
<Text>{t('dashboard.onboardingIntro')}</Text>
|
||||
<ol style={{ margin: '4px 0 0 18px', padding: 0 }}>
|
||||
<li><Link to="/backends">{t('dashboard.onboardingStep1')}</Link></li>
|
||||
<li><Link to="/domains">{t('dashboard.onboardingStep2')}</Link></li>
|
||||
<li><Link to="/ssl">{t('dashboard.onboardingStep3')}</Link></li>
|
||||
</ol>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── KPI tiles (compact strip) ──────────────────── */}
|
||||
<Row gutter={[12, 12]} className="mb-12">
|
||||
<KPI icon={<GlobalOutlined />} label={t('dashboard.kpi.domains')} value={activeDomains} total={(domains.data ?? []).length} />
|
||||
@@ -240,6 +317,39 @@ export default function DashboardPage() {
|
||||
<ResourcesCard r={resources.data} />
|
||||
</Row>
|
||||
|
||||
{/* ── Recent Alerts ──────────────────────────────────
|
||||
Card erscheint nur wenn überhaupt Events da sind, sonst macht
|
||||
sie auf einer frischen Box visuelles Rauschen. Link zur
|
||||
vollständigen Alerts-Seite für Filter + Channel-Config. */}
|
||||
{(recentAlerts.data?.length ?? 0) > 0 && (
|
||||
<Card
|
||||
size="small"
|
||||
className="mb-12"
|
||||
title={<><BellOutlined /> {t('dashboard.alertsCard.title')}</>}
|
||||
extra={<Link to="/alerts">{t('dashboard.alertsCard.viewAll')}</Link>}
|
||||
>
|
||||
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
||||
{(recentAlerts.data ?? []).map(e => (
|
||||
<div key={e.id} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Tag color={
|
||||
e.severity === 'critical' ? 'red'
|
||||
: e.severity === 'error' ? 'red'
|
||||
: e.severity === 'warning' ? 'orange'
|
||||
: 'blue'
|
||||
}>{e.severity}</Tag>
|
||||
<Text strong style={{ flex: '0 0 auto' }}>{e.subject}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 11, flex: '0 0 auto' }}>
|
||||
{new Date(e.fired_at).toLocaleString()}
|
||||
</Text>
|
||||
<Text type="secondary" ellipsis style={{ flex: '1 1 auto', fontSize: 12 }}>
|
||||
{e.message}
|
||||
</Text>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* ── Service-health-grid ─────────────────────────── */}
|
||||
<Card size="small" title={<><DashboardOutlined /> {t('dashboard.servicesCard.title')}</>} className="mb-12">
|
||||
<Row gutter={[8, 8]}>
|
||||
@@ -341,8 +451,33 @@ export default function DashboardPage() {
|
||||
|
||||
{/* ── Cluster ─────────────────────────────────────── */}
|
||||
<Col xs={24} lg={12}>
|
||||
<Card size="small" title={<><ApartmentOutlined /> {t('dashboard.clusterCard.title')}</>} className="h-100">
|
||||
<Card
|
||||
size="small"
|
||||
title={<><ApartmentOutlined /> {t('dashboard.clusterCard.title')}</>}
|
||||
className="h-100"
|
||||
extra={clusterStatus.data && (
|
||||
<Space size={4}>
|
||||
<Tag color={clusterStatus.data.mode === 'cluster' ? 'blue' : 'default'}>
|
||||
{clusterStatus.data.mode === 'cluster'
|
||||
? t('dashboard.clusterCard.modeCluster')
|
||||
: t('dashboard.clusterCard.modeSingle')}
|
||||
</Tag>
|
||||
<Tag color={
|
||||
clusterStatus.data.health === 'ok' ? 'green'
|
||||
: clusterStatus.data.health === 'degraded' ? 'orange'
|
||||
: 'red'
|
||||
}>
|
||||
{t(`dashboard.clusterCard.health.${clusterStatus.data.health}`)}
|
||||
</Tag>
|
||||
</Space>
|
||||
)}
|
||||
>
|
||||
<Statistic title={t('dashboard.clusterCard.nodes')} value={(cluster.data ?? []).length} />
|
||||
{clusterStatus.data?.drift_found && (
|
||||
<Tag color="red" style={{ marginTop: 8 }}>
|
||||
{t('dashboard.clusterCard.drift')}
|
||||
</Tag>
|
||||
)}
|
||||
<Space direction="vertical" style={{ marginTop: 6, width: '100%' }} size={2}>
|
||||
{(cluster.data ?? []).map(n => (
|
||||
<div key={n.id} style={{ fontSize: 12, color: '#334155' }}>
|
||||
@@ -471,3 +606,36 @@ function ResourcesCard({ r }: { r?: Resources | null }) {
|
||||
</Col>
|
||||
)
|
||||
}
|
||||
|
||||
// LicenseChip rendert die License-Info als kompaktes Tag im PageHeader.
|
||||
// Farb-Logik:
|
||||
// * Trial < 7 Tage: rot (Eskalation)
|
||||
// * Trial 7-14 Tage: orange (Warnung)
|
||||
// * Trial > 14 Tage: blau (informativ)
|
||||
// * Aktive Lizenz (kein Trial): grün
|
||||
// * Expired/Invalid: rot
|
||||
// Bei unklarem status → kein Tag (silent fallback, /license-Page hat Detail).
|
||||
function LicenseChip({ data }: { data: {
|
||||
status: string
|
||||
type?: string
|
||||
valid?: boolean
|
||||
valid_until?: string
|
||||
expires_at?: string
|
||||
license_key?: string
|
||||
}}) {
|
||||
const exp = data.valid_until ?? data.expires_at
|
||||
const days = exp ? Math.ceil((new Date(exp).getTime() - Date.now()) / 86_400_000) : null
|
||||
const isTrial = data.type === 'trial' || (!data.license_key && data.status === 'active')
|
||||
if (data.status === 'expired' || data.status === 'invalid' || data.valid === false) {
|
||||
return <Tag color="red">{data.status}</Tag>
|
||||
}
|
||||
if (isTrial) {
|
||||
if (days != null && days <= 7) return <Tag color="red">Trial · {days}d</Tag>
|
||||
if (days != null && days <= 14) return <Tag color="orange">Trial · {days}d</Tag>
|
||||
return <Tag color="blue">{days != null ? `Trial · ${days}d` : 'Trial'}</Tag>
|
||||
}
|
||||
if (data.status === 'active') {
|
||||
return <Tag color="green">License OK</Tag>
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState } from 'react'
|
||||
import { Button, Form, Input, Modal, Select, Switch, Tag, message } from 'antd'
|
||||
import { Button, Divider, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Tooltip, Typography, message } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { GlobalOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import { DeleteOutlined, EditOutlined, GlobalOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import DataTable from '../../components/DataTable'
|
||||
import EmptyState from '../../components/EmptyState'
|
||||
import PageHeader from '../../components/PageHeader'
|
||||
import ActionButtons from '../../components/ActionButtons'
|
||||
import StatusDot from '../../components/StatusDot'
|
||||
@@ -18,6 +19,14 @@ interface Domain {
|
||||
primary_backend_id?: number | null
|
||||
http_to_https: boolean
|
||||
hsts_enabled: boolean
|
||||
hsts_max_age: number
|
||||
hsts_subdomains: boolean
|
||||
hsts_preload: boolean
|
||||
maintenance_mode: boolean
|
||||
maintenance_message?: string | null
|
||||
www_redirect: '' | 'to-naked' | 'to-www'
|
||||
rate_limit_rps: number
|
||||
max_body_kb: number
|
||||
notes?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
@@ -28,10 +37,26 @@ interface DomainFormValues {
|
||||
active: boolean
|
||||
http_to_https: boolean
|
||||
hsts_enabled: boolean
|
||||
hsts_max_age: number
|
||||
hsts_subdomains: boolean
|
||||
hsts_preload: boolean
|
||||
maintenance_mode: boolean
|
||||
maintenance_message?: string
|
||||
www_redirect: '' | 'to-naked' | 'to-www'
|
||||
rate_limit_rps: number
|
||||
max_body_kb: number
|
||||
primary_backend_id?: number | null
|
||||
notes?: string
|
||||
}
|
||||
|
||||
interface ResponseHeader {
|
||||
id: number
|
||||
domain_id: number
|
||||
name: string
|
||||
value: string
|
||||
position: number
|
||||
}
|
||||
|
||||
async function listDomains(): Promise<Domain[]> {
|
||||
const r = await apiClient.get('/domains')
|
||||
if (!isEnvelope(r.data)) return []
|
||||
@@ -52,6 +77,26 @@ async function listBackends(): Promise<BackendLite[]> {
|
||||
return (r.data.data as { backends?: BackendLite[] }).backends ?? []
|
||||
}
|
||||
|
||||
interface TLSCertLite {
|
||||
domain: string
|
||||
status: 'pending' | 'active' | 'renewing' | 'expired' | 'error'
|
||||
not_after?: string | null
|
||||
}
|
||||
async function listCerts(): Promise<TLSCertLite[]> {
|
||||
const r = await apiClient.get('/tls-certs')
|
||||
if (!isEnvelope(r.data)) return []
|
||||
return (r.data.data as { tls_certs?: TLSCertLite[] }).tls_certs ?? []
|
||||
}
|
||||
|
||||
interface HAProxyStat { backend: string; server: string; status: string }
|
||||
async function listHAProxyStats(): Promise<HAProxyStat[]> {
|
||||
try {
|
||||
const r = await apiClient.get('/haproxy/stats')
|
||||
if (!isEnvelope(r.data)) return []
|
||||
return (r.data.data as { backends?: HAProxyStat[] }).backends ?? []
|
||||
} catch { return [] }
|
||||
}
|
||||
|
||||
export default function DomainsPage() {
|
||||
const { t } = useTranslation()
|
||||
const qc = useQueryClient()
|
||||
@@ -61,11 +106,62 @@ export default function DomainsPage() {
|
||||
queryFn: listDomains,
|
||||
})
|
||||
const { data: backends } = useQuery({ queryKey: ['backends'], queryFn: listBackends })
|
||||
// TLS-Certs nebenher laden, damit wir pro Domain den Cert-Status
|
||||
// (vorhanden / gültig / ablaufend / fehlt) als Spalte zeigen können.
|
||||
// Operator sieht so auf einen Blick welche Domains noch self-signed
|
||||
// sind und welche bereits ein gültiges ACME-Cert haben.
|
||||
const { data: certs } = useQuery({ queryKey: ['tls-certs'], queryFn: listCerts })
|
||||
const { data: haproxyStats } = useQuery({
|
||||
queryKey: ['haproxy', 'stats'],
|
||||
queryFn: listHAProxyStats,
|
||||
refetchInterval: 15_000,
|
||||
})
|
||||
const certByDomain = new Map((certs ?? []).map(c => [c.domain, c]))
|
||||
const backendById = (id?: number | null) => backends?.find(b => b.id === id)
|
||||
|
||||
// Gibt 'UP', 'DOWN' oder null zurück. HAProxy-Backend heißt eg_backend_<id>.
|
||||
// Wir aggregieren alle Server: wenn mind. einer UP → UP, sonst DOWN.
|
||||
const backendHealth = (id?: number | null): 'UP' | 'DOWN' | null => {
|
||||
if (!id || !haproxyStats?.length) return null
|
||||
const name = `eg_backend_${id}`
|
||||
const servers = haproxyStats.filter(s => s.backend === name)
|
||||
if (!servers.length) return null
|
||||
return servers.some(s => s.status === 'UP') ? 'UP' : 'DOWN'
|
||||
}
|
||||
|
||||
const [editing, setEditing] = useState<Domain | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
const [headersFor, setHeadersFor] = useState<Domain | null>(null)
|
||||
const [quickBackendOpen, setQuickBackendOpen] = useState(false)
|
||||
const [form] = Form.useForm<DomainFormValues>()
|
||||
const [quickBackendForm] = Form.useForm<{ name: string; scheme: 'http' | 'https'; address: string; port: number }>()
|
||||
|
||||
const quickCreateBackend = useMutation({
|
||||
mutationFn: async (v: { name: string; scheme: 'http' | 'https'; address: string; port: number }) => {
|
||||
// 1) Backend anlegen
|
||||
const bRes = await apiClient.post('/backends', {
|
||||
name: v.name, scheme: v.scheme, lb_algorithm: 'roundrobin',
|
||||
websocket: false, active: true,
|
||||
})
|
||||
const bId = (bRes.data?.data as { id?: number })?.id
|
||||
if (!bId) throw new Error('backend id missing in response')
|
||||
// 2) Ersten Server reinhängen
|
||||
await apiClient.post(`/backends/${bId}/servers`, {
|
||||
backend_id: bId, name: v.address.replace(/[^a-zA-Z0-9-]/g, '-'),
|
||||
address: v.address, port: v.port, weight: 100, active: true,
|
||||
})
|
||||
return bId
|
||||
},
|
||||
onSuccess: (bId) => {
|
||||
message.success(t('domains.quickBackendCreated'))
|
||||
void qc.invalidateQueries({ queryKey: ['backends'] })
|
||||
// Frisch erstellten Backend automatisch im Domain-Form selektieren.
|
||||
form.setFieldsValue({ primary_backend_id: bId })
|
||||
setQuickBackendOpen(false)
|
||||
quickBackendForm.resetFields()
|
||||
},
|
||||
onError: (e: Error) => message.error(t('domains.quickBackendFailed') + ': ' + e.message),
|
||||
})
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: async (v: DomainFormValues) => {
|
||||
@@ -109,36 +205,112 @@ export default function DomainsPage() {
|
||||
render: (id?: number | null) => {
|
||||
if (!id) return <Tag>{t('domains.noBackend')}</Tag>
|
||||
const b = backendById(id)
|
||||
return b
|
||||
? <Tag color="blue">{b.name} ({b.address}:{b.port})</Tag>
|
||||
: <Tag color="orange">#{id}</Tag>
|
||||
const health = backendHealth(id)
|
||||
return (
|
||||
<Space size={4}>
|
||||
{b
|
||||
? <Tag color="blue">{b.name} ({b.address}:{b.port})</Tag>
|
||||
: <Tag color="orange">#{id}</Tag>}
|
||||
{health === 'UP' && <Tag color="green" style={{ margin: 0 }}>UP</Tag>}
|
||||
{health === 'DOWN' && <Tag color="red" style={{ margin: 0 }}>DOWN</Tag>}
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
},
|
||||
{ title: t('domains.active'), dataIndex: 'active', key: 'active', render: (v: boolean) => <StatusDot active={v} /> },
|
||||
{ title: t('domains.httpToHttps'), dataIndex: 'http_to_https', key: 'http_to_https', render: (v: boolean) => <StatusDot active={v} activeLabel="HTTPS" inactiveLabel="HTTP" /> },
|
||||
{ title: t('domains.hsts'), dataIndex: 'hsts_enabled', key: 'hsts', render: (v: boolean) => <StatusDot active={v} /> },
|
||||
{
|
||||
title: t('domains.tlsCert'),
|
||||
key: 'tlsCert',
|
||||
width: 120,
|
||||
render: (_, row) => {
|
||||
const cert = certByDomain.get(row.name)
|
||||
if (!cert) {
|
||||
return (
|
||||
<Tooltip title={t('domains.tlsCertNoneHint')}>
|
||||
<Tag color="default">{t('domains.tlsCertNone')}</Tag>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
if (cert.status === 'expired') {
|
||||
return <Tag color="red">{t('domains.tlsCertExpired')}</Tag>
|
||||
}
|
||||
if (cert.status === 'error') {
|
||||
return <Tag color="red">{t('domains.tlsCertError')}</Tag>
|
||||
}
|
||||
// Days remaining
|
||||
const days = cert.not_after
|
||||
? Math.round((new Date(cert.not_after).getTime() - Date.now()) / 86_400_000)
|
||||
: null
|
||||
if (days != null && days < 30) {
|
||||
return (
|
||||
<Tooltip title={cert.not_after}>
|
||||
<Tag color="orange">{t('domains.tlsCertExpiring', { days })}</Tag>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Tooltip title={cert.not_after}>
|
||||
<Tag color="green">{t('domains.tlsCertValid')}</Tag>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('common.actions'), key: 'actions',
|
||||
render: (_, row) => (
|
||||
<ActionButtons
|
||||
onEdit={() => {
|
||||
setEditing(row)
|
||||
form.setFieldsValue({
|
||||
name: row.name,
|
||||
active: row.active,
|
||||
http_to_https: row.http_to_https,
|
||||
hsts_enabled: row.hsts_enabled,
|
||||
primary_backend_id: row.primary_backend_id ?? null,
|
||||
notes: row.notes ?? '',
|
||||
})
|
||||
}}
|
||||
onDelete={() => del.mutate(row.id)}
|
||||
deleteConfirm={t('domains.deleteConfirm', { name: row.name })}
|
||||
/>
|
||||
<Space size={4}>
|
||||
<Button size="small" onClick={() => setHeadersFor(row)}>
|
||||
{t('domains.headersBtn')}
|
||||
</Button>
|
||||
<ActionButtons
|
||||
onEdit={() => {
|
||||
setEditing(row)
|
||||
form.setFieldsValue({
|
||||
name: row.name,
|
||||
active: row.active,
|
||||
http_to_https: row.http_to_https,
|
||||
hsts_enabled: row.hsts_enabled,
|
||||
hsts_max_age: row.hsts_max_age || 31536000,
|
||||
hsts_subdomains: row.hsts_subdomains,
|
||||
hsts_preload: row.hsts_preload,
|
||||
maintenance_mode: row.maintenance_mode,
|
||||
maintenance_message: row.maintenance_message ?? '',
|
||||
www_redirect: row.www_redirect ?? '',
|
||||
rate_limit_rps: row.rate_limit_rps ?? 0,
|
||||
max_body_kb: row.max_body_kb ?? 0,
|
||||
primary_backend_id: row.primary_backend_id ?? null,
|
||||
notes: row.notes ?? '',
|
||||
})
|
||||
}}
|
||||
onDelete={() => del.mutate(row.id)}
|
||||
deleteConfirm={t('domains.deleteConfirm', { name: row.name })}
|
||||
/>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
// openCreate: reused von extraActions-Button und EmptyState-Action.
|
||||
// Setzt die Defaults für die Create-Modal-Form.
|
||||
const openCreate = () => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({
|
||||
active: true,
|
||||
http_to_https: true,
|
||||
hsts_enabled: false,
|
||||
hsts_max_age: 31536000,
|
||||
hsts_subdomains: false,
|
||||
hsts_preload: false,
|
||||
maintenance_mode: false,
|
||||
maintenance_message: '',
|
||||
www_redirect: '',
|
||||
rate_limit_rps: 0,
|
||||
max_body_kb: 0,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
@@ -152,13 +324,22 @@ export default function DomainsPage() {
|
||||
dataSource={data ?? []}
|
||||
columns={columns}
|
||||
extraActions={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({ active: true, http_to_https: true, hsts_enabled: false })
|
||||
}}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
{t('domains.addDomain')}
|
||||
</Button>
|
||||
}
|
||||
emptyContent={
|
||||
<EmptyState
|
||||
icon={<GlobalOutlined />}
|
||||
title={t('domains.emptyTitle')}
|
||||
description={t('domains.emptyDesc')}
|
||||
action={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
{t('domains.addDomain')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Modal
|
||||
title={editing ? t('domains.editDomain') : t('domains.addDomain')}
|
||||
@@ -183,16 +364,24 @@ export default function DomainsPage() {
|
||||
name="primary_backend_id"
|
||||
extra={t('domains.primaryBackendHint')}
|
||||
>
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder={t('domains.selectBackend')}
|
||||
options={(backends ?? []).filter(b => b.active).map(b => ({
|
||||
value: b.id,
|
||||
label: `${b.name} (${b.address}:${b.port})`,
|
||||
}))}
|
||||
/>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Form.Item name="primary_backend_id" noStyle>
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder={t('domains.selectBackend')}
|
||||
options={(backends ?? []).filter(b => b.active).map(b => ({
|
||||
value: b.id,
|
||||
label: `${b.name} (${b.address}:${b.port})`,
|
||||
}))}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button onClick={() => setQuickBackendOpen(true)} title={t('domains.quickBackendBtnHint')}>
|
||||
{t('domains.quickBackendBtn')}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('domains.active')} name="active" valuePropName="checked">
|
||||
<Switch />
|
||||
@@ -200,14 +389,309 @@ export default function DomainsPage() {
|
||||
<Form.Item label={t('domains.httpToHttps')} name="http_to_https" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Divider plain>
|
||||
<Typography.Text type="secondary">{t('domains.settingsSection')}</Typography.Text>
|
||||
</Divider>
|
||||
|
||||
<Form.Item label={t('domains.hsts')} name="hsts_enabled" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prev, curr) => prev.hsts_enabled !== curr.hsts_enabled}
|
||||
>
|
||||
{({ getFieldValue }) => getFieldValue('hsts_enabled') ? (
|
||||
<>
|
||||
<Form.Item
|
||||
label={t('domains.hstsMaxAge')}
|
||||
name="hsts_max_age"
|
||||
extra={t('domains.hstsMaxAgeHint')}
|
||||
rules={[{ type: 'number', min: 0, message: '≥ 0' }]}
|
||||
>
|
||||
<InputNumber min={0} step={3600} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('domains.hstsSubdomains')}
|
||||
name="hsts_subdomains"
|
||||
valuePropName="checked"
|
||||
extra={t('domains.hstsSubdomainsHint')}
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('domains.hstsPreload')}
|
||||
name="hsts_preload"
|
||||
valuePropName="checked"
|
||||
extra={t('domains.hstsPreloadHint')}
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</>
|
||||
) : null}
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('domains.wwwRedirect')}
|
||||
name="www_redirect"
|
||||
extra={t('domains.wwwRedirectHint')}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: t('domains.wwwRedirectNone') },
|
||||
{ value: 'to-naked', label: t('domains.wwwRedirectToNaked') },
|
||||
{ value: 'to-www', label: t('domains.wwwRedirectToWWW') },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('domains.maintenance')}
|
||||
name="maintenance_mode"
|
||||
valuePropName="checked"
|
||||
extra={t('domains.maintenanceHint')}
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
noStyle
|
||||
shouldUpdate={(prev, curr) => prev.maintenance_mode !== curr.maintenance_mode}
|
||||
>
|
||||
{({ getFieldValue }) => getFieldValue('maintenance_mode') ? (
|
||||
<Form.Item label={t('domains.maintenanceMessage')} name="maintenance_message">
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
placeholder={t('domains.maintenanceMessagePlaceholder')}
|
||||
maxLength={300}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('domains.rateLimit')}
|
||||
name="rate_limit_rps"
|
||||
extra={t('domains.rateLimitHint')}
|
||||
rules={[{ type: 'number', min: 0, message: '≥ 0' }]}
|
||||
>
|
||||
<InputNumber min={0} step={10} style={{ width: '100%' }} addonAfter="req/s" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('domains.maxBody')}
|
||||
name="max_body_kb"
|
||||
extra={t('domains.maxBodyHint')}
|
||||
rules={[{ type: 'number', min: 0, message: '≥ 0' }]}
|
||||
>
|
||||
<InputNumber min={0} step={64} style={{ width: '100%' }} addonAfter="KiB" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t('domains.notes')} name="notes">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{headersFor && (
|
||||
<HeadersModal
|
||||
domain={headersFor}
|
||||
onClose={() => setHeadersFor(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Quick-Add-Backend: schmales Sub-Modal direkt aus dem Domain-
|
||||
Anlegen-Flow heraus. Spart drei Navigations-Klicks (Backends-
|
||||
Seite öffnen → Backend anlegen → Server anlegen → zurück). */}
|
||||
<Modal
|
||||
title={t('domains.quickBackendTitle')}
|
||||
open={quickBackendOpen}
|
||||
onCancel={() => { setQuickBackendOpen(false); quickBackendForm.resetFields() }}
|
||||
onOk={() => { void quickBackendForm.submit() }}
|
||||
confirmLoading={quickCreateBackend.isPending}
|
||||
width={520}
|
||||
>
|
||||
<Form
|
||||
form={quickBackendForm}
|
||||
layout="vertical"
|
||||
initialValues={{ scheme: 'http', port: 80 }}
|
||||
onFinish={(v) => quickCreateBackend.mutate(v)}
|
||||
>
|
||||
<Form.Item label={t('domains.quickBackendName')} name="name" rules={[{ required: true }]}>
|
||||
<Input placeholder="app1" />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('domains.quickBackendScheme')} name="scheme">
|
||||
<Select options={[
|
||||
{ value: 'http', label: 'http' },
|
||||
{ value: 'https', label: 'https' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('domains.quickBackendAddress')} name="address" rules={[{ required: true }]}>
|
||||
<Input placeholder="10.0.0.10" />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('domains.quickBackendPort')} name="port" rules={[{ required: true, type: 'number', min: 1, max: 65535 }]}>
|
||||
<InputNumber style={{ width: '100%' }} min={1} max={65535} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Response-Headers Modal ────────────────────────────────────────────
|
||||
//
|
||||
// Eigenes Modal weil Headers eine 1:n-Relation sind die nicht in das
|
||||
// Domain-Form passt. Lädt /domains/:id/headers, erlaubt Inline-Add via
|
||||
// kleinem Sub-Form, Inline-Edit per Modal pro Row und Delete.
|
||||
|
||||
interface HeadersModalProps {
|
||||
domain: Domain
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
async function listHeaders(domainID: number): Promise<ResponseHeader[]> {
|
||||
const r = await apiClient.get(`/domains/${domainID}/headers`)
|
||||
if (!isEnvelope(r.data)) return []
|
||||
return (r.data.data as { headers?: ResponseHeader[] }).headers ?? []
|
||||
}
|
||||
|
||||
function HeadersModal({ domain, onClose }: HeadersModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const qc = useQueryClient()
|
||||
const { data, isFetching } = useQuery({
|
||||
queryKey: ['domain-headers', domain.id],
|
||||
queryFn: () => listHeaders(domain.id),
|
||||
})
|
||||
|
||||
const [editing, setEditing] = useState<ResponseHeader | null>(null)
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
const [hForm] = Form.useForm<{ name: string; value: string; position: number }>()
|
||||
|
||||
const invalidate = () => {
|
||||
void qc.invalidateQueries({ queryKey: ['domain-headers', domain.id] })
|
||||
}
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: async (v: { name: string; value: string; position: number }) =>
|
||||
apiClient.post(`/domains/${domain.id}/headers`, v),
|
||||
onSuccess: () => {
|
||||
message.success(t('common.save'))
|
||||
setAddOpen(false); hForm.resetFields()
|
||||
invalidate()
|
||||
},
|
||||
})
|
||||
const update = useMutation({
|
||||
mutationFn: async ({ id, v }: { id: number; v: { name: string; value: string; position: number } }) =>
|
||||
apiClient.put(`/domains/${domain.id}/headers/${id}`, v),
|
||||
onSuccess: () => {
|
||||
message.success(t('common.save'))
|
||||
setEditing(null); hForm.resetFields()
|
||||
invalidate()
|
||||
},
|
||||
})
|
||||
const del = useMutation({
|
||||
mutationFn: async (id: number) =>
|
||||
apiClient.delete(`/domains/${domain.id}/headers/${id}`),
|
||||
onSuccess: invalidate,
|
||||
})
|
||||
|
||||
const columns: ColumnsType<ResponseHeader> = [
|
||||
{ title: t('domains.headerName'), dataIndex: 'name', key: 'name' },
|
||||
{ title: t('domains.headerValue'), dataIndex: 'value', key: 'value', ellipsis: true },
|
||||
{
|
||||
title: t('common.actions'), key: 'actions', width: 100,
|
||||
render: (_, row) => (
|
||||
<Space size={4}>
|
||||
<Button
|
||||
type="text" size="small" icon={<EditOutlined />}
|
||||
onClick={() => {
|
||||
setEditing(row)
|
||||
hForm.setFieldsValue({ name: row.name, value: row.value, position: row.position })
|
||||
}}
|
||||
/>
|
||||
<Popconfirm
|
||||
title={t('domains.headerDeleteConfirm', { name: row.name })}
|
||||
okText={t('common.yes')} cancelText={t('common.no')}
|
||||
onConfirm={() => del.mutate(row.id)}
|
||||
>
|
||||
<Button type="text" size="small" danger icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
open
|
||||
title={t('domains.headersTitle', { name: domain.name })}
|
||||
onCancel={onClose}
|
||||
footer={
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={invalidate}>{t('common.refresh')}</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
|
||||
setAddOpen(true); hForm.resetFields()
|
||||
hForm.setFieldsValue({ name: '', value: '', position: (data?.length ?? 0) })
|
||||
}}>
|
||||
{t('domains.addHeader')}
|
||||
</Button>
|
||||
<Button onClick={onClose}>{t('common.close')}</Button>
|
||||
</Space>
|
||||
}
|
||||
width={720}
|
||||
>
|
||||
<Typography.Paragraph type="secondary">
|
||||
{t('domains.headersHint')}
|
||||
</Typography.Paragraph>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={isFetching}
|
||||
dataSource={data ?? []}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
locale={{ emptyText: t('domains.headersEmpty') }}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={addOpen || editing !== null}
|
||||
title={editing ? t('domains.editHeader') : t('domains.addHeader')}
|
||||
onCancel={() => { setAddOpen(false); setEditing(null); hForm.resetFields() }}
|
||||
onOk={() => { void hForm.submit() }}
|
||||
confirmLoading={create.isPending || update.isPending}
|
||||
>
|
||||
<Form
|
||||
form={hForm}
|
||||
layout="vertical"
|
||||
onFinish={(v) => {
|
||||
if (editing) update.mutate({ id: editing.id, v })
|
||||
else create.mutate(v)
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
label={t('domains.headerName')}
|
||||
name="name"
|
||||
extra={t('domains.headerNameHint')}
|
||||
rules={[
|
||||
{ required: true },
|
||||
{ pattern: /^[A-Za-z0-9-]+$/, message: t('domains.headerNamePattern') },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="X-Frame-Options" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('domains.headerValue')}
|
||||
name="value"
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<Input.TextArea rows={2} placeholder="DENY" />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('domains.headerPosition')} name="position" initialValue={0}>
|
||||
<InputNumber min={0} step={1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@ import { Button, Form, Input, Modal, Popconfirm, Select, Space, Tag, message } f
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { GroupOutlined } from '@ant-design/icons'
|
||||
|
||||
import DataTable from '../../components/DataTable'
|
||||
import EmptyState from '../../components/EmptyState'
|
||||
|
||||
import apiClient, { isEnvelope } from '../../api/client'
|
||||
import type { AddressGroup, AddressObject } from './types'
|
||||
@@ -84,15 +87,30 @@ export default function AddressGroupsTab() {
|
||||
},
|
||||
]
|
||||
|
||||
const openCreate = () => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({ member_ids: [] })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button type="primary" className="mb-16" onClick={() => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({ member_ids: [] })
|
||||
}}>
|
||||
<Button type="primary" className="mb-16" onClick={openCreate}>
|
||||
{t('fw.ag.add')}
|
||||
</Button>
|
||||
<DataTable rowKey="id" loading={isLoading} dataSource={groups ?? []} columns={columns} />
|
||||
<DataTable
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
dataSource={groups ?? []}
|
||||
columns={columns}
|
||||
emptyContent={
|
||||
<EmptyState
|
||||
icon={<GroupOutlined />}
|
||||
title={t('fw.ag.emptyTitle')}
|
||||
description={t('fw.ag.emptyDesc')}
|
||||
action={<Button type="primary" onClick={openCreate}>{t('fw.ag.add')}</Button>}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Modal
|
||||
title={editing ? t('fw.ag.edit') : t('fw.ag.add')}
|
||||
open={editing !== null || creating}
|
||||
|
||||
@@ -3,7 +3,10 @@ import { Button, Form, Input, Modal, Popconfirm, Select, Space, Tag, message } f
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { EnvironmentOutlined } from '@ant-design/icons'
|
||||
|
||||
import DataTable from '../../components/DataTable'
|
||||
import EmptyState from '../../components/EmptyState'
|
||||
|
||||
import apiClient, { isEnvelope } from '../../api/client'
|
||||
import type { AddressObject } from './types'
|
||||
@@ -77,15 +80,32 @@ export default function AddressObjectsTab() {
|
||||
},
|
||||
]
|
||||
|
||||
const openCreate = () => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({ kind: 'host' })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button type="primary" className="mb-16" onClick={() => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({ kind: 'host' })
|
||||
}}>
|
||||
<Button type="primary" className="mb-16" onClick={openCreate}>
|
||||
{t('fw.ao.add')}
|
||||
</Button>
|
||||
<DataTable rowKey="id" loading={isLoading} dataSource={data ?? []} columns={columns} />
|
||||
<DataTable
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
dataSource={data ?? []}
|
||||
columns={columns}
|
||||
emptyContent={
|
||||
<EmptyState
|
||||
icon={<EnvironmentOutlined />}
|
||||
title={t('fw.ao.emptyTitle')}
|
||||
description={t('fw.ao.emptyDesc')}
|
||||
action={
|
||||
<Button type="primary" onClick={openCreate}>{t('fw.ao.add')}</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Modal
|
||||
title={editing ? t('fw.ao.edit') : t('fw.ao.add')}
|
||||
open={editing !== null || creating}
|
||||
|
||||
@@ -3,7 +3,10 @@ import { Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Swi
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { BranchesOutlined } from '@ant-design/icons'
|
||||
|
||||
import DataTable from '../../components/DataTable'
|
||||
import EmptyState from '../../components/EmptyState'
|
||||
|
||||
import apiClient, { isEnvelope } from '../../api/client'
|
||||
import type { FwZone, NATRule } from './types'
|
||||
@@ -132,15 +135,30 @@ export default function NATRulesTab() {
|
||||
},
|
||||
]
|
||||
|
||||
const openCreate = () => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({ priority: 100, enabled: true, kind: 'dnat' })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button type="primary" className="mb-16" onClick={() => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({ priority: 100, enabled: true, kind: 'dnat' })
|
||||
}}>
|
||||
<Button type="primary" className="mb-16" onClick={openCreate}>
|
||||
{t('fw.nat.add')}
|
||||
</Button>
|
||||
<DataTable rowKey="id" loading={isLoading} dataSource={data ?? []} columns={columns} />
|
||||
<DataTable
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
dataSource={data ?? []}
|
||||
columns={columns}
|
||||
emptyContent={
|
||||
<EmptyState
|
||||
icon={<BranchesOutlined />}
|
||||
title={t('fw.nat.emptyTitle')}
|
||||
description={t('fw.nat.emptyDesc')}
|
||||
action={<Button type="primary" onClick={openCreate}>{t('fw.nat.add')}</Button>}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Modal
|
||||
title={editing ? t('fw.nat.edit') : t('fw.nat.add')}
|
||||
open={editing !== null || creating}
|
||||
|
||||
@@ -3,8 +3,10 @@ import { Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Swi
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FireOutlined } from '@ant-design/icons'
|
||||
|
||||
import DataTable from '../../components/DataTable'
|
||||
import SystemRulesCard from './SystemRules'
|
||||
import EmptyState from '../../components/EmptyState'
|
||||
|
||||
import apiClient, { isEnvelope } from '../../api/client'
|
||||
import type { AddressGroup, AddressObject, FwRule, FwService, FwZone, ServiceGroup, Zone } from './types'
|
||||
@@ -197,20 +199,34 @@ export default function RulesTab() {
|
||||
},
|
||||
]
|
||||
|
||||
const openCreate = () => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({
|
||||
priority: 100, enabled: true, action: 'accept', log: false,
|
||||
src_zone: 'any', dst_zone: 'any',
|
||||
src_kind: 'any', dst_kind: 'any', service_kind: 'any',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SystemRulesCard />
|
||||
<Button type="primary" className="mb-16" onClick={() => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({
|
||||
priority: 100, enabled: true, action: 'accept', log: false,
|
||||
src_zone: 'any', dst_zone: 'any',
|
||||
src_kind: 'any', dst_kind: 'any', service_kind: 'any',
|
||||
})
|
||||
}}>
|
||||
<Button type="primary" className="mb-16" onClick={openCreate}>
|
||||
{t('fw.rule.add')}
|
||||
</Button>
|
||||
<DataTable rowKey="id" loading={isLoading} dataSource={rules ?? []} columns={columns} />
|
||||
<DataTable
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
dataSource={rules ?? []}
|
||||
columns={columns}
|
||||
emptyContent={
|
||||
<EmptyState
|
||||
icon={<FireOutlined />}
|
||||
title={t('fw.rule.emptyTitle')}
|
||||
description={t('fw.rule.emptyDesc')}
|
||||
action={<Button type="primary" onClick={openCreate}>{t('fw.rule.add')}</Button>}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Modal
|
||||
title={editing ? t('fw.rule.edit') : t('fw.rule.add')}
|
||||
open={editing !== null || creating}
|
||||
|
||||
@@ -3,7 +3,10 @@ import { Button, Form, Input, Modal, Popconfirm, Select, Space, Tag, message } f
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { GroupOutlined } from '@ant-design/icons'
|
||||
|
||||
import DataTable from '../../components/DataTable'
|
||||
import EmptyState from '../../components/EmptyState'
|
||||
|
||||
import apiClient, { isEnvelope } from '../../api/client'
|
||||
import type { FwService, ServiceGroup } from './types'
|
||||
@@ -84,15 +87,30 @@ export default function ServiceGroupsTab() {
|
||||
},
|
||||
]
|
||||
|
||||
const openCreate = () => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({ member_ids: [] })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button type="primary" className="mb-16" onClick={() => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({ member_ids: [] })
|
||||
}}>
|
||||
<Button type="primary" className="mb-16" onClick={openCreate}>
|
||||
{t('fw.sg.add')}
|
||||
</Button>
|
||||
<DataTable rowKey="id" loading={isLoading} dataSource={groups ?? []} columns={columns} />
|
||||
<DataTable
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
dataSource={groups ?? []}
|
||||
columns={columns}
|
||||
emptyContent={
|
||||
<EmptyState
|
||||
icon={<GroupOutlined />}
|
||||
title={t('fw.sg.emptyTitle')}
|
||||
description={t('fw.sg.emptyDesc')}
|
||||
action={<Button type="primary" onClick={openCreate}>{t('fw.sg.add')}</Button>}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Modal
|
||||
title={editing ? t('fw.sg.edit') : t('fw.sg.add')}
|
||||
open={editing !== null || creating}
|
||||
|
||||
@@ -3,7 +3,10 @@ import { Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Tag
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ApiOutlined } from '@ant-design/icons'
|
||||
|
||||
import DataTable from '../../components/DataTable'
|
||||
import EmptyState from '../../components/EmptyState'
|
||||
|
||||
import apiClient, { isEnvelope } from '../../api/client'
|
||||
import type { FwService } from './types'
|
||||
@@ -88,15 +91,30 @@ export default function ServicesTab() {
|
||||
},
|
||||
]
|
||||
|
||||
const openCreate = () => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({ proto: 'tcp' })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button type="primary" className="mb-16" onClick={() => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({ proto: 'tcp' })
|
||||
}}>
|
||||
<Button type="primary" className="mb-16" onClick={openCreate}>
|
||||
{t('fw.svc.add')}
|
||||
</Button>
|
||||
<DataTable rowKey="id" loading={isLoading} dataSource={data ?? []} columns={columns} />
|
||||
<DataTable
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
dataSource={data ?? []}
|
||||
columns={columns}
|
||||
emptyContent={
|
||||
<EmptyState
|
||||
icon={<ApiOutlined />}
|
||||
title={t('fw.svc.emptyTitle')}
|
||||
description={t('fw.svc.emptyDesc')}
|
||||
action={<Button type="primary" onClick={openCreate}>{t('fw.svc.add')}</Button>}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Modal
|
||||
title={editing ? t('fw.svc.edit') : t('fw.svc.add')}
|
||||
open={editing !== null || creating}
|
||||
|
||||
@@ -4,8 +4,11 @@ import type { ColumnsType } from 'antd/es/table'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { ApartmentOutlined } from '@ant-design/icons'
|
||||
|
||||
import apiClient, { isEnvelope } from '../../api/client'
|
||||
import DataTable from '../../components/DataTable'
|
||||
import EmptyState from '../../components/EmptyState'
|
||||
import type { FwZone } from './types'
|
||||
|
||||
interface FormValues {
|
||||
@@ -77,14 +80,29 @@ export default function ZonesTab() {
|
||||
},
|
||||
]
|
||||
|
||||
const openCreate = () => { setCreating(true); form.resetFields() }
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button type="primary" className="mb-16" onClick={() => {
|
||||
setCreating(true); form.resetFields()
|
||||
}}>
|
||||
<Button type="primary" className="mb-16" onClick={openCreate}>
|
||||
{t('fw.zone.add')}
|
||||
</Button>
|
||||
<DataTable rowKey="id" loading={isLoading} dataSource={data ?? []} columns={columns} />
|
||||
<DataTable
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
dataSource={data ?? []}
|
||||
columns={columns}
|
||||
emptyContent={
|
||||
<EmptyState
|
||||
icon={<ApartmentOutlined />}
|
||||
title={t('fw.zone.emptyTitle')}
|
||||
description={t('fw.zone.emptyDesc')}
|
||||
action={
|
||||
<Button type="primary" onClick={openCreate}>{t('fw.zone.add')}</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editing ? t('fw.zone.edit') : t('fw.zone.add')}
|
||||
|
||||
@@ -9,6 +9,7 @@ import ServicesTab from './Services'
|
||||
import ServiceGroupsTab from './ServiceGroups'
|
||||
import RulesTab from './Rules'
|
||||
import NATRulesTab from './NATRules'
|
||||
import SystemRulesCard from './SystemRules'
|
||||
import ZonesTab from './Zones'
|
||||
|
||||
export default function FirewallPage() {
|
||||
@@ -22,6 +23,7 @@ export default function FirewallPage() {
|
||||
{ key: 'addrGrp', label: t('fw.tabs.addrGrp'), children: <AddressGroupsTab /> },
|
||||
{ key: 'services', label: t('fw.tabs.services'), children: <ServicesTab /> },
|
||||
{ key: 'svcGrp', label: t('fw.tabs.svcGrp'), children: <ServiceGroupsTab /> },
|
||||
{ key: 'system', label: t('fw.tabs.system'), children: <SystemRulesCard /> },
|
||||
]
|
||||
|
||||
return (
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next'
|
||||
|
||||
import apiClient, { isEnvelope } from '../../api/client'
|
||||
import DataTable from '../../components/DataTable'
|
||||
import EmptyState from '../../components/EmptyState'
|
||||
import PageHeader from '../../components/PageHeader'
|
||||
import ActionButtons from '../../components/ActionButtons'
|
||||
import StatusDot from '../../components/StatusDot'
|
||||
@@ -122,6 +123,11 @@ export default function ForwardProxyPage() {
|
||||
},
|
||||
]
|
||||
|
||||
const openCreate = () => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({ priority: 100, active: true, action: 'allow', acl_type: 'dstdomain' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
@@ -144,13 +150,22 @@ export default function ForwardProxyPage() {
|
||||
dataSource={data ?? []}
|
||||
columns={cols}
|
||||
extraActions={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({ priority: 100, active: true, action: 'allow', acl_type: 'dstdomain' })
|
||||
}}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
{t('fwd.add')}
|
||||
</Button>
|
||||
}
|
||||
emptyContent={
|
||||
<EmptyState
|
||||
icon={<CloudServerOutlined />}
|
||||
title={t('fwd.emptyTitle')}
|
||||
description={t('fwd.emptyDesc')}
|
||||
action={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
{t('fwd.add')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Button, Card, Form, Input, InputNumber, Modal, Select, Switch, Tag, Typ
|
||||
import { NodeIndexOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import PageHeader from '../../components/PageHeader'
|
||||
import ActionButtons from '../../components/ActionButtons'
|
||||
import EmptyState from '../../components/EmptyState'
|
||||
import StatusDot from '../../components/StatusDot'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
@@ -159,6 +160,11 @@ export default function IPAddressesPage() {
|
||||
},
|
||||
]
|
||||
|
||||
const openCreate = () => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({ prefix: 24, is_vip: false, active: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
@@ -193,13 +199,22 @@ export default function IPAddressesPage() {
|
||||
dataSource={ips ?? []}
|
||||
columns={columns}
|
||||
extraActions={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({ prefix: 24, is_vip: false, active: true })
|
||||
}}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
{t('ips.addAddress')}
|
||||
</Button>
|
||||
}
|
||||
emptyContent={
|
||||
<EmptyState
|
||||
icon={<NodeIndexOutlined />}
|
||||
title={t('ips.emptyTitle')}
|
||||
description={t('ips.emptyDesc')}
|
||||
action={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
{t('ips.addAddress')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Modal
|
||||
title={editing ? t('ips.editAddress') : t('ips.addAddress')}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
Button, Card, DatePicker, Input, Select, Space, Switch, Table, Tag, Tooltip, Typography, message,
|
||||
} from 'antd'
|
||||
@@ -72,18 +72,52 @@ function toCSV(rows: Entry[]): string {
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
// localStorage-Key für die Filter-Persistenz. Filter werden so über
|
||||
// Reloads + Tab-Wechsel hinweg gehalten — sonst muss der Operator nach
|
||||
// jedem F5 source/level/grep neu eintippen.
|
||||
const LOGS_FILTERS_KEY = 'edgeguard.logs.filters.v1'
|
||||
|
||||
// Filter aus localStorage lesen + sicher dekodieren. range wird als
|
||||
// String[2] (ISO) serialisiert; beim Lesen rekonstruieren wir es als
|
||||
// String — die DatePicker-Range erwartet Dayjs aber range-Restoration
|
||||
// vereinfachen wir hier zu null, weil Datumsbereiche meist nicht über
|
||||
// Sessions hinweg interessant sind (logs sind zeitnah).
|
||||
function loadStoredFilters(): Filters {
|
||||
const empty: Filters = { sources: [], levels: [], range: null, grep: '', limit: 200 }
|
||||
try {
|
||||
const raw = localStorage.getItem(LOGS_FILTERS_KEY)
|
||||
if (!raw) return empty
|
||||
const v = JSON.parse(raw) as Partial<Filters>
|
||||
return {
|
||||
sources: Array.isArray(v.sources) ? v.sources : [],
|
||||
levels: Array.isArray(v.levels) ? v.levels : [],
|
||||
range: null, // Range nicht persistieren (Logs sind „jetzt"-relevant)
|
||||
grep: typeof v.grep === 'string' ? v.grep : '',
|
||||
limit: typeof v.limit === 'number' && v.limit > 0 ? v.limit : 200,
|
||||
}
|
||||
} catch {
|
||||
return empty
|
||||
}
|
||||
}
|
||||
|
||||
export default function LogsPage() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [filters, setFilters] = useState<Filters>({
|
||||
sources: [], // [] = alle
|
||||
levels: [], // [] = alle
|
||||
range: null,
|
||||
grep: '',
|
||||
limit: 200,
|
||||
})
|
||||
const [filters, setFilters] = useState<Filters>(() => loadStoredFilters())
|
||||
const [autoRefresh, setAutoRefresh] = useState(true)
|
||||
|
||||
// Filter-State in localStorage persistieren. Range bewusst weglassen
|
||||
// (Datumsbereiche sind „jetzt"-relevant, nicht zwischen Sessions).
|
||||
useEffect(() => {
|
||||
try {
|
||||
const { range: _, ...persistable } = filters
|
||||
void _
|
||||
localStorage.setItem(LOGS_FILTERS_KEY, JSON.stringify(persistable))
|
||||
} catch {
|
||||
// localStorage voll / disabled — sw allowed, einfach ignorieren.
|
||||
}
|
||||
}, [filters])
|
||||
|
||||
// Sources-Liste vom Backend (statisch im internal/services/syslogs).
|
||||
const sourcesQuery = useQuery({
|
||||
queryKey: ['logs', 'sources'],
|
||||
@@ -236,6 +270,19 @@ export default function LogsPage() {
|
||||
options={[100, 200, 500, 1000].map((n) => ({ value: n, label: `${n} ${t('logs.limit')}` }))}
|
||||
style={{ width: 130 }}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => setFilters({ sources: [], levels: [], range: null, grep: '', limit: 200 })}
|
||||
disabled={
|
||||
filters.sources.length === 0 &&
|
||||
filters.levels.length === 0 &&
|
||||
!filters.range &&
|
||||
!filters.grep &&
|
||||
filters.limit === 200
|
||||
}
|
||||
>
|
||||
{t('logs.filter.reset')}
|
||||
</Button>
|
||||
<Text type="secondary">{t('logs.found', { n: entries.length })}</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Alert, Button, Form, Input, InputNumber, Modal, Select, Space, Switch, Tabs, Tag, Typography, message,
|
||||
Alert, Button, Card, Col, Form, Input, InputNumber, Modal, Row, Select, Space, Statistic, Switch, Tabs, Tag, Tooltip, Typography, message,
|
||||
} from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { ClockCircleOutlined, DatabaseOutlined, PlusOutlined, SettingOutlined } from '@ant-design/icons'
|
||||
import { ClockCircleOutlined, DatabaseOutlined, PlusOutlined, ReloadOutlined, SettingOutlined } from '@ant-design/icons'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import apiClient, { isEnvelope } from '../../api/client'
|
||||
import DataTable from '../../components/DataTable'
|
||||
import EmptyState from '../../components/EmptyState'
|
||||
import PageHeader from '../../components/PageHeader'
|
||||
import ActionButtons from '../../components/ActionButtons'
|
||||
import StatusDot from '../../components/StatusDot'
|
||||
@@ -46,6 +47,24 @@ interface SystemIface {
|
||||
addr_info?: Array<{ family: 'inet' | 'inet6'; local: string; prefixlen: number }>
|
||||
}
|
||||
|
||||
interface NTPStatus {
|
||||
synced: boolean
|
||||
reference?: string
|
||||
stratum?: number
|
||||
offset_ms?: number
|
||||
freq_ppm?: number
|
||||
rms_offset_ms?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
async function fetchNTPStatus(): Promise<NTPStatus | null> {
|
||||
try {
|
||||
const r = await apiClient.get('/ntp/status')
|
||||
if (!isEnvelope(r.data)) return null
|
||||
return r.data.data as NTPStatus
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
async function listPools(): Promise<Pool[]> {
|
||||
const r = await apiClient.get('/ntp/pools')
|
||||
if (!isEnvelope(r.data)) return []
|
||||
@@ -63,6 +82,12 @@ async function listSystemInterfaces(): Promise<SystemIface[]> {
|
||||
|
||||
export default function NTPPage() {
|
||||
const { t } = useTranslation()
|
||||
const { data: ntpStatus, refetch: refetchStatus } = useQuery({
|
||||
queryKey: ['ntp', 'status'],
|
||||
queryFn: fetchNTPStatus,
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
@@ -70,6 +95,58 @@ export default function NTPPage() {
|
||||
title={t('ntp.title')}
|
||||
subtitle={t('ntp.intro')}
|
||||
/>
|
||||
|
||||
<Card
|
||||
size="small"
|
||||
className="mb-12"
|
||||
title={<><ClockCircleOutlined /> {t('ntp.statusCard.title')}</>}
|
||||
extra={<Button size="small" icon={<ReloadOutlined />} onClick={() => refetchStatus()}>{t('common.refresh')}</Button>}
|
||||
>
|
||||
{ntpStatus?.error ? (
|
||||
<Alert type="warning" showIcon message={ntpStatus.error} />
|
||||
) : ntpStatus ? (
|
||||
<Row gutter={12}>
|
||||
<Col xs={12} sm={6}>
|
||||
<Statistic
|
||||
title={t('ntp.statusCard.sync')}
|
||||
value={ntpStatus.synced ? t('ntp.statusCard.synced') : t('ntp.statusCard.notSynced')}
|
||||
valueStyle={{ color: ntpStatus.synced ? '#16a34a' : '#cf1322', fontSize: 14 }}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Statistic
|
||||
title={t('ntp.statusCard.source')}
|
||||
value={ntpStatus.reference || '—'}
|
||||
valueStyle={{ fontSize: 13, fontFamily: 'monospace' }}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Statistic
|
||||
title={t('ntp.statusCard.stratum')}
|
||||
value={ntpStatus.stratum ?? '—'}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Tooltip title={t('ntp.statusCard.offsetHint')}>
|
||||
<Statistic
|
||||
title={t('ntp.statusCard.offset')}
|
||||
value={ntpStatus.offset_ms != null
|
||||
? (ntpStatus.offset_ms >= 0 ? '+' : '') + ntpStatus.offset_ms.toFixed(3) + ' ms'
|
||||
: '—'}
|
||||
valueStyle={{
|
||||
fontSize: 13,
|
||||
color: ntpStatus.offset_ms != null && Math.abs(ntpStatus.offset_ms) > 100
|
||||
? '#d48806' : undefined,
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Col>
|
||||
</Row>
|
||||
) : (
|
||||
<Typography.Text type="secondary">{t('ntp.statusCard.loading')}</Typography.Text>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Tabs
|
||||
defaultActiveKey="pools"
|
||||
items={[
|
||||
@@ -141,6 +218,11 @@ function PoolsTab() {
|
||||
},
|
||||
]
|
||||
|
||||
const openCreate = () => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({ kind: 'pool', iburst: true, prefer: false, active: true } as Pool)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
@@ -149,13 +231,22 @@ function PoolsTab() {
|
||||
dataSource={data ?? []}
|
||||
columns={cols}
|
||||
extraActions={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({ kind: 'pool', iburst: true, prefer: false, active: true } as Pool)
|
||||
}}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
{t('ntp.pool.add')}
|
||||
</Button>
|
||||
}
|
||||
emptyContent={
|
||||
<EmptyState
|
||||
icon={<ClockCircleOutlined />}
|
||||
title={t('ntp.pool.emptyTitle')}
|
||||
description={t('ntp.pool.emptyDesc')}
|
||||
action={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
{t('ntp.pool.add')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState } from 'react'
|
||||
import { Button, Card, Form, Input, InputNumber, Modal, Select, Space, Switch, Tag, Tooltip, Typography, message } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { PlusOutlined } from '@ant-design/icons'
|
||||
import { ClusterOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import DataTable from '../../components/DataTable'
|
||||
import EmptyState from '../../components/EmptyState'
|
||||
import ActionButtons from '../../components/ActionButtons'
|
||||
import StatusDot from '../../components/StatusDot'
|
||||
|
||||
@@ -149,6 +150,11 @@ export default function InterfacesTab() {
|
||||
},
|
||||
]
|
||||
|
||||
const openCreate = () => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({ type: 'ethernet', role: 'lan', active: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card title={t('networks.systemDiscovered')} className="mb-12" size="small">
|
||||
@@ -172,13 +178,22 @@ export default function InterfacesTab() {
|
||||
dataSource={ifs ?? []}
|
||||
columns={columns}
|
||||
extraActions={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({ type: 'ethernet', role: 'lan', active: true })
|
||||
}}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
{t('networks.addInterface')}
|
||||
</Button>
|
||||
}
|
||||
emptyContent={
|
||||
<EmptyState
|
||||
icon={<ClusterOutlined />}
|
||||
title={t('networks.emptyTitle')}
|
||||
description={t('networks.emptyDesc')}
|
||||
action={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
{t('networks.addInterface')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import apiClient, { isEnvelope } from '../../api/client'
|
||||
import EmptyState from '../../components/EmptyState'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
@@ -168,6 +169,14 @@ export default function RoutesTab() {
|
||||
render: (v?: number) => v ?? '—' },
|
||||
]
|
||||
|
||||
const openCreate = () => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({
|
||||
metric: 100, table_name: 'main', active: true,
|
||||
destination: '', gateway: '', dev: '',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card
|
||||
@@ -207,13 +216,7 @@ export default function RoutesTab() {
|
||||
title={t('routes.managedTitle')}
|
||||
extra={
|
||||
<Button type="primary" size="small" icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({
|
||||
metric: 100, table_name: 'main', active: true,
|
||||
destination: '', gateway: '', dev: '',
|
||||
})
|
||||
}}>
|
||||
onClick={openCreate}>
|
||||
{t('routes.add')}
|
||||
</Button>
|
||||
}
|
||||
@@ -226,7 +229,18 @@ export default function RoutesTab() {
|
||||
dataSource={managed.data ?? []}
|
||||
columns={managedColumns}
|
||||
pagination={false}
|
||||
locale={{ emptyText: t('routes.empty') }}
|
||||
locale={{ emptyText: (
|
||||
<EmptyState
|
||||
icon={<EnvironmentOutlined />}
|
||||
title={t('routes.emptyTitle')}
|
||||
description={t('routes.emptyDesc')}
|
||||
action={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
{t('routes.add')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) }}
|
||||
style={{ marginTop: 12 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { BranchesOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import DataTable from '../../components/DataTable'
|
||||
import EmptyState from '../../components/EmptyState'
|
||||
import PageHeader from '../../components/PageHeader'
|
||||
import ActionButtons from '../../components/ActionButtons'
|
||||
import StatusDot from '../../components/StatusDot'
|
||||
@@ -119,6 +120,11 @@ export default function RoutingRulesPage() {
|
||||
},
|
||||
]
|
||||
|
||||
const openCreate = () => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({ priority: 100, path_prefix: '/', active: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
@@ -132,13 +138,22 @@ export default function RoutingRulesPage() {
|
||||
dataSource={rules ?? []}
|
||||
columns={columns}
|
||||
extraActions={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({ priority: 100, path_prefix: '/', active: true })
|
||||
}}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
{t('routing.addRule')}
|
||||
</Button>
|
||||
}
|
||||
emptyContent={
|
||||
<EmptyState
|
||||
icon={<BranchesOutlined />}
|
||||
title={t('routing.emptyTitle')}
|
||||
description={t('routing.emptyDesc')}
|
||||
action={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
{t('routing.addRule')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Modal
|
||||
title={editing ? t('routing.editRule') : t('routing.addRule')}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { useState } from 'react'
|
||||
import { Alert, AutoComplete, Button, Card, Form, Input, Space, Tabs, Tag, Typography, message } from 'antd'
|
||||
import { SafetyCertificateOutlined } from '@ant-design/icons'
|
||||
import { Alert, AutoComplete, Button, Card, Col, Form, Input, Popconfirm, Row, Space, Statistic, Tabs, Tag, Tooltip, Typography, message } from 'antd'
|
||||
import { ExclamationCircleOutlined, ReloadOutlined, SafetyCertificateOutlined } from '@ant-design/icons'
|
||||
import PageHeader from '../../components/PageHeader'
|
||||
import ActionButtons from '../../components/ActionButtons'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import DataTable from '../../components/DataTable'
|
||||
import EmptyState from '../../components/EmptyState'
|
||||
|
||||
import apiClient, { isEnvelope } from '../../api/client'
|
||||
|
||||
@@ -79,6 +80,18 @@ function daysUntil(s?: string | null): number | null {
|
||||
return Math.round((t - Date.now()) / 86_400_000)
|
||||
}
|
||||
|
||||
// relativeAgo: kompakte Vergangenheits-Anzeige für "last_renewed_at".
|
||||
// Wir übergeben das i18n-`t` als zweites Argument damit die JSX-Render-
|
||||
// Funktion in der Column-Definition diese Helper-Funktion auch dann
|
||||
// nutzen kann wenn sie ausserhalb des Components definiert ist.
|
||||
function relativeAgo(ms: number, t: (k: string, v?: Record<string, unknown>) => string): string {
|
||||
if (ms < 0) return '—'
|
||||
if (ms < 60_000) return t('ssl.relAgo.justNow')
|
||||
if (ms < 3_600_000) return t('ssl.relAgo.minutes', { n: Math.round(ms / 60_000) })
|
||||
if (ms < 86_400_000) return t('ssl.relAgo.hours', { n: Math.round(ms / 3_600_000) })
|
||||
return t('ssl.relAgo.days', { n: Math.round(ms / 86_400_000) })
|
||||
}
|
||||
|
||||
export default function SSLPage() {
|
||||
const { t } = useTranslation()
|
||||
const qc = useQueryClient()
|
||||
@@ -139,8 +152,38 @@ export default function SSLPage() {
|
||||
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['tls-certs'] }) },
|
||||
})
|
||||
|
||||
// Force-Renew re-uses /tls-certs/issue — der Endpoint upsertet, also
|
||||
// ist ein zweiter Issue für dieselbe Domain effektiv ein Renew. Wir
|
||||
// mappen die Row-ID auf die Domain damit die Mutation nur eine ID
|
||||
// braucht (UI-seitig).
|
||||
const renewMut = useMutation({
|
||||
mutationFn: async (domain: string) => {
|
||||
const r = await apiClient.post('/tls-certs/issue', { domain })
|
||||
return r.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success(t('ssl.renewSuccess'))
|
||||
void qc.invalidateQueries({ queryKey: ['tls-certs'] })
|
||||
},
|
||||
onError: (e: Error) => {
|
||||
message.error(t('ssl.renewFailed') + ': ' + e.message)
|
||||
},
|
||||
})
|
||||
|
||||
const columns: ColumnsType<TLSCert> = [
|
||||
{ title: t('ssl.domain'), dataIndex: 'domain', key: 'domain', render: (s: string) => <code>{s}</code> },
|
||||
{
|
||||
title: t('ssl.domain'), dataIndex: 'domain', key: 'domain',
|
||||
render: (s: string, row) => (
|
||||
<Space size={6}>
|
||||
<code>{s}</code>
|
||||
{row.last_error && (
|
||||
<Tooltip title={row.last_error}>
|
||||
<ExclamationCircleOutlined style={{ color: '#cf1322' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ title: t('ssl.issuer'), dataIndex: 'issuer', key: 'issuer' },
|
||||
{
|
||||
title: t('ssl.status'), dataIndex: 'status', key: 'status',
|
||||
@@ -156,13 +199,46 @@ export default function SSLPage() {
|
||||
return `${d}d`
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('ssl.lastRenewed'), key: 'lastRenewed', width: 130,
|
||||
render: (_, row) => {
|
||||
if (!row.last_renewed_at) {
|
||||
return <Typography.Text type="secondary" style={{ fontSize: 12 }}>—</Typography.Text>
|
||||
}
|
||||
const ms = Date.now() - new Date(row.last_renewed_at).getTime()
|
||||
const rel = relativeAgo(ms, t)
|
||||
return (
|
||||
<Tooltip title={new Date(row.last_renewed_at).toLocaleString()}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{rel}</Typography.Text>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('common.actions'), key: 'actions',
|
||||
render: (_, row) => (
|
||||
<ActionButtons
|
||||
onDelete={() => delMut.mutate(row.id)}
|
||||
deleteConfirm={t('ssl.deleteConfirm', { domain: row.domain })}
|
||||
/>
|
||||
<Space size={4}>
|
||||
{row.issuer === 'letsencrypt' && (
|
||||
<Popconfirm
|
||||
title={t('ssl.renewConfirmTitle')}
|
||||
description={t('ssl.renewConfirmDesc', { domain: row.domain })}
|
||||
okText={t('common.yes')} cancelText={t('common.no')}
|
||||
onConfirm={() => renewMut.mutate(row.domain)}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
loading={renewMut.isPending && renewMut.variables === row.domain}
|
||||
>
|
||||
{t('ssl.renewBtn')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
<ActionButtons
|
||||
onDelete={() => delMut.mutate(row.id)}
|
||||
deleteConfirm={t('ssl.deleteConfirm', { domain: row.domain })}
|
||||
/>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
@@ -225,6 +301,19 @@ export default function SSLPage() {
|
||||
},
|
||||
]
|
||||
|
||||
// Aggregate counts — operator-glance health. Berechnet aus der
|
||||
// bereits geladenen Liste; keine zusätzlichen API-Calls nötig.
|
||||
const total = certs?.length ?? 0
|
||||
const expiring = (certs ?? []).filter((c) => {
|
||||
const d = daysUntil(c.not_after)
|
||||
return d != null && d >= 0 && d < 30
|
||||
}).length
|
||||
const expired = (certs ?? []).filter((c) => {
|
||||
const d = daysUntil(c.not_after)
|
||||
return d != null && d < 0
|
||||
}).length
|
||||
const inError = (certs ?? []).filter((c) => !!c.last_error || c.status === 'error').length
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
@@ -233,10 +322,57 @@ export default function SSLPage() {
|
||||
subtitle={t('ssl.intro')}
|
||||
/>
|
||||
|
||||
{total > 0 && (
|
||||
<Row gutter={12} style={{ marginBottom: 16 }}>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small"><Statistic title={t('ssl.statTotal')} value={total} /></Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title={t('ssl.statExpiring')}
|
||||
value={expiring}
|
||||
valueStyle={expiring > 0 ? { color: '#d48806' } : undefined}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title={t('ssl.statExpired')}
|
||||
value={expired}
|
||||
valueStyle={expired > 0 ? { color: '#cf1322' } : undefined}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title={t('ssl.statErrors')}
|
||||
value={inError}
|
||||
valueStyle={inError > 0 ? { color: '#cf1322' } : undefined}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
<Tabs items={tabs} defaultActiveKey="letsencrypt" />
|
||||
|
||||
<Typography.Title level={5} style={{ marginTop: 24 }}>{t('ssl.installedTitle')}</Typography.Title>
|
||||
<DataTable rowKey="id" loading={isLoading} dataSource={certs ?? []} columns={columns} />
|
||||
<DataTable
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
dataSource={certs ?? []}
|
||||
columns={columns}
|
||||
emptyContent={
|
||||
<EmptyState
|
||||
icon={<SafetyCertificateOutlined />}
|
||||
title={t('ssl.emptyTitle')}
|
||||
description={t('ssl.emptyDesc')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Card, Descriptions, Spin } from 'antd'
|
||||
import { SettingOutlined } from '@ant-design/icons'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Alert, Button, Card, Descriptions, Form, Input, InputNumber, Space, Spin, Switch, Typography, message } from 'antd'
|
||||
import { CloudDownloadOutlined, CloudSyncOutlined, DatabaseOutlined, ExclamationCircleOutlined, FileSearchOutlined, LockOutlined, MailOutlined, ReloadOutlined, SettingOutlined, StopOutlined, ToolOutlined } from '@ant-design/icons'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import apiClient, { isEnvelope } from '../../api/client'
|
||||
@@ -9,16 +10,31 @@ import PageHeader from '../../components/PageHeader'
|
||||
interface SetupStatus {
|
||||
completed: boolean
|
||||
admin_email: string
|
||||
acme_email: string
|
||||
fqdn: string
|
||||
}
|
||||
|
||||
interface ContactEmailValues {
|
||||
admin_email: string
|
||||
acme_email: string
|
||||
}
|
||||
|
||||
interface SystemHealth {
|
||||
status: string
|
||||
version: string
|
||||
}
|
||||
|
||||
interface ChangePasswordValues {
|
||||
current_password: string
|
||||
new_password: string
|
||||
confirm_password: string
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { t } = useTranslation()
|
||||
const qc = useQueryClient()
|
||||
const [msg, msgCtx] = message.useMessage()
|
||||
const [pwForm] = Form.useForm<ChangePasswordValues>()
|
||||
|
||||
const { data: setupStatus, isLoading: loadingSetup } = useQuery({
|
||||
queryKey: ['setup', 'status'],
|
||||
@@ -38,12 +54,226 @@ export default function SettingsPage() {
|
||||
},
|
||||
})
|
||||
|
||||
const [emailForm] = Form.useForm<ContactEmailValues>()
|
||||
const updateEmails = useMutation({
|
||||
mutationFn: async (v: ContactEmailValues) => {
|
||||
const r = await apiClient.post('/setup/contact-emails', v)
|
||||
return r.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
msg.success(t('settings.emailsSaved'))
|
||||
void qc.invalidateQueries({ queryKey: ['setup', 'status'] })
|
||||
},
|
||||
onError: (e: Error) => {
|
||||
msg.error(t('settings.emailsFailed') + ': ' + e.message)
|
||||
},
|
||||
})
|
||||
|
||||
const { data: maintenance, refetch: refetchMaintenance } = useQuery({
|
||||
queryKey: ['system', 'maintenance'],
|
||||
queryFn: async () => {
|
||||
const r = await apiClient.get('/system/maintenance')
|
||||
return isEnvelope(r.data)
|
||||
? (r.data.data as { enabled: boolean; message: string })
|
||||
: { enabled: false, message: '' }
|
||||
},
|
||||
})
|
||||
const [maintMessage, setMaintMessage] = useState('')
|
||||
// Bei Daten-Aktualisierung: lokales Textarea mit DB-Wert syncen,
|
||||
// wenn der Operator gerade nicht tippt. Trigger via key-Prop unten.
|
||||
const toggleMaintenance = useMutation({
|
||||
mutationFn: async (vals: { enabled: boolean; message: string }) => {
|
||||
const r = await apiClient.post('/system/maintenance', vals)
|
||||
return r.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
msg.success(t('settings.maintenanceSaved'))
|
||||
void refetchMaintenance()
|
||||
},
|
||||
onError: (e: Error) => {
|
||||
msg.error(t('settings.maintenanceFailed') + ': ' + e.message)
|
||||
},
|
||||
})
|
||||
|
||||
const { data: backupRetention } = useQuery({
|
||||
queryKey: ['system', 'backup-retention'],
|
||||
queryFn: async () => {
|
||||
const r = await apiClient.get('/system/backup-retention')
|
||||
return isEnvelope(r.data)
|
||||
? (r.data.data as { keep: number; default: number })
|
||||
: { keep: 0, default: 14 }
|
||||
},
|
||||
})
|
||||
const setBackupRetention = useMutation({
|
||||
mutationFn: async (keep: number) => {
|
||||
const r = await apiClient.post('/system/backup-retention', { keep })
|
||||
return r.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
msg.success(t('settings.backupRetentionSaved'))
|
||||
void qc.invalidateQueries({ queryKey: ['system', 'backup-retention'] })
|
||||
},
|
||||
onError: (e: Error) => msg.error(t('settings.backupRetentionFailed') + ': ' + e.message),
|
||||
})
|
||||
|
||||
const haproxyReload = useMutation({
|
||||
mutationFn: async () => apiClient.post('/system/haproxy-reload'),
|
||||
onSuccess: () => msg.success(t('settings.haproxyReloadOk')),
|
||||
onError: (e: Error) => msg.error(t('settings.haproxyReloadFailed') + ': ' + e.message),
|
||||
})
|
||||
const renderConfigs = useMutation({
|
||||
mutationFn: async () => apiClient.post('/system/render-configs'),
|
||||
onSuccess: () => msg.success(t('settings.renderConfigsOk')),
|
||||
onError: (e: Error) => msg.error(t('settings.renderConfigsFailed') + ': ' + e.message),
|
||||
})
|
||||
const triggerBackup = useMutation({
|
||||
mutationFn: async () => apiClient.post('/backups'),
|
||||
onSuccess: () => msg.success(t('settings.backupNowOk')),
|
||||
onError: (e: Error) => msg.error(t('settings.backupNowFailed') + ': ' + e.message),
|
||||
})
|
||||
|
||||
const [restartingService, setRestartingService] = useState<string | null>(null)
|
||||
const serviceRestart = useMutation({
|
||||
mutationFn: async (service: string) => {
|
||||
setRestartingService(service)
|
||||
await apiClient.post('/system/service-restart', { service })
|
||||
},
|
||||
onSuccess: (_, service) => {
|
||||
msg.success(t('settings.serviceRestartOk', { service }))
|
||||
setRestartingService(null)
|
||||
void qc.invalidateQueries({ queryKey: ['system', 'services'] })
|
||||
},
|
||||
onError: (e: Error, service) => {
|
||||
msg.error(t('settings.serviceRestartFailed', { service }) + ': ' + e.message)
|
||||
setRestartingService(null)
|
||||
},
|
||||
})
|
||||
|
||||
interface ServiceStatus { label: string; unit: string; active: boolean; state: string }
|
||||
const { data: services, refetch: refetchServices } = useQuery({
|
||||
queryKey: ['system', 'services'],
|
||||
queryFn: async () => {
|
||||
const r = await apiClient.get('/system/services')
|
||||
return isEnvelope(r.data) ? (r.data.data as { services: ServiceStatus[] }).services : []
|
||||
},
|
||||
refetchInterval: 15_000,
|
||||
})
|
||||
|
||||
// Restartable services — subset der Allowlist; API lehnt andere ab.
|
||||
const RESTARTABLE = ['haproxy', 'squid', 'unbound', 'chrony', 'edgeguard-scheduler']
|
||||
|
||||
const { data: upgradeStatus, refetch: refetchUpgrade } = useQuery({
|
||||
queryKey: ['system', 'upgrade-status'],
|
||||
queryFn: async () => {
|
||||
const r = await apiClient.get('/system/upgrade-status')
|
||||
return isEnvelope(r.data)
|
||||
? (r.data.data as {
|
||||
state: string
|
||||
result: string
|
||||
exec_main_pid: number
|
||||
exit_code: number
|
||||
started_at: string
|
||||
finished_at: string
|
||||
log: string[]
|
||||
})
|
||||
: null
|
||||
},
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
|
||||
const { data: dbSize } = useQuery({
|
||||
queryKey: ['system', 'db-size'],
|
||||
queryFn: async () => {
|
||||
const r = await apiClient.get('/system/db-size')
|
||||
return isEnvelope(r.data)
|
||||
? (r.data.data as {
|
||||
total_bytes: number
|
||||
human_total: string
|
||||
top_tables: { name: string; bytes: number; human_size: string }[]
|
||||
})
|
||||
: null
|
||||
},
|
||||
// DB-Größe ändert sich langsam → 5 min Refresh, eher konservativ.
|
||||
refetchInterval: 5 * 60_000,
|
||||
})
|
||||
|
||||
const { data: auditRetention } = useQuery({
|
||||
queryKey: ['system', 'audit-retention'],
|
||||
queryFn: async () => {
|
||||
const r = await apiClient.get('/system/audit-retention')
|
||||
return isEnvelope(r.data)
|
||||
? (r.data.data as { days: number; default: number })
|
||||
: { days: 0, default: 90 }
|
||||
},
|
||||
})
|
||||
const setAuditRetention = useMutation({
|
||||
mutationFn: async (days: number) => {
|
||||
const r = await apiClient.post('/system/audit-retention', { days })
|
||||
return r.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
msg.success(t('settings.auditRetentionSaved'))
|
||||
void qc.invalidateQueries({ queryKey: ['system', 'audit-retention'] })
|
||||
},
|
||||
onError: (e: Error) => msg.error(t('settings.auditRetentionFailed') + ': ' + e.message),
|
||||
})
|
||||
|
||||
const { data: autoUpdate } = useQuery({
|
||||
queryKey: ['system', 'auto-update'],
|
||||
queryFn: async () => {
|
||||
const r = await apiClient.get('/system/auto-update')
|
||||
return isEnvelope(r.data) ? (r.data.data as { enabled: boolean }) : { enabled: false }
|
||||
},
|
||||
})
|
||||
const toggleAutoUpdate = useMutation({
|
||||
mutationFn: async (enabled: boolean) => {
|
||||
const r = await apiClient.post('/system/auto-update', { enabled })
|
||||
return r.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
msg.success(t('settings.autoUpdateToggled'))
|
||||
void qc.invalidateQueries({ queryKey: ['system', 'auto-update'] })
|
||||
},
|
||||
onError: (e: Error) => {
|
||||
msg.error(t('settings.autoUpdateFailed') + ': ' + e.message)
|
||||
},
|
||||
})
|
||||
|
||||
const changePassword = useMutation({
|
||||
mutationFn: async (v: { current_password: string; new_password: string }) => {
|
||||
const r = await apiClient.post('/auth/change-password', v)
|
||||
return r.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
msg.success(t('settings.passwordChanged'))
|
||||
pwForm.resetFields()
|
||||
},
|
||||
onError: (e: Error) => {
|
||||
// API liefert 401 mit error="invalid_current_password" oder
|
||||
// 400 mit error-Message; wir zeigen beides als Toast.
|
||||
msg.error(t('settings.passwordChangeFailed') + ': ' + e.message)
|
||||
},
|
||||
})
|
||||
|
||||
// Form-Pre-Fill nach Status-Reload: setup-status liefert die zwei
|
||||
// Email-Felder; wir resetten das Form drauf damit nach Save der frische
|
||||
// Wert sichtbar wird.
|
||||
useEffect(() => {
|
||||
if (setupStatus) {
|
||||
emailForm.setFieldsValue({
|
||||
admin_email: setupStatus.admin_email,
|
||||
acme_email: setupStatus.acme_email,
|
||||
})
|
||||
}
|
||||
}, [setupStatus, emailForm])
|
||||
|
||||
if (loadingSetup || loadingHealth) {
|
||||
return <Spin />
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{msgCtx}
|
||||
<PageHeader
|
||||
icon={<SettingOutlined />}
|
||||
title={t('settings.title')}
|
||||
@@ -54,18 +284,399 @@ export default function SettingsPage() {
|
||||
<Descriptions column={1}>
|
||||
<Descriptions.Item label={t('settings.version')}>{health?.version ?? '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('settings.status')}>{health?.status ?? '—'}</Descriptions.Item>
|
||||
{dbSize && (
|
||||
<Descriptions.Item label={t('settings.dbSize')}>
|
||||
<Space direction="vertical" size={2}>
|
||||
<Typography.Text>{dbSize.human_total}</Typography.Text>
|
||||
{dbSize.top_tables.length > 0 && (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
|
||||
{t('settings.dbSizeTop')}:{' '}
|
||||
{dbSize.top_tables.slice(0, 3).map(t =>
|
||||
`${t.name} (${t.human_size})`
|
||||
).join(', ')}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Space>
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Card title={t('settings.setupInfo')} size="small">
|
||||
<Card
|
||||
title={<><ToolOutlined /> {t('settings.actionsCardTitle')}</>}
|
||||
className="mb-12"
|
||||
size="small"
|
||||
>
|
||||
<Space wrap>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
loading={haproxyReload.isPending}
|
||||
onClick={() => haproxyReload.mutate()}
|
||||
>
|
||||
{t('settings.haproxyReloadBtn')}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
loading={renderConfigs.isPending}
|
||||
onClick={() => renderConfigs.mutate()}
|
||||
>
|
||||
{t('settings.renderConfigsBtn')}
|
||||
</Button>
|
||||
<Button
|
||||
icon={<DatabaseOutlined />}
|
||||
loading={triggerBackup.isPending}
|
||||
onClick={() => triggerBackup.mutate()}
|
||||
>
|
||||
{t('settings.backupNowBtn')}
|
||||
</Button>
|
||||
</Space>
|
||||
<Typography.Paragraph type="secondary" style={{ fontSize: 12, marginTop: 8, marginBottom: 0 }}>
|
||||
{t('settings.actionsHint')}
|
||||
</Typography.Paragraph>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={<><ReloadOutlined /> {t('settings.serviceRestartCardTitle')}</>}
|
||||
className="mb-12"
|
||||
size="small"
|
||||
extra={<Button size="small" icon={<ReloadOutlined />} onClick={() => refetchServices()}>{t('common.refresh')}</Button>}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={4}>
|
||||
{RESTARTABLE.map((svc) => {
|
||||
const status = services?.find(s => s.unit === svc + '.service' || s.unit === svc)
|
||||
return (
|
||||
<Space key={svc} style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<Space size={6}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block', width: 8, height: 8, borderRadius: '50%',
|
||||
background: status?.active ? '#22c55e' : '#ef4444',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Typography.Text style={{ fontFamily: 'monospace', fontSize: 13 }}>{svc}</Typography.Text>
|
||||
{status && (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
|
||||
{status.state}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Space>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
loading={restartingService === svc}
|
||||
onClick={() => serviceRestart.mutate(svc)}
|
||||
>
|
||||
{t('settings.serviceRestartBtn')}
|
||||
</Button>
|
||||
</Space>
|
||||
)
|
||||
})}
|
||||
</Space>
|
||||
<Typography.Paragraph type="secondary" style={{ fontSize: 12, marginTop: 8, marginBottom: 0 }}>
|
||||
{t('settings.serviceRestartHint')}
|
||||
</Typography.Paragraph>
|
||||
</Card>
|
||||
|
||||
{upgradeStatus && upgradeStatus.started_at && (
|
||||
<Card
|
||||
title={<><CloudDownloadOutlined /> {t('settings.upgradeStatusCardTitle')}</>}
|
||||
className="mb-12"
|
||||
size="small"
|
||||
extra={
|
||||
<Button size="small" icon={<ReloadOutlined />} onClick={() => refetchUpgrade()}>
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Descriptions size="small" column={2} bordered>
|
||||
<Descriptions.Item label={t('settings.upgradeStatusStarted')}>
|
||||
{upgradeStatus.started_at
|
||||
? new Date(upgradeStatus.started_at).toLocaleString()
|
||||
: '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('settings.upgradeStatusFinished')}>
|
||||
{upgradeStatus.finished_at
|
||||
? new Date(upgradeStatus.finished_at).toLocaleString()
|
||||
: '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('settings.upgradeStatusResult')}>
|
||||
{upgradeStatus.result === 'success' ? (
|
||||
<Typography.Text type="success">{t('settings.upgradeStatusOk')}</Typography.Text>
|
||||
) : (
|
||||
<Typography.Text type="danger">
|
||||
{upgradeStatus.result || upgradeStatus.state}
|
||||
{upgradeStatus.exit_code !== 0 && ` (exit ${upgradeStatus.exit_code})`}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('settings.upgradeStatusState')}>
|
||||
{upgradeStatus.state}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
{upgradeStatus.log.length > 0 && (
|
||||
<details style={{ marginTop: 12 }}>
|
||||
<summary style={{ cursor: 'pointer', fontSize: 12, color: '#475569' }}>
|
||||
{t('settings.upgradeStatusShowLog', { n: upgradeStatus.log.length })}
|
||||
</summary>
|
||||
<pre style={{
|
||||
marginTop: 8, padding: 8, background: '#f8fafc',
|
||||
fontSize: 11, lineHeight: 1.4, overflow: 'auto', maxHeight: 320,
|
||||
border: '1px solid #e2e8f0', borderRadius: 4,
|
||||
}}>{upgradeStatus.log.join('\n')}</pre>
|
||||
</details>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card title={t('settings.setupInfo')} className="mb-12" size="small">
|
||||
<Descriptions column={1}>
|
||||
<Descriptions.Item label={t('settings.adminEmail')}>{setupStatus?.admin_email ?? '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('settings.fqdn')}>{setupStatus?.fqdn ?? '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('settings.setupCompleted')}>
|
||||
{setupStatus?.completed ? t('common.yes') : t('common.no')}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={<><MailOutlined /> {t('settings.emailsCardTitle')}</>}
|
||||
className="mb-12"
|
||||
size="small"
|
||||
>
|
||||
<Form<ContactEmailValues>
|
||||
form={emailForm}
|
||||
layout="vertical"
|
||||
onFinish={(v) => updateEmails.mutate(v)}
|
||||
>
|
||||
<Form.Item
|
||||
label={t('settings.adminEmail')}
|
||||
name="admin_email"
|
||||
extra={t('settings.adminEmailHint')}
|
||||
rules={[{ required: true, type: 'email' }]}
|
||||
>
|
||||
<Input type="email" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('settings.acmeEmail')}
|
||||
name="acme_email"
|
||||
extra={t('settings.acmeEmailHint')}
|
||||
rules={[{ required: true, type: 'email' }]}
|
||||
>
|
||||
<Input type="email" />
|
||||
</Form.Item>
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" loading={updateEmails.isPending}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
<Button onClick={() => emailForm.resetFields()}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={<><StopOutlined style={{ color: maintenance?.enabled ? '#cf1322' : undefined }} /> {t('settings.maintenanceCardTitle')}</>}
|
||||
className="mb-12"
|
||||
size="small"
|
||||
>
|
||||
{maintenance?.enabled && (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
icon={<ExclamationCircleOutlined />}
|
||||
message={t('settings.maintenanceActiveTitle')}
|
||||
description={t('settings.maintenanceActiveDesc')}
|
||||
className="mb-12"
|
||||
/>
|
||||
)}
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Space>
|
||||
<Switch
|
||||
checked={maintenance?.enabled ?? false}
|
||||
loading={toggleMaintenance.isPending}
|
||||
onChange={(checked) => toggleMaintenance.mutate({
|
||||
enabled: checked,
|
||||
message: maintMessage || maintenance?.message || '',
|
||||
})}
|
||||
/>
|
||||
<Typography.Text>
|
||||
{maintenance?.enabled ? t('settings.maintenanceOn') : t('settings.maintenanceOff')}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
<Form.Item
|
||||
label={t('settings.maintenanceMessage')}
|
||||
extra={t('settings.maintenanceMessageHint')}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Input.TextArea
|
||||
key={maintenance?.message ?? ''}
|
||||
defaultValue={maintenance?.message ?? ''}
|
||||
onChange={(e) => setMaintMessage(e.target.value)}
|
||||
placeholder={t('settings.maintenanceMessagePlaceholder')}
|
||||
rows={2}
|
||||
maxLength={500}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{t('settings.maintenanceHint')}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={<><DatabaseOutlined /> {t('settings.backupRetentionCardTitle')}</>}
|
||||
className="mb-12"
|
||||
size="small"
|
||||
>
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
<Space>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={365}
|
||||
step={1}
|
||||
value={backupRetention?.keep ?? 0}
|
||||
onChange={(v) => setBackupRetention.mutate((v as number) ?? 0)}
|
||||
disabled={setBackupRetention.isPending}
|
||||
addonAfter={t('settings.backupRetentionUnit')}
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
<Typography.Text type="secondary">
|
||||
{(backupRetention?.keep ?? 0) === 0
|
||||
? t('settings.backupRetentionDefault', { n: backupRetention?.default ?? 14 })
|
||||
: t('settings.backupRetentionCustom', { n: backupRetention?.keep })}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{t('settings.backupRetentionHint')}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={<><FileSearchOutlined /> {t('settings.auditRetentionCardTitle')}</>}
|
||||
className="mb-12"
|
||||
size="small"
|
||||
>
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
<Space>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={3650}
|
||||
step={30}
|
||||
value={auditRetention?.days ?? 0}
|
||||
onChange={(v) => setAuditRetention.mutate((v as number) ?? 0)}
|
||||
disabled={setAuditRetention.isPending}
|
||||
addonAfter={t('settings.auditRetentionUnit')}
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
<Typography.Text type="secondary">
|
||||
{(auditRetention?.days ?? 0) === 0
|
||||
? t('settings.auditRetentionDefault', { n: auditRetention?.default ?? 90 })
|
||||
: t('settings.auditRetentionCustom', { n: auditRetention?.days })}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{t('settings.auditRetentionHint')}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={<><CloudSyncOutlined /> {t('settings.autoUpdateCardTitle')}</>}
|
||||
className="mb-12"
|
||||
size="small"
|
||||
>
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
<Space>
|
||||
<Switch
|
||||
checked={autoUpdate?.enabled ?? false}
|
||||
loading={toggleAutoUpdate.isPending}
|
||||
onChange={(checked) => toggleAutoUpdate.mutate(checked)}
|
||||
/>
|
||||
<Typography.Text>
|
||||
{autoUpdate?.enabled ? t('settings.autoUpdateOn') : t('settings.autoUpdateOff')}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{t('settings.autoUpdateHint')}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card title={<><LockOutlined /> {t('settings.passwordCardTitle')}</>} size="small">
|
||||
<Form<ChangePasswordValues>
|
||||
form={pwForm}
|
||||
layout="vertical"
|
||||
onFinish={(v) => {
|
||||
if (v.new_password !== v.confirm_password) {
|
||||
msg.error(t('settings.passwordMismatch'))
|
||||
return
|
||||
}
|
||||
changePassword.mutate({
|
||||
current_password: v.current_password,
|
||||
new_password: v.new_password,
|
||||
})
|
||||
}}
|
||||
// Wir lassen den Submit-Button explizit click-bar — autoComplete
|
||||
// off damit der Browser nicht "Current password" mit dem im
|
||||
// Manager gespeicherten autofill'd.
|
||||
autoComplete="off"
|
||||
>
|
||||
<Form.Item
|
||||
label={t('settings.currentPassword')}
|
||||
name="current_password"
|
||||
rules={[{ required: true }]}
|
||||
>
|
||||
<Input.Password autoComplete="current-password" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('settings.newPassword')}
|
||||
name="new_password"
|
||||
extra={t('settings.newPasswordHint')}
|
||||
rules={[
|
||||
{ required: true },
|
||||
{ min: 12, message: t('settings.passwordMinLen') },
|
||||
]}
|
||||
>
|
||||
<Input.Password autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('settings.confirmPassword')}
|
||||
name="confirm_password"
|
||||
dependencies={['new_password']}
|
||||
rules={[
|
||||
{ required: true },
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue('new_password') === value) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return Promise.reject(new Error(t('settings.passwordMismatch')))
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={changePassword.isPending}
|
||||
>
|
||||
{t('settings.changePasswordBtn')}
|
||||
</Button>
|
||||
<Button onClick={() => pwForm.resetFields()}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, Card, Form, Input, message, Typography } from 'antd'
|
||||
import { Alert, Button, Card, Form, Input, Space, Typography, message } from 'antd'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -17,13 +17,27 @@ interface SetupValues {
|
||||
license_key?: string
|
||||
}
|
||||
|
||||
// FQDN-Regex: erlaubt RFC-1123-Labels (a-z 0-9 -) durch Punkte getrennt,
|
||||
// 1+ Labels, keine führenden/abschließenden Bindestriche, kein TLD-Zwang
|
||||
// (wir verifizieren live nicht die DNS-Existenz, nur die Form-Plausibilität).
|
||||
const FQDN_RE = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/
|
||||
|
||||
export default function SetupPage({ onComplete: _onComplete }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const onFinish = async (vals: SetupValues) => {
|
||||
try {
|
||||
await apiClient.post('/setup/complete', vals)
|
||||
// FQDN immer lower-casen — der Server erwartet das auch im
|
||||
// EqualFold-Vergleich beim Login, also vereinheitlichen wir hier
|
||||
// damit FQDN + ACME-Cert-Subject identisch werden.
|
||||
const normalised: SetupValues = {
|
||||
...vals,
|
||||
admin_email: vals.admin_email.trim().toLowerCase(),
|
||||
acme_email: vals.acme_email.trim().toLowerCase(),
|
||||
fqdn: vals.fqdn.trim().toLowerCase(),
|
||||
}
|
||||
await apiClient.post('/setup/complete', normalised)
|
||||
message.success(t('setup.successTitle'))
|
||||
// Setup doesn't issue a session — the operator must log in.
|
||||
navigate('/login', { replace: true })
|
||||
@@ -34,47 +48,75 @@ export default function SetupPage({ onComplete: _onComplete }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', minHeight: '100vh', alignItems: 'center', justifyContent: 'center', background: '#f0f2f5' }}>
|
||||
<Card style={{ width: 520 }}>
|
||||
<Typography.Title level={3}>{t('setup.title')}</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">{t('setup.intro')}</Typography.Paragraph>
|
||||
<div style={{ display: 'flex', minHeight: '100vh', alignItems: 'center', justifyContent: 'center', background: '#f0f2f5', padding: 24 }}>
|
||||
<Card style={{ width: 560 }}>
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
<Typography.Title level={3} style={{ marginBottom: 0 }}>{t('setup.title')}</Typography.Title>
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
||||
{t('setup.intro')}
|
||||
</Typography.Paragraph>
|
||||
</Space>
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={t('setup.preflightTitle')}
|
||||
description={t('setup.preflightDesc')}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
<Form layout="vertical" onFinish={onFinish}>
|
||||
<Form.Item
|
||||
label={t('setup.adminEmail')}
|
||||
name="admin_email"
|
||||
extra={t('setup.adminEmailHint')}
|
||||
rules={[{ required: true, type: 'email' }]}
|
||||
>
|
||||
<Input autoComplete="email" autoFocus />
|
||||
<Input autoComplete="email" autoFocus placeholder="admin@example.com" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('setup.adminPassword')}
|
||||
name="admin_password"
|
||||
extra={t('setup.passwordRule')}
|
||||
rules={[{ required: true, min: 12, message: t('setup.passwordRule') }]}
|
||||
help={t('setup.passwordRule')}
|
||||
>
|
||||
<Input.Password autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('setup.fqdn')}
|
||||
name="fqdn"
|
||||
rules={[{ required: true }]}
|
||||
extra={t('setup.fqdnHint')}
|
||||
rules={[
|
||||
{ required: true },
|
||||
{
|
||||
pattern: FQDN_RE,
|
||||
message: t('setup.fqdnInvalid'),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input placeholder="eg.example.com" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('setup.acmeEmail')}
|
||||
name="acme_email"
|
||||
extra={t('setup.acmeEmailHint')}
|
||||
rules={[{ required: true, type: 'email' }]}
|
||||
>
|
||||
<Input />
|
||||
<Input placeholder="ops@example.com" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('setup.licenseKey')}
|
||||
name="license_key"
|
||||
extra={t('setup.licenseKeyHint')}
|
||||
>
|
||||
<Input />
|
||||
<Input placeholder="EG-XXXX-XXXX-XXXX" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Button type="primary" htmlType="submit" block>
|
||||
{t('setup.submit')}
|
||||
</Button>
|
||||
|
||||
@@ -4,12 +4,13 @@ import {
|
||||
Row, Select, Switch, Tag, Typography, message,
|
||||
} from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { KeyOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import { KeyOutlined, PlusOutlined, ThunderboltOutlined } from '@ant-design/icons'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import apiClient, { isEnvelope } from '../../api/client'
|
||||
import DataTable from '../../components/DataTable'
|
||||
import EmptyState from '../../components/EmptyState'
|
||||
import ActionButtons from '../../components/ActionButtons'
|
||||
import StatusDot from '../../components/StatusDot'
|
||||
import type { WGInterface } from './types'
|
||||
@@ -113,6 +114,14 @@ export default function ClientsTab() {
|
||||
},
|
||||
]
|
||||
|
||||
const openCreate = () => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({
|
||||
allowed_ips: '0.0.0.0/0,::/0', persistent_keepalive: 25,
|
||||
role: 'wan', active: true, generate_keypair: true,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Alert
|
||||
@@ -127,16 +136,22 @@ export default function ClientsTab() {
|
||||
dataSource={clients ?? []}
|
||||
columns={cols}
|
||||
extraActions={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({
|
||||
allowed_ips: '0.0.0.0/0,::/0', persistent_keepalive: 25,
|
||||
role: 'wan', active: true, generate_keypair: true,
|
||||
})
|
||||
}}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
{t('wg.iface.addClient')}
|
||||
</Button>
|
||||
}
|
||||
emptyContent={
|
||||
<EmptyState
|
||||
icon={<ThunderboltOutlined />}
|
||||
title={t('wg.iface.emptyClientTitle')}
|
||||
description={t('wg.iface.emptyClientDesc')}
|
||||
action={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
{t('wg.iface.addClient')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -6,13 +6,14 @@ import {
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import {
|
||||
DownloadOutlined, KeyOutlined, PlusOutlined, QrcodeOutlined,
|
||||
TeamOutlined,
|
||||
TeamOutlined, ThunderboltOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import apiClient, { isEnvelope } from '../../api/client'
|
||||
import DataTable from '../../components/DataTable'
|
||||
import EmptyState from '../../components/EmptyState'
|
||||
import ActionButtons from '../../components/ActionButtons'
|
||||
import StatusDot from '../../components/StatusDot'
|
||||
import type { WGInterface, WGPeer } from './types'
|
||||
@@ -166,6 +167,13 @@ export default function ServersTab() {
|
||||
},
|
||||
]
|
||||
|
||||
const openCreate = () => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({
|
||||
listen_port: 51820, role: 'wan', active: true, generate_keypair: true,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Alert
|
||||
@@ -180,15 +188,22 @@ export default function ServersTab() {
|
||||
dataSource={servers ?? []}
|
||||
columns={cols}
|
||||
extraActions={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({
|
||||
listen_port: 51820, role: 'wan', active: true, generate_keypair: true,
|
||||
})
|
||||
}}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
{t('wg.iface.addServer')}
|
||||
</Button>
|
||||
}
|
||||
emptyContent={
|
||||
<EmptyState
|
||||
icon={<ThunderboltOutlined />}
|
||||
title={t('wg.iface.emptyServerTitle')}
|
||||
description={t('wg.iface.emptyServerDesc')}
|
||||
action={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
{t('wg.iface.addServer')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
@@ -407,6 +422,14 @@ function PeerDrawer({ iface, onClose }: PeerDrawerProps) {
|
||||
},
|
||||
]
|
||||
|
||||
const openPeerCreate = () => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({
|
||||
allowed_ips: '', enabled: true,
|
||||
generate_keypair: true, generate_psk: false,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
@@ -427,16 +450,22 @@ function PeerDrawer({ iface, onClose }: PeerDrawerProps) {
|
||||
dataSource={peers ?? []}
|
||||
columns={cols}
|
||||
extraActions={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({
|
||||
allowed_ips: '', enabled: true,
|
||||
generate_keypair: true, generate_psk: false,
|
||||
})
|
||||
}}>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openPeerCreate}>
|
||||
{t('wg.peer.add')}
|
||||
</Button>
|
||||
}
|
||||
emptyContent={
|
||||
<EmptyState
|
||||
icon={<TeamOutlined />}
|
||||
title={t('wg.peer.emptyTitle')}
|
||||
description={t('wg.peer.emptyDesc')}
|
||||
action={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openPeerCreate}>
|
||||
{t('wg.peer.add')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -22,5 +22,11 @@ export default defineConfig({
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: false,
|
||||
// Explizites Target — Vites Default rotiert sonst mit jeder
|
||||
// Vite-Version und bricht uns potenziell auf älteren Mobil-Browsern.
|
||||
// Diese Liste deckt iOS Safari 15+, Android Chrome 90+, modernes
|
||||
// Firefox/Edge ab. Quelle für „blank page on iOS" sind oft
|
||||
// ES2022-Features (Top-Level-await, `.at(-1)` etc) — `es2020` schließt das aus.
|
||||
target: ['es2020', 'chrome90', 'firefox88', 'safari15', 'edge90'],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -54,6 +54,12 @@ case "$1" in
|
||||
# gehört nur edgeguard.
|
||||
install -d -m 0700 -o "$EG_USER" -g "$EG_USER" /var/lib/edgeguard/acme-account
|
||||
|
||||
# Cluster-TLS-Dir 0700 — Cluster-CA-Key + peer.key sind sehr
|
||||
# sensitiv (CA-Compromise = vollständiger Cluster-Bruch). Wird
|
||||
# beim API-Boot mit Material gefüllt; postinst legt nur den
|
||||
# Dir an damit der API-User schreiben kann.
|
||||
install -d -m 0700 -o "$EG_USER" -g "$EG_USER" /var/lib/edgeguard/cluster-tls
|
||||
|
||||
# Backup-Verzeichnis: edgeguard-owned damit der scheduler /
|
||||
# API-Prozess schreiben kann. Mode 0750 — Backups enthalten
|
||||
# einen pg_dump der edgeguard-DB + license_key, also nicht
|
||||
@@ -101,6 +107,11 @@ edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl restart chrony.service
|
||||
edgeguard ALL=(root) NOPASSWD: /bin/systemctl restart chrony.service
|
||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/apt-get update -qq
|
||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/apt-get update
|
||||
# Auto-Update-Toggle (Settings → Automatische Updates) schreibt/entfernt
|
||||
# exakt diese Conf-Datei. Restrict auf den genauen Pfad damit edgeguard
|
||||
# NICHT beliebig in /etc/apt/apt.conf.d schreiben darf.
|
||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/tee /etc/apt/apt.conf.d/52edgeguard-auto-updates
|
||||
edgeguard ALL=(root) NOPASSWD: /bin/rm -f /etc/apt/apt.conf.d/52edgeguard-auto-updates
|
||||
# Backup-Pfad: pg_dump als postgres-User. Whitelist exakt mit
|
||||
# --clean --if-exists --no-owner --no-acl + dem festen DB-Namen.
|
||||
edgeguard ALL=(postgres) NOPASSWD: /usr/bin/pg_dump --clean --if-exists --no-owner --no-acl edgeguard
|
||||
@@ -458,7 +469,16 @@ ROUTESUNIT
|
||||
# ── systemd: pick up new units + restart haproxy with our cfg
|
||||
systemctl daemon-reload
|
||||
systemctl restart haproxy.service || true
|
||||
systemctl enable --now edgeguard-api.service edgeguard-scheduler.service || true
|
||||
systemctl enable edgeguard-api.service edgeguard-scheduler.service || true
|
||||
# restart (NICHT enable --now): das neue Unit-File könnte ein
|
||||
# geänderter ReadWritePaths-Block sein (Befund 1.0.87:
|
||||
# /var/lib/apt fehlte → apt-get update aus dem Update-Banner-
|
||||
# Handler schrieb in read-only Filesystem und failte still).
|
||||
# daemon-reload allein lädt die neue Unit nur für NÄCHSTE Starts;
|
||||
# der laufende Daemon behält das alte Sandbox-Snapshot bis zum
|
||||
# restart. Bei Erst-Install ist nichts running, dann ist das
|
||||
# ein normaler Start.
|
||||
systemctl restart edgeguard-api.service edgeguard-scheduler.service || true
|
||||
;;
|
||||
|
||||
abort-upgrade|abort-remove|abort-deconfigure)
|
||||
|
||||
95
scripts/apt-repo/cleanup-old.sh
Executable file
95
scripts/apt-repo/cleanup-old.sh
Executable file
@@ -0,0 +1,95 @@
|
||||
#!/bin/bash
|
||||
# Cleanup-old: löscht alle EdgeGuard-Versionen im Gitea Package
|
||||
# Registry außer den letzten N (default 10).
|
||||
#
|
||||
# Wird vom Makefile direkt nach erfolgreichem Upload aufgerufen. Reihen-
|
||||
# folge ist wichtig: ERST der neue Build hochladen, DANN die ältesten
|
||||
# wegschmeißen — sonst riskieren wir bei Cleanup-vor-Upload eine Lücke.
|
||||
#
|
||||
# Voraussetzungen:
|
||||
# - ~/.gitea-token mit write-Package-Scope
|
||||
# - jq + curl
|
||||
#
|
||||
# Aufruf:
|
||||
# ./cleanup-old.sh # KEEP=10 (default)
|
||||
# KEEP=20 ./cleanup-old.sh # mehr behalten
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
KEEP="${KEEP:-10}"
|
||||
OWNER="projekte"
|
||||
DIST="trixie"
|
||||
COMPONENT="main"
|
||||
BASE="https://git.netcell-it.de"
|
||||
|
||||
if [ -z "${GITEA_TOKEN:-}" ]; then
|
||||
if [ -r "$HOME/.gitea-token" ]; then
|
||||
GITEA_TOKEN="$(tr -d '\n' < "$HOME/.gitea-token")"
|
||||
else
|
||||
echo "cleanup-old: missing ~/.gitea-token and GITEA_TOKEN env" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
TOK="$GITEA_TOKEN"
|
||||
|
||||
# Pkg-Spec: "name:arch". arch="all" für noarch-Pakete (meta + UI),
|
||||
# "amd64" für edgeguard-api. Mehrere arch je name = mehrere Zeilen.
|
||||
PKGS=(
|
||||
"edgeguard:all"
|
||||
"edgeguard-api:amd64"
|
||||
"edgeguard-ui:all"
|
||||
)
|
||||
|
||||
cleanup_pkg() {
|
||||
local pkg="$1"
|
||||
local arch="$2"
|
||||
|
||||
# Versionen sammeln. Gitea Package-API liefert flache Liste:
|
||||
# /api/v1/packages/{owner}?type=debian&q={name}
|
||||
# Wir filtern client-seitig auf name (exact-match — Gitea-Query ist
|
||||
# leider substring-fuzzy) und sort -V (semver) absteigend.
|
||||
local raw
|
||||
raw="$(curl -fsS -H "Authorization: token $TOK" \
|
||||
"$BASE/api/v1/packages/$OWNER?type=debian&q=$pkg&limit=1000")"
|
||||
|
||||
local versions
|
||||
versions="$(printf '%s' "$raw" | jq -r --arg n "$pkg" \
|
||||
'.[] | select(.name==$n) | .version' | sort -V -r | awk '!seen[$0]++')"
|
||||
|
||||
if [ -z "$versions" ]; then
|
||||
echo " $pkg ($arch): no versions found, skip"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local count
|
||||
count="$(printf '%s\n' "$versions" | wc -l)"
|
||||
if [ "$count" -le "$KEEP" ]; then
|
||||
echo " $pkg ($arch): $count versions, ≤ keep=$KEEP, nothing to delete"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local to_delete
|
||||
to_delete="$(printf '%s\n' "$versions" | tail -n +$((KEEP+1)))"
|
||||
|
||||
local kept_count=$((count - $(printf '%s\n' "$to_delete" | wc -l)))
|
||||
echo " $pkg ($arch): $count versions total, keeping $kept_count newest, deleting $(printf '%s\n' "$to_delete" | wc -l)"
|
||||
|
||||
while IFS= read -r v; do
|
||||
[ -z "$v" ] && continue
|
||||
# DELETE-Endpoint für Debian-Pakete:
|
||||
# /api/packages/{owner}/debian/pool/{dist}/{comp}/{name}/{version}/{arch}
|
||||
local url="$BASE/api/packages/$OWNER/debian/pool/$DIST/$COMPONENT/$pkg/$v/$arch"
|
||||
local http
|
||||
http="$(curl -sS -o /dev/null -w '%{http_code}' \
|
||||
-X DELETE -H "Authorization: token $TOK" "$url")"
|
||||
case "$http" in
|
||||
204|404) echo " delete $pkg $v $arch -> $http" ;;
|
||||
*) echo " delete $pkg $v $arch -> $http (failed)" ;;
|
||||
esac
|
||||
done <<< "$to_delete"
|
||||
}
|
||||
|
||||
for spec in "${PKGS[@]}"; do
|
||||
IFS=':' read -r pkg arch <<< "$spec"
|
||||
cleanup_pkg "$pkg" "$arch"
|
||||
done
|
||||
91
scripts/apt-repo/publish.sh
Executable file
91
scripts/apt-repo/publish.sh
Executable file
@@ -0,0 +1,91 @@
|
||||
#!/bin/bash
|
||||
# Publish EdgeGuard deb packages to the Gitea Package Registry in
|
||||
# the correct dependency order, with fail-fast on partial failure.
|
||||
#
|
||||
# Wichtig: das Meta-Paket `edgeguard` deklariert harte Versions-
|
||||
# Dependencies auf `edgeguard-api (= X.Y.Z)` und `edgeguard-ui (= X.Y.Z)`.
|
||||
# Wenn beim Upload die api/ui-Pakete fehlschlagen, darf das Meta NICHT
|
||||
# hochgeladen werden — sonst sieht apt eine inkonsistente Lage:
|
||||
# "edgeguard X.Y.Z available, depends api X.Y.Z, but api only Y.Z-1 in
|
||||
# Packages" → "no choices are installable" → Self-Service-Update broken.
|
||||
# Befund 2026-05-17 nach Gitea-Disk-Full-Vorfall.
|
||||
#
|
||||
# Aufruf:
|
||||
# ./publish.sh <version> <arch>
|
||||
# arch = amd64 | arm64. arm64 publiziert NUR edgeguard-api (das einzige
|
||||
# arch-spezifische Paket); amd64 publiziert alle drei.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${1:?usage: publish.sh <version> <arch>}"
|
||||
ARCH="${2:?usage: publish.sh <version> <arch>}"
|
||||
|
||||
BASE="https://git.netcell-it.de/api/packages/projekte/debian/pool/trixie/main/upload"
|
||||
|
||||
if [ -z "${GITEA_TOKEN:-}" ]; then
|
||||
if [ -r "$HOME/.gitea-token" ]; then
|
||||
GITEA_TOKEN="$(tr -d '\n' < "$HOME/.gitea-token")"
|
||||
else
|
||||
echo "publish: missing ~/.gitea-token and GITEA_TOKEN env" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
TOK="$GITEA_TOKEN"
|
||||
|
||||
# upload_one <local-deb-path>
|
||||
# 201 → OK, frisch hochgeladen
|
||||
# 409 → bereits vorhanden (idempotent, OK)
|
||||
# alles andere → harter Fehler, abort
|
||||
upload_one() {
|
||||
local file="$1"
|
||||
if [ ! -f "$file" ]; then
|
||||
echo " ✗ $file: not found" >&2
|
||||
return 1
|
||||
fi
|
||||
local name
|
||||
name="$(basename "$file")"
|
||||
echo " -> publish $name"
|
||||
# curl -w schreibt HTTP-Code in stdout-tail, body in /tmp.
|
||||
local body http
|
||||
body="$(mktemp)"
|
||||
http="$(curl -sS -o "$body" -w '%{http_code}' \
|
||||
-H "Authorization: token $TOK" \
|
||||
--upload-file "$file" "$BASE")"
|
||||
case "$http" in
|
||||
201)
|
||||
rm -f "$body"
|
||||
;;
|
||||
409)
|
||||
echo " (already exists in registry — OK, idempotent skip)"
|
||||
rm -f "$body"
|
||||
;;
|
||||
*)
|
||||
echo " ✗ HTTP $http"
|
||||
cat "$body" >&2
|
||||
rm -f "$body"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Upload order: api zuerst, dann ui, dann zuletzt das Meta-Paket.
|
||||
# Diese Reihenfolge garantiert: wenn ein Schritt failt, ist das Meta
|
||||
# NICHT im Registry (die strikte Dep-Constraint bleibt erfüllbar).
|
||||
case "$ARCH" in
|
||||
amd64)
|
||||
upload_one "build/deb/edgeguard-api_${VERSION}_amd64.deb"
|
||||
upload_one "build/deb/edgeguard-ui_${VERSION}_all.deb"
|
||||
upload_one "build/deb/edgeguard_${VERSION}_all.deb"
|
||||
;;
|
||||
arm64)
|
||||
upload_one "build/deb/edgeguard-api_${VERSION}_arm64.deb"
|
||||
# Meta + UI sind arch=all → werden vom amd64-Publish abgedeckt.
|
||||
# arm64-Publish lädt NUR das api-Binary.
|
||||
;;
|
||||
*)
|
||||
echo "publish: unknown arch '$ARCH' (expected amd64 or arm64)" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "publish: ok ($VERSION/$ARCH)"
|
||||
Reference in New Issue
Block a user