diff --git a/VERSION b/VERSION index 591bdbc..3148141 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.18 +1.2.47 diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index 3e65f45..f692b3c 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -61,7 +61,7 @@ import ( usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users" ) -var version = "1.2.13" +var version = "1.2.35" func main() { addr := os.Getenv("EDGEGUARD_API_ADDR") @@ -186,10 +186,8 @@ func main() { } else { slog.Warn("cluster: cannot normalize primary URL for push", "primary", st.PrimaryFQDN, "error", normErr) } - // Logical Replication liefert Änderungen automatisch — aber Service- - // Configs (haproxy.cfg, nftables …) müssen nach jeder Änderung neu - // gerendert werden. Diese Goroutine erkennt hash-Änderungen und rendert. - go runSecondaryConfigRender(context.Background(), pool) + // runSecondaryConfigRender wird weiter unten gestartet sobald + // clusterAggregator verfügbar ist (braucht mTLS-Client für Cert-Sync). } // Phase 3.3: Cluster-CA + Peer-Cert. Founder-Pfad — auf einem @@ -236,6 +234,12 @@ func main() { } } + // Secondary-Config-Render: jetzt wo der Aggregator bereit ist starten. + // Aggregator wird für Cert-Sync (mTLS GET /agent/cluster/tls-certs) benötigt. + if nodeID != "" && st != nil && st.IsClusterNode && st.PrimaryFQDN != "" { + go runSecondaryConfigRender(context.Background(), pool, secrets.New(""), clusterAggregator, nodeID) + } + auditRepo := audit.New(pool) domainsRepo := domains.New(pool) domainHeadersRepo := domainheaders.New(pool) @@ -706,14 +710,24 @@ func runClusterHeartbeat(ctx context.Context, pool *pgxpoolPool, localID, versio // Service-Configs wenn die Logical Replication Änderungen vom Primary // geliefert hat. Erkennt das an einem geänderten config_hash. // Tick: 5 min — balanciert Reaktionszeit gegen Reload-Overhead. -func runSecondaryConfigRender(ctx context.Context, pool *pgxpoolPool) { +// +// Cert-Sync läuft auf jedem Tick unabhängig vom config_hash, da certbot- +// Renewals auf dem Primary den Hash nicht ändern. +func runSecondaryConfigRender(ctx context.Context, pool *pgxpoolPool, box *secrets.Box, agg *aggregator.Aggregator, localID string) { const tick = 5 * time.Minute t := time.NewTicker(tick) defer t.Stop() var lastHash string render := func() { - rCtx, cancel := context.WithTimeout(ctx, 60*time.Second) + rCtx, cancel := context.WithTimeout(ctx, 90*time.Second) defer cancel() + + // TLS-Zertifikate bei jedem Tick synchronisieren — unabhängig vom + // config_hash, da certbot-Renewals den Hash nicht berühren. + if err := handlers.SyncTLSCertsFromPrimary(rCtx, pool, agg, localID); err != nil { + slog.Warn("cluster: cert sync failed", "error", err) + } + hash, err := cluster.ComputeConfigHash(rCtx, pool) if err != nil || hash == lastHash { return @@ -728,10 +742,32 @@ func runSecondaryConfigRender(ctx context.Context, pool *pgxpoolPool) { if err := firewallrender.New(pool).Render(rCtx); err != nil { slog.Warn("cluster: secondary nftables render failed", "error", err) } - // Weitere Dienste (Squid, Unbound, Chrony, WireGuard) werden bei - // Änderungen an ihren spezifischen Tabellen ebenfalls neu gerendert. - // render-config ohne Reload: die Dienste merken Änderungen selbst - // (HAProxy/nftables über systemctl reload, der oben bereits läuft). + // WireGuard — Interface-Configs + wg-quick@ reload + if err := wgrender.New(pool, box).Render(rCtx); err != nil { + slog.Warn("cluster: secondary wireguard render failed", "error", err) + } + // Squid forward proxy + if err := squidrender.New(pool).Render(rCtx); err != nil { + slog.Warn("cluster: secondary squid render failed", "error", err) + } + // Unbound DNS + if err := unboundrender.New(pool).Render(rCtx); err != nil { + slog.Warn("cluster: secondary unbound render failed", "error", err) + } + // Chrony NTP + if err := chronyrender.New(pool).Render(rCtx); err != nil { + slog.Warn("cluster: secondary chrony render failed", "error", err) + } + // Netzwerk-Interfaces (VLAN/Bridge/Bond) — erstellt Interface-Objekte, + // weist aber KEINE IPs zu (das ist node-spezifisch und darf nicht aus + // der Replikation kommen — sonst IP-Konflikt mit dem Primary). + if err := networkifs.NewGenerator(networkifs.New(pool)).Render(rCtx); err != nil { + slog.Warn("cluster: secondary interfaces render failed", "error", err) + } + // IP-Adressen werden auf dem Secondary NICHT aus der Replikation + // angewendet. Jeder Node konfiguriert seine eigenen IPs statisch + // (z.B. /etc/network/interfaces). Floating-Service-IPs werden von + // Keepalived verwaltet — nicht vom Renderer. } // Initialer Check nach kurzem Delay (Replication braucht einen Moment) select { diff --git a/cmd/edgeguard-scheduler/main.go b/cmd/edgeguard-scheduler/main.go index 95c3327..4e75a65 100644 --- a/cmd/edgeguard-scheduler/main.go +++ b/cmd/edgeguard-scheduler/main.go @@ -41,7 +41,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts" ) -var version = "1.2.15" +var version = "1.2.35" const ( // renewTickInterval — how often we re-evaluate expiring certs. diff --git a/go.mod b/go.mod index 515d38d..e6240ed 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( ) require ( + github.com/boombuler/barcode v1.0.1 // indirect github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect @@ -51,6 +52,7 @@ require ( github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/philhofer/fwd v1.2.0 // indirect github.com/pkg/sftp v1.13.10 // indirect + github.com/pquerna/otp v1.5.0 // indirect github.com/rs/xid v1.6.0 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect github.com/tinylib/msgp v1.6.1 // indirect diff --git a/go.sum b/go.sum index dc2d4ec..5da4e6c 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,6 @@ +github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/boombuler/barcode v1.0.1 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs= +github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= @@ -107,6 +110,8 @@ github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1Hbe github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= +github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4= github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= diff --git a/internal/aggregator/aggregator.go b/internal/aggregator/aggregator.go index 1e3ad69..f100d0b 100644 --- a/internal/aggregator/aggregator.go +++ b/internal/aggregator/aggregator.go @@ -229,6 +229,43 @@ func (a *Aggregator) PostPeer(ctx context.Context, p models.HANode, path string) return res } +// PostPeerWithBody sendet einen POST-Request mit JSON-Body an einen Peer. +// Wird für VIP-Schwenk-Tests genutzt (/agent/cluster/vip-cmd). +func (a *Aggregator) PostPeerWithBody(ctx context.Context, p models.HANode, path string, body []byte) 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() + return res + } + reqCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, target, strings.NewReader(string(body))) + if err != nil { + res.Err = err.Error() + return res + } + req.Header.Set("Content-Type", "application/json") + 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() + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted && resp.StatusCode != http.StatusNoContent { + res.Err = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody))) + res.Duration = time.Since(start).Milliseconds() + return res + } + res.OK = true + res.Data = respBody + res.Duration = time.Since(start).Milliseconds() + return res +} + // 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 diff --git a/internal/cluster/confighash.go b/internal/cluster/confighash.go index 9a9e71f..582d8f9 100644 --- a/internal/cluster/confighash.go +++ b/internal/cluster/confighash.go @@ -41,6 +41,7 @@ type hashTable struct { SkipUpdatedAt bool // setze true wenn updated_at semantisch relevant ist MigrationDefault bool // Tabelle hat migrations-erzeugte Default-Rows (firewall_zones, ntp_pools…) // → zählt nicht als "user hat config" bei der Empty-DB-Erkennung + CustomSQL string // wenn gesetzt: direkt als Hash-Query verwenden (überschreibt hashSQL) } // hashSpec ist die Reihenfolge-stabile Liste. NEUE Tabellen hier @@ -70,9 +71,33 @@ var hashSpec = []hashTable{ {Name: "ntp_pools", MigrationDefault: true}, - // network_interfaces, ip_addresses, static_routes, dns_settings, ntp_settings - // sind node-spezifisch (jeder Node hat eigene IPs/Routes/Listen-Adressen) - // und fließen NICHT in den Drift-Hash ein. + // network_interfaces + ip_addresses werden seit 0030 repliziert — + // VLAN/Bridge/Bond-Definitionen und Gateway-IPs müssen auf dem Secondary + // für Failover bereitstehen. Ethernet-IPs werden im Secondary-Renderer + // herausgefiltert (eth0 = cloud-init / Keepalived). + // + // ip_addresses.interface_id ist ein node-lokaler Autoincrement-PK, der + // zwischen zwei unabhängigen DBs divergiert (utm-1: eth0=6, utm-2: eth0=1). + // Wir hashen daher semantisch: address + prefix + flags + interface_name + // statt interface_id — sonst False-Positive-Drift auf logisch identischen Nodes. + {Name: "network_interfaces"}, + {Name: "ip_addresses", CustomSQL: ` + SELECT COALESCE(md5(string_agg(rh, '|' ORDER BY rh)), '') + FROM ( + SELECT md5(jsonb_build_object( + 'address', ia.address, + 'prefix', ia.prefix, + 'is_vip', ia.is_vip, + 'active', ia.active, + 'vip_priority', ia.vip_priority, + 'description', ia.description, + 'iface', ni.name + )::text) AS rh + FROM ip_addresses ia + JOIN network_interfaces ni ON ia.interface_id = ni.id + ) sub`}, + + // static_routes, dns_settings, ntp_settings bleiben node-spezifisch. } // hashSQL rendert die SHA-Input-SQL für eine Tabelle. @@ -112,7 +137,11 @@ func ComputeConfigHash(ctx context.Context, pool *pgxpool.Pool) (string, error) hasUserConfig := false for _, t := range hashSpec { var s string - if err := pool.QueryRow(ctx, hashSQL(t)).Scan(&s); err != nil { + sql := t.CustomSQL + if sql == "" { + sql = hashSQL(t) + } + if err := pool.QueryRow(ctx, sql).Scan(&s); err != nil { // Migration fehlt o.ä. → leeren string nehmen, weiter. s = "" } diff --git a/internal/database/migrations/0030_cluster_sync_replication.sql b/internal/database/migrations/0030_cluster_sync_replication.sql new file mode 100644 index 0000000..b92c7dd --- /dev/null +++ b/internal/database/migrations/0030_cluster_sync_replication.sql @@ -0,0 +1,17 @@ +-- +goose Up +-- +goose StatementBegin + +-- network_interfaces und ip_addresses werden in die Cluster-Replikation +-- aufgenommen. Das ALTER PUBLICATION erfordert den Superuser (postgres), +-- daher läuft es im postinst via `sudo -u postgres psql`, nicht hier. +-- Diese Migration dient nur als Versions-Marker für goose. +SELECT 1; + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin + +SELECT 1; + +-- +goose StatementEnd diff --git a/internal/database/migrations/0031_forward_proxy_settings.sql b/internal/database/migrations/0031_forward_proxy_settings.sql new file mode 100644 index 0000000..0e83dd5 --- /dev/null +++ b/internal/database/migrations/0031_forward_proxy_settings.sql @@ -0,0 +1,25 @@ +-- +goose Up +-- +goose StatementBegin + +-- forward_proxy_settings — Singleton-Row für globale Squid-Einstellungen. +-- listen_addresses: Komma-separierte IPs auf denen Squid lauscht. +-- Leer = alle Interfaces (http_port 3128). Typisch: LAN/VLAN-Gateway-IPs. +CREATE TABLE IF NOT EXISTS forward_proxy_settings ( + id INTEGER PRIMARY KEY DEFAULT 1, + listen_addresses TEXT NOT NULL DEFAULT '', + listen_port INTEGER NOT NULL DEFAULT 3128, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT forward_proxy_settings_singleton CHECK (id = 1) +); + +INSERT INTO forward_proxy_settings (id) VALUES (1) ON CONFLICT DO NOTHING; + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin + +DROP TABLE IF EXISTS forward_proxy_settings; + +-- +goose StatementEnd diff --git a/internal/database/migrations/0032_proxy_dns_extended_settings.sql b/internal/database/migrations/0032_proxy_dns_extended_settings.sql new file mode 100644 index 0000000..91b3a61 --- /dev/null +++ b/internal/database/migrations/0032_proxy_dns_extended_settings.sql @@ -0,0 +1,37 @@ +-- +goose Up +-- +goose StatementBegin + +ALTER TABLE forward_proxy_settings + ADD COLUMN IF NOT EXISTS cache_mem_mb INTEGER NOT NULL DEFAULT 64, + ADD COLUMN IF NOT EXISTS cache_dir_mb INTEGER NOT NULL DEFAULT 100, + ADD COLUMN IF NOT EXISTS max_obj_size_mb INTEGER NOT NULL DEFAULT 4, + ADD COLUMN IF NOT EXISTS connect_timeout INTEGER NOT NULL DEFAULT 60, + ADD COLUMN IF NOT EXISTS read_timeout INTEGER NOT NULL DEFAULT 300, + ADD COLUMN IF NOT EXISTS request_timeout INTEGER NOT NULL DEFAULT 300; + +ALTER TABLE dns_settings + ADD COLUMN IF NOT EXISTS prefetch BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS serve_expired BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS msg_cache_size_mb INTEGER NOT NULL DEFAULT 64, + ADD COLUMN IF NOT EXISTS rrset_cache_size_mb INTEGER NOT NULL DEFAULT 128; + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin + +ALTER TABLE forward_proxy_settings + DROP COLUMN IF EXISTS cache_mem_mb, + DROP COLUMN IF EXISTS cache_dir_mb, + DROP COLUMN IF EXISTS max_obj_size_mb, + DROP COLUMN IF EXISTS connect_timeout, + DROP COLUMN IF EXISTS read_timeout, + DROP COLUMN IF EXISTS request_timeout; + +ALTER TABLE dns_settings + DROP COLUMN IF EXISTS prefetch, + DROP COLUMN IF EXISTS serve_expired, + DROP COLUMN IF EXISTS msg_cache_size_mb, + DROP COLUMN IF EXISTS rrset_cache_size_mb; + +-- +goose StatementEnd diff --git a/internal/database/migrations/0033_cluster_settings_splitbrain.sql b/internal/database/migrations/0033_cluster_settings_splitbrain.sql new file mode 100644 index 0000000..a62796e --- /dev/null +++ b/internal/database/migrations/0033_cluster_settings_splitbrain.sql @@ -0,0 +1,18 @@ +-- +goose Up +-- Dual-path VRRP + Gateway-Tracking für Split-Brain-Schutz. +-- hb_* = zweite VRRP-Instanz (VI_HB) auf dediziertem Heartbeat-Interface. +-- gw_check_ip = Gateway-IP die von chk_gateway angepingt wird (weight -110). +ALTER TABLE cluster_settings + ADD COLUMN IF NOT EXISTS hb_interface VARCHAR, + ADD COLUMN IF NOT EXISTS hb_src_ip VARCHAR, + ADD COLUMN IF NOT EXISTS hb_peer_ip VARCHAR, + ADD COLUMN IF NOT EXISTS hb_router_id INTEGER NOT NULL DEFAULT 52, + ADD COLUMN IF NOT EXISTS gw_check_ip VARCHAR; + +-- +goose Down +ALTER TABLE cluster_settings + DROP COLUMN IF EXISTS hb_interface, + DROP COLUMN IF EXISTS hb_src_ip, + DROP COLUMN IF EXISTS hb_peer_ip, + DROP COLUMN IF EXISTS hb_router_id, + DROP COLUMN IF EXISTS gw_check_ip; diff --git a/internal/database/migrations/0034_totp.sql b/internal/database/migrations/0034_totp.sql new file mode 100644 index 0000000..04ccf77 --- /dev/null +++ b/internal/database/migrations/0034_totp.sql @@ -0,0 +1,9 @@ +-- +goose Up +ALTER TABLE users + ADD COLUMN totp_secret TEXT, + ADD COLUMN totp_enabled BOOLEAN NOT NULL DEFAULT false; + +-- +goose Down +ALTER TABLE users + DROP COLUMN totp_secret, + DROP COLUMN totp_enabled; diff --git a/internal/firewall/firewall.go b/internal/firewall/firewall.go index e4a867a..572fd61 100644 --- a/internal/firewall/firewall.go +++ b/internal/firewall/firewall.go @@ -363,11 +363,24 @@ func (g *Generator) loadAutoRules(ctx context.Context) []AutoFWRule { } } - // Squid Forward-Proxy: wenn ≥1 aktive ACL → tcp 3128 inbound - // (squid bindet aktuell 0.0.0.0:3128, daher kein DstIP-Filter). - var aclCount int - if err := g.Pool.QueryRow(ctx, `SELECT count(*) FROM forward_proxy_acls WHERE active`).Scan(&aclCount); err == nil && aclCount > 0 { - out = append(out, AutoFWRule{Proto: "tcp", Port: 3128, Comment: "Forward-Proxy (Squid)"}) + // Squid Forward-Proxy: lese Port + Listen-Adressen aus + // forward_proxy_settings. Für jede nicht-loopback IP eine + // Auto-Rule; leere Liste = alle Interfaces (generische Regel). + var squidAddrs string + var squidPort int + if err := g.Pool.QueryRow(ctx, + `SELECT listen_addresses, listen_port FROM forward_proxy_settings WHERE id=1`, + ).Scan(&squidAddrs, &squidPort); err == nil && squidPort > 0 { + addrs := splitCSV(squidAddrs) + if len(addrs) == 0 { + out = append(out, AutoFWRule{Proto: "tcp", Port: squidPort, Comment: "Forward-Proxy (Squid)"}) + } else { + for _, ip := range addrs { + if !isLoopback(ip) { + out = append(out, AutoFWRule{Proto: "tcp", Port: squidPort, DstIP: ip, Comment: "Forward-Proxy (Squid) auf " + ip}) + } + } + } } // WireGuard server-mode: udp pro aktive iface. diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index 15ad81b..fda9f5d 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -60,15 +60,22 @@ func (h *AuthHandler) WithClusterTLS(store *clustertls.Store) *AuthHandler { return h } +const totpPendingCookie = "edgeguard_totp_pending" + // 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) { g := rg.Group("/auth") g.POST("/login", h.Login) g.POST("/logout", h.Logout) + g.POST("/totp-verify", h.TOTPVerify) g.GET("/me", requireAuth, h.Me) g.POST("/reset-password", h.ResetPassword) g.POST("/change-password", requireAuth, h.ChangePassword) + // TOTP self-service (authenticated user manages own 2FA) + g.POST("/totp/setup", requireAuth, h.TOTPSetup) + g.POST("/totp/confirm", requireAuth, h.TOTPConfirm) + g.DELETE("/totp", requireAuth, h.TOTPDisable) } type loginRequest struct { @@ -77,9 +84,10 @@ type loginRequest struct { } type loginResponse struct { - Actor string `json:"actor"` - Role string `json:"role"` - ExpiresAt time.Time `json:"expires_at"` + Actor string `json:"actor"` + Role string `json:"role"` + ExpiresAt time.Time `json:"expires_at"` + TOTPRequired bool `json:"totp_required,omitempty"` } func (h *AuthHandler) Login(c *gin.Context) { @@ -101,12 +109,13 @@ func (h *AuthHandler) Login(c *gin.Context) { email := strings.TrimSpace(req.Email) actor, role := "", "admin" remote := c.ClientIP() + var totpEnabled bool // 1. Try DB users table first. if h.Users != nil { - u, hash, dbErr := h.Users.FindByEmail(c.Request.Context(), email) + ai, dbErr := h.Users.FindForAuth(c.Request.Context(), email) if dbErr == nil { - if !u.Active { + if !ai.Active { if h.Audit != nil { _ = h.Audit.Log(c.Request.Context(), email, "auth.login.failed", email, gin.H{"reason": "account_disabled", "remote": remote}, h.NodeID) @@ -114,7 +123,7 @@ func (h *AuthHandler) Login(c *gin.Context) { response.Unauthorized(c, errors.New("account_disabled")) return } - if !usersvc.VerifyPassword(hash, req.Password) { + if !usersvc.VerifyPassword(ai.PasswordHash, req.Password) { if h.Audit != nil { _ = h.Audit.Log(c.Request.Context(), email, "auth.login.failed", email, gin.H{"reason": "invalid_credentials", "remote": remote}, h.NodeID) @@ -122,9 +131,10 @@ func (h *AuthHandler) Login(c *gin.Context) { response.Unauthorized(c, errors.New("invalid_credentials")) return } - actor = u.Email - role = u.Role - h.Users.RecordLogin(c.Request.Context(), u.ID) + actor = ai.Email + role = ai.Role + totpEnabled = ai.TOTPEnabled + h.Users.RecordLogin(c.Request.Context(), ai.ID) } } @@ -133,16 +143,13 @@ func (h *AuthHandler) Login(c *gin.Context) { if strings.EqualFold(st.AdminEmail, email) && st.VerifyAdminPassword(req.Password) { actor = st.AdminEmail role = "admin" - // Auto-migrate: insert the setup-store admin into the DB so it - // shows up in user management from this point on. if h.Users != nil { _, _ = h.Users.Upsert(c.Request.Context(), st.AdminEmail, req.Password, "admin", true) } } } - // 3. Auth federation: cluster nodes forward failed auth to the primary - // via mTLS so users can log in with their primary credentials on any node. + // 3. Auth federation: cluster nodes forward failed auth to the primary. if actor == "" && st.IsClusterNode && st.PrimaryFQDN != "" && h.ClusterTLS != nil { if a, r, err := h.checkWithPrimary(c.Request.Context(), st.PrimaryFQDN, email, req.Password); err == nil { actor = a @@ -161,6 +168,21 @@ func (h *AuthHandler) Login(c *gin.Context) { return } + // TOTP gate: password OK but 2FA required → issue a short-lived pending + // cookie and tell the UI to show the TOTP input. + if totpEnabled { + pending, ptok, err := h.Signer.IssueWithRoleTTL(actor, "totp_pending", 2*time.Minute) + if err != nil { + response.Internal(c, err) + return + } + c.SetSameSite(http.SameSiteStrictMode) + c.SetCookie(totpPendingCookie, pending, int(2*time.Minute/time.Second), "/", "", true, true) + _ = ptok + response.OK(c, loginResponse{TOTPRequired: true}) + return + } + raw, tok, err := h.Signer.IssueWithRole(actor, role) if err != nil { response.Internal(c, err) @@ -179,6 +201,146 @@ func (h *AuthHandler) Login(c *gin.Context) { }) } +type totpVerifyRequest struct { + Code string `json:"code" binding:"required"` +} + +// TOTPVerify completes the two-step login: verifies the TOTP code from the +// pending cookie and, on success, issues a full session JWT. +func (h *AuthHandler) TOTPVerify(c *gin.Context) { + var req totpVerifyRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err) + return + } + pendingRaw, err := c.Cookie(totpPendingCookie) + if err != nil || pendingRaw == "" { + response.Unauthorized(c, errors.New("no_pending_totp")) + return + } + ptok, err := h.Signer.Verify(pendingRaw) + if err != nil || ptok.Role != "totp_pending" { + response.Unauthorized(c, errors.New("invalid_pending_token")) + return + } + + if h.Users == nil { + response.Internal(c, errors.New("users repo unavailable")) + return + } + ai, err := h.Users.FindForAuth(c.Request.Context(), ptok.Actor) + if err != nil || !ai.TOTPEnabled || ai.TOTPSecret == nil { + response.Unauthorized(c, errors.New("totp_not_configured")) + return + } + if !usersvc.VerifyTOTP(*ai.TOTPSecret, req.Code) { + if h.Audit != nil { + _ = h.Audit.Log(c.Request.Context(), ptok.Actor, "auth.totp.failed", + ptok.Actor, gin.H{"remote": c.ClientIP()}, h.NodeID) + } + response.Unauthorized(c, errors.New("invalid_totp_code")) + return + } + + // Clear pending cookie, issue full session. + c.SetSameSite(http.SameSiteStrictMode) + c.SetCookie(totpPendingCookie, "", -1, "/", "", true, true) + + raw, tok, err := h.Signer.IssueWithRole(ptok.Actor, ai.Role) + if err != nil { + response.Internal(c, err) + return + } + setSessionCookie(c, raw, tok.Exp) + + if h.Audit != nil { + _ = h.Audit.Log(c.Request.Context(), ptok.Actor, "auth.login.success", + ptok.Actor, gin.H{"role": ai.Role, "remote": c.ClientIP(), "totp": true}, h.NodeID) + } + response.OK(c, loginResponse{ + Actor: tok.Actor, + Role: tok.Role, + ExpiresAt: time.Unix(tok.Exp, 0).UTC(), + }) +} + +// TOTPSetup generates a new TOTP secret for the authenticated user and returns +// the provisioning URI (renders as QR code in the UI). Secret is not saved yet. +func (h *AuthHandler) TOTPSetup(c *gin.Context) { + tok := CurrentToken(c) + if tok == nil { + response.Unauthorized(c, nil) + return + } + secret, uri, err := usersvc.GenerateTOTPSecret(tok.Actor) + if err != nil { + response.Internal(c, err) + return + } + response.OK(c, gin.H{"secret": secret, "uri": uri}) +} + +type totpConfirmRequest struct { + Secret string `json:"secret" binding:"required"` + Code string `json:"code" binding:"required"` +} + +// TOTPConfirm verifies the code against the provisioned secret and, on success, +// enables TOTP for the user. +func (h *AuthHandler) TOTPConfirm(c *gin.Context) { + var req totpConfirmRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err) + return + } + tok := CurrentToken(c) + if tok == nil || h.Users == nil { + response.Unauthorized(c, nil) + return + } + u, _, err := h.Users.FindByEmail(c.Request.Context(), tok.Actor) + if err != nil { + response.Internal(c, err) + return + } + if err := h.Users.ConfirmTOTP(c.Request.Context(), u.ID, req.Secret, req.Code); err != nil { + if err.Error() == "invalid_totp_code" { + response.Err(c, http.StatusUnprocessableEntity, err) + return + } + response.Internal(c, err) + return + } + if h.Audit != nil { + _ = h.Audit.Log(c.Request.Context(), tok.Actor, "auth.totp.enabled", + tok.Actor, nil, h.NodeID) + } + response.OK(c, gin.H{"ok": true}) +} + +// TOTPDisable disables TOTP for the authenticated user. +func (h *AuthHandler) TOTPDisable(c *gin.Context) { + tok := CurrentToken(c) + if tok == nil || h.Users == nil { + response.Unauthorized(c, nil) + return + } + u, _, err := h.Users.FindByEmail(c.Request.Context(), tok.Actor) + if err != nil { + response.Internal(c, err) + return + } + if err := h.Users.DisableTOTP(c.Request.Context(), u.ID); err != nil { + response.Internal(c, err) + return + } + if h.Audit != nil { + _ = h.Audit.Log(c.Request.Context(), tok.Actor, "auth.totp.disabled", + tok.Actor, nil, h.NodeID) + } + response.OK(c, gin.H{"ok": true}) +} + func (h *AuthHandler) Logout(c *gin.Context) { clearSessionCookie(c) response.OK(c, gin.H{"logged_out": true}) diff --git a/internal/handlers/cluster.go b/internal/handlers/cluster.go index 3b45294..15ce07f 100644 --- a/internal/handlers/cluster.go +++ b/internal/handlers/cluster.go @@ -75,6 +75,8 @@ func (h *ClusterHandler) Register(rg *gin.RouterGroup) { g.PUT("/vip-settings", h.UpdateVIPSettings) g.POST("/rolling-update", h.RollingUpdate) g.GET("/rolling-update/status", h.RollingUpdateStatus) + g.GET("/vip-status", h.VIPStatus) + g.POST("/vip-test", h.VIPTest) if h.TLSStore != nil { g.GET("/cert-status", h.CertStatus) g.POST("/renew-self", h.RenewSelf) @@ -130,9 +132,12 @@ func (h *ClusterHandler) GetVIPSettings(c *gin.Context) { return } var cs vipSettingsRow - row := h.Store.Pool.QueryRow(c.Request.Context(), - `SELECT vip_address, vip_interface, vip_auth_pass, vrrp_router_id FROM cluster_settings WHERE id = 1`) - if err := row.Scan(&cs.VIPAddress, &cs.VIPInterface, &cs.VIPAuthPass, &cs.VRRPRouterID); err != nil { + row := h.Store.Pool.QueryRow(c.Request.Context(), ` + SELECT vip_address, vip_interface, vip_auth_pass, vrrp_router_id, + hb_interface, hb_src_ip, hb_peer_ip, hb_router_id, gw_check_ip + FROM cluster_settings WHERE id = 1`) + if err := row.Scan(&cs.VIPAddress, &cs.VIPInterface, &cs.VIPAuthPass, &cs.VRRPRouterID, + &cs.HBInterface, &cs.HBSrcIP, &cs.HBPeerIP, &cs.HBRouterID, &cs.GWCheckIP); err != nil { response.Internal(c, err) return } @@ -154,10 +159,14 @@ func (h *ClusterHandler) UpdateVIPSettings(c *gin.Context) { } _, err := h.Store.Pool.Exec(c.Request.Context(), ` UPDATE cluster_settings - SET vip_address=$1, vip_interface=$2, vip_auth_pass=$3, vrrp_router_id=$4, updated_at=NOW() + SET vip_address=$1, vip_interface=$2, vip_auth_pass=$3, vrrp_router_id=$4, + hb_interface=$5, hb_src_ip=$6, hb_peer_ip=$7, hb_router_id=$8, gw_check_ip=$9, + updated_at=NOW() WHERE id=1`, nullIfEmpty(req.VIPAddress), nullIfEmpty(req.VIPInterface), - nullIfEmpty(req.VIPAuthPass), req.VRRPRouterID) + nullIfEmpty(req.VIPAuthPass), req.VRRPRouterID, + nullIfEmpty(req.HBInterface), nullIfEmpty(req.HBSrcIP), + nullIfEmpty(req.HBPeerIP), req.HBRouterID, nullIfEmpty(req.GWCheckIP)) if err != nil { response.Internal(c, err) return @@ -181,6 +190,11 @@ type vipSettingsRow struct { VIPInterface *string `json:"vip_interface"` VIPAuthPass *string `json:"vip_auth_pass"` VRRPRouterID int `json:"vrrp_router_id"` + HBInterface *string `json:"hb_interface"` + HBSrcIP *string `json:"hb_src_ip"` + HBPeerIP *string `json:"hb_peer_ip"` + HBRouterID int `json:"hb_router_id"` + GWCheckIP *string `json:"gw_check_ip"` } func nullIfEmpty(s *string) *string { @@ -218,6 +232,9 @@ func (h *ClusterHandler) RegisterAgent(rg *gin.RouterGroup) { g.GET("/master-key", h.AgentMasterKey) g.GET("/version", h.AgentVersion) g.POST("/trigger-update", h.AgentTriggerUpdate) + g.GET("/active-ips", h.AgentActiveIPs) + g.POST("/vip-cmd", h.AgentVIPCmd) + g.GET("/tls-certs", h.AgentTLSCerts) } // AgentIdentity gibt die eigene ha_nodes-Row zurück. Wird vom Primary diff --git a/internal/handlers/cluster_certsync.go b/internal/handlers/cluster_certsync.go new file mode 100644 index 0000000..06b0f89 --- /dev/null +++ b/internal/handlers/cluster_certsync.go @@ -0,0 +1,118 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/gin-gonic/gin" + "github.com/jackc/pgx/v5/pgxpool" + + "git.netcell-it.de/projekte/edgeguard-native/internal/aggregator" + "git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response" + "git.netcell-it.de/projekte/edgeguard-native/internal/models" +) + +const tlsCertDir = "/etc/edgeguard/tls" + +// AgentTLSCerts liefert alle .pem-Dateien aus /etc/edgeguard/tls/ als +// Base64-Map. Wird vom Secondary via mTLS aufgerufen um Zertifikate +// des Primary zu spiegeln. +func (h *ClusterHandler) AgentTLSCerts(c *gin.Context) { + entries, err := os.ReadDir(tlsCertDir) + if err != nil { + response.Internal(c, err) + return + } + certs := make(map[string]string, len(entries)) + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".pem") { + continue + } + data, err := os.ReadFile(filepath.Join(tlsCertDir, e.Name())) + if err != nil { + continue + } + certs[e.Name()] = base64.StdEncoding.EncodeToString(data) + } + response.OK(c, gin.H{"certs": certs}) +} + +// SyncTLSCertsFromPrimary holt alle TLS-Zertifikate vom Primary via mTLS +// und schreibt geänderte Dateien nach /etc/edgeguard/tls/. Relädt HAProxy +// wenn mindestens ein Zertifikat aktualisiert wurde. +// +// Läuft auf dem Secondary bei jedem runSecondaryConfigRender-Tick — +// nicht hash-gated, da certbot-Renewals den config_hash nicht ändern. +func SyncTLSCertsFromPrimary(ctx context.Context, pool *pgxpool.Pool, agg *aggregator.Aggregator, localID string) error { + if agg == nil { + return nil + } + // Primary-Peer aus ha_nodes ermitteln + rows, err := pool.Query(ctx, + `SELECT id, fqdn, api_url FROM ha_nodes WHERE id != $1 LIMIT 1`, localID) + if err != nil { + return err + } + defer rows.Close() + var primary *models.HANode + for rows.Next() { + n := &models.HANode{} + if err := rows.Scan(&n.ID, &n.FQDN, &n.APIURL); err != nil { + continue + } + primary = n + } + if primary == nil { + return nil // kein Peer → Single-Node + } + + results := agg.FanOut(ctx, []models.HANode{*primary}, "/agent/cluster/tls-certs", localID) + if len(results) == 0 || !results[0].OK { + return nil // Primary nicht erreichbar — nächster Tick + } + + var payload struct { + Certs map[string]string `json:"certs"` + } + if err := json.Unmarshal(results[0].Data, &payload); err != nil { + return err + } + + if err := os.MkdirAll(tlsCertDir, 0o750); err != nil { + return err + } + + changed := false + for name, b64 := range payload.Certs { + data, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + slog.Warn("cert-sync: base64 decode failed", "file", name, "error", err) + continue + } + path := filepath.Join(tlsCertDir, name) + existing, readErr := os.ReadFile(path) + if readErr == nil && bytes.Equal(existing, data) { + continue // unverändert + } + if err := os.WriteFile(path, data, 0o640); err != nil { + slog.Warn("cert-sync: write failed", "file", name, "error", err) + continue + } + changed = true + slog.Info("cert-sync: updated", "file", name) + } + + if changed { + if err := exec.Command("sudo", "-n", "/usr/bin/systemctl", "reload", "haproxy.service").Run(); err != nil { + slog.Warn("cert-sync: haproxy reload failed", "error", err) + } + } + return nil +} diff --git a/internal/handlers/cluster_viptest.go b/internal/handlers/cluster_viptest.go new file mode 100644 index 0000000..43ba79a --- /dev/null +++ b/internal/handlers/cluster_viptest.go @@ -0,0 +1,319 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "os/exec" + "strings" + + "github.com/gin-gonic/gin" + "github.com/jackc/pgx/v5/pgxpool" + + "git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response" + "git.netcell-it.de/projekte/edgeguard-native/internal/models" +) + +// vipInfo enthält die für einen VIP-Schwenk notwendigen Daten. +type vipInfo struct { + ID int64 `json:"id"` + Address string `json:"address"` + Prefix int `json:"prefix"` + Device string `json:"device"` +} + +// VIPStatusEntry kombiniert einen VIP mit den Nodes die ihn gerade halten. +type VIPStatusEntry struct { + VIP vipInfo `json:"vip"` + ActiveOn []string `json:"active_on"` // FQDNs der Nodes mit diesem VIP +} + +// AgentActiveIPs gibt alle aktiven IPv4-Adressen dieses Nodes zurück. +// Wird vom Primary genutzt um zu prüfen welcher Node welchen VIP hält. +func (h *ClusterHandler) AgentActiveIPs(c *gin.Context) { + ips, err := localActiveIPs() + if err != nil { + response.Internal(c, err) + return + } + response.OK(c, gin.H{"ips": ips}) +} + +// vipCmdRequest ist der Body für den AgentVIPCmd-Endpoint. +type vipCmdRequest struct { + Action string `json:"action"` // "add" | "del" + Address string `json:"address"` // z.B. "10.0.5.1" + Prefix int `json:"prefix"` // z.B. 24 + Device string `json:"device"` // z.B. "vlan100" +} + +// AgentVIPCmd führt `ip addr add/del` auf diesem Node aus. +// Wird vom Primary via mTLS für VIP-Schwenk-Tests aufgerufen. +func (h *ClusterHandler) AgentVIPCmd(c *gin.Context) { + var req vipCmdRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err) + return + } + if req.Action != "add" && req.Action != "del" { + response.BadRequest(c, simpleError("action must be 'add' or 'del'")) + return + } + if req.Address == "" || req.Device == "" || req.Prefix <= 0 || req.Prefix > 128 { + response.BadRequest(c, simpleError("address, device, prefix required")) + return + } + if err := runVIPCmd(req.Action, req.Address, req.Prefix, req.Device); err != nil { + slog.Warn("cluster: agent vip-cmd failed", + "action", req.Action, "addr", req.Address, "dev", req.Device, "error", err) + response.Internal(c, err) + return + } + slog.Info("cluster: agent vip-cmd ok", + "action", req.Action, "addr", req.Address, "prefix", req.Prefix, + "dev", req.Device, "caller", c.ClientIP()) + response.OK(c, gin.H{"ok": true}) +} + +// VIPStatus liest alle VIPs (is_vip=true) aus der DB und fragt alle Nodes +// welche davon sie gerade aktiv haben. Nur sinnvoll im Cluster-Modus. +func (h *ClusterHandler) VIPStatus(c *gin.Context) { + vips, err := loadVIPs(c.Request.Context(), h.Store.Pool) + if err != nil { + response.Internal(c, err) + return + } + nodeIPs := h.collectActiveIPs(c.Request.Context()) + result := make([]VIPStatusEntry, 0, len(vips)) + for _, v := range vips { + entry := VIPStatusEntry{VIP: v} + for fqdn, ips := range nodeIPs { + for _, ip := range ips { + if ip == v.Address { + entry.ActiveOn = append(entry.ActiveOn, fqdn) + break + } + } + } + result = append(result, entry) + } + response.OK(c, gin.H{"vips": result}) +} + +// vipTestRequest steuert einen VIP-Schwenk. +type vipTestRequest struct { + IPAddressID int64 `json:"ip_address_id"` + Action string `json:"action"` // "to_secondary" | "restore" +} + +// vipTestStep beschreibt einen Schritt des Schwenk-Prozesses. +type vipTestStep struct { + Step string `json:"step"` + OK bool `json:"ok"` + Message string `json:"message,omitempty"` +} + +// VIPTest schwenkt einen VIP vom Primary auf den Secondary ("to_secondary") +// oder zurück ("restore"). Nur vom Primary aufzurufen. +func (h *ClusterHandler) VIPTest(c *gin.Context) { + var req vipTestRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err) + return + } + if req.Action != "to_secondary" && req.Action != "restore" { + response.BadRequest(c, simpleError("action must be 'to_secondary' or 'restore'")) + return + } + + vips, err := loadVIPs(c.Request.Context(), h.Store.Pool) + if err != nil { + response.Internal(c, err) + return + } + var target *vipInfo + for i := range vips { + if vips[i].ID == req.IPAddressID { + target = &vips[i] + break + } + } + if target == nil { + response.NotFound(c, simpleError("VIP not found or not marked as VIP")) + return + } + + all, err := h.Store.List(c.Request.Context()) + if err != nil { + response.Internal(c, err) + return + } + var peer *models.HANode + for i := range all { + if all[i].ID != h.LocalID { + peer = &all[i] + break + } + } + if peer == nil { + response.BadRequest(c, simpleError("kein Secondary-Peer gefunden")) + return + } + + var steps []vipTestStep + addrPfx := fmt.Sprintf("%s/%d", target.Address, target.Prefix) + + if req.Action == "to_secondary" { + // 1. VIP auf Secondary via mTLS hinzufügen + steps = append(steps, h.peerVIPCmd(c.Request.Context(), *peer, target, "add", + fmt.Sprintf("add %s dev %s auf %s", addrPfx, target.Device, peer.FQDN))) + // 2. VIP vom Primary entfernen (nur wenn Secondary-Add erfolgreich) + if steps[0].OK { + steps = append(steps, localVIPStep(target, "del", + fmt.Sprintf("del %s dev %s lokal", addrPfx, target.Device))) + } + } else { + // 1. VIP auf Primary zurückholen + steps = append(steps, localVIPStep(target, "add", + fmt.Sprintf("add %s dev %s lokal", addrPfx, target.Device))) + // 2. VIP auf Secondary entfernen + steps = append(steps, h.peerVIPCmd(c.Request.Context(), *peer, target, "del", + fmt.Sprintf("del %s dev %s auf %s", addrPfx, target.Device, peer.FQDN))) + } + + slog.Info("cluster: vip-test", "action", req.Action, "vip", target.Address, + "dev", target.Device, "peer", peer.FQDN, "actor", actorOf(c)) + response.OK(c, gin.H{"steps": steps}) +} + +// ── Hilfsfunktionen ─────────────────────────────────────────────────────── + +func loadVIPs(ctx context.Context, pool *pgxpool.Pool) ([]vipInfo, error) { + rows, err := pool.Query(ctx, ` + SELECT ia.id, ia.address, ia.prefix, ni.name + FROM ip_addresses ia + JOIN network_interfaces ni ON ni.id = ia.interface_id + WHERE ia.is_vip = true AND ia.active = true + ORDER BY ni.name, ia.address`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []vipInfo + for rows.Next() { + var v vipInfo + if err := rows.Scan(&v.ID, &v.Address, &v.Prefix, &v.Device); err != nil { + return nil, err + } + out = append(out, v) + } + return out, rows.Err() +} + +// collectActiveIPs fragt alle Cluster-Nodes (lokal + Peers via mTLS) nach +// ihren aktiven IPv4-Adressen und gibt eine Map[fqdn][]ip zurück. +func (h *ClusterHandler) collectActiveIPs(ctx context.Context) map[string][]string { + result := make(map[string][]string) + if h.Store == nil { + return result + } + all, err := h.Store.List(ctx) + if err != nil { + return result + } + // Lokaler Node + if ips, err := localActiveIPs(); err == nil { + for _, n := range all { + if n.ID == h.LocalID { + result[n.FQDN] = ips + break + } + } + } + // Peers via mTLS-Aggregator + if h.Aggregator != nil { + var peers []models.HANode + for _, n := range all { + if n.ID != h.LocalID { + peers = append(peers, n) + } + } + if len(peers) > 0 { + peerResults := h.Aggregator.FanOut(ctx, peers, "/agent/cluster/active-ips", h.LocalID) + for _, pr := range peerResults { + if !pr.OK || len(pr.Data) == 0 { + continue + } + var payload struct { + IPs []string `json:"ips"` + } + if err := json.Unmarshal(pr.Data, &payload); err == nil { + result[pr.FQDN] = payload.IPs + } + } + } + } + return result +} + +// localActiveIPs liest alle aktiven IPv4-Adressen des lokalen Nodes via `ip`. +func localActiveIPs() ([]string, error) { + out, err := exec.Command("ip", "-4", "-o", "addr", "show").Output() + if err != nil { + return nil, err + } + var ips []string + for _, line := range strings.Split(string(out), "\n") { + parts := strings.Fields(line) + for i, p := range parts { + if p == "inet" && i+1 < len(parts) { + addr := strings.SplitN(parts[i+1], "/", 2)[0] + ips = append(ips, addr) + } + } + } + return ips, nil +} + +// peerVIPCmd ruft AgentVIPCmd auf dem Peer via mTLS auf. +func (h *ClusterHandler) peerVIPCmd(ctx context.Context, peer models.HANode, vip *vipInfo, action, stepLabel string) vipTestStep { + step := vipTestStep{Step: stepLabel} + if h.Aggregator == nil { + step.Message = "aggregator nicht verfügbar" + return step + } + body, _ := json.Marshal(vipCmdRequest{ + Action: action, + Address: vip.Address, + Prefix: vip.Prefix, + Device: vip.Device, + }) + res := h.Aggregator.PostPeerWithBody(ctx, peer, "/agent/cluster/vip-cmd", body) + step.OK = res.OK + if !res.OK { + step.Message = res.Err + } + return step +} + +// localVIPStep führt ip addr add/del auf dem lokalen Node aus. +func localVIPStep(vip *vipInfo, action, stepLabel string) vipTestStep { + step := vipTestStep{Step: stepLabel} + if err := runVIPCmd(action, vip.Address, vip.Prefix, vip.Device); err != nil { + step.Message = err.Error() + return step + } + step.OK = true + return step +} + +// runVIPCmd führt `sudo /usr/lib/edgeguard/vip-cmd.sh {action} {addr/prefix} {dev}` aus. +func runVIPCmd(action, address string, prefix int, device string) error { + addrPfx := fmt.Sprintf("%s/%d", address, prefix) + out, err := exec.Command("sudo", "-n", "/usr/lib/edgeguard/vip-cmd.sh", action, addrPfx, device).CombinedOutput() + if err != nil { + return fmt.Errorf("vip-cmd.sh %s %s %s: %s", action, addrPfx, device, strings.TrimSpace(string(out))) + } + return nil +} diff --git a/internal/handlers/dns.go b/internal/handlers/dns.go index 7032f1c..23fee38 100644 --- a/internal/handlers/dns.go +++ b/internal/handlers/dns.go @@ -306,7 +306,7 @@ func (h *DNSHandler) UpdateSettings(c *gin.Context) { // cached RRs from the resolver. Useful after DNS propagation or when // stale records need to be evicted immediately. func (h *DNSHandler) FlushCache(c *gin.Context) { - out, err := exec.CommandContext(c.Request.Context(), "unbound-control", "flush_zone", ".").CombinedOutput() + out, err := exec.CommandContext(c.Request.Context(), "/usr/sbin/unbound-control", "flush_zone", ".").CombinedOutput() if err != nil { slog.Error("dns flush-cache failed", "err", err, "out", string(out)) response.Internal(c, err) @@ -388,7 +388,7 @@ func validateZone(z *models.DNSZone) error { // stats_noreset liest die Zähler ohne sie zurückzusetzen — safe für // wiederholte Aufrufe aus dem UI. func (h *DNSHandler) Stats(c *gin.Context) { - out, err := exec.Command("unbound-control", "stats_noreset").Output() + out, err := exec.Command("/usr/sbin/unbound-control", "stats_noreset").Output() if err != nil { response.OK(c, gin.H{ "error": "unbound-control nicht verfügbar: " + err.Error(), diff --git a/internal/handlers/forwardproxy.go b/internal/handlers/forwardproxy.go index 46a2f43..fbe9e45 100644 --- a/internal/handlers/forwardproxy.go +++ b/internal/handlers/forwardproxy.go @@ -39,6 +39,8 @@ func (h *ForwardProxyHandler) reload(ctx context.Context, op string) { func (h *ForwardProxyHandler) Register(rg *gin.RouterGroup) { base := rg.Group("/forward-proxy") base.GET("/stats", h.Stats) + base.GET("/settings", h.GetSettings) + base.PUT("/settings", h.UpdateSettings) g := base.Group("/acls") g.GET("", h.List) @@ -48,6 +50,34 @@ func (h *ForwardProxyHandler) Register(rg *gin.RouterGroup) { g.DELETE("/:id", h.Delete) } +func (h *ForwardProxyHandler) GetSettings(c *gin.Context) { + s, err := h.Repo.GetSettings(c.Request.Context()) + if err != nil { + response.Internal(c, err) + return + } + response.OK(c, s) +} + +func (h *ForwardProxyHandler) UpdateSettings(c *gin.Context) { + var req models.ForwardProxySettings + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err) + return + } + if req.ListenPort <= 0 || req.ListenPort > 65535 { + req.ListenPort = 3128 + } + out, err := h.Repo.UpdateSettings(c.Request.Context(), req) + if err != nil { + response.Internal(c, err) + return + } + _ = h.Audit.Log(c.Request.Context(), actorOf(c), "forward_proxy.settings.update", "settings", out, h.NodeID) + response.OK(c, out) + h.reload(c.Request.Context(), "settings.update") +} + func (h *ForwardProxyHandler) List(c *gin.Context) { out, err := h.Repo.List(c.Request.Context()) if err != nil { diff --git a/internal/handlers/system.go b/internal/handlers/system.go index c2b9fcd..5576922 100644 --- a/internal/handlers/system.go +++ b/internal/handlers/system.go @@ -122,6 +122,7 @@ func (h *SystemHandler) Register(rg *gin.RouterGroup) { g.GET("/ipv6", h.IPv6) g.POST("/ipv6", h.SetIPv6) g.GET("/config-preview", h.ConfigPreview) + g.GET("/vip-status", h.VIPStatus) } // RegisterAgent mountet die read-only System-Endpoints auf der mTLS- @@ -188,6 +189,7 @@ var servicesToCheck = []struct{ Label, Unit string }{ {"edgeguard-scheduler", "edgeguard-scheduler"}, {"haproxy", "haproxy"}, {"nftables", "nftables"}, + {"keepalived", "keepalived"}, {"unbound", "unbound"}, {"chrony", "chrony"}, {"squid", "squid"}, @@ -1077,6 +1079,87 @@ func classifyLinkType(ifc net.Interface) string { return "" } +// VIPStatus returns the VRRP state and active VIPs for this node. +// Uses net.Interfaces() (no shell-out) to check which VIPs from +// ip_addresses WHERE is_vip=true are currently assigned locally. +// MASTER = at least one VIP is locally present; BACKUP = none present. +func (h *SystemHandler) VIPStatus(c *gin.Context) { + type vipEntry struct { + Address string `json:"address"` + Prefix int `json:"prefix"` + Device string `json:"device"` + Active bool `json:"active"` + } + type vipStatus struct { + VRRPState string `json:"vrrp_state"` + KeepalivedActive bool `json:"keepalived_active"` + VIPs []vipEntry `json:"vips"` + } + + ctx := c.Request.Context() + + // keepalived service active? + kaOut, _ := exec.CommandContext(ctx, "systemctl", "is-active", "keepalived").Output() + kaActive := strings.TrimSpace(string(kaOut)) == "active" + + // query VIPs from DB + var dbVIPs []vipEntry + if h.Pool != nil { + rows, err := h.Pool.Query(ctx, + `SELECT a.address, a.prefix, COALESCE(i.name,'') AS device + FROM ip_addresses a + LEFT JOIN network_interfaces i ON i.id = a.interface_id + WHERE a.is_vip = true AND a.active = true + ORDER BY a.address`) + if err == nil { + defer rows.Close() + for rows.Next() { + var e vipEntry + if err2 := rows.Scan(&e.Address, &e.Prefix, &e.Device); err2 == nil { + dbVIPs = append(dbVIPs, e) + } + } + } + } + + // build set of locally assigned IPs + localIPs := make(map[string]bool) + if ifaces, err := net.Interfaces(); err == nil { + for _, ifc := range ifaces { + if addrs, err2 := ifc.Addrs(); err2 == nil { + for _, a := range addrs { + if ipnet, ok := a.(*net.IPNet); ok { + localIPs[ipnet.IP.String()] = true + } + } + } + } + } + + anyActive := false + for i := range dbVIPs { + dbVIPs[i].Active = localIPs[dbVIPs[i].Address] + if dbVIPs[i].Active { + anyActive = true + } + } + + state := "UNKNOWN" + if kaActive { + if anyActive { + state = "MASTER" + } else { + state = "BACKUP" + } + } + + response.OK(c, vipStatus{ + VRRPState: state, + KeepalivedActive: kaActive, + VIPs: dbVIPs, + }) +} + func flagsToList(f net.Flags) []string { var out []string if f&net.FlagUp != 0 { diff --git a/internal/handlers/users.go b/internal/handlers/users.go index 95601ae..3728a95 100644 --- a/internal/handlers/users.go +++ b/internal/handlers/users.go @@ -35,6 +35,7 @@ func (h *UsersHandler) Register(rg *gin.RouterGroup) { g.PUT("/:id", h.Update) g.POST("/:id/password", h.SetPassword) g.DELETE("/:id", h.Delete) + g.DELETE("/:id/totp", h.DisableTOTP) } func (h *UsersHandler) List(c *gin.Context) { @@ -164,3 +165,22 @@ func (h *UsersHandler) Delete(c *gin.Context) { c.Param("id"), nil, h.NodeID) response.OK(c, gin.H{"ok": true}) } + +// DisableTOTP allows an admin to disable 2FA for any user. +func (h *UsersHandler) DisableTOTP(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + if err := h.Repo.DisableTOTP(c.Request.Context(), id); err != nil { + if errors.Is(err, users.ErrNotFound) { + response.NotFound(c, err) + return + } + response.Internal(c, err) + return + } + _ = h.Audit.Log(c.Request.Context(), actorOf(c), "user.totp.disabled", + c.Param("id"), nil, h.NodeID) + response.OK(c, gin.H{"ok": true}) +} diff --git a/internal/keepalived/keepalived.conf.tpl b/internal/keepalived/keepalived.conf.tpl index d5fdcea..9bcee4c 100644 --- a/internal/keepalived/keepalived.conf.tpl +++ b/internal/keepalived/keepalived.conf.tpl @@ -2,8 +2,6 @@ global_defs { router_id {{ .RouterID }} script_user root enable_script_security - vrrp_garp_interval 0 - vrrp_gna_interval 0 } vrrp_script chk_edgeguard { @@ -13,7 +11,23 @@ vrrp_script chk_edgeguard { fall 3 rise 2 } - +{{ if .GWCheckIP }} +vrrp_script chk_gateway { + script "/usr/lib/edgeguard/keepalived-gw-check.sh {{ .GWCheckIP }}" + interval 5 + weight -110 + fall 2 + rise 2 +} +{{ end }} +{{ if .HBInterface }} +vrrp_sync_group VG_1 { + group { + VI_1 + VI_HB + } +} +{{ end }} vrrp_instance VI_1 { state {{ .State }} interface {{ .Interface }} @@ -29,12 +43,30 @@ vrrp_instance VI_1 { auth_pass {{ .AuthPass }} } virtual_ipaddress { - {{ .VIP }} - } +{{ range .VIPs }} {{ .Address }}/{{ .Prefix }} dev {{ .Device }} +{{ end }} } track_script { chk_edgeguard - } +{{ if .GWCheckIP }} chk_gateway +{{ end }} } notify_master "/usr/lib/edgeguard/keepalived-master.sh" notify_backup "/usr/lib/edgeguard/keepalived-backup.sh" notify_fault "/usr/lib/edgeguard/keepalived-backup.sh" } +{{ if .HBInterface }} +vrrp_instance VI_HB { + state {{ .State }} + interface {{ .HBInterface }} + virtual_router_id {{ .HBRouterID }} + priority {{ .Priority }} + advert_int 1 +{{ if .HBSrcIP }} unicast_src_ip {{ .HBSrcIP }} + unicast_peer { + {{ .HBPeerIP }} + } +{{ end }} authentication { + auth_type PASS + auth_pass {{ .AuthPass }} + } +} +{{ end }} diff --git a/internal/keepalived/keepalived.go b/internal/keepalived/keepalived.go index 09f3a54..1d3ccce 100644 --- a/internal/keepalived/keepalived.go +++ b/internal/keepalived/keepalived.go @@ -14,6 +14,7 @@ import ( "fmt" "os" "os/exec" + "strings" "text/template" "github.com/jackc/pgx/v5/pgxpool" @@ -29,16 +30,30 @@ var cfgTpl string var tpl = template.Must(template.New("keepalived").Parse(cfgTpl)) +// VIPEntry ist eine einzelne VIP-Adresse die keepalived verwaltet. +type VIPEntry struct { + Address string // z.B. 89.163.205.100 + Prefix int // z.B. 24 + Device string // z.B. eth0 +} + // View ist der Template-Kontext. type View struct { - State string // MASTER | BACKUP - Interface string - RouterID int - Priority int // MASTER=200, BACKUP=100 - SrcIP string // eigene Public-IP (für unicast_src_ip) - PeerIP string // Peer-Public-IP (für unicast_peer) - AuthPass string - VIP string + State string // MASTER | BACKUP + Interface string // Interface für VRRP-Advertisements (VI_1) + RouterID int + Priority int // MASTER=200, BACKUP=100 + SrcIP string // eigene Public-IP (unicast_src_ip) + PeerIP string // Peer-Public-IP (unicast_peer) + AuthPass string + VIPs []VIPEntry // alle is_vip=true Einträge aus ip_addresses + // Dual-path VRRP (Split-Brain-Schutz, Migration 0033) + HBInterface string + HBSrcIP string + HBPeerIP string + HBRouterID int + // GW-Tracking + GWCheckIP string } type generator struct { @@ -53,15 +68,15 @@ func New(pool *pgxpool.Pool, localID string) configgen.Generator { func (g *generator) Name() string { return "keepalived" } func (g *generator) Render(ctx context.Context) error { - cs, local, peer, err := g.loadData(ctx) + cs, vips, local, peer, err := g.loadData(ctx) if err != nil { return fmt.Errorf("keepalived: load: %w", err) } - if cs.VIPAddress == nil || *cs.VIPAddress == "" { - // Kein VIP konfiguriert → keepalived.conf nicht schreiben. + if len(vips) == 0 { + // Keine VIPs konfiguriert → keepalived.conf nicht schreiben. return nil } - v := g.buildView(cs, local, peer) + v := g.buildView(cs, vips, local, peer) var buf bytes.Buffer if err := tpl.Execute(&buf, v); err != nil { return fmt.Errorf("keepalived: template: %w", err) @@ -75,23 +90,47 @@ func (g *generator) Render(ctx context.Context) error { return nil } -func (g *generator) loadData(ctx context.Context) (*models.ClusterSettings, *models.HANode, *models.HANode, error) { +func (g *generator) loadData(ctx context.Context) (*models.ClusterSettings, []VIPEntry, *models.HANode, *models.HANode, error) { var cs models.ClusterSettings - row := g.pool.QueryRow(ctx, `SELECT id, vip_address, vip_interface, vip_auth_pass, vrrp_router_id FROM cluster_settings WHERE id = 1`) - if err := row.Scan(&cs.ID, &cs.VIPAddress, &cs.VIPInterface, &cs.VIPAuthPass, &cs.VRRPRouterID); err != nil { - return nil, nil, nil, fmt.Errorf("cluster_settings: %w", err) + row := g.pool.QueryRow(ctx, ` + SELECT id, vip_address, vip_interface, vip_auth_pass, vrrp_router_id, + hb_interface, hb_src_ip, hb_peer_ip, hb_router_id, gw_check_ip + FROM cluster_settings WHERE id = 1`) + if err := row.Scan(&cs.ID, &cs.VIPAddress, &cs.VIPInterface, &cs.VIPAuthPass, &cs.VRRPRouterID, + &cs.HBInterface, &cs.HBSrcIP, &cs.HBPeerIP, &cs.HBRouterID, &cs.GWCheckIP); err != nil { + return nil, nil, nil, nil, fmt.Errorf("cluster_settings: %w", err) } - rows, err := g.pool.Query(ctx, `SELECT id, fqdn, role, pg_role, public_ip, status FROM ha_nodes ORDER BY joined_at`) + // Alle VIPs aus ip_addresses (is_vip=true, active=true) inkl. Interface-Name. + vipRows, err := g.pool.Query(ctx, ` + SELECT ia.address, ia.prefix, ni.name + FROM ip_addresses ia + JOIN network_interfaces ni ON ia.interface_id = ni.id + WHERE ia.is_vip = true AND ia.active = true + ORDER BY ni.name, ia.address`) if err != nil { - return nil, nil, nil, fmt.Errorf("ha_nodes: %w", err) + return nil, nil, nil, nil, fmt.Errorf("ip_addresses: %w", err) } - defer rows.Close() + defer vipRows.Close() + var vips []VIPEntry + for vipRows.Next() { + var v VIPEntry + if err := vipRows.Scan(&v.Address, &v.Prefix, &v.Device); err != nil { + continue + } + vips = append(vips, v) + } + + nodeRows, err := g.pool.Query(ctx, `SELECT id, fqdn, role, pg_role, public_ip, status FROM ha_nodes ORDER BY joined_at`) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("ha_nodes: %w", err) + } + defer nodeRows.Close() var local, peer *models.HANode - for rows.Next() { + for nodeRows.Next() { n := &models.HANode{} - if err := rows.Scan(&n.ID, &n.FQDN, &n.Role, &n.PGRole, &n.PublicIP, &n.Status); err != nil { + if err := nodeRows.Scan(&n.ID, &n.FQDN, &n.Role, &n.PGRole, &n.PublicIP, &n.Status); err != nil { continue } if n.ID == g.localID { @@ -101,17 +140,22 @@ func (g *generator) loadData(ctx context.Context) (*models.ClusterSettings, *mod } } if local == nil { - return nil, nil, nil, fmt.Errorf("local node %s not in ha_nodes", g.localID) + return nil, nil, nil, nil, fmt.Errorf("local node %s not in ha_nodes", g.localID) } - return &cs, local, peer, nil + return &cs, vips, local, peer, nil } -func (g *generator) buildView(cs *models.ClusterSettings, local, peer *models.HANode) View { +func (g *generator) buildView(cs *models.ClusterSettings, vips []VIPEntry, local, peer *models.HANode) View { v := View{ - RouterID: cs.VRRPRouterID, - VIP: deref(cs.VIPAddress), - Interface: deref(cs.VIPInterface), - AuthPass: deref(cs.VIPAuthPass), + RouterID: cs.VRRPRouterID, + VIPs: vips, + Interface: deref(cs.VIPInterface), + AuthPass: deref(cs.VIPAuthPass), + HBInterface: deref(cs.HBInterface), + HBSrcIP: deref(cs.HBSrcIP), + HBPeerIP: deref(cs.HBPeerIP), + HBRouterID: cs.HBRouterID, + GWCheckIP: deref(cs.GWCheckIP), } if v.Interface == "" { v.Interface = "eth0" @@ -119,9 +163,17 @@ func (g *generator) buildView(cs *models.ClusterSettings, local, peer *models.HA if v.AuthPass == "" { v.AuthPass = "edgeguard" } + if v.HBRouterID == 0 { + v.HBRouterID = 52 + } - // Primary-Node bekommt höhere Priorität und startet als MASTER. - if local.PGRole == "primary" || local.Role == "primary" { + // pg_role=standby ist das härtere Signal — ein Standby-Node ist niemals + // MASTER, auch wenn role='primary' noch aus dem Join-Prozess stammt. + // Reihenfolge: standby → BACKUP; sonst primary-Check. + if local.PGRole == "standby" { + v.State = "BACKUP" + v.Priority = 100 + } else if local.PGRole == "primary" || local.Role == "primary" { v.State = "MASTER" v.Priority = 200 } else { @@ -143,7 +195,11 @@ func reloadKeepalived() error { // keepalived läuft noch nicht — erster Render beim Start. return nil } - return exec.Command("systemctl", "reload-or-restart", "keepalived").Run() + cmd := exec.Command("sudo", "-n", "/usr/bin/systemctl", "reload-or-restart", "keepalived.service") + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("sudo systemctl reload-or-restart keepalived.service: %w (output: %s)", err, strings.TrimSpace(string(out))) + } + return nil } func deref(s *string) string { diff --git a/internal/models/cluster_settings.go b/internal/models/cluster_settings.go index d5ae7e3..4d14eee 100644 --- a/internal/models/cluster_settings.go +++ b/internal/models/cluster_settings.go @@ -4,12 +4,19 @@ import "time" // ClusterSettings ist die Singleton-Tabelle (id=1) für VIP/VRRP- // und Replikations-Konfiguration. Angelegt in Migration 0029. +// hb_* = zweite VRRP-Instanz für Split-Brain-Schutz (0033). +// gw_check_ip = Gateway-IP für vrrp_script chk_gateway (0033). type ClusterSettings struct { ID int `gorm:"column:id;primaryKey" json:"id"` VIPAddress *string `gorm:"column:vip_address" json:"vip_address,omitempty"` VIPInterface *string `gorm:"column:vip_interface" json:"vip_interface,omitempty"` VIPAuthPass *string `gorm:"column:vip_auth_pass" json:"vip_auth_pass,omitempty"` VRRPRouterID int `gorm:"column:vrrp_router_id" json:"vrrp_router_id"` + HBInterface *string `gorm:"column:hb_interface" json:"hb_interface,omitempty"` + HBSrcIP *string `gorm:"column:hb_src_ip" json:"hb_src_ip,omitempty"` + HBPeerIP *string `gorm:"column:hb_peer_ip" json:"hb_peer_ip,omitempty"` + HBRouterID int `gorm:"column:hb_router_id" json:"hb_router_id"` + GWCheckIP *string `gorm:"column:gw_check_ip" json:"gw_check_ip,omitempty"` CreatedAt time.Time `gorm:"column:created_at" json:"created_at"` UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"` } diff --git a/internal/models/dns.go b/internal/models/dns.go index 87cd2b5..65ac83a 100644 --- a/internal/models/dns.go +++ b/internal/models/dns.go @@ -40,16 +40,20 @@ func (DNSRecord) TableName() string { return "dns_records" } // Optionen. Default kommt aus der Migration (alle Werte sinnvoll // für die typische LAN-Resolver-Rolle). type DNSSettings struct { - ID int64 `gorm:"primaryKey" json:"id"` - ListenAddresses string `gorm:"column:listen_addresses" json:"listen_addresses"` - ListenPort int `gorm:"column:listen_port" json:"listen_port"` - UpstreamForwards string `gorm:"column:upstream_forwards" json:"upstream_forwards"` - AccessACL string `gorm:"column:access_acl" json:"access_acl"` - DNSSEC bool `gorm:"column:dnssec" json:"dnssec"` - QNameMinimisation bool `gorm:"column:qname_minimisation" json:"qname_minimisation"` - CacheMinTTL int `gorm:"column:cache_min_ttl" json:"cache_min_ttl"` - CacheMaxTTL int `gorm:"column:cache_max_ttl" json:"cache_max_ttl"` - UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"` + ID int64 `gorm:"primaryKey" json:"id"` + ListenAddresses string `gorm:"column:listen_addresses" json:"listen_addresses"` + ListenPort int `gorm:"column:listen_port" json:"listen_port"` + UpstreamForwards string `gorm:"column:upstream_forwards" json:"upstream_forwards"` + AccessACL string `gorm:"column:access_acl" json:"access_acl"` + DNSSEC bool `gorm:"column:dnssec" json:"dnssec"` + QNameMinimisation bool `gorm:"column:qname_minimisation" json:"qname_minimisation"` + CacheMinTTL int `gorm:"column:cache_min_ttl" json:"cache_min_ttl"` + CacheMaxTTL int `gorm:"column:cache_max_ttl" json:"cache_max_ttl"` + Prefetch bool `gorm:"column:prefetch" json:"prefetch"` + ServeExpired bool `gorm:"column:serve_expired" json:"serve_expired"` + MsgCacheSizeMB int `gorm:"column:msg_cache_size_mb" json:"msg_cache_size_mb"` + RRSetCacheSizeMB int `gorm:"column:rrset_cache_size_mb" json:"rrset_cache_size_mb"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"` } func (DNSSettings) TableName() string { return "dns_settings" } diff --git a/internal/models/forward_proxy_settings.go b/internal/models/forward_proxy_settings.go new file mode 100644 index 0000000..1017314 --- /dev/null +++ b/internal/models/forward_proxy_settings.go @@ -0,0 +1,19 @@ +package models + +import "time" + +type ForwardProxySettings struct { + ID int `gorm:"primaryKey" json:"id"` + ListenAddresses string `gorm:"column:listen_addresses" json:"listen_addresses"` + ListenPort int `gorm:"column:listen_port" json:"listen_port"` + CacheMemMB int `gorm:"column:cache_mem_mb" json:"cache_mem_mb"` + CacheDirMB int `gorm:"column:cache_dir_mb" json:"cache_dir_mb"` + MaxObjSizeMB int `gorm:"column:max_obj_size_mb" json:"max_obj_size_mb"` + ConnectTimeout int `gorm:"column:connect_timeout" json:"connect_timeout"` + ReadTimeout int `gorm:"column:read_timeout" json:"read_timeout"` + RequestTimeout int `gorm:"column:request_timeout" json:"request_timeout"` + CreatedAt time.Time `gorm:"column:created_at" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"` +} + +func (ForwardProxySettings) TableName() string { return "forward_proxy_settings" } diff --git a/internal/services/dns/dns.go b/internal/services/dns/dns.go index f9b0e74..270710c 100644 --- a/internal/services/dns/dns.go +++ b/internal/services/dns/dns.go @@ -204,12 +204,16 @@ func (r *Repo) DeleteRecord(ctx context.Context, id int64) error { func (r *Repo) GetSettings(ctx context.Context) (*models.DNSSettings, error) { row := r.Pool.QueryRow(ctx, ` SELECT id, listen_addresses, listen_port, upstream_forwards, access_acl, - dnssec, qname_minimisation, cache_min_ttl, cache_max_ttl, updated_at + dnssec, qname_minimisation, cache_min_ttl, cache_max_ttl, + prefetch, serve_expired, msg_cache_size_mb, rrset_cache_size_mb, + updated_at FROM dns_settings WHERE id=1`) var s models.DNSSettings if err := row.Scan(&s.ID, &s.ListenAddresses, &s.ListenPort, &s.UpstreamForwards, &s.AccessACL, &s.DNSSEC, &s.QNameMinimisation, - &s.CacheMinTTL, &s.CacheMaxTTL, &s.UpdatedAt); err != nil { + &s.CacheMinTTL, &s.CacheMaxTTL, + &s.Prefetch, &s.ServeExpired, &s.MsgCacheSizeMB, &s.RRSetCacheSizeMB, + &s.UpdatedAt); err != nil { return nil, err } return &s, nil @@ -220,16 +224,22 @@ func (r *Repo) UpdateSettings(ctx context.Context, s models.DNSSettings) (*model UPDATE dns_settings SET listen_addresses=$1, listen_port=$2, upstream_forwards=$3, access_acl=$4, dnssec=$5, qname_minimisation=$6, cache_min_ttl=$7, cache_max_ttl=$8, + prefetch=$9, serve_expired=$10, msg_cache_size_mb=$11, rrset_cache_size_mb=$12, updated_at=NOW() WHERE id=1 RETURNING id, listen_addresses, listen_port, upstream_forwards, access_acl, - dnssec, qname_minimisation, cache_min_ttl, cache_max_ttl, updated_at`, + dnssec, qname_minimisation, cache_min_ttl, cache_max_ttl, + prefetch, serve_expired, msg_cache_size_mb, rrset_cache_size_mb, + updated_at`, s.ListenAddresses, s.ListenPort, s.UpstreamForwards, s.AccessACL, - s.DNSSEC, s.QNameMinimisation, s.CacheMinTTL, s.CacheMaxTTL) + s.DNSSEC, s.QNameMinimisation, s.CacheMinTTL, s.CacheMaxTTL, + s.Prefetch, s.ServeExpired, s.MsgCacheSizeMB, s.RRSetCacheSizeMB) var out models.DNSSettings if err := row.Scan(&out.ID, &out.ListenAddresses, &out.ListenPort, &out.UpstreamForwards, &out.AccessACL, &out.DNSSEC, &out.QNameMinimisation, - &out.CacheMinTTL, &out.CacheMaxTTL, &out.UpdatedAt); err != nil { + &out.CacheMinTTL, &out.CacheMaxTTL, + &out.Prefetch, &out.ServeExpired, &out.MsgCacheSizeMB, &out.RRSetCacheSizeMB, + &out.UpdatedAt); err != nil { return nil, err } return &out, nil diff --git a/internal/services/forwardproxy/forwardproxy.go b/internal/services/forwardproxy/forwardproxy.go index da36db1..d8c6d9b 100644 --- a/internal/services/forwardproxy/forwardproxy.go +++ b/internal/services/forwardproxy/forwardproxy.go @@ -1,6 +1,6 @@ // Package forwardproxy provides CRUD against the forward_proxy_acls -// table. Renderer in internal/squid consumes the same rows to emit -// /etc/edgeguard/squid/squid.conf. +// table and settings in forward_proxy_settings. Renderer in internal/squid +// consumes both tables to emit /etc/edgeguard/squid/squid.conf. package forwardproxy import ( @@ -97,6 +97,52 @@ func (r *Repo) Delete(ctx context.Context, id int64) error { return nil } +// Settings returns the singleton forward_proxy_settings row. +func (r *Repo) GetSettings(ctx context.Context) (*models.ForwardProxySettings, error) { + var s models.ForwardProxySettings + if err := r.Pool.QueryRow(ctx, ` +SELECT id, listen_addresses, listen_port, + cache_mem_mb, cache_dir_mb, max_obj_size_mb, + connect_timeout, read_timeout, request_timeout, + created_at, updated_at +FROM forward_proxy_settings WHERE id=1`).Scan( + &s.ID, &s.ListenAddresses, &s.ListenPort, + &s.CacheMemMB, &s.CacheDirMB, &s.MaxObjSizeMB, + &s.ConnectTimeout, &s.ReadTimeout, &s.RequestTimeout, + &s.CreatedAt, &s.UpdatedAt, + ); err != nil { + return nil, err + } + return &s, nil +} + +func (r *Repo) UpdateSettings(ctx context.Context, s models.ForwardProxySettings) (*models.ForwardProxySettings, error) { + var out models.ForwardProxySettings + if err := r.Pool.QueryRow(ctx, ` +UPDATE forward_proxy_settings SET + listen_addresses=$1, listen_port=$2, + cache_mem_mb=$3, cache_dir_mb=$4, max_obj_size_mb=$5, + connect_timeout=$6, read_timeout=$7, request_timeout=$8, + updated_at=NOW() +WHERE id=1 +RETURNING id, listen_addresses, listen_port, + cache_mem_mb, cache_dir_mb, max_obj_size_mb, + connect_timeout, read_timeout, request_timeout, + created_at, updated_at`, + s.ListenAddresses, s.ListenPort, + s.CacheMemMB, s.CacheDirMB, s.MaxObjSizeMB, + s.ConnectTimeout, s.ReadTimeout, s.RequestTimeout, + ).Scan( + &out.ID, &out.ListenAddresses, &out.ListenPort, + &out.CacheMemMB, &out.CacheDirMB, &out.MaxObjSizeMB, + &out.ConnectTimeout, &out.ReadTimeout, &out.RequestTimeout, + &out.CreatedAt, &out.UpdatedAt, + ); err != nil { + return nil, err + } + return &out, nil +} + func scan(row interface{ Scan(...any) error }) (*models.ForwardProxyACL, error) { var a models.ForwardProxyACL if err := row.Scan( diff --git a/internal/services/ipaddresses/apply.go b/internal/services/ipaddresses/apply.go index 455aae6..e5400c2 100644 --- a/internal/services/ipaddresses/apply.go +++ b/internal/services/ipaddresses/apply.go @@ -22,19 +22,37 @@ func NewGenerator(repo *Repo) *Generator { return &Generator{Repo: repo} } // Render schreibt /etc/edgeguard/ip-addresses.conf (Format: dev|addr/prefix) // und triggert das apply-Skript via sudo. func (g *Generator) Render(ctx context.Context) error { + return g.render(ctx, false) +} + +// RenderSecondary wie Render, aber schließt Ethernet-Interface-IPs aus. +// Auf einem Secondary-Node werden eth0-IPs (Public-IP + VIP) von +// cloud-init bzw. Keepalived verwaltet — edgeguard soll sie nicht +// überschreiben oder entfernen. +func (g *Generator) RenderSecondary(ctx context.Context) error { + return g.render(ctx, true) +} + +func (g *Generator) render(ctx context.Context, excludeEthernet bool) error { type addrRow struct { dev string addr string prefix int } - rows, err := g.Repo.Pool.Query(ctx, ` + q := ` SELECT ni.name, ia.address, ia.prefix FROM ip_addresses ia JOIN network_interfaces ni ON ni.id = ia.interface_id - WHERE ia.active = true - ORDER BY ni.name, ia.address`, - ) + WHERE ia.active = true` + if excludeEthernet { + q += ` + AND ni.type != 'ethernet'` + } + q += ` + ORDER BY ni.name, ia.address` + + rows, err := g.Repo.Pool.Query(ctx, q) if err != nil { return fmt.Errorf("query: %w", err) } diff --git a/internal/services/session/session.go b/internal/services/session/session.go index db6e98b..54f7e55 100644 --- a/internal/services/session/session.go +++ b/internal/services/session/session.go @@ -122,6 +122,15 @@ func (s *Signer) Issue(actor string) (string, *Token, error) { return s.IssueWithRole(actor, "") } +// IssueWithRoleTTL issues a token with a custom TTL (overrides s.TTL for this call). +func (s *Signer) IssueWithRoleTTL(actor, role string, ttl time.Duration) (string, *Token, error) { + orig := s.TTL + s.TTL = ttl + raw, tok, err := s.IssueWithRole(actor, role) + s.TTL = orig + return raw, tok, err +} + // Verify checks a token. Returns ErrInvalidToken or ErrExpiredToken. func (s *Signer) Verify(raw string) (*Token, error) { if raw == "" { diff --git a/internal/services/users/users.go b/internal/services/users/users.go index d49bcc4..c096fa6 100644 --- a/internal/services/users/users.go +++ b/internal/services/users/users.go @@ -12,6 +12,7 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" + "github.com/pquerna/otp/totp" "golang.org/x/crypto/bcrypt" ) @@ -27,22 +28,30 @@ type User struct { Email string `json:"email"` Role string `json:"role"` Active bool `json:"active"` + TOTPEnabled bool `json:"totp_enabled"` LastLoginAt *time.Time `json:"last_login_at"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } +// AuthInfo is returned by FindForAuth — contains credentials needed during login. +type AuthInfo struct { + User + PasswordHash string + TOTPSecret *string +} + type Repo struct { pool *pgxpool.Pool } func New(pool *pgxpool.Pool) *Repo { return &Repo{pool: pool} } -const selectCols = `id, email, role, active, last_login_at, created_at, updated_at` +const selectCols = `id, email, role, active, totp_enabled, last_login_at, created_at, updated_at` func scan(row pgx.Row) (User, error) { var u User - err := row.Scan(&u.ID, &u.Email, &u.Role, &u.Active, + err := row.Scan(&u.ID, &u.Email, &u.Role, &u.Active, &u.TOTPEnabled, &u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt) return u, err } @@ -71,7 +80,7 @@ func (r *Repo) FindByEmail(ctx context.Context, email string) (User, string, err var hash string err := r.pool.QueryRow(ctx, `SELECT `+selectCols+`, password_hash FROM users WHERE lower(email)=lower($1)`, - email).Scan(&u.ID, &u.Email, &u.Role, &u.Active, + email).Scan(&u.ID, &u.Email, &u.Role, &u.Active, &u.TOTPEnabled, &u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt, &hash) if errors.Is(err, pgx.ErrNoRows) { return u, "", ErrNotFound @@ -79,6 +88,70 @@ func (r *Repo) FindByEmail(ctx context.Context, email string) (User, string, err return u, hash, err } +// FindForAuth returns full auth credentials including TOTP secret. ErrNotFound if absent. +func (r *Repo) FindForAuth(ctx context.Context, email string) (*AuthInfo, error) { + var a AuthInfo + err := r.pool.QueryRow(ctx, + `SELECT `+selectCols+`, password_hash, totp_secret FROM users WHERE lower(email)=lower($1)`, + email).Scan(&a.ID, &a.Email, &a.Role, &a.Active, &a.TOTPEnabled, + &a.LastLoginAt, &a.CreatedAt, &a.UpdatedAt, &a.PasswordHash, &a.TOTPSecret) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return &a, err +} + +// GenerateTOTPSecret creates a new TOTP secret for the given email and returns +// the secret + the otpauth:// provisioning URI (for QR code rendering in the UI). +// The secret is NOT saved yet — call ConfirmTOTP after the user verifies the code. +func GenerateTOTPSecret(email string) (secret, uri string, err error) { + key, err := totp.Generate(totp.GenerateOpts{ + Issuer: "EdgeGuard", + AccountName: email, + }) + if err != nil { + return "", "", err + } + return key.Secret(), key.URL(), nil +} + +// ConfirmTOTP verifies the given TOTP code against the (not-yet-saved) secret +// and, on success, persists it and enables TOTP for the user. +func (r *Repo) ConfirmTOTP(ctx context.Context, userID int64, secret, code string) error { + if !totp.Validate(code, secret) { + return errors.New("invalid_totp_code") + } + tag, err := r.pool.Exec(ctx, + `UPDATE users SET totp_secret=$1, totp_enabled=true, updated_at=NOW() WHERE id=$2`, + secret, userID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// DisableTOTP clears the TOTP secret and disables 2FA for the given user. +func (r *Repo) DisableTOTP(ctx context.Context, userID int64) error { + tag, err := r.pool.Exec(ctx, + `UPDATE users SET totp_secret=NULL, totp_enabled=false, updated_at=NOW() WHERE id=$1`, + userID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// VerifyTOTP checks a live TOTP code against the stored secret. +func VerifyTOTP(secret, code string) bool { + return totp.Validate(code, secret) +} + func (r *Repo) Count(ctx context.Context) (int, error) { var n int err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&n) diff --git a/internal/squid/squid.cfg.tpl b/internal/squid/squid.cfg.tpl index bd690a5..709b4a7 100644 --- a/internal/squid/squid.cfg.tpl +++ b/internal/squid/squid.cfg.tpl @@ -2,12 +2,14 @@ # Source: internal/squid/squid.go (template: squid.cfg.tpl). # Re-generate via `edgeguard-ctl render-config --only=squid`. -http_port {{.ListenPort}} +{{range .ListenAddrs -}} +{{if .Addr}}http_port {{.Addr}}:{{.Port}} +{{else}}http_port {{.Port}} +{{end}}{{- end}} -# Standard cache directory + small in-memory cache. Forward proxy -# isn't a CDN — we keep cache modest to avoid disk pressure. -cache_dir ufs /var/spool/squid 100 16 256 -cache_mem 64 MB +cache_dir ufs /var/spool/squid {{.CacheDirMB}} 16 256 +cache_mem {{.CacheMemMB}} MB +maximum_object_size {{.MaxObjSizeMB}} MB # Logging — combined access log, rotated by logrotate. access_log /var/log/squid/access.log squid @@ -56,7 +58,9 @@ http_access allow localhost http_access allow localnet http_access deny all -# Hostnames + visible name — operator can override via squid.conf -# drop-in if needed. +connect_timeout {{.ConnectTimeout}} seconds +read_timeout {{.ReadTimeout}} seconds +request_timeout {{.RequestTimeout}} seconds + visible_hostname edgeguard-proxy forwarded_for on diff --git a/internal/squid/squid.go b/internal/squid/squid.go index 0d306fd..6b35e15 100644 --- a/internal/squid/squid.go +++ b/internal/squid/squid.go @@ -11,6 +11,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "text/template" "github.com/jackc/pgx/v5/pgxpool" @@ -21,8 +22,8 @@ import ( ) const ( - confPath = "/etc/edgeguard/squid/squid.conf" - listenPort = 3128 + confPath = "/etc/edgeguard/squid/squid.conf" + defaultListenPort = 3128 ) //go:embed squid.cfg.tpl @@ -30,9 +31,20 @@ var cfgTpl string var tpl = template.Must(template.New("squid").Parse(cfgTpl)) +type ListenAddr struct { + Addr string // empty = all interfaces + Port int +} + type View struct { - ListenPort int - ACLs []models.ForwardProxyACL + ListenAddrs []ListenAddr + ACLs []models.ForwardProxyACL + CacheMemMB int + CacheDirMB int + MaxObjSizeMB int + ConnectTimeout int + ReadTimeout int + RequestTimeout int } type Generator struct { @@ -52,7 +64,45 @@ func (g *Generator) renderBuf(ctx context.Context) (bytes.Buffer, error) { if err != nil { return bytes.Buffer{}, fmt.Errorf("list acls: %w", err) } - view := View{ListenPort: listenPort, ACLs: acls} + + // Read all settings — fall back to defaults if table not migrated yet. + s := models.ForwardProxySettings{ + ListenPort: defaultListenPort, + CacheMemMB: 64, + CacheDirMB: 100, + MaxObjSizeMB: 4, + ConnectTimeout: 60, + ReadTimeout: 300, + RequestTimeout: 300, + } + _ = g.Pool.QueryRow(ctx, ` +SELECT listen_addresses, listen_port, + cache_mem_mb, cache_dir_mb, max_obj_size_mb, + connect_timeout, read_timeout, request_timeout +FROM forward_proxy_settings WHERE id=1`).Scan( + &s.ListenAddresses, &s.ListenPort, + &s.CacheMemMB, &s.CacheDirMB, &s.MaxObjSizeMB, + &s.ConnectTimeout, &s.ReadTimeout, &s.RequestTimeout, + ) + + var listenAddrs []ListenAddr + for _, raw := range splitCSV(s.ListenAddresses) { + listenAddrs = append(listenAddrs, ListenAddr{Addr: raw, Port: s.ListenPort}) + } + if len(listenAddrs) == 0 { + listenAddrs = []ListenAddr{{Addr: "", Port: s.ListenPort}} + } + + view := View{ + ListenAddrs: listenAddrs, + ACLs: acls, + CacheMemMB: s.CacheMemMB, + CacheDirMB: s.CacheDirMB, + MaxObjSizeMB: s.MaxObjSizeMB, + ConnectTimeout: s.ConnectTimeout, + ReadTimeout: s.ReadTimeout, + RequestTimeout: s.RequestTimeout, + } var body bytes.Buffer if err := tpl.Execute(&body, view); err != nil { return bytes.Buffer{}, fmt.Errorf("template: %w", err) @@ -60,6 +110,17 @@ func (g *Generator) renderBuf(ctx context.Context) (bytes.Buffer, error) { return body, nil } +func splitCSV(s string) []string { + var out []string + for _, p := range strings.Split(s, ",") { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} + func (g *Generator) RenderToString(ctx context.Context) (string, error) { buf, err := g.renderBuf(ctx) if err != nil { diff --git a/internal/unbound/unbound.cfg.tpl b/internal/unbound/unbound.cfg.tpl index 193b88f..c73efcf 100644 --- a/internal/unbound/unbound.cfg.tpl +++ b/internal/unbound/unbound.cfg.tpl @@ -31,8 +31,10 @@ server: do-tcp: yes cache-min-ttl: {{.Settings.CacheMinTTL}} cache-max-ttl: {{.Settings.CacheMaxTTL}} - msg-cache-size: 64m - rrset-cache-size: 128m + msg-cache-size: {{.Settings.MsgCacheSizeMB}}m + rrset-cache-size: {{.Settings.RRSetCacheSizeMB}}m + prefetch: {{if .Settings.Prefetch}}yes{{else}}no{{end}} + serve-expired: {{if .Settings.ServeExpired}}yes{{else}}no{{end}} num-threads: 2 # Hardening diff --git a/internal/wireguard/systemd.go b/internal/wireguard/systemd.go index 1761222..7265b18 100644 --- a/internal/wireguard/systemd.go +++ b/internal/wireguard/systemd.go @@ -34,6 +34,21 @@ func stopWGQuick(iface string) error { return nil } +func enableWGQuick(iface string) error { + cmd := exec.Command("sudo", "-n", "/usr/bin/systemctl", "enable", "wg-quick@"+iface+".service") + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("systemctl enable wg-quick@%s: %w: %s", iface, err, string(out)) + } + return nil +} + +func disableWGQuick(iface string) error { + cmd := exec.Command("sudo", "-n", "/usr/bin/systemctl", "disable", "wg-quick@"+iface+".service") + // Ignore failures — unit may already be disabled. + _ = cmd.Run() + return nil +} + // symlinkWGQuickConf creates (or atomically replaces) the symlink // /etc/wireguard/.conf → target via sudo. /etc/wireguard/ is // owned root:root 700 so the edgeguard user cannot write to it directly; diff --git a/internal/wireguard/wireguard.go b/internal/wireguard/wireguard.go index c3f7e4d..0f901f0 100644 --- a/internal/wireguard/wireguard.go +++ b/internal/wireguard/wireguard.go @@ -152,6 +152,7 @@ func (g *Generator) Render(ctx context.Context) error { } _ = os.Remove(filepath.Join(ConfDir, e.Name())) _ = stopWGQuick(ifaceName) + _ = disableWGQuick(ifaceName) } } return nil @@ -235,6 +236,7 @@ func (g *Generator) renderIface(ctx context.Context, ifc models.WireguardInterfa if err := symlinkWGQuickConf(ifc.Name, path); err != nil { return fmt.Errorf("symlink: %w", err) } + _ = enableWGQuick(ifc.Name) if existing, err := os.ReadFile(path); err == nil && bytes.Equal(existing, body.Bytes()) { return startWGQuick(ifc.Name) } diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json index c5e1c96..73f3ee5 100644 --- a/management-ui/src/i18n/locales/de/common.json +++ b/management-ui/src/i18n/locales/de/common.json @@ -152,7 +152,10 @@ "emptyTitle": "Noch keine eigenen Firewall-Regeln.", "emptyDesc": "Die System-Regeln oben halten SSH (rate-limited), HTTPS :443 und Mgmt-UI :3443 immer offen (Anti-Lockout). Eigene Regeln für app-spezifische Inbound-Ports oder zonenübergreifende Forwards anlegen.", "logEnabled": "Logging aktiv — gematchte Pakete werden ins Firewall-Log geschrieben", - "ruleDisabled": "Regel deaktiviert" + "ruleDisabled": "Regel deaktiviert", + "enabled": "Aktiv", + "unnamed": "(kein Name)", + "zeroHitHint": "Keine Treffer seit dem letzten Neustart — möglicherweise ungenutzte oder überlagerte Regel" }, "kpi": { "policyRules": "Policy-Regeln", @@ -281,7 +284,12 @@ "loggedInAs": "Angemeldet als", "forgotPassword": "Passwort vergessen?", "viewerBadge": "Nur lesen", - "viewerHint": "Dieser Account hat die Rolle Betrachter — Änderungen sind gesperrt. Ein Admin kann die Rolle anpassen." + "viewerHint": "Dieser Account hat die Rolle Betrachter — Änderungen sind gesperrt. Ein Admin kann die Rolle anpassen.", + "totp": { + "prompt": "Bitte gib den 6-stelligen Code aus deiner Authenticator-App ein.", + "verify": "Code bestätigen", + "invalidCode": "Ungültiger Code" + } }, "reset": { "title": "Admin-Passwort zurücksetzen", @@ -392,6 +400,17 @@ "backends": "Backends", "attached": "{{count}}/{{total}} Domains haben einen Primary-Backend" }, + "vipCard": { + "title": "VIP / VRRP", + "noVips": "Keine VIPs konfiguriert", + "keepalivedInactive": "keepalived läuft nicht", + "state": { + "MASTER": "MASTER", + "BACKUP": "BACKUP", + "FAULT": "FAULT", + "UNKNOWN": "Unbekannt" + } + }, "systemCard": { "title": "System", "version": "Version", @@ -399,9 +418,16 @@ "ifaces": "Interfaces", "wg": "WireGuard" }, + "networkServicesCard": { + "title": "Netzwerk-Dienste", + "configure": "Konfigurieren" + }, "alertsCard": { "title": "Aktuelle Alerts", - "viewAll": "Alle anzeigen" + "viewAll": "Alle anzeigen", + "summary": "{{critical}} kritisch · {{warning}} Warnung", + "summaryWarning": "{{warning}} Warnung", + "summaryCritical": "{{critical}} kritisch" }, "downBackendsAlert": "{{count}} Backend(s) komplett ausgefallen — kein Server UP", "maintenanceAlert": "{{count}} Domain(s) im Wartungs-Modus", @@ -664,7 +690,18 @@ "hintPrimary": "Auf dem Primary: edgeguard-ctl cluster-init-replication", "hintStandby": "Auf dem Secondary: edgeguard-ctl cluster-setup-standby ", "hintKeepalived": "Keepalived auf beiden Nodes: sudo systemctl enable --now keepalived", - "hintFailover": "Bei Failover: edgeguard-ctl promote (auf dem Secondary)" + "hintFailover": "Bei Failover: edgeguard-ctl promote (auf dem Secondary)", + "splitBrainSection": "Split-Brain-Schutz (Dual-Path VRRP + Gateway-Tracking)", + "hbInterface": "Heartbeat-Interface", + "hbInterfaceHelp": "Zweites Interface für die VI_HB-Instanz — VRRP-Advertisements laufen hier unabhängig von VI_1. Leer lassen um zu deaktivieren.", + "hbSrcIp": "Heartbeat-Quell-IP", + "hbSrcIpHelp": "Eigene IP auf dem Heartbeat-Interface (unicast_src_ip für VI_HB).", + "hbPeerIp": "Heartbeat-Peer-IP", + "hbPeerIpHelp": "Peer-IP auf dem Heartbeat-Interface (unicast_peer für VI_HB).", + "hbRouterId": "Heartbeat Router-ID", + "hbRouterIdHelp": "VRRP virtual_router_id für VI_HB — muss sich von der Haupt-Router-ID unterscheiden. Standard: 52.", + "gwCheckIp": "Gateway-Check-IP", + "gwCheckIpHelp": "Upstream-Gateway-IP die alle 5 s angepingt wird. Nicht erreichbar → Priorität sinkt um 110 → Failover wird ausgelöst. Leer lassen um zu deaktivieren." }, "loadTitle": "Per-Node Resources (mTLS-Aggregator)", "loadEmpty": "Keine Node-Resources verfügbar — Agent-Listener nicht erreichbar?", @@ -699,7 +736,27 @@ "step3SetupDesc": "Setup-Wizard auf dem neuen Knoten öffnen (https://:3443/setup), \"Vorhandenem Cluster beitreten\" wählen, Primary-FQDN ({{primaryFqdn}}) eingeben und den Token oben einfügen.", "generateNewToken": "Neuen Token generieren", "setupWizardHint": "Setup-Wizard auf dem neuen Knoten öffnen", - "newNodeFqdnLabel": "FQDN des neuen Knotens" + "newNodeFqdnLabel": "FQDN des neuen Knotens", + "vipTest": { + "cardTitle": "VIP-Schwenk Test", + "cardDesc": "Verschiebt einen VIP temporär auf den Secondary um zu prüfen ob die Dienste korrekt antworten. Keepalived ist nicht beteiligt — reiner ip addr add/del Test.", + "colAddress": "VIP-Adresse", + "colInterface": "Interface", + "colActiveOn": "Aktiv auf", + "swingBtn": "→ Secondary", + "restoreBtn": "← Primary", + "swingOk": "VIP erfolgreich auf Secondary geschwenkt", + "restoreOk": "VIP zurück auf Primary", + "swingFailed": "VIP-Schwenk fehlgeschlagen", + "restoreFailed": "VIP-Rückschwenk fehlgeschlagen", + "noVips": "Keine VIPs konfiguriert (ip_addresses mit is_vip=true)", + "steps": "Schritte", + "stepOk": "OK", + "stepFail": "Fehler", + "confirmSwing": "{{addr}} auf Secondary verschieben?", + "confirmRestore": "{{addr}} zurück auf Primary?", + "unknown": "unbekannt" + } }, "ssl": { "title": "SSL-Zertifikate", @@ -1126,7 +1183,16 @@ "flushCacheFailed": "Cache-Flush fehlgeschlagen", "upstreamForwardsInvalid": "Jeder Forwarder muss eine gültige IP sein (z.B. 1.1.1.1 oder 9.9.9.9)", "accessACLInvalid": "Jeder Eintrag muss eine gültige IP oder CIDR sein (z.B. 10.0.0.0/8)", - "cacheTTLError": "Cache-Max-TTL muss ≥ Cache-Min-TTL sein" + "cacheTTLError": "Cache-Max-TTL muss ≥ Cache-Min-TTL sein", + "cacheSection": "Cache", + "prefetch": "Häufige Records vorausladen", + "prefetchExtra": "Häufig abgefragte Records werden vor Ablauf der TTL neu aufgelöst — reduziert Latenz für bekannte Namen.", + "serveExpired": "Abgelaufene Records ausliefern", + "serveExpiredExtra": "Stale Cache-Einträge zurückgeben wenn Upstream-Resolver nicht erreichbar ist. Reduziert SERVFAIL bei Ausfällen.", + "msgCacheSizeMB": "Message-Cache (MB)", + "msgCacheSizeMBExtra": "RAM für DNS-Antwort-Cache (msg-cache-size). Standard 64 MB.", + "rrsetCacheSizeMB": "RRset-Cache (MB)", + "rrsetCacheSizeMBExtra": "RAM für Resource-Record-Cache (rrset-cache-size). Sollte ~2x Message-Cache sein. Standard 128 MB." } }, "fwd": { @@ -1180,6 +1246,28 @@ "dstdom_regex": "dstdom_regex — Domain-Regex", "srcdom_regex": "srcdom_regex — Quell-Domain-Regex", "browser": "browser — User-Agent-Regex" + }, + "settings": { + "title": "Proxy-Einstellungen", + "listenAddresses": "Listen-Adressen", + "listenAddressesExtra": "Komma-getrennte IPs auf denen Squid lauscht (z.B. 10.0.5.1, 10.0.20.1). Leer = alle Interfaces.", + "listenPort": "Port", + "listenPortExtra": "Standard: 3128.", + "saveFailed": "Einstellungen konnten nicht gespeichert werden.", + "cacheSection": "Cache", + "cacheMemMB": "RAM-Cache (MB)", + "cacheMemMBExtra": "RAM den Squid für Caching nutzt (cache_mem). Standard 64 MB.", + "cacheDirMB": "Disk-Cache (MB)", + "cacheDirMBExtra": "Speicherplatz für den UFS-Cache. Standard 100 MB.", + "maxObjSizeMB": "Max. Objektgröße (MB)", + "maxObjSizeMBExtra": "Größtes Objekt das Squid cached. Größere Objekte werden direkt durchgeleitet. Standard 4 MB.", + "timeoutSection": "Timeouts", + "connectTimeout": "Verbindungs-Timeout (s)", + "connectTimeoutExtra": "Sekunden, die Squid beim Verbindungsaufbau zum Upstream wartet.", + "readTimeout": "Lese-Timeout (s)", + "readTimeoutExtra": "Sekunden zwischen aufeinanderfolgenden Lesevorgängen vom Upstream.", + "requestTimeout": "Anfrage-Timeout (s)", + "requestTimeoutExtra": "Maximale Zeit für einen vollständigen Request/Response-Zyklus." } }, "common": { @@ -1205,6 +1293,8 @@ "retry": "Erneut versuchen", "close": "Schließen", "refresh": "Aktualisieren", + "back": "Zurück", + "next": "Weiter", "up": "UP", "down": "DOWN", "relTime": { @@ -1542,7 +1632,24 @@ "errorEmailTaken": "Diese E-Mail-Adresse wird bereits verwendet.", "cannotDeleteSelf": "Das eigene Konto kann nicht gelöscht werden.", "you": "Ich", - "never": "Nie" + "never": "Nie", + "totp": { + "on": "2FA", + "off": "–", + "setup": "2FA einrichten", + "manage": "2FA verwalten", + "disable": "2FA deaktivieren", + "disableFor": "2FA für {{email}} deaktivieren", + "enabled": "2FA wurde aktiviert", + "disabled": "2FA wurde deaktiviert", + "setupTitle": "Zwei-Faktor-Authentifizierung einrichten", + "manageTitle": "Zwei-Faktor-Authentifizierung", + "scanHint": "Scanne den QR-Code mit Google Authenticator, Authy oder einer kompatiblen App.", + "enterCode": "Gib den 6-stelligen Code aus deiner Authenticator-App ein:", + "confirm": "Bestätigen & aktivieren", + "alreadyEnabled": "2FA ist für diesen Account aktiv.", + "disableHint": "Klicke auf 'Deaktivieren' um 2FA für diesen Account zu entfernen." + } }, "audit": { "title": "Audit-Log", diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json index 563a6b8..632764f 100644 --- a/management-ui/src/i18n/locales/en/common.json +++ b/management-ui/src/i18n/locales/en/common.json @@ -152,7 +152,10 @@ "emptyTitle": "No custom firewall rules yet.", "emptyDesc": "The system rules above keep SSH (rate-limited), HTTPS :443 and the mgmt UI :3443 open (anti-lockout). Add custom rules for app-specific inbound ports or cross-zone forwards.", "logEnabled": "Logging active — matched packets are written to the firewall log", - "ruleDisabled": "Rule disabled" + "ruleDisabled": "Rule disabled", + "enabled": "Active", + "unnamed": "(unnamed)", + "zeroHitHint": "No hits since last restart — possibly unused or shadowed rule" }, "kpi": { "policyRules": "Policy Rules", @@ -281,7 +284,12 @@ "loggedInAs": "Signed in as", "forgotPassword": "Forgot your password?", "viewerBadge": "Read-only", - "viewerHint": "Your account has viewer role — all changes are blocked. Contact an admin to change your role." + "viewerHint": "Your account has viewer role — all changes are blocked. Contact an admin to change your role.", + "totp": { + "prompt": "Enter the 6-digit code from your authenticator app.", + "verify": "Verify code", + "invalidCode": "Invalid code" + } }, "reset": { "title": "Reset admin password", @@ -392,6 +400,17 @@ "backends": "Backends", "attached": "{{count}}/{{total}} domains have a primary backend" }, + "vipCard": { + "title": "VIP / VRRP", + "noVips": "No VIPs configured", + "keepalivedInactive": "keepalived not running", + "state": { + "MASTER": "MASTER", + "BACKUP": "BACKUP", + "FAULT": "FAULT", + "UNKNOWN": "Unknown" + } + }, "systemCard": { "title": "System", "version": "Version", @@ -399,9 +418,16 @@ "ifaces": "Interfaces", "wg": "WireGuard" }, + "networkServicesCard": { + "title": "Network services", + "configure": "Configure" + }, "alertsCard": { - "title": "Recent alerts", - "viewAll": "View all" + "title": "Active alerts", + "viewAll": "View all", + "summary": "{{critical}} critical · {{warning}} warning", + "summaryWarning": "{{warning}} warning", + "summaryCritical": "{{critical}} critical" }, "downBackendsAlert": "{{count}} backend(s) completely down — no server UP", "maintenanceAlert": "{{count}} domain(s) in maintenance mode", @@ -664,7 +690,18 @@ "hintPrimary": "On primary: edgeguard-ctl cluster-init-replication", "hintStandby": "On secondary: edgeguard-ctl cluster-setup-standby ", "hintKeepalived": "Keepalived on both nodes: sudo systemctl enable --now keepalived", - "hintFailover": "On failover: edgeguard-ctl promote (on the secondary node)" + "hintFailover": "On failover: edgeguard-ctl promote (on the secondary node)", + "splitBrainSection": "Split-brain protection (dual-path VRRP + gateway tracking)", + "hbInterface": "Heartbeat interface", + "hbInterfaceHelp": "Second interface for VI_HB instance — VRRP advertisements run here independently of VI_1. Leave empty to disable.", + "hbSrcIp": "Heartbeat source IP", + "hbSrcIpHelp": "Own IP on the heartbeat interface (unicast_src_ip for VI_HB).", + "hbPeerIp": "Heartbeat peer IP", + "hbPeerIpHelp": "Peer IP on the heartbeat interface (unicast_peer for VI_HB).", + "hbRouterId": "Heartbeat router ID", + "hbRouterIdHelp": "VRRP virtual_router_id for VI_HB — must differ from main Router ID. Default: 52.", + "gwCheckIp": "Gateway check IP", + "gwCheckIpHelp": "Upstream gateway IP to ping every 5 s. If unreachable: priority drops by 110 → failover triggers. Leave empty to disable." }, "loadTitle": "Per-node resources (mTLS aggregator)", "loadEmpty": "No node resources available — agent listener unreachable?", @@ -699,7 +736,27 @@ "step3SetupDesc": "Open the setup wizard on the new node (https://:3443/setup), choose \"Join existing cluster\", enter the primary FQDN ({{primaryFqdn}}) and paste the token above.", "generateNewToken": "Generate new token", "setupWizardHint": "Open the setup wizard on the new node", - "newNodeFqdnLabel": "New node FQDN" + "newNodeFqdnLabel": "New node FQDN", + "vipTest": { + "cardTitle": "VIP failover test", + "cardDesc": "Temporarily move a VIP to the secondary to test that services respond correctly. Keepalived is not involved — this is a raw ip addr add/del test.", + "colAddress": "VIP address", + "colInterface": "Interface", + "colActiveOn": "Active on", + "swingBtn": "→ Secondary", + "restoreBtn": "← Primary", + "swingOk": "VIP successfully moved to secondary", + "restoreOk": "VIP restored to primary", + "swingFailed": "VIP swing failed", + "restoreFailed": "VIP restore failed", + "noVips": "No VIPs configured (ip_addresses with is_vip=true)", + "steps": "Steps", + "stepOk": "OK", + "stepFail": "Failed", + "confirmSwing": "Move {{addr}} to secondary?", + "confirmRestore": "Restore {{addr}} to primary?", + "unknown": "unknown" + } }, "ssl": { "title": "SSL certificates", @@ -1126,7 +1183,16 @@ "flushCacheFailed": "Flush failed", "upstreamForwardsInvalid": "Each forwarder must be a valid IP (e.g. 1.1.1.1 or 9.9.9.9)", "accessACLInvalid": "Each entry must be a valid IP or CIDR (e.g. 10.0.0.0/8 or 192.168.1.0/24)", - "cacheTTLError": "Cache max-TTL must be ≥ cache min-TTL" + "cacheTTLError": "Cache max-TTL must be ≥ cache min-TTL", + "cacheSection": "Cache", + "prefetch": "Prefetch popular records", + "prefetchExtra": "Re-fetch records before TTL expires if queried frequently — reduces latency for hot names.", + "serveExpired": "Serve expired records", + "serveExpiredExtra": "Return stale cache entries when upstream resolvers are unreachable. Reduces SERVFAIL during outages.", + "msgCacheSizeMB": "Message cache (MB)", + "msgCacheSizeMBExtra": "RAM for DNS response cache (msg-cache-size). Default 64 MB.", + "rrsetCacheSizeMB": "RRset cache (MB)", + "rrsetCacheSizeMBExtra": "RAM for resource-record cache (rrset-cache-size). Should be ~2x message cache. Default 128 MB." } }, "fwd": { @@ -1180,6 +1246,28 @@ "dstdom_regex": "dstdom_regex — destination domain regex", "srcdom_regex": "srcdom_regex — source domain regex", "browser": "browser — User-Agent regex" + }, + "settings": { + "title": "Proxy settings", + "listenAddresses": "Listen addresses", + "listenAddressesExtra": "Comma-separated IPs Squid listens on (e.g. 10.0.5.1, 10.0.20.1). Leave empty to listen on all interfaces.", + "listenPort": "Port", + "listenPortExtra": "Default: 3128.", + "saveFailed": "Settings could not be saved.", + "cacheSection": "Cache", + "cacheMemMB": "In-memory cache (MB)", + "cacheMemMBExtra": "RAM used by Squid for caching (cache_mem). Default 64 MB.", + "cacheDirMB": "Disk cache (MB)", + "cacheDirMBExtra": "Disk space for the UFS cache. Default 100 MB.", + "maxObjSizeMB": "Max. object size (MB)", + "maxObjSizeMBExtra": "Largest object Squid will cache. Objects above this are fetched fresh. Default 4 MB.", + "timeoutSection": "Timeouts", + "connectTimeout": "Connect timeout (s)", + "connectTimeoutExtra": "Seconds to wait when opening a connection to the upstream server.", + "readTimeout": "Read timeout (s)", + "readTimeoutExtra": "Seconds Squid waits between consecutive reads from the upstream.", + "requestTimeout": "Request timeout (s)", + "requestTimeoutExtra": "Maximum time for a complete request/response cycle." } }, "common": { @@ -1205,6 +1293,8 @@ "retry": "Retry", "close": "Close", "refresh": "Refresh", + "back": "Back", + "next": "Next", "up": "UP", "down": "DOWN", "relTime": { @@ -1542,7 +1632,24 @@ "errorEmailTaken": "This email address is already in use.", "cannotDeleteSelf": "You cannot delete your own account.", "you": "You", - "never": "Never" + "never": "Never", + "totp": { + "on": "2FA", + "off": "–", + "setup": "Set up 2FA", + "manage": "Manage 2FA", + "disable": "Disable 2FA", + "disableFor": "Disable 2FA for {{email}}", + "enabled": "2FA has been enabled", + "disabled": "2FA has been disabled", + "setupTitle": "Set up two-factor authentication", + "manageTitle": "Two-factor authentication", + "scanHint": "Scan the QR code with Google Authenticator, Authy, or any compatible app.", + "enterCode": "Enter the 6-digit code from your authenticator app:", + "confirm": "Confirm & activate", + "alreadyEnabled": "2FA is active for this account.", + "disableHint": "Click 'Disable 2FA' to remove two-factor authentication from this account." + } }, "audit": { "title": "Audit log", diff --git a/management-ui/src/pages/Cluster/index.tsx b/management-ui/src/pages/Cluster/index.tsx index de853b7..8b9d46a 100644 --- a/management-ui/src/pages/Cluster/index.tsx +++ b/management-ui/src/pages/Cluster/index.tsx @@ -1,6 +1,6 @@ -import { Alert, Button, Card, Descriptions, Input, Popconfirm, Space, Spin, Table, Tag, Tooltip, Typography, message } from 'antd' +import { Alert, Button, Card, Descriptions, Input, List, Popconfirm, Space, Spin, Table, Tag, Tooltip, Typography, message } from 'antd' import type { ColumnsType } from 'antd/es/table' -import { ApartmentOutlined, CopyOutlined, DeleteOutlined, KeyOutlined, ReloadOutlined } from '@ant-design/icons' +import { ApartmentOutlined, CopyOutlined, DeleteOutlined, KeyOutlined, ReloadOutlined, SwapOutlined } from '@ant-design/icons' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' @@ -87,6 +87,28 @@ interface CertStatus { peer?: CertInfo } +interface VIPInfo { + id: number + address: string + prefix: number + device: string +} + +interface VIPStatusEntry { + vip: VIPInfo + active_on: string[] +} + +interface VIPTestStep { + step: string + ok: boolean + message?: string +} + +interface VIPTestResult { + steps: VIPTestStep[] +} + function statusTag(s: HANode['status'], t: (k: string) => string) { switch (s) { case 'online': return {t('cluster.status.online')} @@ -272,6 +294,42 @@ export default function ClusterPage() { onError: () => message.error(t('cluster.joinTokenFailed')), }) + const isClusterMode = data?.mode === 'cluster' + + const vipStatusQuery = useQuery({ + queryKey: ['cluster', 'vip-status'], + queryFn: async () => { + const r = await apiClient.get('/cluster/vip-status') + const payload = isEnvelope(r.data) ? (r.data.data as { vips?: VIPStatusEntry[] }) : null + return payload?.vips ?? [] + }, + enabled: isClusterMode, + refetchInterval: 30_000, + retry: 1, + }) + + const [vipTestResult, setVipTestResult] = useState<{ id: number; steps: VIPTestStep[] } | null>(null) + + const vipSwing = useMutation({ + mutationFn: async ({ id, action }: { id: number; action: 'to_secondary' | 'restore' }) => { + const r = await apiClient.post('/cluster/vip-test', { ip_address_id: id, action }) + return isEnvelope(r.data) ? (r.data.data as VIPTestResult) : null + }, + onSuccess: (result, { id, action }) => { + if (result) setVipTestResult({ id, steps: result.steps }) + const allOk = result?.steps.every(s => s.ok) ?? false + if (allOk) { + const key = action === 'to_secondary' ? 'cluster.vipTest.swingOk' : 'cluster.vipTest.restoreOk' + void message.success(t(key)) + } else { + const key = action === 'to_secondary' ? 'cluster.vipTest.swingFailed' : 'cluster.vipTest.restoreFailed' + void message.error(t(key)) + } + void vipStatusQuery.refetch() + }, + onError: (e: Error) => void message.error(e.message), + }) + const primaryFqdn = data?.local_node?.fqdn ?? window.location.hostname const peerColumns: ColumnsType = [ @@ -641,6 +699,134 @@ export default function ClusterPage() { )} + {/* ── VIP-Schwenk Test ─────────────────────────────────── */} + {isClusterMode && ( + {t('cluster.vipTest.cardTitle')}} + className="mb-16" + extra={ + + } + > + + {vipStatusQuery.isLoading ? ( + + ) : (vipStatusQuery.data?.length ?? 0) === 0 ? ( + {t('cluster.vipTest.noVips')} + ) : ( + + size="small" + rowKey={r => String(r.vip.id)} + dataSource={vipStatusQuery.data ?? []} + pagination={false} + expandable={{ + expandedRowRender: r => { + const res = vipTestResult?.id === r.vip.id ? vipTestResult : null + if (!res) return null + return ( + ( + + + {s.ok ? t('cluster.vipTest.stepOk') : t('cluster.vipTest.stepFail')} + {s.step} + {s.message && {s.message}} + + + )} + /> + ) + }, + rowExpandable: r => vipTestResult?.id === r.vip.id, + }} + columns={[ + { + title: t('cluster.vipTest.colAddress'), + key: 'address', + render: (_, r) => ( + {r.vip.address}/{r.vip.prefix} + ), + }, + { + title: t('cluster.vipTest.colInterface'), + key: 'device', + width: 120, + render: (_, r) => {r.vip.device}, + }, + { + title: t('cluster.vipTest.colActiveOn'), + key: 'activeOn', + render: (_, r) => { + if (!r.active_on || r.active_on.length === 0) { + return {t('cluster.vipTest.unknown')} + } + return ( + + {r.active_on.map(fqdn => {fqdn})} + + ) + }, + }, + { + title: t('common.actions'), + key: 'actions', + width: 200, + render: (_, r) => { + const localFqdn = data?.local_node?.fqdn + const onLocal = r.active_on?.includes(localFqdn ?? '') ?? false + const onPeer = r.active_on?.some(f => f !== localFqdn) ?? false + const loading = vipSwing.isPending && (vipSwing.variables as { id: number })?.id === r.vip.id + return ( + + {!isViewer && !onPeer && ( + vipSwing.mutate({ id: r.vip.id, action: 'to_secondary' })} + > + + + )} + {!isViewer && onPeer && ( + vipSwing.mutate({ id: r.vip.id, action: 'restore' })} + > + + + )} + {isViewer && ( + + + + )} + + ) + }, + }, + ]} + /> + )} + + )} + {/* ── Per-Node Resources ────────────────────────────────── */} s.trim()) .filter(Boolean), - } : undefined + } : { + listen_addresses: [], + listen_port: 53, + upstream_forwards: '1.1.1.1, 9.9.9.9', + access_acl: '10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16', + dnssec: false, + qname_minimisation: true, + cache_min_ttl: 60, + cache_max_ttl: 86400, + prefetch: false, + serve_expired: false, + msg_cache_size_mb: 64, + rrset_cache_size_mb: 128, + } const save = useMutation({ mutationFn: async (v: SettingsForm) => { @@ -566,9 +584,19 @@ function SettingsTab() { - + + + + + + + + {t('dns.settings.cacheSection')} + - + - + + + + + + + diff --git a/management-ui/src/pages/Dashboard/index.tsx b/management-ui/src/pages/Dashboard/index.tsx index a33d66a..c8c1fcd 100644 --- a/management-ui/src/pages/Dashboard/index.tsx +++ b/management-ui/src/pages/Dashboard/index.tsx @@ -2,11 +2,11 @@ import { Alert, Card, Col, Progress, Row, Space, Statistic, Tag, Tooltip, Typogr import { ApartmentOutlined, ApiOutlined, BellOutlined, BranchesOutlined, ClusterOutlined, DashboardOutlined, DatabaseOutlined, FireOutlined, GlobalOutlined, - SafetyCertificateOutlined, ThunderboltOutlined, + NodeIndexOutlined, SafetyCertificateOutlined, ThunderboltOutlined, } from '@ant-design/icons' import { Link } from 'react-router-dom' import { useQuery } from '@tanstack/react-query' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import apiClient, { isEnvelope } from '../../api/client' @@ -16,10 +16,9 @@ import UpdateBanner from '../../components/UpdateBanner' const { Text } = Typography -// useAuditLive verbindet sich mit dem WebSocket-Stream /audit/live. -// Server sendet beim Connect die letzten 50 Einträge (oldest→newest), -// danach jeden neuen INSERT direkt. UI behält das letzte `keep` und -// zeigt newest-first. Reconnect alle 2s bei Drop. +// ── Live audit stream ───────────────────────────────────────── +// Server sends the last 50 entries on connect, then each new INSERT. +// Reconnects every 2s on drop. function useAuditLive(keep = 15) { const [data, setData] = useState([]) const wsRef = useRef(null) @@ -37,15 +36,14 @@ function useAuditLive(keep = 15) { try { const e: AuditEntry = JSON.parse(ev.data as string) setData((prev) => { - // dedupe by id (Snapshot + live können sich kurz überschneiden) if (prev.some((p) => p.id === e.id)) return prev const next = [e, ...prev] return next.length > keep ? next.slice(0, keep) : next }) - } catch { /* ignore parse fail */ } + } catch { /* ignore */ } } ws.onclose = () => { if (!cancelled) scheduleReconnect() } - ws.onerror = () => { /* onclose feuert danach */ } + ws.onerror = () => { /* onclose fires next */ } } const scheduleReconnect = () => { if (timer) clearTimeout(timer) @@ -110,6 +108,16 @@ interface HAProxyFrontend { bytes_in: number; bytes_out: number req_tot: number; req_rate: number } +interface VIPEntry { address: string; prefix: number; device: string; active: boolean } +interface VIPStatus { vrrp_state: string; keepalived_active: boolean; vips: VIPEntry[] } +interface AlertEvent { + id: number + kind: string + severity: 'info' | 'warning' | 'error' | 'critical' + subject: string + message: string + fired_at: string +} // ── Fetchers ────────────────────────────────────────────────── @@ -152,15 +160,14 @@ function haCheckHint(code: string): string { L6CON: 'SSL/TLS handshake failed — certificate or ALPN mismatch', L6TOUT: 'SSL/TLS timeout', L6RSP: 'SSL/TLS protocol error', - L7STS: 'HTTP check: unexpected status code (health-check path returns non-2xx)', - L7RSP: 'HTTP check: unexpected response from server', - L7TOUT: 'HTTP check: timeout waiting for response', - SOCKERR: 'Socket error — OS-level problem (EAGAIN, ECONNRESET …)', + L7STS: 'HTTP check: unexpected status code', + L7RSP: 'HTTP check: unexpected response', + L7TOUT: 'HTTP check: timeout', + SOCKERR: 'Socket error (EAGAIN, ECONNRESET …)', INI: 'Check not yet performed since last reload', } return hints[code] ?? code } - function formatUptime(sec: number): string { if (sec < 60) return `${sec}s` const days = Math.floor(sec / 86400) @@ -176,17 +183,14 @@ function relativeFromIso(iso: string, t: (k: string, v?: Record return relativeTime(Math.floor(ts / 1000), t) } -// ── Page ────────────────────────────────────────────────────── - -interface AlertEvent { - id: number - kind: string - severity: 'info' | 'warning' | 'error' | 'critical' - subject: string - message: string - fired_at: string +const HA_FRONTEND_LABELS: Record = { + public_http: 'HTTP In', + public_https: 'HTTPS In', + mgmt_https: 'Management', } +// ── Page ────────────────────────────────────────────────────── + export default function DashboardPage() { const { t } = useTranslation() @@ -195,7 +199,6 @@ export default function DashboardPage() { queryFn: () => fetchOne<{ status: string; version: string }>('/system/health'), refetchInterval: 30_000, }) - const recentAlerts = useQuery({ queryKey: ['alerts', 'events', 'recent'], queryFn: () => fetchList('/alerts/events?limit=10', 'events'), @@ -223,9 +226,7 @@ export default function DashboardPage() { }, refetchInterval: 10_000, }) - const haproxyBackends = { data: haproxyStats.data?.backends } const auditEntries = useAuditLive(15) - const domains = useQuery({ queryKey: ['domains'], queryFn: () => fetchList('/domains', 'domains') }) const backends = useQuery({ queryKey: ['backends'], queryFn: () => fetchList('/backends', 'backends') }) const ifaces = useQuery({ queryKey: ['network-interfaces'], queryFn: () => fetchList('/network-interfaces', 'interfaces') }) @@ -234,9 +235,6 @@ 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<{ @@ -246,19 +244,11 @@ export default function DashboardPage() { }>('/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 + status: string; type?: string; valid?: boolean + valid_until?: string; expires_at?: string; license_key?: string }>('/license/status'), refetchInterval: 5 * 60_000, }) @@ -268,56 +258,42 @@ export default function DashboardPage() { queryFn: () => fetchList('/wireguard/status', 'status'), refetchInterval: 10_000, }) + const vipStatus = useQuery({ + queryKey: ['system', 'vip-status'], + queryFn: () => fetchOne('/system/vip-status'), + refetchInterval: 10_000, + }) - // Derived stats (same as before — KPI tiles) - const activeBackends = (backends.data ?? []).filter(b => b.active).length - const activeDomains = (domains.data ?? []).filter(d => d.active).length - const activeIfaces = (ifaces.data ?? []).filter(i => i.active).length - const activeFwRules = (fwRules.data ?? []).filter(r => r.enabled).length - const activeNAT = (fwNAT.data ?? []).filter(r => r.enabled).length - const wgServers = (wgIfaces.data ?? []).filter(i => i.mode === 'server' && i.active).length - const wgClients = (wgIfaces.data ?? []).filter(i => i.mode === 'client' && i.active).length - const wgConnected = (wgStatus.data ?? []).filter(s => s.last_handshake_unix > 0 - && Date.now() / 1000 - s.last_handshake_unix < 180).length - - const now = Date.now() + // ── Derived stats ── + const activeBackends = (backends.data ?? []).filter(b => b.active).length + const activeDomains = (domains.data ?? []).filter(d => d.active).length + const activeIfaces = (ifaces.data ?? []).filter(i => i.active).length + const activeFwRules = (fwRules.data ?? []).filter(r => r.enabled).length + const activeNAT = (fwNAT.data ?? []).filter(r => r.enabled).length + const wgServers = (wgIfaces.data ?? []).filter(i => i.mode === 'server' && i.active).length + const wgClients = (wgIfaces.data ?? []).filter(i => i.mode === 'client' && i.active).length + const wgConnected = (wgStatus.data ?? []).filter(s => + s.last_handshake_unix > 0 && Date.now() / 1000 - s.last_handshake_unix < 180).length + const now = Date.now() const maintenanceDomains = (domains.data ?? []).filter(d => d.maintenance_mode) - // HAProxy frontend technical name → display label. - const HA_FRONTEND_LABELS: Record = { - public_http: 'HTTP In', - public_https: 'HTTPS In', - mgmt_https: 'Management', + const backendMap = new Map() + for (const b of backends.data ?? []) backendMap.set(b.id, b.name) + const resolveHAName = (n: string): string => { + const m = /^eg_backend_(\d+)$/.exec(n) + const id = m ? Number(m[1]) : null + return id != null ? (backendMap.get(id) ?? n) : n } - // eg_backend_ → friendly name lookup for HAProxy stats display. - const backendIdToName = useMemo(() => { - const m = new Map() - for (const b of backends.data ?? []) m.set(b.id, b.name) - return m - }, [backends.data]) - const resolveHAName = (haName: string): string => { - const match = /^eg_backend_(\d+)$/.exec(haName) - const id = match ? Number(match[1]) : null - return id != null ? (backendIdToName.get(id) ?? haName) : haName - } - - // Backends die komplett DOWN sind (mind. 1 Server mit echtem Check, - // aber kein einziger UP) — liefert friendly Backend-Namen. const downBackends = (() => { - const stats = haproxyBackends.data ?? [] + const stats = haproxyStats.data?.backends ?? [] const bklist = backends.data ?? [] if (!stats.length || !bklist.length) return [] - // gruppiere Stat-Zeilen nach HAProxy-Backend-Name (eg_backend_) const byBackend = new Map() - for (const s of stats) { - const arr = byBackend.get(s.backend) ?? []; arr.push(s) - byBackend.set(s.backend, arr) - } + for (const s of stats) { const arr = byBackend.get(s.backend) ?? []; arr.push(s); byBackend.set(s.backend, arr) } const down: string[] = [] for (const [haName, servers] of byBackend) { - const hasRealCheck = servers.some(s => s.status !== 'no check') - if (hasRealCheck && !servers.some(s => s.status === 'UP')) { + if (servers.some(s => s.status !== 'no check') && !servers.some(s => s.status === 'UP')) { const m = /^eg_backend_(\d+)$/.exec(haName) const id = m ? Number(m[1]) : null const b = id != null ? bklist.find(x => x.id === id) : null @@ -327,15 +303,9 @@ export default function DashboardPage() { return down })() - const certsExpired = (tlsCerts.data ?? []).filter(c => { - if (!c.not_after) return false - return new Date(c.not_after).getTime() < now - }) - const certsSoon = (tlsCerts.data ?? []).filter(c => { - if (!c.not_after) return false - const exp = new Date(c.not_after).getTime() - return exp >= now && exp - now < 30 * 86_400_000 - }) + const alerts = recentAlerts.data ?? [] + const nCritical = alerts.filter(e => e.severity === 'critical' || e.severity === 'error').length + const nWarning = alerts.filter(e => e.severity === 'warning').length return (
@@ -345,13 +315,6 @@ 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 ?? '—'} @@ -360,15 +323,10 @@ export default function DashboardPage() { } /> - {/* ── 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. */} + {/* ─ Onboarding ─────────────────────────────────────── */} {(domains.data?.length ?? 0) === 0 && (backends.data?.length ?? 0) === 0 && ( @@ -383,35 +341,47 @@ export default function DashboardPage() { /> )} + {/* ─ Alert bar (compact) ────────────────────────────── */} + {alerts.length > 0 && ( + 0 ? 'error' : 'warning'} + showIcon + icon={} + className="mb-12" + message={ + + {t('dashboard.alertsCard.title')} + + {nCritical > 0 && nWarning > 0 + ? t('dashboard.alertsCard.summary', { critical: nCritical, warning: nWarning }) + : nCritical > 0 + ? t('dashboard.alertsCard.summaryCritical', { critical: nCritical }) + : t('dashboard.alertsCard.summaryWarning', { warning: nWarning }) + } + + + } + action={{t('dashboard.alertsCard.viewAll')} →} + /> + )} + + {/* ─ Operational alerts ─────────────────────────────── */} {downBackends.length > 0 && ( - {downBackends.map(name => {name})} - - } + description={{downBackends.map(n => {n})}} /> )} - {maintenanceDomains.length > 0 && ( - {maintenanceDomains.map(d => {d.name})} - - } + description={{maintenanceDomains.map(d => {d.name})}} /> )} - {/* ── KPI tiles (compact strip) ──────────────────── */} + {/* ─ KPI strip ──────────────────────────────────────── */} } label={t('dashboard.kpi.domains')} value={activeDomains} total={(domains.data ?? []).length} /> } label={t('dashboard.kpi.backends')} value={activeBackends} total={(backends.data ?? []).length} /> @@ -421,284 +391,59 @@ export default function DashboardPage() { } label={t('dashboard.kpi.wg')} value={wgConnected} total={wgServers + wgClients} /> - {/* ── Resources strip (load / mem / disk / conntrack / uptime) ── */} + {/* ─ Resources strip ────────────────────────────────── */} - {/* ── 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} - -
- ))} -
-
+ {/* ─ Services health bar ────────────────────────────── */} + {(services.data?.length ?? 0) > 0 && ( + )} - {/* ── Service-health-grid ─────────────────────────── */} - {t('dashboard.servicesCard.title')}} className="mb-12"> - - {(services.data ?? []).map(s => ( - - -
- - {s.label} - - -
-
- - ))} -
-
- + {/* ─ HA/Cluster row ─────────────────────────────────── */} - {/* ── Recent activity (audit log) ─────────────────── */} - {t('dashboard.activityCard.title')}} className="h-100"> - {auditEntries.length === 0 ? ( - {t('dashboard.activityCard.empty')} - ) : ( - - {auditEntries.map(e => ( -
- - {e.action} - {e.actor} - {e.subject && {e.subject}} - {relativeFromIso(e.created_at, t)} - -
- ))} -
- )} -
+ - - {/* ── HAProxy backend live health ─────────────────── */} - {t('dashboard.haproxyCard.title')}} className="h-100"> - {(haproxyStats.data?.frontends ?? []).length > 0 && ( -
- - {t('dashboard.haproxyCard.frontends')} - - - {(haproxyStats.data?.frontends ?? []).map((f) => ( -
- - {HA_FRONTEND_LABELS[f.name] ?? f.name} - - {f.sessions} sess - {f.req_rate > 0 ? ` · ${f.req_rate}/s` : ''} - {` · ↓${formatBytes(f.bytes_in)} ↑${formatBytes(f.bytes_out)}`} - - -
- ))} -
-
- )} - {(haproxyBackends.data ?? []).length === 0 ? ( - haproxyStats.data?.error ? ( - - ⚠ HAProxy socket: {haproxyStats.data.error} - - ) : ( - {t('dashboard.haproxyCard.empty')} - ) - ) : ( - - {(haproxyBackends.data ?? []).map((b, i) => ( -
- - {resolveHAName(b.backend)}/{b.server} - {b.status} - {b.health && b.health !== 'L7OK' && b.health !== 'L4OK' && ( - - {b.health} - - )} - - {b.sessions} sess{b.req_rate > 0 ? ` · ${b.req_rate}/s` : ''} · ↓{formatBytes(b.bytes_in)} ↑{formatBytes(b.bytes_out)} - - {formatUptime(b.last_change_sec)} - -
- ))} -
- )} -
+ +
- {/* ── WireGuard live ─────────────────────────────── */} - - {t('dashboard.wgCard.title')}} className="h-100"> - {(wgIfaces.data ?? []).length === 0 ? ( - {t('dashboard.wgCard.empty')} - ) : ( - - {(wgIfaces.data ?? []).map(ifc => { - const status = (wgStatus.data ?? []).filter(s => s.interface === ifc.name) - const nowSec = Date.now() / 1000 - const onlineCount = status.filter(s => s.last_handshake_unix > 0 && nowSec - s.last_handshake_unix < 180).length - const totalRx = status.reduce((acc, s) => acc + s.transfer_rx, 0) - const totalTx = status.reduce((acc, s) => acc + s.transfer_tx, 0) - return ( -
- - {ifc.name} - {ifc.mode} - - - {ifc.mode === 'server' ? ( - status.length > 0 && ( -
- {t('dashboard.wgCard.peersOnline', { online: onlineCount, total: status.length })} - {' · ▼'}{formatBytes(totalRx)}{' ▲'}{formatBytes(totalTx)} -
- ) - ) : ( - status.map(s => ( -
- {s.endpoint && {s.endpoint} · } - {relativeTime(s.last_handshake_unix, t)} - {' · ▼'}{formatBytes(s.transfer_rx)}{' ▲'}{formatBytes(s.transfer_tx)} -
- )) - )} -
- ) - })} -
- )} -
+ {/* ─ Traffic row ────────────────────────────────────── */} + + + - - {/* ── Cluster ─────────────────────────────────────── */} - - {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 => ( -
- {n.fqdn} {n.role} -
- ))} -
-
+ + +
- {/* ── Firewall ────────────────────────────────────── */} - - {t('dashboard.firewallCard.title')}} className="h-100"> - - - {(fwZones.data ?? []).map(z => ( - {z.name.toUpperCase()} - ))} - -
- {t('dashboard.firewallCard.activeRules', { rules: activeFwRules, nat: activeNAT })} -
-
+ {/* ─ Infrastructure row ─────────────────────────────── */} + + + - - {/* ── SSL ─────────────────────────────────────────── */} - - {t('dashboard.sslCard.title')}} className="h-100"> - - {certsExpired.length > 0 && ( - - )} - {certsSoon.length > 0 && ( - - )} - {certsExpired.length === 0 && certsSoon.length === 0 && ( -
- {t('dashboard.sslCard.allFresh')} -
- )} -
+ + + + + +
- {/* ── Routing summary ─────────────────────────────── */} - - {t('dashboard.routingCard.title')}} className="h-100"> - - - - -
- {t('dashboard.routingCard.attached', { - count: (domains.data ?? []).filter(d => d.primary_backend_id).length, - total: (domains.data ?? []).length, - })} -
-
+ {/* ─ Routing + Activity row ─────────────────────────── */} + + + + + +
@@ -707,8 +452,9 @@ export default function DashboardPage() { // ── Sub-components ──────────────────────────────────────────── -interface KPIProps { icon: React.ReactNode; label: string; value: number; total?: number } +// ── KPI tile ───────────────────────────────────────────────── +interface KPIProps { icon: React.ReactNode; label: string; value: number; total?: number } function KPI({ icon, label, value, total }: KPIProps) { return ( @@ -726,6 +472,8 @@ function KPI({ icon, label, value, total }: KPIProps) { ) } +// ── Resources strip ─────────────────────────────────────────── + function ResourcesCard({ r }: { r?: Resources | null }) { const { t } = useTranslation() if (!r) return null @@ -773,39 +521,490 @@ 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). +// ── Services health bar ─────────────────────────────────────── + +const SVC_STATE_COLOR: Record = { + active: { bg: '#F0FDF4', border: '#BBF7D0', text: '#166534' }, + failed: { bg: '#FEF2F2', border: '#FECACA', text: '#991B1B' }, + 'kernel-loaded':{ bg: '#F0FDF4', border: '#BBF7D0', text: '#166534' }, +} +const SVC_DEFAULT_COLORS = { bg: '#F8FAFC', border: '#E2E8F0', text: '#64748B' } + +function ServicesBar({ services }: { services: ServiceStatus[] }) { + const { t } = useTranslation() + return ( + +
+ + {t('dashboard.servicesCard.title')} + + {services.map(s => { + const c = SVC_STATE_COLOR[s.state] ?? SVC_DEFAULT_COLORS + return ( + + + + {s.label} + + + ) + })} +
+
+ ) +} + +// ── VIP / VRRP card ─────────────────────────────────────────── + +const VRRP_STATE_COLOR: Record = { + MASTER: 'green', BACKUP: 'blue', FAULT: 'red', UNKNOWN: 'default', +} + +function VIPCard({ data }: { data?: VIPStatus | null }) { + const { t } = useTranslation() + const state = data?.vrrp_state ?? 'UNKNOWN' + const stateLabel = t(`dashboard.vipCard.state.${state}`) + const stateColor = VRRP_STATE_COLOR[state] ?? 'default' + + return ( + + + {t('dashboard.vipCard.title')} +
+ } + extra={ + + {data && ( + + {stateLabel} + + )} + {data && !data.keepalived_active && ( + {t('dashboard.vipCard.keepalivedInactive')} + )} + + } + > + {!data ? ( + + ) : data.vips.length === 0 ? ( + {t('dashboard.vipCard.noVips')} + ) : ( + + {data.vips.map((v) => ( +
+ + + {v.address}/{v.prefix} + + + + {v.active ? 'active' : 'standby'} + + {v.device && ( + dev {v.device} + )} + +
+ ))} +
+ )} +
+ ) +} + +// ── Cluster card ────────────────────────────────────────────── + +interface ClusterStatusCardProps { + nodes: ClusterNode[] + status: { mode: string; health: string; drift_found: boolean } | null +} +function ClusterStatusCard({ nodes, status }: ClusterStatusCardProps) { + const { t } = useTranslation() + return ( + {t('dashboard.clusterCard.title')}} + extra={status && ( + + + {status.mode === 'cluster' ? t('dashboard.clusterCard.modeCluster') : t('dashboard.clusterCard.modeSingle')} + + + {t(`dashboard.clusterCard.health.${status.health}`)} + + + )} + > + + {status?.drift_found && ( + {t('dashboard.clusterCard.drift')} + )} + + {nodes.map(n => ( +
+ {n.fqdn} + {n.role} +
+ ))} +
+
+ ) +} + +// ── HAProxy combined card ───────────────────────────────────── + +interface HAProxyFullCardProps { + stats: { backends: HAProxyBackend[]; frontends: HAProxyFrontend[]; error?: string } + resolveHAName: (n: string) => string +} +function HAProxyFullCard({ stats, resolveHAName }: HAProxyFullCardProps) { + const { t } = useTranslation() + const totalSessions = (stats.frontends ?? []).reduce((acc, f) => acc + f.sessions, 0) + const totalReqRate = (stats.frontends ?? []).reduce((acc, f) => acc + f.req_rate, 0) + const totalBytesIn = (stats.frontends ?? []).reduce((acc, f) => acc + f.bytes_in, 0) + const totalBytesOut = (stats.frontends ?? []).reduce((acc, f) => acc + f.bytes_out, 0) + + return ( + {t('dashboard.haproxyCard.title')}} + extra={ + stats.frontends.length > 0 && ( + + {totalSessions} sess + {totalReqRate > 0 && {totalReqRate}/s} + ↓{formatBytes(totalBytesIn)} ↑{formatBytes(totalBytesOut)} + + ) + } + > + {stats.error && ( + + ⚠ HAProxy socket: {stats.error} + + )} + + {/* Listeners */} + {stats.frontends.length > 0 && ( + <> + + {t('dashboard.haproxyCard.frontends')} + +
+ {stats.frontends.map(f => ( + + {HA_FRONTEND_LABELS[f.name] ?? f.name} + {f.sessions} sess{f.req_rate > 0 ? ` · ${f.req_rate}/s` : ''} + + ))} +
+ + )} + + {/* Backends */} + {stats.backends.length === 0 && !stats.error ? ( + {t('dashboard.haproxyCard.empty')} + ) : ( + <> + + Backends + + + {stats.backends.map((b, i) => ( +
+ {resolveHAName(b.backend)}/{b.server} + {b.status} + {b.health && b.health !== 'L7OK' && b.health !== 'L4OK' && ( + + {b.health} + + )} + + {b.sessions} sess{b.req_rate > 0 ? ` · ${b.req_rate}/s` : ''} · ↓{formatBytes(b.bytes_in)} ↑{formatBytes(b.bytes_out)} + + {formatUptime(b.last_change_sec)} +
+ ))} +
+ + )} +
+ ) +} + +// ── WireGuard card ──────────────────────────────────────────── + +function WGCard({ ifaces, status }: { ifaces: WGIface[]; status: WGStatusRow[] }) { + const { t } = useTranslation() + return ( + {t('dashboard.wgCard.title')}} + > + {ifaces.length === 0 ? ( + {t('dashboard.wgCard.empty')} + ) : ( + + {ifaces.map(ifc => { + const peers = status.filter(s => s.interface === ifc.name) + const nowSec = Date.now() / 1000 + const online = peers.filter(s => s.last_handshake_unix > 0 && nowSec - s.last_handshake_unix < 180).length + const totalRx = peers.reduce((acc, s) => acc + s.transfer_rx, 0) + const totalTx = peers.reduce((acc, s) => acc + s.transfer_tx, 0) + return ( +
+ + {ifc.name} + {ifc.mode} + + + {ifc.mode === 'server' && peers.length > 0 && ( +
+ {t('dashboard.wgCard.peersOnline', { online, total: peers.length })} + {' · ▼'}{formatBytes(totalRx)}{' ▲'}{formatBytes(totalTx)} +
+ )} + {ifc.mode === 'client' && peers.map(s => ( +
+ {s.endpoint && {s.endpoint} · } + {relativeTime(s.last_handshake_unix, t)} + {' · ▼'}{formatBytes(s.transfer_rx)}{' ▲'}{formatBytes(s.transfer_tx)} +
+ ))} +
+ ) + })} +
+ )} +
+ ) +} + +// ── Network services card ───────────────────────────────────── + +const NET_SERVICES = [ + { unit: 'unbound', label: 'DNS', sub: 'Unbound', to: '/dns' }, + { unit: 'squid', label: 'Forward Proxy', sub: 'Squid', to: '/forward-proxy' }, + { unit: 'chrony', label: 'NTP', sub: 'Chrony', to: '/ntp' }, + { unit: 'postgresql', label: 'Database', sub: 'PostgreSQL', to: undefined }, + { unit: 'keepalived', label: 'VRRP', sub: 'keepalived', to: '/settings' }, +] + +function NetworkServicesCard({ services }: { services: ServiceStatus[] }) { + const { t } = useTranslation() + const svcMap = Object.fromEntries(services.map(s => [s.unit, s])) + return ( + {t('dashboard.networkServicesCard.title')}} + > + + {NET_SERVICES.map(item => { + const svc = svcMap[item.unit] + const active = svc?.active ?? false + return ( +
+ + +
+
{item.label}
+
{item.sub}
+
+
+ + + {svc?.state ?? '—'} + + {item.to && ( + + {t('dashboard.networkServicesCard.configure')} → + + )} + +
+ ) + })} +
+
+ ) +} + +// ── Firewall summary card ───────────────────────────────────── + +function FirewallSummaryCard({ zones, rules, nat }: { zones: FwZone[]; rules: number; nat: number }) { + const { t } = useTranslation() + return ( + {t('dashboard.firewallCard.title')}} + extra={{t('dashboard.networkServicesCard.configure')} →} + > + + + + + + + + + + {zones.map(z => ( + {z.name.toUpperCase()} + ))} + + + ) +} + +// ── SSL summary card ────────────────────────────────────────── + +function SSLSummaryCard({ certs, now }: { certs: TLSCert[]; now: number }) { + const { t } = useTranslation() + const expired = certs.filter(c => c.not_after && new Date(c.not_after).getTime() < now) + const soon = certs.filter(c => { + if (!c.not_after) return false + const exp = new Date(c.not_after).getTime() + return exp >= now && exp - now < 30 * 86_400_000 + }) + return ( + {t('dashboard.sslCard.title')}} + extra={{t('dashboard.networkServicesCard.configure')} →} + > + + {expired.length > 0 && ( + + )} + {soon.length > 0 && ( + + )} + {expired.length === 0 && soon.length === 0 && ( +
✓ {t('dashboard.sslCard.allFresh')}
+ )} +
+ ) +} + +// ── Routing summary card ────────────────────────────────────── + +function RoutingSummaryCard({ domains, backends }: { domains: Domain[]; backends: Backend[] }) { + const { t } = useTranslation() + const attached = domains.filter(d => d.primary_backend_id).length + return ( + {t('dashboard.routingCard.title')}} + extra={{t('dashboard.networkServicesCard.configure')} →} + > + + + + +
+ {t('dashboard.routingCard.attached', { count: attached, total: domains.length })} +
+
+ ) +} + +// ── Activity card ───────────────────────────────────────────── + +function ActivityCard({ entries }: { entries: AuditEntry[] }) { + const { t } = useTranslation() + return ( + {t('dashboard.activityCard.title')}} + extra={View all →} + > + {entries.length === 0 ? ( + {t('dashboard.activityCard.empty')} + ) : ( + + {entries.map(e => ( +
+
+ {e.action} + {e.actor} + {e.subject && {e.subject}} + + {relativeFromIso(e.created_at, t)} + +
+
+ ))} +
+ )} +
+ ) +} + +// ── License chip ────────────────────────────────────────────── + function LicenseChip({ data }: { data: { - status: string - type?: string - valid?: boolean - valid_until?: string - expires_at?: string - license_key?: string + status: string; type?: string; valid?: boolean + valid_until?: string; expires_at?: string; license_key?: string }}) { const { t } = useTranslation() - const exp = data.valid_until ?? data.expires_at + 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) { - const trialLabel = days != null - ? t('dashboard.licenseTrialDays', { days }) - : t('dashboard.licenseTrial') - if (days != null && days <= 7) return {trialLabel} - if (days != null && days <= 14) return {trialLabel} - return {trialLabel} - } - if (data.status === 'active') { - return {t('dashboard.licenseOk')} + const label = days != null ? t('dashboard.licenseTrialDays', { days }) : t('dashboard.licenseTrial') + if (days != null && days <= 7) return {label} + if (days != null && days <= 14) return {label} + return {label} } + if (data.status === 'active') return {t('dashboard.licenseOk')} return null } diff --git a/management-ui/src/pages/Firewall/NATRules.tsx b/management-ui/src/pages/Firewall/NATRules.tsx index 28f71fe..43dcc90 100644 --- a/management-ui/src/pages/Firewall/NATRules.tsx +++ b/management-ui/src/pages/Firewall/NATRules.tsx @@ -116,7 +116,17 @@ export default function NATRulesTab() { const columns: ColumnsType = [ { title: '#', dataIndex: 'priority', key: 'priority', width: 70 }, - { title: t('fw.nat.kind'), dataIndex: 'kind', key: 'kind', render: (k: NATRule['kind']) => {k.toUpperCase()} }, + { title: t('fw.nat.kind'), dataIndex: 'kind', key: 'kind', width: 100, render: (k: NATRule['kind']) => {k.toUpperCase()} }, + { + title: t('fw.nat.name'), key: 'name', width: 200, + render: (_, r) => ( +
+ {r.name &&
{r.name}
} + {r.comment &&
{r.comment}
} + {!r.name && !r.comment && } +
+ ), + }, { title: t('fw.nat.match'), key: 'match', render: (_, r) => ( @@ -126,11 +136,11 @@ export default function NATRulesTab() { {r.proto && {r.proto}} {r.match_src_cidr && src={r.match_src_cidr}} {r.match_dst_cidr && dst={r.match_dst_cidr}} - {r.match_dport_start && dport={r.match_dport_start}{r.match_dport_end !== r.match_dport_start ? `-${r.match_dport_end}` : ''}} + {r.match_dport_start && dport={r.match_dport_start}{r.match_dport_end && r.match_dport_end !== r.match_dport_start ? `-${r.match_dport_end}` : ''}} ), }, - { title: t('fw.nat.target'), key: 'target', render: (_, r) => renderTarget(r) }, + { title: t('fw.nat.target'), key: 'target', width: 200, render: (_, r) => renderTarget(r) }, { title: t('fw.nat.enabled'), dataIndex: 'enabled', key: 'enabled', width: 80, render: (v: boolean, row: NATRule) => ( diff --git a/management-ui/src/pages/Firewall/Rules.tsx b/management-ui/src/pages/Firewall/Rules.tsx index 9425539..3ab5afa 100644 --- a/management-ui/src/pages/Firewall/Rules.tsx +++ b/management-ui/src/pages/Firewall/Rules.tsx @@ -7,7 +7,8 @@ import type { ColumnsType } from 'antd/es/table' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import { - ArrowDownOutlined, ArrowUpOutlined, CopyOutlined, EyeOutlined, FireOutlined, PlusOutlined, + ArrowDownOutlined, ArrowUpOutlined, CopyOutlined, DeleteOutlined, EditOutlined, + EyeOutlined, FireOutlined, PlusOutlined, WarningOutlined, } from '@ant-design/icons' const { Text } = Typography @@ -159,10 +160,27 @@ export default function RulesTab() { if (cidr) return cidr return 'any' } + + // Auto-generates a human-readable one-liner like "LAN/any → WAN/10.0.0.0/24 · HTTPS" + const autoDescription = (r: FwRule): string => { + const src = renderAddrCompact(r.src_address_object_id, r.src_address_group_id, r.src_cidr) + const dst = renderAddrCompact(r.dst_address_object_id, r.dst_address_group_id, r.dst_cidr) + const svc = r.service_object_id ? svLabel(r.service_object_id) + : r.service_group_id ? sgLabel(r.service_group_id) + : 'any' + return `${r.src_zone}/${src} → ${r.dst_zone}/${dst} · ${svc}` + } + const renderService = (objID?: number | null, grpID?: number | null) => { if (objID) return {svLabel(objID)} if (grpID) return ⊂ {sgLabel(grpID)} - return any + return any + } + + const renderAddr = (objID?: number | null, grpID?: number | null, cidr?: string | null) => { + const label = renderAddrCompact(objID, grpID, cidr) + if (label === 'any') return any + return {label} } // ── Filter state ───────────────────────────────────────────── @@ -294,11 +312,7 @@ export default function RulesTab() { render: (_, r) => (
- {(r.src_address_object_id || r.src_address_group_id || r.src_cidr) && ( - - {renderAddrCompact(r.src_address_object_id, r.src_address_group_id, r.src_cidr)} - - )} + {renderAddr(r.src_address_object_id, r.src_address_group_id, r.src_cidr)}
), }, @@ -311,11 +325,7 @@ export default function RulesTab() { render: (_, r) => (
- {(r.dst_address_object_id || r.dst_address_group_id || r.dst_cidr) && ( - - {renderAddrCompact(r.dst_address_object_id, r.dst_address_group_id, r.dst_cidr)} - - )} + {renderAddr(r.dst_address_object_id, r.dst_address_group_id, r.dst_cidr)}
), }, @@ -327,11 +337,16 @@ export default function RulesTab() { title: t('fw.rule.name'), key: 'name', ellipsis: true, render: (_, r) => (
- {r.name &&
{r.name}
} - {r.comment && ( -
{r.comment}
- )} - {!r.name && !r.comment && } + {r.name + ?
{r.name}
+ :
+ {t('fw.rule.unnamed')} +
+ } + {r.comment + ?
{r.comment}
+ :
{autoDescription(r)}
+ }
), }, @@ -339,7 +354,13 @@ export default function RulesTab() { title: t('fw.rule.hits'), key: 'hits', width: 80, align: 'right' as const, render: (_, r) => { const c = counterByID.get(r.id) - if (!c || c.packets === 0) return + if (!c || c.packets === 0) return ( + + + {r.enabled ? <>0 : '—'} + + + ) return ( @@ -372,19 +393,19 @@ export default function RulesTab() { ), }, { - title: '', key: 'move', width: 60, + title: '', key: 'move', width: 52, render: (_, row) => { const idx = sortedRules.findIndex(r => r.id === row.id) const swapping = swap.isPending return ( - + - + + + + + + + + + - - {(sysAddrs ?? []).length === 0 - ? - : ( - `${r.ifname}-${r.address}`} - dataSource={sysAddrs ?? []} - - columns={[ - { title: t('ips.interface'), dataIndex: 'ifname', key: 'ifname', render: (s: string) => {s} }, - { title: t('ips.address'), key: 'addr', render: (_, row: SystemAddress) => {row.address}/{row.prefix} }, - { title: t('ips.family'), dataIndex: 'family', key: 'family', render: (f: string) => {f === 'inet' ? 'IPv4' : 'IPv6'} }, - ]} - /> - ) - } - - - {t('ips.managedTitle')} } /> + + + {(sysAddrs ?? []).length === 0 + ? + : ( + `${r.ifname}-${r.address}`} + dataSource={sysAddrs ?? []} + columns={[ + { title: t('ips.interface'), dataIndex: 'ifname', key: 'ifname', render: (s: string) => {s} }, + { title: t('ips.address'), key: 'addr', render: (_, row: SystemAddress) => {row.address}/{row.prefix} }, + { title: t('ips.family'), dataIndex: 'family', key: 'family', render: (f: string) => {f === 'inet' ? 'IPv4' : 'IPv6'} }, + ]} + /> + ) + } + { try { const r = await apiClient.post('/auth/login', vals) - if (isEnvelope(r.data)) { - const u = r.data.data as SessionUser - onLogin(u) - navigate('/dashboard', { replace: true }) + if (!isEnvelope(r.data)) return + const d = r.data.data as { totp_required?: boolean } & SessionUser + if (d.totp_required) { + setTotpRequired(true) return } + onLogin(d) + navigate('/dashboard', { replace: true }) } catch (e: unknown) { const err = e as { message?: string; status?: number } if (err.status === 503) { - // setup-mode → drop to wizard navigate('/setup', { replace: true }) return } @@ -38,36 +44,73 @@ export default function LoginPage({ onLogin }: Props) { } } + const onTOTPVerify = async () => { + if (!totpCode || totpCode.length < 6) return + setVerifying(true) + try { + const r = await apiClient.post('/auth/totp-verify', { code: totpCode }) + if (isEnvelope(r.data)) { + onLogin(r.data.data as SessionUser) + navigate('/dashboard', { replace: true }) + } + } catch (e: unknown) { + const err = e as { message?: string } + message.error(err.message ?? t('auth.totp.invalidCode')) + setTotpCode('') + } finally { + setVerifying(false) + } + } + return (
{t('app.title')} -
- - - - - - - - + +
+ ) : ( +
+ +
+ {t('auth.totp.prompt')} +
+ setTotpCode(e.target.value.replace(/\D/g, ''))} + onPressEnter={onTOTPVerify} + style={{ textAlign: 'center', letterSpacing: 8, fontSize: 20, marginBottom: 16 }} + autoFocus + /> + - - -
- {t('auth.forgotPassword')} -
+ +
+ )} + + {!totpRequired && ( +
+ {t('auth.forgotPassword')} +
+ )}
) diff --git a/management-ui/src/pages/NTP/index.tsx b/management-ui/src/pages/NTP/index.tsx index c807180..0463820 100644 --- a/management-ui/src/pages/NTP/index.tsx +++ b/management-ui/src/pages/NTP/index.tsx @@ -381,6 +381,7 @@ function SettingsTab() { for (const i of sys ?? []) { if (i.ifname === 'lo') continue for (const a of i.addr_info ?? []) { + if (a.local.startsWith('fe80:')) continue ipOptions.push({ value: a.local, label: `${a.local} — ${i.ifname} (${a.family === 'inet' ? 'IPv4' : 'IPv6'})`, diff --git a/management-ui/src/pages/Settings/index.tsx b/management-ui/src/pages/Settings/index.tsx index 847cc64..39734c2 100644 --- a/management-ui/src/pages/Settings/index.tsx +++ b/management-ui/src/pages/Settings/index.tsx @@ -39,6 +39,11 @@ interface VIPSettingsValues { vip_interface?: string vip_auth_pass?: string vrrp_router_id?: number + hb_interface?: string + hb_src_ip?: string + hb_peer_ip?: string + hb_router_id?: number + gw_check_ip?: string } export default function SettingsPage() { @@ -839,6 +844,31 @@ export default function SettingsPage() { extra={t('cluster.vipCard.vrrpRouterIdHelp')}> + + + {t('cluster.vipCard.splitBrainSection')} + + + + + + + + + + + + + + + + + {!isViewer && ( , + , + ] + : [ + , + totpTarget?.totp_enabled + ? + : , + ] + } + destroyOnHidden + > + {totpStep === 0 && ( +
+ {totpTarget?.totp_enabled ? ( + <> + + {t('users.totp.alreadyEnabled')} + {t('users.totp.disableHint')} + + ) : ( + <> + {t('users.totp.scanHint')} + {totpUri && } + + {totpSecret} + + + )} +
+ )} + {totpStep === 1 && ( +
+ {t('users.totp.enterCode')} + setTotpCode(e.target.value.replace(/\D/g, ''))} + onPressEnter={() => void confirmTOTP()} + style={{ textAlign: 'center', letterSpacing: 8, fontSize: 20, width: 200 }} + autoFocus + /> +
+ )} +
) } diff --git a/management-ui/src/styles/enterprise.css b/management-ui/src/styles/enterprise.css index 89a7574..ba76fd2 100644 --- a/management-ui/src/styles/enterprise.css +++ b/management-ui/src/styles/enterprise.css @@ -2850,6 +2850,17 @@ h1, h2, h3, h4, h5, h6 { .fw-rule-row--disabled td:first-child { opacity: 1 !important; } +/* Strikethrough on disabled rule name */ +.fw-rule-row--disabled .fw-rule-name { + text-decoration: line-through; + text-decoration-color: #94A3B8; +} + +/* Zero-hit rule: amber left border (potentially unused/shadowed policy) */ +.fw-rule-row--zero-hit td:first-child { + border-left: 3px solid #FAAD14 !important; + padding-left: 5px; +} /* Enabled status dot */ .fw-rule-dot { @@ -2869,6 +2880,31 @@ h1, h2, h3, h4, h5, h6 { line-height: 1; } +/* Action buttons: hidden by default, reveal on row hover */ +.fw-row-actions { + opacity: 0; + transition: opacity 0.12s ease; +} +.ant-table-row:hover .fw-row-actions { + opacity: 1; +} + +/* Natural-language rule description */ +.fw-rule-desc { + font-size: 11px; + color: #94A3B8; + margin-top: 2px; + font-family: ui-monospace, 'Cascadia Code', monospace; + letter-spacing: 0.01em; +} + +/* "any" address label */ +.fw-addr-any { + font-size: 11px; + color: #94A3B8; + font-style: italic; +} + /* Filter bar */ .fw-filter-bar { display: flex; diff --git a/packaging/debian/edgeguard-api/DEBIAN/control b/packaging/debian/edgeguard-api/DEBIAN/control index 71b2af4..03133e6 100644 --- a/packaging/debian/edgeguard-api/DEBIAN/control +++ b/packaging/debian/edgeguard-api/DEBIAN/control @@ -12,7 +12,7 @@ Description: EdgeGuard — native Reverse-Proxy / LB / Forward-Proxy / VPN / Fir PG Streaming Replication + provider Floating-IP for HTTP ingress). . This package ships the management API, scheduler and CLI. -Depends: postgresql-16 | postgresql-17, haproxy (>= 2.8), squid, wireguard-tools, unbound, chrony, nftables, certbot, openssl, sudo, adduser, systemd, ca-certificates, ulogd2, ulogd2-json, iputils-ping, traceroute, dnsutils, curl, netcat-openbsd +Depends: postgresql-16 | postgresql-17, haproxy (>= 2.8), squid, wireguard-tools, unbound, chrony, nftables, keepalived, certbot, openssl, sudo, adduser, systemd, ca-certificates, ulogd2, ulogd2-json, iputils-ping, traceroute, dnsutils, curl, netcat-openbsd Recommends: edgeguard-keydb (>= 6.3.4-edgeguard1), apparmor, fail2ban Section: admin Priority: optional diff --git a/packaging/debian/edgeguard-api/DEBIAN/postinst b/packaging/debian/edgeguard-api/DEBIAN/postinst index 2961c10..7a2f950 100755 --- a/packaging/debian/edgeguard-api/DEBIAN/postinst +++ b/packaging/debian/edgeguard-api/DEBIAN/postinst @@ -25,6 +25,12 @@ case "$1" in if getent group haproxy >/dev/null; then usermod -a -G haproxy "$EG_USER" || true fi + # unbound-Gruppe: unbound-control-Socket ist root:unbound srw-rw---- + # → edgeguard muss in der Gruppe sein um stats_noreset + flush aufrufen + # zu können (kein sudo nötig, Gruppe reicht). + if getent group unbound >/dev/null; then + usermod -a -G unbound "$EG_USER" || true + fi # systemd-journal + adm: damit edgeguard-api `journalctl -u …` # ohne sudo lesen kann — wird für /api/v1/logs gebraucht # (zentrale Log-Übersicht über alle Services). @@ -90,9 +96,13 @@ edgeguard ALL=(root) NOPASSWD: /usr/sbin/nft list table inet edgeguard edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl start wg-quick@*.service edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl restart wg-quick@*.service edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl stop wg-quick@*.service +edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl enable wg-quick@*.service +edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl disable wg-quick@*.service edgeguard ALL=(root) NOPASSWD: /bin/systemctl start wg-quick@*.service edgeguard ALL=(root) NOPASSWD: /bin/systemctl restart wg-quick@*.service edgeguard ALL=(root) NOPASSWD: /bin/systemctl stop wg-quick@*.service +edgeguard ALL=(root) NOPASSWD: /bin/systemctl enable wg-quick@*.service +edgeguard ALL=(root) NOPASSWD: /bin/systemctl disable wg-quick@*.service edgeguard ALL=(root) NOPASSWD: /usr/bin/wg show all dump edgeguard ALL=(root) NOPASSWD: /usr/bin/wg show * # WireGuard symlink: /etc/wireguard/ ist root:root 700; edgeguard-api @@ -139,6 +149,8 @@ edgeguard ALL=(root) NOPASSWD: /usr/bin/systemd-run --unit=edgeguard-restore.ser # Keepalived reload: VIP-Settings-Änderung triggert keepalived-Reload. edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl reload-or-restart keepalived.service edgeguard ALL=(root) NOPASSWD: /bin/systemctl reload-or-restart keepalived.service +# VIP-Schwenk-Test: dediziertes Script mit interner Input-Validierung. +edgeguard ALL=(root) NOPASSWD: /usr/lib/edgeguard/vip-cmd.sh SUDOERS # ── Distro-Conf-Includes für die per-Service Renderer ───────── @@ -422,6 +434,7 @@ create_iface() { local typ="$1" name="$2" parent="$3" vlan_id="$4" mtu="$5" members="$6" case "$typ" in vlan) + ip link set "$parent" up 2>/dev/null || true ip link add link "$parent" name "$name" type vlan id "$vlan_id" || return 1 ;; bridge) @@ -464,13 +477,31 @@ fi while IFS='|' read -r typ name parent vlan_id mtu members; do [ -z "$typ" ] && continue case "$typ" in '#'*) continue;; esac - if ! ip link show "$name" >/dev/null 2>&1; then + if ip link show "$name" >/dev/null 2>&1; then + # Typ-Änderung: altes Interface löschen und neu erstellen + existing_type=$(ip -d link show "$name" 2>/dev/null | awk '/^[[:space:]]/{print $1; exit}') + case "$typ" in + vlan) expected="vlan" ;; + bridge) expected="bridge_slave" ;; # bridge selbst hat keinen eigenen type-String + bond) expected="bond_slave" ;; + *) expected="" ;; + esac + current_type=$(ip -d link show "$name" 2>/dev/null | grep -oE 'vlan |bridge |bond ' | head -1 | tr -d ' ') + if [ -n "$current_type" ] && [ "$current_type" != "$typ" ]; then + ip link set "$name" down 2>/dev/null || true + ip link del "$name" 2>/dev/null || true + if ! create_iface "$typ" "$name" "$parent" "$vlan_id" "$mtu" "$members"; then + echo "edgeguard-interfaces: failed to recreate $typ $name" >&2 + fi + else + [ -n "$mtu" ] && ip link set "$name" mtu "$mtu" 2>/dev/null || true + [ "$typ" = "vlan" ] && [ -n "$parent" ] && ip link set "$parent" up 2>/dev/null || true + ip link set "$name" up 2>/dev/null || true + fi + else if ! create_iface "$typ" "$name" "$parent" "$vlan_id" "$mtu" "$members"; then echo "edgeguard-interfaces: failed to create $typ $name" >&2 fi - else - [ -n "$mtu" ] && ip link set "$name" mtu "$mtu" 2>/dev/null || true - ip link set "$name" up 2>/dev/null || true fi done < "$CONF" @@ -574,12 +605,40 @@ IPADDRUNIT # werden von Keepalived als notify_master / notify_backup / check # aufgerufen. Kein Auto-Promote — keepalived-master.sh loggt nur. install -d -m 0755 /usr/lib/edgeguard - for script in keepalived-check.sh keepalived-master.sh keepalived-backup.sh; do + for script in keepalived-check.sh keepalived-master.sh keepalived-backup.sh keepalived-gw-check.sh; do if [ -f "/usr/lib/edgeguard/${script}" ]; then chmod 0755 "/usr/lib/edgeguard/${script}" fi done + # ── VIP-Schwenk-Script ─────────────────────────────────────── + # Führt `ip addr add/del` für VIP-Failover-Tests aus. Läuft via + # sudo (Whitelist in sudoers). Interne Input-Validierung verhindert + # Command-Injection trotz breiter sudoers-Regel. + cat > /usr/lib/edgeguard/vip-cmd.sh <<'VIPCMD' +#!/bin/bash +# vip-cmd.sh {add|del} {address/prefix} {device} +# Wird von edgeguard-api via sudo für VIP-Schwenk-Tests aufgerufen. +# Nach `ip addr add` werden squid/unbound/haproxy reloaded damit sie +# die neu aktive VIP sofort binden. +set -e +action="$1" addrpfx="$2" dev="$3" +case "$action" in + add|del) ;; + *) echo "vip-cmd: ungültige action '$action'" >&2; exit 1 ;; +esac +[[ -z "$addrpfx" || -z "$dev" ]] && { echo "vip-cmd: fehlende Parameter" >&2; exit 1; } +[[ "$addrpfx" =~ ^[0-9a-fA-F.:\/]+$ ]] || { echo "vip-cmd: ungültige Adresse '$addrpfx'" >&2; exit 1; } +[[ "$dev" =~ ^[a-zA-Z0-9._-]+$ ]] || { echo "vip-cmd: ungültiges Device '$dev'" >&2; exit 1; } +ip addr "$action" "$addrpfx" dev "$dev" +if [ "$action" = "add" ]; then + for svc in squid.service unbound.service haproxy.service; do + systemctl is-active --quiet "$svc" && systemctl reload "$svc" 2>/dev/null || true + done +fi +VIPCMD + chmod 0755 /usr/lib/edgeguard/vip-cmd.sh + # ── Self-signed default cert so HAProxy starts cleanly ─────── # HAProxy `bind :443 ssl crt /etc/edgeguard/tls/` needs at least # one PEM in the directory to come up. Operator runs certbot @@ -625,6 +684,25 @@ IPADDRUNIT exit 1 fi + # ALTER PUBLICATION erfordert den PG-Superuser (edgeguard ist nicht + # Owner der Publication). Idempotent — No-Op auf Secondary-Nodes + # (wo die Publication nicht existiert) und wenn die Tabellen schon + # drin sind. + sudo -u postgres psql edgeguard <<'EOSQL' 2>/dev/null || true +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_publication WHERE pubname = 'edgeguard_shared') THEN + IF NOT EXISTS ( + SELECT 1 FROM pg_publication_tables + WHERE pubname = 'edgeguard_shared' AND tablename = 'network_interfaces' + ) THEN + ALTER PUBLICATION edgeguard_shared ADD TABLE network_interfaces, ip_addresses; + END IF; + END IF; +END; +$$; +EOSQL + # ── Render initial service configs ─────────────────────────── # Writes /etc/edgeguard/haproxy/haproxy.cfg + nftables.d/ # ruleset.nft from the (just-migrated, empty) PG state. @@ -641,6 +719,19 @@ IPADDRUNIT echo "postinst: edgeguard-ctl render-config (nftables) failed — aborting" >&2 exit 1 fi + # Keepalived-Config rendern (optional — nur wenn VIPs konfiguriert) + # /etc/keepalived/ wird vom keepalived-Paket als root angelegt; + # edgeguard braucht Schreibrecht damit der Config-Generator den + # atomic-write (tempfile → rename) durchführen kann. + install -d -m 0755 /etc/keepalived + chown "$EG_USER":"$EG_USER" /etc/keepalived + sudo -n -u "$EG_USER" /usr/bin/edgeguard-ctl render-config --only=keepalived || true + if [ -f /etc/keepalived/keepalived.conf ]; then + systemctl enable keepalived >/dev/null 2>&1 || true + systemctl is-active --quiet keepalived \ + && systemctl reload keepalived \ + || systemctl start keepalived || true + fi # ── HAProxy systemd drop-in: read EdgeGuard config ─────────── # Keeps the distro /etc/haproxy/haproxy.cfg untouched (it's a @@ -665,6 +756,15 @@ IPADDRUNIT # restart. Bei Erst-Install ist nichts running, dann ist das # ein normaler Start. systemctl restart edgeguard-api.service edgeguard-scheduler.service || true + + # WireGuard-Sicherung: enabled-aber-inaktive wg-quick@-Interfaces + # nach dem API-Restart starten. Der API-Renderer kann wg-quick@ + # kurz flippen; falls er dabei fehlschlägt, stellt dieser Block + # das Interface wieder her ohne manuellen Eingriff. + while IFS= read -r wg_unit; do + [ -n "$wg_unit" ] || continue + systemctl is-active --quiet "$wg_unit" || systemctl start "$wg_unit" 2>/dev/null || true + done < <(systemctl list-unit-files 'wg-quick@*.service' --state=enabled --no-legend 2>/dev/null | awk '{print $1}') ;; abort-upgrade|abort-remove|abort-deconfigure) diff --git a/packaging/scripts/keepalived-gw-check.sh b/packaging/scripts/keepalived-gw-check.sh new file mode 100644 index 0000000..49560b3 --- /dev/null +++ b/packaging/scripts/keepalived-gw-check.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# keepalived-gw-check.sh +# Wird von vrrp_script chk_gateway aufgerufen. +# weight -110: 200 (MASTER) - 110 = 90 < 100 (BACKUP-Prio) → Failover ausgelöst. +# Exit 0 = GW erreichbar (OK), Exit 1 = GW nicht erreichbar (Gewicht abziehen). +GW="${1:-}" +if [ -z "$GW" ]; then exit 0; fi +ping -c 1 -W 2 "$GW" > /dev/null 2>&1 diff --git a/packaging/scripts/keepalived-master.sh b/packaging/scripts/keepalived-master.sh index 0c1bc53..7086722 100644 --- a/packaging/scripts/keepalived-master.sh +++ b/packaging/scripts/keepalived-master.sh @@ -3,11 +3,22 @@ # # KEIN Auto-Promote — Split-Brain-Schutz durch manuelle Promotion. # Admin muss "edgeguard-ctl promote" ausführen wenn PG-Failover gewünscht. -# -# Was wir tun: Alert loggen + edgeguard-api benachrichtigen. + logger -t keepalived -p daemon.warning \ "MASTER: VIP übernommen — PG-Rolle ist noch '$(cat /var/lib/edgeguard/pg_role 2>/dev/null || echo standby)'. Für PG-Failover: edgeguard-ctl promote" +# Dienste reloaden/starten damit sie die neu aktiven VIPs binden. +# Squid + Unbound + HAProxy binden beim Start an spezifische IPs — war der Dienst +# während des BACKUP-Zustands gecrasht oder gestoppt, muss er gestartet werden. +for svc in squid.service unbound.service haproxy.service; do + if systemctl is-active --quiet "$svc"; then + systemctl reload "$svc" 2>/dev/null || systemctl restart "$svc" 2>/dev/null || true + else + systemctl start "$svc" 2>/dev/null || true + fi +done +logger -t keepalived -p daemon.info "MASTER: squid/unbound/haproxy reload-or-start nach VIP-Übernahme" + # Alert an die API schicken (best-effort, ignoriert Fehler) curl -sf --max-time 3 -X POST \ -H "Content-Type: application/json" \ diff --git a/scripts/apt-repo/build-package.sh b/scripts/apt-repo/build-package.sh index 569f880..b7b7078 100755 --- a/scripts/apt-repo/build-package.sh +++ b/scripts/apt-repo/build-package.sh @@ -81,7 +81,7 @@ build_api() { # Keepalived notify-scripts → /usr/lib/edgeguard/ # postinst setzt chmod 0755 nach der Installation. mkdir -p "$build_dir/usr/lib/edgeguard" - for script in keepalived-check.sh keepalived-master.sh keepalived-backup.sh; do + for script in keepalived-check.sh keepalived-master.sh keepalived-backup.sh keepalived-gw-check.sh; do install -m 0755 "$REPO_ROOT/packaging/scripts/$script" \ "$build_dir/usr/lib/edgeguard/$script" done