diff --git a/CLAUDE.md b/CLAUDE.md index 0190ac5..b011077 100644 --- a/CLAUDE.md +++ b/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="", project_id=8, session_name=$(printenv ARCHITECT_SESSION), limit=6) +ac_recall(query="", project_id=8, session_name="$(printenv ARCHITECT_SESSION)") +ac_search_code(query="", 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` --- diff --git a/Makefile b/Makefile index f28264a..e765202 100644 --- a/Makefile +++ b/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 diff --git a/VERSION b/VERSION index f9ef507..1171c84 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.78 +1.1.42 diff --git a/agent.md b/agent.md index 854c77f..502900e 100644 --- a/agent.md +++ b/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) --- diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index ba8ca69..ca91e72 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -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 { diff --git a/cmd/edgeguard-ctl/cluster_join.go b/cmd/edgeguard-ctl/cluster_join.go new file mode 100644 index 0000000..2a6ffec --- /dev/null +++ b/cmd/edgeguard-ctl/cluster_join.go @@ -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 --token +// [--insecure] +// [--cn ] +// +// --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 --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 :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 /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 +} diff --git a/cmd/edgeguard-ctl/cluster_renew.go b/cmd/edgeguard-ctl/cluster_renew.go new file mode 100644 index 0000000..7ee0fa6 --- /dev/null +++ b/cmd/edgeguard-ctl/cluster_renew.go @@ -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 ] [--tls-dir ] +// +// 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 +} diff --git a/cmd/edgeguard-ctl/main.go b/cmd/edgeguard-ctl/main.go index 0ca81fc..3718944 100644 --- a/cmd/edgeguard-ctl/main.go +++ b/cmd/edgeguard-ctl/main.go @@ -28,7 +28,16 @@ Commands: render-config Regenerate haproxy / nftables configs from PG (--no-reload, --only=) wg-import [--path ] 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 --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: diff --git a/cmd/edgeguard-scheduler/main.go b/cmd/edgeguard-scheduler/main.go index da91942..78b805d 100644 --- a/cmd/edgeguard-scheduler/main.go +++ b/cmd/edgeguard-scheduler/main.go @@ -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) } } diff --git a/deploy/systemd/edgeguard-api.service b/deploy/systemd/edgeguard-api.service index fbf689c..7679a39 100644 --- a/deploy/systemd/edgeguard-api.service +++ b/deploy/systemd/edgeguard-api.service @@ -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. .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 diff --git a/deploy/systemd/edgeguard-scheduler.service b/deploy/systemd/edgeguard-scheduler.service index 048cd62..1c91386 100644 --- a/deploy/systemd/edgeguard-scheduler.service +++ b/deploy/systemd/edgeguard-scheduler.service @@ -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 diff --git a/internal/aggregator/aggregator.go b/internal/aggregator/aggregator.go new file mode 100644 index 0000000..012fd2d --- /dev/null +++ b/internal/aggregator/aggregator.go @@ -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 / 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 diff --git a/internal/cluster/clustertls/clustertls.go b/internal/cluster/clustertls/clustertls.go new file mode 100644 index 0000000..def94ce --- /dev/null +++ b/internal/cluster/clustertls/clustertls.go @@ -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})) +} diff --git a/internal/cluster/heartbeat.go b/internal/cluster/heartbeat.go new file mode 100644 index 0000000..0ddac79 --- /dev/null +++ b/internal/cluster/heartbeat.go @@ -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 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 +} diff --git a/internal/cluster/jointoken/jointoken.go b/internal/cluster/jointoken/jointoken.go new file mode 100644 index 0000000..91a502d --- /dev/null +++ b/internal/cluster/jointoken/jointoken.go @@ -0,0 +1,237 @@ +// Package jointoken implementiert one-shot Cluster-Join-Token für +// Phase 3.4. +// +// Format: "eg-join-v1.." +// +// 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] +} diff --git a/internal/cluster/store.go b/internal/cluster/store.go index 6a883cb..f809aff 100644 --- a/internal/cluster/store.go +++ b/internal/cluster/store.go @@ -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. diff --git a/internal/database/migrations/0023_domains_settings.sql b/internal/database/migrations/0023_domains_settings.sql new file mode 100644 index 0000000..bd8187d --- /dev/null +++ b/internal/database/migrations/0023_domains_settings.sql @@ -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 diff --git a/internal/database/migrations/0024_domain_headers_ratelimit.sql b/internal/database/migrations/0024_domain_headers_ratelimit.sql new file mode 100644 index 0000000..8210735 --- /dev/null +++ b/internal/database/migrations/0024_domain_headers_ratelimit.sql @@ -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 diff --git a/internal/database/migrations/0025_join_tokens_used.sql b/internal/database/migrations/0025_join_tokens_used.sql new file mode 100644 index 0000000..162b30a --- /dev/null +++ b/internal/database/migrations/0025_join_tokens_used.sql @@ -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 diff --git a/internal/firewall/ruleset.nft.tpl b/internal/firewall/ruleset.nft.tpl index 6ae2c56..dbcb3a4 100644 --- a/internal/firewall/ruleset.nft.tpl +++ b/internal/firewall/ruleset.nft.tpl @@ -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 diff --git a/internal/handlers/audit.go b/internal/handlers/audit.go index 55a9ead..fae4bf6 100644 --- a/internal/handlers/audit.go +++ b/internal/handlers/audit.go @@ -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). diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index 6eaabdf..f1838aa 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -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}) } diff --git a/internal/handlers/cluster.go b/internal/handlers/cluster.go index 490c80b..3c4153e 100644 --- a/internal/handlers/cluster.go +++ b/internal/handlers/cluster.go @@ -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:// + 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) +} diff --git a/internal/handlers/domains.go b/internal/handlers/domains.go index f99c9ce..02cfbce 100644 --- a/internal/handlers/domains.go +++ b/internal/handlers/domains.go @@ -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 diff --git a/internal/handlers/ntp.go b/internal/handlers/ntp.go index e673f86..95f9c66 100644 --- a/internal/handlers/ntp.go +++ b/internal/handlers/ntp.go @@ -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 { diff --git a/internal/handlers/setup.go b/internal/handlers/setup.go index 1f3ca37..6c91434 100644 --- a/internal/handlers/setup.go +++ b/internal/handlers/setup.go @@ -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 { diff --git a/internal/handlers/system.go b/internal/handlers/system.go index 1ae0184..73712b3 100644 --- a/internal/handlers/system.go +++ b/internal/handlers/system.go @@ -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@ + "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 -} diff --git a/internal/haproxy/haproxy.cfg.tpl b/internal/haproxy/haproxy.cfg.tpl index 0668c90..850bbc4 100644 --- a/internal/haproxy/haproxy.cfg.tpl +++ b/internal/haproxy/haproxy.cfg.tpl @@ -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}} diff --git a/internal/haproxy/haproxy.go b/internal/haproxy/haproxy.go index 96c51a0..5cf7d1f 100644 --- a/internal/haproxy/haproxy.go +++ b/internal/haproxy/haproxy.go @@ -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 "" + } } diff --git a/internal/haproxy/haproxy_test.go b/internal/haproxy/haproxy_test.go index 6552973..e6a9847 100644 --- a/internal/haproxy/haproxy_test.go +++ b/internal/haproxy/haproxy_test.go @@ -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) { diff --git a/internal/models/domain.go b/internal/models/domain.go index 52f80ff..e942606 100644 --- a/internal/models/domain.go +++ b/internal/models/domain.go @@ -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" } diff --git a/internal/models/domain_response_header.go b/internal/models/domain_response_header.go new file mode 100644 index 0000000..9a017bc --- /dev/null +++ b/internal/models/domain_response_header.go @@ -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" } diff --git a/internal/services/apt/apt.go b/internal/services/apt/apt.go new file mode 100644 index 0000000..53ef08f --- /dev/null +++ b/internal/services/apt/apt.go @@ -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 `. +// (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 +} diff --git a/internal/services/audit/audit.go b/internal/services/audit/audit.go index f03cb23..38785f4 100644 --- a/internal/services/audit/audit.go +++ b/internal/services/audit/audit.go @@ -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 +} diff --git a/internal/services/domainheaders/domainheaders.go b/internal/services/domainheaders/domainheaders.go new file mode 100644 index 0000000..2e4eee3 --- /dev/null +++ b/internal/services/domainheaders/domainheaders.go @@ -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 +} diff --git a/internal/services/domains/domains.go b/internal/services/domains/domains.go index cb96ed6..ab62a10 100644 --- a/internal/services/domains/domains.go +++ b/internal/services/domains/domains.go @@ -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 } diff --git a/internal/services/setup/setup.go b/internal/services/setup/setup.go index 6f5788a..0b74566 100644 --- a/internal/services/setup/setup.go +++ b/internal/services/setup/setup.go @@ -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 { diff --git a/management-ui/src/App.tsx b/management-ui/src/App.tsx index a99a7f7..28881c8 100644 --- a/management-ui/src/App.tsx +++ b/management-ui/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/management-ui/src/components/DataTable.tsx b/management-ui/src/components/DataTable.tsx index 2e1b258..40398ab 100644 --- a/management-ui/src/components/DataTable.tsx +++ b/management-ui/src/components/DataTable.tsx @@ -35,6 +35,13 @@ interface DataTableProps extends Omit, '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. ) 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(dataIndex: string | string[] | undefined) { @@ -80,6 +87,7 @@ export default function DataTable( toolbar, extraActions, renderMobileCard, + emptyContent, rowKey, loading, ...rest @@ -165,6 +173,12 @@ export default function DataTable( {...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, diff --git a/management-ui/src/components/EmptyState.tsx b/management-ui/src/components/EmptyState.tsx new file mode 100644 index 0000000..29552c8 --- /dev/null +++ b/management-ui/src/components/EmptyState.tsx @@ -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: }}` 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. 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 ( + {icon} + ) : Empty.PRESENTED_IMAGE_SIMPLE} + imageStyle={{ height: 70, marginBottom: 12, display: 'flex', justifyContent: 'center' }} + description={ + + {title} + {description && ( + + {description} + + )} + + } + > + {action} + + ) +} diff --git a/management-ui/src/components/ErrorBoundary.tsx b/management-ui/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..07952b2 --- /dev/null +++ b/management-ui/src/components/ErrorBoundary.tsx @@ -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 ( +
+
+
+ EdgeGuard konnte nicht laden +
+
+ {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.'} +
+
+            {err.name}: {err.message}
+          
+ +
+
+ ) + } +} diff --git a/management-ui/src/components/Layout/AppLayout.tsx b/management-ui/src/components/Layout/AppLayout.tsx index 79326d6..2eecdea 100644 --- a/management-ui/src/components/Layout/AppLayout.tsx +++ b/management-ui/src/components/Layout/AppLayout.tsx @@ -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 = { '/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() {
setSidebarOpen(true)} /> +
diff --git a/management-ui/src/components/Layout/Header.tsx b/management-ui/src/components/Layout/Header.tsx index 43464a1..b6f2318 100644 --- a/management-ui/src/components/Layout/Header.tsx +++ b/management-ui/src/components/Layout/Header.tsx @@ -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) {

{pageTitle}

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + } + title={t('audit.empty.title')} + description={t('audit.empty.desc')} + /> + } + /> + + + + + {t('audit.page.showing', { from: offset + 1, to: offset + (entries?.length ?? 0) })} + + + +
+ ) +} diff --git a/management-ui/src/pages/Backends/index.tsx b/management-ui/src/pages/Backends/index.tsx index a41fa17..4d2f17f 100644 --- a/management-ui/src/pages/Backends/index.tsx +++ b/management-ui/src/pages/Backends/index.tsx @@ -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 { return (r.data.data as { domains?: DomainFull[] }).domains ?? [] } +interface HAProxyStat { backend: string; server: string; status: string } +async function listHAProxyStats(): Promise { + 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 {ds.map(d => {d.name})} }, }, + { + title: t('backends.liveStatus'), key: 'liveStatus', width: 110, + render: (_, row) => { + const s = backendLiveStatus(row.id) + if (!s) return + const color = s === 'UP' ? 'green' : s === 'DEGRADED' ? 'orange' : 'red' + return {s} + }, + }, { title: t('backends.active'), dataIndex: 'active', key: 'active', render: (v: boolean) => }, { 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 (
!!record.id, }} extraActions={ - } + emptyContent={ + } + title={t('backends.emptyTitle')} + description={t('backends.emptyDesc')} + action={ + + } + /> + } /> 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 (
{msgCtx} + + {all.length > 0 && ( + + + + 48 ? { color: '#d48806' } + : { color: '#0F172A' } + } + /> + + + + + + + + + + + + + + + 0 ? { color: '#cf1322' } : undefined} + /> + + + + )} + + ), }, ] @@ -118,6 +274,13 @@ export default function ClusterPage() { subtitle={t('cluster.intro', { count: 1 + data.peers.length })} extra={ + {data.mode === 'cluster' ? t('cluster.modeCluster') : t('cluster.modeSingle')} @@ -171,6 +334,13 @@ export default function ClusterPage() { {data.local_node.version ? {data.local_node.version} : '—'} + + + + {lastSeenRelative(data.local_node.last_seen, now)} + + + {data.local_node.mgmt_ip || '—'} @@ -190,6 +360,46 @@ export default function ClusterPage() { )} + {(certStatus.data?.has_ca || certStatus.data?.has_peer) && ( + renewSelf.mutate()} + > + + + )} + > + + {certStatus.data.ca && ( + <> + + {certStatus.data.ca.common_name} + + + + + + )} + {certStatus.data.peer && ( + <> + + {certStatus.data.peer.common_name} + + + + + + )} + + + )} + {data.peers.length > 0 && ( )} + + {/* 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). */} + + + 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) => ( + + {r.fqdn || r.node_id} + {!r.ok && {r.error || 'error'}} + + ), + }, + { + title: t('cluster.col.load'), key: 'load', width: 110, + render: (_, r) => r.ok && r.data + ? + {r.data.load_avg_1.toFixed(2)} / {r.data.load_avg_5.toFixed(2)} / {r.data.load_avg_15.toFixed(2)} + + : , + }, + { + title: t('cluster.col.mem'), key: 'mem', width: 110, + render: (_, r) => r.ok && r.data + ? {r.data.mem_used_pct.toFixed(0)}% + : , + }, + { + title: t('cluster.col.disk'), key: 'disk', width: 110, + render: (_, r) => r.ok && r.data + ? {r.data.disk_used_pct.toFixed(0)}% + : , + }, + { + title: t('cluster.col.conntrack'), key: 'ct', width: 130, + render: (_, r) => r.ok && r.data + ? + {r.data.conntrack_count}/{r.data.conntrack_max} + + : , + }, + { + title: t('cluster.col.uptime'), key: 'up', width: 100, + render: (_, r) => r.ok && r.data + ? {formatUptime(r.data.uptime_sec)} + : , + }, + { + title: t('cluster.col.fetchMs'), key: 'ms', width: 80, + render: (_, r) => ( + {r.duration_ms}ms + ), + }, + ]} + /> + + + setJoinTokenOpen(false)} + footer={} + width={720} + > + {joinToken ? ( + + + + + + + + {joinToken.ca_fingerprint} + + +
+ {t('cluster.joinCmdLabel')} + + +
+
+ ) : null} +
) } + +// CertExpiry rendert " 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 ( + + + {label} + + + ) +} + +// 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` +} diff --git a/management-ui/src/pages/DNS/index.tsx b/management-ui/src/pages/DNS/index.tsx index f0a52b0..16baf3f 100644 --- a/management-ui/src/pages/DNS/index.tsx +++ b/management-ui/src/pages/DNS/index.tsx @@ -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 ( <> } onClick={() => { - setCreating(true); form.resetFields() - form.setFieldsValue({ zone_type: 'local', active: true } as Zone) - }}> + } + emptyContent={ + } + title={t('dns.zone.emptyTitle')} + description={t('dns.zone.emptyDesc')} + action={ + + } + /> + } /> { + setCreating(true); form.resetFields() + form.setFieldsValue({ record_type: 'A', ttl: 300, active: true } as DNSRecord) + } + return ( } onClick={() => { - setCreating(true); form.resetFields() - form.setFieldsValue({ record_type: 'A', ttl: 300, active: true } as DNSRecord) - }}> + } + emptyContent={ + } + title={t('dns.record.emptyTitle')} + description={t('dns.record.emptyDesc')} + action={ + + } + /> + } /> fetchOne<{ status: string; version: string }>('/system/health'), refetchInterval: 30_000, }) + + const recentAlerts = useQuery({ + queryKey: ['alerts', 'events', 'recent'], + queryFn: () => fetchList('/alerts/events?limit=10', 'events'), + refetchInterval: 60_000, + }) const services = useQuery({ queryKey: ['system', 'services'], queryFn: () => fetchList('/system/services', 'services'), @@ -186,6 +203,34 @@ export default function DashboardPage() { const fwZones = useQuery({ queryKey: ['fw-zones'], queryFn: () => fetchList('/firewall/zones', 'zones') }) const tlsCerts = useQuery({ queryKey: ['tls-certs'], queryFn: () => fetchList('/tls-certs', 'tls_certs') }) const cluster = useQuery({ queryKey: ['cluster', 'nodes'], queryFn: () => fetchList('/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('/wireguard/interfaces', 'interfaces') }) const wgStatus = useQuery({ queryKey: ['wg', 'status'], @@ -219,12 +264,44 @@ export default function DashboardPage() { subtitle={t('dashboard.welcomeHint')} extra={ + {/* 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. */} + + {license.data && } v{health.data?.version ?? '—'} } /> + {/* ── 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 && ( + + {t('dashboard.onboardingIntro')} +
    +
  1. {t('dashboard.onboardingStep1')}
  2. +
  3. {t('dashboard.onboardingStep2')}
  4. +
  5. {t('dashboard.onboardingStep3')}
  6. +
+ + } + /> + )} + {/* ── KPI tiles (compact strip) ──────────────────── */} } label={t('dashboard.kpi.domains')} value={activeDomains} total={(domains.data ?? []).length} /> @@ -240,6 +317,39 @@ export default function DashboardPage() { + {/* ── 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 && ( + {t('dashboard.alertsCard.title')}} + extra={{t('dashboard.alertsCard.viewAll')}} + > + + {(recentAlerts.data ?? []).map(e => ( +
+ {e.severity} + {e.subject} + + {new Date(e.fired_at).toLocaleString()} + + + {e.message} + +
+ ))} +
+
+ )} + {/* ── Service-health-grid ─────────────────────────── */} {t('dashboard.servicesCard.title')}} className="mb-12"> @@ -341,8 +451,33 @@ export default function DashboardPage() { {/* ── Cluster ─────────────────────────────────────── */}
- {t('dashboard.clusterCard.title')}} className="h-100"> + {t('dashboard.clusterCard.title')}} + className="h-100" + extra={clusterStatus.data && ( + + + {clusterStatus.data.mode === 'cluster' + ? t('dashboard.clusterCard.modeCluster') + : t('dashboard.clusterCard.modeSingle')} + + + {t(`dashboard.clusterCard.health.${clusterStatus.data.health}`)} + + + )} + > + {clusterStatus.data?.drift_found && ( + + {t('dashboard.clusterCard.drift')} + + )} {(cluster.data ?? []).map(n => (
@@ -471,3 +606,36 @@ function ResourcesCard({ r }: { r?: Resources | null }) { ) } + +// 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 {data.status} + } + if (isTrial) { + if (days != null && days <= 7) return Trial · {days}d + if (days != null && days <= 14) return Trial · {days}d + return {days != null ? `Trial · ${days}d` : 'Trial'} + } + if (data.status === 'active') { + return License OK + } + return null +} diff --git a/management-ui/src/pages/Domains/index.tsx b/management-ui/src/pages/Domains/index.tsx index 4cabcb3..91c1d33 100644 --- a/management-ui/src/pages/Domains/index.tsx +++ b/management-ui/src/pages/Domains/index.tsx @@ -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 { const r = await apiClient.get('/domains') if (!isEnvelope(r.data)) return [] @@ -52,6 +77,26 @@ async function listBackends(): Promise { 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 { + 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 { + 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_. + // 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(null) const [creating, setCreating] = useState(false) + const [headersFor, setHeadersFor] = useState(null) + const [quickBackendOpen, setQuickBackendOpen] = useState(false) const [form] = Form.useForm() + 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 {t('domains.noBackend')} const b = backendById(id) - return b - ? {b.name} ({b.address}:{b.port}) - : #{id} + const health = backendHealth(id) + return ( + + {b + ? {b.name} ({b.address}:{b.port}) + : #{id}} + {health === 'UP' && UP} + {health === 'DOWN' && DOWN} + + ) }, }, { title: t('domains.active'), dataIndex: 'active', key: 'active', render: (v: boolean) => }, { title: t('domains.httpToHttps'), dataIndex: 'http_to_https', key: 'http_to_https', render: (v: boolean) => }, { title: t('domains.hsts'), dataIndex: 'hsts_enabled', key: 'hsts', render: (v: boolean) => }, + { + title: t('domains.tlsCert'), + key: 'tlsCert', + width: 120, + render: (_, row) => { + const cert = certByDomain.get(row.name) + if (!cert) { + return ( + + {t('domains.tlsCertNone')} + + ) + } + if (cert.status === 'expired') { + return {t('domains.tlsCertExpired')} + } + if (cert.status === 'error') { + return {t('domains.tlsCertError')} + } + // 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 ( + + {t('domains.tlsCertExpiring', { days })} + + ) + } + return ( + + {t('domains.tlsCertValid')} + + ) + }, + }, { title: t('common.actions'), key: 'actions', render: (_, row) => ( - { - 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 })} - /> + + + { + 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 })} + /> + ), }, ] + // 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 (
} onClick={() => { - setCreating(true); form.resetFields() - form.setFieldsValue({ active: true, http_to_https: true, hsts_enabled: false }) - }}> + } + emptyContent={ + } + title={t('domains.emptyTitle')} + description={t('domains.emptyDesc')} + action={ + + } + /> + } /> - b.active).map(b => ({ + value: b.id, + label: `${b.name} (${b.address}:${b.port})`, + }))} + style={{ width: '100%' }} + /> + + + @@ -200,14 +389,309 @@ export default function DomainsPage() { + + + {t('domains.settingsSection')} + + + prev.hsts_enabled !== curr.hsts_enabled} + > + {({ getFieldValue }) => getFieldValue('hsts_enabled') ? ( + <> + + + + + + + + + + + ) : null} + + + + + + + + + + + + +
) } + +// ── 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 { + 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(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 = [ + { 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) => ( + + + + + + } + width={720} + > + + {t('domains.headersHint')} + +
+ + + { setAddOpen(false); setEditing(null); hForm.resetFields() }} + onOk={() => { void hForm.submit() }} + confirmLoading={create.isPending || update.isPending} + > +
{ + if (editing) update.mutate({ id: editing.id, v }) + else create.mutate(v) + }} + > + + + + + + + + + + +
+ + ) +} diff --git a/management-ui/src/pages/Firewall/AddressGroups.tsx b/management-ui/src/pages/Firewall/AddressGroups.tsx index 16090eb..eb2e93a 100644 --- a/management-ui/src/pages/Firewall/AddressGroups.tsx +++ b/management-ui/src/pages/Firewall/AddressGroups.tsx @@ -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 ( <> - - + } + title={t('fw.ag.emptyTitle')} + description={t('fw.ag.emptyDesc')} + action={} + /> + } + /> { + setCreating(true); form.resetFields() + form.setFieldsValue({ kind: 'host' }) + } + return ( <> - - + } + title={t('fw.ao.emptyTitle')} + description={t('fw.ao.emptyDesc')} + action={ + + } + /> + } + /> { + setCreating(true); form.resetFields() + form.setFieldsValue({ priority: 100, enabled: true, kind: 'dnat' }) + } + return ( <> - - + } + title={t('fw.nat.emptyTitle')} + description={t('fw.nat.emptyDesc')} + action={} + /> + } + /> { + 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 ( <> - - - + } + title={t('fw.rule.emptyTitle')} + description={t('fw.rule.emptyDesc')} + action={} + /> + } + /> { + setCreating(true); form.resetFields() + form.setFieldsValue({ member_ids: [] }) + } + return ( <> - - + } + title={t('fw.sg.emptyTitle')} + description={t('fw.sg.emptyDesc')} + action={} + /> + } + /> { + setCreating(true); form.resetFields() + form.setFieldsValue({ proto: 'tcp' }) + } + return ( <> - - + } + title={t('fw.svc.emptyTitle')} + description={t('fw.svc.emptyDesc')} + action={} + /> + } + /> { setCreating(true); form.resetFields() } + return ( <> - - + } + title={t('fw.zone.emptyTitle')} + description={t('fw.zone.emptyDesc')} + action={ + + } + /> + } + /> }, { key: 'services', label: t('fw.tabs.services'), children: }, { key: 'svcGrp', label: t('fw.tabs.svcGrp'), children: }, + { key: 'system', label: t('fw.tabs.system'), children: }, ] return ( diff --git a/management-ui/src/pages/ForwardProxy/index.tsx b/management-ui/src/pages/ForwardProxy/index.tsx index 272a66e..2914cd6 100644 --- a/management-ui/src/pages/ForwardProxy/index.tsx +++ b/management-ui/src/pages/ForwardProxy/index.tsx @@ -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 (
} onClick={() => { - setCreating(true); form.resetFields() - form.setFieldsValue({ priority: 100, active: true, action: 'allow', acl_type: 'dstdomain' }) - }}> + } + emptyContent={ + } + title={t('fwd.emptyTitle')} + description={t('fwd.emptyDesc')} + action={ + + } + /> + } /> { + setCreating(true); form.resetFields() + form.setFieldsValue({ prefix: 24, is_vip: false, active: true }) + } + return (
} onClick={() => { - setCreating(true); form.resetFields() - form.setFieldsValue({ prefix: 24, is_vip: false, active: true }) - }}> + } + emptyContent={ + } + title={t('ips.emptyTitle')} + description={t('ips.emptyDesc')} + action={ + + } + /> + } /> + 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({ - sources: [], // [] = alle - levels: [], // [] = alle - range: null, - grep: '', - limit: 200, - }) + const [filters, setFilters] = useState(() => 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 }} /> + {t('logs.found', { n: entries.length })} diff --git a/management-ui/src/pages/NTP/index.tsx b/management-ui/src/pages/NTP/index.tsx index 6e3bb73..5837d3c 100644 --- a/management-ui/src/pages/NTP/index.tsx +++ b/management-ui/src/pages/NTP/index.tsx @@ -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 { + 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 { const r = await apiClient.get('/ntp/pools') if (!isEnvelope(r.data)) return [] @@ -63,6 +82,12 @@ async function listSystemInterfaces(): Promise { export default function NTPPage() { const { t } = useTranslation() + const { data: ntpStatus, refetch: refetchStatus } = useQuery({ + queryKey: ['ntp', 'status'], + queryFn: fetchNTPStatus, + refetchInterval: 30_000, + }) + return (
+ + {t('ntp.statusCard.title')}} + extra={} + > + {ntpStatus?.error ? ( + + ) : ntpStatus ? ( + +
+ + + + + + + + + + + = 0 ? '+' : '') + ntpStatus.offset_ms.toFixed(3) + ' ms' + : '—'} + valueStyle={{ + fontSize: 13, + color: ntpStatus.offset_ms != null && Math.abs(ntpStatus.offset_ms) > 100 + ? '#d48806' : undefined, + }} + /> + + + + ) : ( + {t('ntp.statusCard.loading')} + )} + + { + setCreating(true); form.resetFields() + form.setFieldsValue({ kind: 'pool', iburst: true, prefer: false, active: true } as Pool) + } + return ( <> } onClick={() => { - setCreating(true); form.resetFields() - form.setFieldsValue({ kind: 'pool', iburst: true, prefer: false, active: true } as Pool) - }}> + } + emptyContent={ + } + title={t('ntp.pool.emptyTitle')} + description={t('ntp.pool.emptyDesc')} + action={ + + } + /> + } /> { + setCreating(true); form.resetFields() + form.setFieldsValue({ type: 'ethernet', role: 'lan', active: true }) + } + return (
@@ -172,13 +178,22 @@ export default function InterfacesTab() { dataSource={ifs ?? []} columns={columns} extraActions={ - } + emptyContent={ + } + title={t('networks.emptyTitle')} + description={t('networks.emptyDesc')} + action={ + + } + /> + } /> v ?? '—' }, ] + const openCreate = () => { + setCreating(true); form.resetFields() + form.setFieldsValue({ + metric: 100, table_name: 'main', active: true, + destination: '', gateway: '', dev: '', + }) + } + return (
} - onClick={() => { - setCreating(true); form.resetFields() - form.setFieldsValue({ - metric: 100, table_name: 'main', active: true, - destination: '', gateway: '', dev: '', - }) - }}> + onClick={openCreate}> {t('routes.add')} } @@ -226,7 +229,18 @@ export default function RoutesTab() { dataSource={managed.data ?? []} columns={managedColumns} pagination={false} - locale={{ emptyText: t('routes.empty') }} + locale={{ emptyText: ( + } + title={t('routes.emptyTitle')} + description={t('routes.emptyDesc')} + action={ + + } + /> + ) }} style={{ marginTop: 12 }} /> diff --git a/management-ui/src/pages/RoutingRules/index.tsx b/management-ui/src/pages/RoutingRules/index.tsx index 6f2f18f..a657310 100644 --- a/management-ui/src/pages/RoutingRules/index.tsx +++ b/management-ui/src/pages/RoutingRules/index.tsx @@ -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 (
} onClick={() => { - setCreating(true); form.resetFields() - form.setFieldsValue({ priority: 100, path_prefix: '/', active: true }) - }}> + } + emptyContent={ + } + title={t('routing.emptyTitle')} + description={t('routing.emptyDesc')} + action={ + + } + /> + } /> ) => 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 = [ - { title: t('ssl.domain'), dataIndex: 'domain', key: 'domain', render: (s: string) => {s} }, + { + title: t('ssl.domain'), dataIndex: 'domain', key: 'domain', + render: (s: string, row) => ( + + {s} + {row.last_error && ( + + + + )} + + ), + }, { 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 + } + const ms = Date.now() - new Date(row.last_renewed_at).getTime() + const rel = relativeAgo(ms, t) + return ( + + {rel} + + ) + }, + }, { title: t('common.actions'), key: 'actions', render: (_, row) => ( - delMut.mutate(row.id)} - deleteConfirm={t('ssl.deleteConfirm', { domain: row.domain })} - /> + + {row.issuer === 'letsencrypt' && ( + renewMut.mutate(row.domain)} + > + + + )} + delMut.mutate(row.id)} + deleteConfirm={t('ssl.deleteConfirm', { domain: row.domain })} + /> + ), }, ] @@ -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 (
+ {total > 0 && ( + +
+ + + + + 0 ? { color: '#d48806' } : undefined} + /> + + + + + 0 ? { color: '#cf1322' } : undefined} + /> + + + + + 0 ? { color: '#cf1322' } : undefined} + /> + + + + )} + {t('ssl.installedTitle')} - + } + title={t('ssl.emptyTitle')} + description={t('ssl.emptyDesc')} + /> + } + /> ) } diff --git a/management-ui/src/pages/Settings/index.tsx b/management-ui/src/pages/Settings/index.tsx index 0e61f49..413eed1 100644 --- a/management-ui/src/pages/Settings/index.tsx +++ b/management-ui/src/pages/Settings/index.tsx @@ -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() const { data: setupStatus, isLoading: loadingSetup } = useQuery({ queryKey: ['setup', 'status'], @@ -38,12 +54,226 @@ export default function SettingsPage() { }, }) + const [emailForm] = Form.useForm() + 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(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 } return (
+ {msgCtx} } title={t('settings.title')} @@ -54,18 +284,399 @@ export default function SettingsPage() { {health?.version ?? '—'} {health?.status ?? '—'} + {dbSize && ( + + + {dbSize.human_total} + {dbSize.top_tables.length > 0 && ( + + {t('settings.dbSizeTop')}:{' '} + {dbSize.top_tables.slice(0, 3).map(t => + `${t.name} (${t.human_size})` + ).join(', ')} + + )} + + + )} - + {t('settings.actionsCardTitle')}} + className="mb-12" + size="small" + > + + + + + + + {t('settings.actionsHint')} + + + + {t('settings.serviceRestartCardTitle')}} + className="mb-12" + size="small" + extra={} + > + + {RESTARTABLE.map((svc) => { + const status = services?.find(s => s.unit === svc + '.service' || s.unit === svc) + return ( + + + + {svc} + {status && ( + + {status.state} + + )} + + + + ) + })} + + + {t('settings.serviceRestartHint')} + + + + {upgradeStatus && upgradeStatus.started_at && ( + {t('settings.upgradeStatusCardTitle')}} + className="mb-12" + size="small" + extra={ + + } + > + + + {upgradeStatus.started_at + ? new Date(upgradeStatus.started_at).toLocaleString() + : '—'} + + + {upgradeStatus.finished_at + ? new Date(upgradeStatus.finished_at).toLocaleString() + : '—'} + + + {upgradeStatus.result === 'success' ? ( + {t('settings.upgradeStatusOk')} + ) : ( + + {upgradeStatus.result || upgradeStatus.state} + {upgradeStatus.exit_code !== 0 && ` (exit ${upgradeStatus.exit_code})`} + + )} + + + {upgradeStatus.state} + + + {upgradeStatus.log.length > 0 && ( +
+ + {t('settings.upgradeStatusShowLog', { n: upgradeStatus.log.length })} + +
{upgradeStatus.log.join('\n')}
+
+ )} +
+ )} + + - {setupStatus?.admin_email ?? '—'} {setupStatus?.fqdn ?? '—'} {setupStatus?.completed ? t('common.yes') : t('common.no')} + + {t('settings.emailsCardTitle')}} + className="mb-12" + size="small" + > + + form={emailForm} + layout="vertical" + onFinish={(v) => updateEmails.mutate(v)} + > + + + + + + + + + + + + + + + + {t('settings.maintenanceCardTitle')}} + className="mb-12" + size="small" + > + {maintenance?.enabled && ( + } + message={t('settings.maintenanceActiveTitle')} + description={t('settings.maintenanceActiveDesc')} + className="mb-12" + /> + )} + + + toggleMaintenance.mutate({ + enabled: checked, + message: maintMessage || maintenance?.message || '', + })} + /> + + {maintenance?.enabled ? t('settings.maintenanceOn') : t('settings.maintenanceOff')} + + + + setMaintMessage(e.target.value)} + placeholder={t('settings.maintenanceMessagePlaceholder')} + rows={2} + maxLength={500} + /> + + + {t('settings.maintenanceHint')} + + + + + {t('settings.backupRetentionCardTitle')}} + className="mb-12" + size="small" + > + + + setBackupRetention.mutate((v as number) ?? 0)} + disabled={setBackupRetention.isPending} + addonAfter={t('settings.backupRetentionUnit')} + style={{ width: 200 }} + /> + + {(backupRetention?.keep ?? 0) === 0 + ? t('settings.backupRetentionDefault', { n: backupRetention?.default ?? 14 }) + : t('settings.backupRetentionCustom', { n: backupRetention?.keep })} + + + + {t('settings.backupRetentionHint')} + + + + + {t('settings.auditRetentionCardTitle')}} + className="mb-12" + size="small" + > + + + setAuditRetention.mutate((v as number) ?? 0)} + disabled={setAuditRetention.isPending} + addonAfter={t('settings.auditRetentionUnit')} + style={{ width: 200 }} + /> + + {(auditRetention?.days ?? 0) === 0 + ? t('settings.auditRetentionDefault', { n: auditRetention?.default ?? 90 }) + : t('settings.auditRetentionCustom', { n: auditRetention?.days })} + + + + {t('settings.auditRetentionHint')} + + + + + {t('settings.autoUpdateCardTitle')}} + className="mb-12" + size="small" + > + + + toggleAutoUpdate.mutate(checked)} + /> + + {autoUpdate?.enabled ? t('settings.autoUpdateOn') : t('settings.autoUpdateOff')} + + + + {t('settings.autoUpdateHint')} + + + + + {t('settings.passwordCardTitle')}} size="small"> + + 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" + > + + + + + + + ({ + validator(_, value) { + if (!value || getFieldValue('new_password') === value) { + return Promise.resolve() + } + return Promise.reject(new Error(t('settings.passwordMismatch'))) + }, + }), + ]} + > + + + + + + + + + +
) } diff --git a/management-ui/src/pages/Setup/index.tsx b/management-ui/src/pages/Setup/index.tsx index 7203b79..26e1a49 100644 --- a/management-ui/src/pages/Setup/index.tsx +++ b/management-ui/src/pages/Setup/index.tsx @@ -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 ( -
- - {t('setup.title')} - {t('setup.intro')} +
+ + + {t('setup.title')} + + {t('setup.intro')} + + + + +
- + + + + - + + - + - + + diff --git a/management-ui/src/pages/Wireguard/Clients.tsx b/management-ui/src/pages/Wireguard/Clients.tsx index c3f38e2..9f07992 100644 --- a/management-ui/src/pages/Wireguard/Clients.tsx +++ b/management-ui/src/pages/Wireguard/Clients.tsx @@ -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 ( <> } 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, - }) - }}> + } + emptyContent={ + } + title={t('wg.iface.emptyClientTitle')} + description={t('wg.iface.emptyClientDesc')} + action={ + + } + /> + } /> { + setCreating(true); form.resetFields() + form.setFieldsValue({ + listen_port: 51820, role: 'wan', active: true, generate_keypair: true, + }) + } + return ( <> } onClick={() => { - setCreating(true); form.resetFields() - form.setFieldsValue({ - listen_port: 51820, role: 'wan', active: true, generate_keypair: true, - }) - }}> + } + emptyContent={ + } + title={t('wg.iface.emptyServerTitle')} + description={t('wg.iface.emptyServerDesc')} + action={ + + } + /> + } /> { + setCreating(true); form.resetFields() + form.setFieldsValue({ + allowed_ips: '', enabled: true, + generate_keypair: true, generate_psk: false, + }) + } + return ( } onClick={() => { - setCreating(true); form.resetFields() - form.setFieldsValue({ - allowed_ips: '', enabled: true, - generate_keypair: true, generate_psk: false, - }) - }}> + } + emptyContent={ + } + title={t('wg.peer.emptyTitle')} + description={t('wg.peer.emptyDesc')} + action={ + + } + /> + } /> &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 diff --git a/scripts/apt-repo/publish.sh b/scripts/apt-repo/publish.sh new file mode 100755 index 0000000..cd02453 --- /dev/null +++ b/scripts/apt-repo/publish.sh @@ -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 +# 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 }" +ARCH="${2:?usage: publish.sh }" + +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 +# 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)"