Compare commits
119 Commits
2d027b3044
...
v1.2.88
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90f0df4c45 | ||
|
|
66c71c5fa8 | ||
|
|
f7dd7a3a4b | ||
|
|
025854150d | ||
|
|
134466293d | ||
|
|
13d0b557c1 | ||
|
|
ac3223411a | ||
|
|
19107ee702 | ||
|
|
2ab9da8e36 | ||
|
|
f0120b64f3 | ||
|
|
4f31e18d66 | ||
|
|
8a4fefee73 | ||
|
|
801fa26da7 | ||
|
|
220d9d7050 | ||
|
|
c83bb7b137 | ||
|
|
08119f8ccf | ||
|
|
8041e3924d | ||
|
|
884c52a8f3 | ||
|
|
05ac3344fa | ||
|
|
4d81d31022 | ||
|
|
4f887df658 | ||
|
|
66dec8cf61 | ||
|
|
3a122ffb0f | ||
|
|
bf16ce6666 | ||
|
|
bd32bc343a | ||
|
|
72f793552e | ||
|
|
b7b40ad641 | ||
|
|
d425b696f1 | ||
|
|
9580070a50 | ||
|
|
414dad6b3b | ||
|
|
112a945b5c | ||
|
|
1740cb7ae2 | ||
|
|
e999eb68c2 | ||
|
|
a84b9ae10a | ||
|
|
836016648c | ||
|
|
6a4460dfdc | ||
|
|
98aa7c0bcd | ||
|
|
b48ba65ce3 | ||
|
|
13cb9a8fc4 | ||
|
|
1d06b28064 | ||
|
|
49899e984c | ||
|
|
0ee754e231 | ||
|
|
eaa6a04234 | ||
|
|
884f84d3f1 | ||
|
|
bc6db1fc2b | ||
|
|
25c7cd0cb5 | ||
|
|
c1a4ccff8f | ||
|
|
ffb579879d | ||
|
|
6eb6d6d6a4 | ||
|
|
357113e7be | ||
|
|
8d83de6b0f | ||
|
|
6bb1c5c6d3 | ||
|
|
de28523708 | ||
|
|
8ac066d99a | ||
|
|
5ace250879 | ||
|
|
fd69081336 | ||
|
|
6a5aff69d9 | ||
|
|
2e84268f61 | ||
|
|
60e2aec5d9 | ||
|
|
afe2c951b9 | ||
|
|
7e5a7a83c0 | ||
|
|
a0b3bccbc6 | ||
|
|
33f6fef009 | ||
|
|
31d3485a2f | ||
|
|
0cbc781d4f | ||
|
|
bf09fd2cef | ||
|
|
7dd76d4b58 | ||
|
|
40414d472b | ||
|
|
9dee904169 | ||
|
|
d89569017d | ||
|
|
dc2c1b7471 | ||
|
|
2a9310e947 | ||
|
|
76b5a4586c | ||
|
|
fa86b14635 | ||
|
|
abde59f9b4 | ||
|
|
0a31a253b3 | ||
|
|
e2b8560c65 | ||
|
|
22146c422d | ||
|
|
2a76a6599b | ||
|
|
6a9019058a | ||
|
|
a6ef320c57 | ||
|
|
1c39a0e533 | ||
|
|
d87068535d | ||
|
|
7c50e28f55 | ||
|
|
74d3f25d21 | ||
|
|
44f184d505 | ||
|
|
192384c448 | ||
|
|
22528a54a9 | ||
|
|
36eea71c65 | ||
|
|
f378771dca | ||
|
|
123ee6e34f | ||
|
|
40a1b63c02 | ||
|
|
7f4606ba16 | ||
|
|
1c60affd27 | ||
|
|
7c86656c36 | ||
|
|
67c5180168 | ||
|
|
da554e9e82 | ||
|
|
326f79cf00 | ||
|
|
4406f9ac5b | ||
|
|
feae18772c | ||
|
|
b9dfab6664 | ||
|
|
ac6c580318 | ||
|
|
99b4225e64 | ||
|
|
711c4c7eb1 | ||
|
|
f29d74bad7 | ||
|
|
f6615fd27d | ||
|
|
844f9ddc83 | ||
|
|
4629679ba9 | ||
|
|
57b9cd89b2 | ||
|
|
757b58f607 | ||
|
|
c99b0cef4c | ||
|
|
cfb0e9ed01 | ||
|
|
73619c17f8 | ||
|
|
8d7a43bc8c | ||
|
|
ac068bc9dd | ||
|
|
0856c2fc10 | ||
|
|
d3a3b93c1b | ||
|
|
d4267e2003 | ||
|
|
8b0e8a8ab4 |
2
Makefile
2
Makefile
@@ -4,7 +4,7 @@
|
||||
|
||||
GO ?= $(shell which go || echo /usr/local/go/bin/go)
|
||||
MODULE := git.netcell-it.de/projekte/edgeguard-native
|
||||
BINARIES := edgeguard-api edgeguard-scheduler edgeguard-ctl
|
||||
BINARIES := edgeguard-api edgeguard-scheduler edgeguard-ctl edgeguard-waf
|
||||
VERSION := $(shell cat VERSION 2>/dev/null || echo 0.0.1-dev)
|
||||
LDFLAGS := -s -w -X main.version=$(VERSION)
|
||||
GOFLAGS := -trimpath -mod=readonly
|
||||
|
||||
@@ -40,6 +40,7 @@ import (
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/aggregator"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/clustertls"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/jointoken"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/clusterjoin"
|
||||
aptsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/apt"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/domainheaders"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/domains"
|
||||
@@ -58,9 +59,10 @@ import (
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
|
||||
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
|
||||
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
|
||||
wafsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/waf"
|
||||
)
|
||||
|
||||
var version = "1.1.89"
|
||||
var version = "1.2.35"
|
||||
|
||||
func main() {
|
||||
addr := os.Getenv("EDGEGUARD_API_ADDR")
|
||||
@@ -111,7 +113,7 @@ func main() {
|
||||
|
||||
requireAuth := handlers.RequireAuth(signer)
|
||||
|
||||
setupHdl := handlers.NewSetupHandler(setupStore)
|
||||
setupHdl := handlers.NewSetupHandler(setupStore).WithVersion(version)
|
||||
setupHdl.Register(v1)
|
||||
|
||||
// systemHdl exists früh damit sowohl der frühe (DB-pool nicht
|
||||
@@ -175,6 +177,20 @@ func main() {
|
||||
go runClusterHeartbeat(context.Background(), pool, nodeID, version)
|
||||
}
|
||||
|
||||
// Secondary: push config_hash to primary every 5 min so the primary's
|
||||
// ha_nodes reflects actual state. Without this, the primary retains the
|
||||
// stale hash written at join-time and the drift banner never clears.
|
||||
// st.IsClusterNode + PrimaryFQDN are only set on joined secondary nodes.
|
||||
if nodeID != "" && st != nil && st.IsClusterNode && st.PrimaryFQDN != "" {
|
||||
if primaryURL, normErr := clusterjoin.NormalizePrimaryURL(st.PrimaryFQDN); normErr == nil {
|
||||
go runPrimaryPush(context.Background(), pool, nodeID, st.FQDN, version, primaryURL)
|
||||
} else {
|
||||
slog.Warn("cluster: cannot normalize primary URL for push", "primary", st.PrimaryFQDN, "error", normErr)
|
||||
}
|
||||
// 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
|
||||
// frisch installierten Single-Node generieren wir die CA und
|
||||
// signieren uns selbst, damit der Agent-Listener auf :8443
|
||||
@@ -219,6 +235,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)
|
||||
@@ -268,14 +290,23 @@ func main() {
|
||||
systemHdl.WithAudit(auditRepo, nodeID)
|
||||
systemHdl.WithDB(pool)
|
||||
systemHdl.WithConfigPreviewers(map[string]func(context.Context) (string, error){
|
||||
"haproxy": haproxy.New(pool).RenderToString,
|
||||
"nftables": firewallrender.New(pool).RenderToString,
|
||||
"squid": squidrender.New(pool).RenderToString,
|
||||
"unbound": unboundrender.New(pool).RenderToString,
|
||||
"haproxy": haproxy.New(pool).RenderToString,
|
||||
"nftables": firewallrender.New(pool).RenderToString,
|
||||
"squid": squidrender.New(pool).RenderToString,
|
||||
"unbound": unboundrender.New(pool).RenderToString,
|
||||
"chrony": chronyrender.New(pool).RenderToString,
|
||||
"wireguard": wgrender.New(pool, secretsBox).RenderToString,
|
||||
})
|
||||
setupHdl.WithAudit(auditRepo, nodeID)
|
||||
setupHdl.WithClusterSupport(clusterStore, func(ctx context.Context) error {
|
||||
return firewallrender.New(pool).Render(ctx)
|
||||
})
|
||||
// Cluster-Node-Startup: Primary in lokalen ha_nodes eintragen damit
|
||||
// nftables @peer_ipv4 korrekt ist — auch ohne erneuten Join.
|
||||
go setupHdl.StartupPeerSync()
|
||||
usersRepo := usersvc.New(pool)
|
||||
authHdl.WithAudit(auditRepo, nodeID).WithUsers(usersRepo)
|
||||
authHdl.WithAudit(auditRepo, nodeID).WithUsers(usersRepo).WithClusterTLS(clusterTLSStore)
|
||||
systemHdl.WithUsers(usersRepo)
|
||||
|
||||
haproxyReloader := func(ctx context.Context) error {
|
||||
return haproxy.New(pool).Render(ctx)
|
||||
@@ -315,7 +346,9 @@ func main() {
|
||||
clusterHdl := handlers.NewClusterHandler(clusterStore, nodeID).
|
||||
WithAggregator(clusterAggregator).
|
||||
WithJoinFlow(clusterTLSStore, joinTokens).
|
||||
WithPeerReloader(peerReloader)
|
||||
WithPeerReloader(peerReloader).
|
||||
WithAudit(auditRepo, nodeID).
|
||||
WithVersion(version)
|
||||
clusterHdl.Register(authed)
|
||||
// /cluster/issue-cert läuft PUBLIC — joining Peer hat noch
|
||||
// keine Session/Cert. Token + Nonce-Tracking ist die einzige
|
||||
@@ -351,6 +384,8 @@ func main() {
|
||||
return firewallrender.New(pool).Render(ctx)
|
||||
}
|
||||
handlers.NewFirewallHandler(fwZones, fwAddrObj, fwAddrGrp, fwSvc, fwSvcGrp, fwRules, fwNAT, auditRepo, nodeID, fwReloader, pool).Register(authed)
|
||||
handlers.NewCrowdSecHandler(auditRepo, nodeID).Register(authed)
|
||||
handlers.NewWafHandler(wafsvc.New(pool), auditRepo, nodeID, haproxyReloader).Register(authed)
|
||||
|
||||
// withFW wraps a service-reloader so that AFTER the service is
|
||||
// reloaded, the firewall is also re-rendered. Necessary for
|
||||
@@ -401,6 +436,16 @@ func main() {
|
||||
}
|
||||
handlers.NewNTPHandler(ntpRepo, auditRepo, nodeID, withFW(chronyReloader)).Register(authed)
|
||||
|
||||
// Wire all service reloaders into systemHdl so RenderConfigs
|
||||
// re-renders every service from DB state in one shot.
|
||||
systemHdl.WithAllReloaders(map[string]func(context.Context) error{
|
||||
"nftables": fwReloader,
|
||||
"wireguard": wgReloader,
|
||||
"squid": squidReloader,
|
||||
"unbound": unboundReloader,
|
||||
"chrony": chronyReloader,
|
||||
})
|
||||
|
||||
// License — node-local key store + DB-mirror of last verify
|
||||
// result. Real verify runs against license.netcell-it.com via
|
||||
// internal/license; the scheduler triggers daily re-verify.
|
||||
@@ -436,7 +481,11 @@ func main() {
|
||||
// Listener wird nur gestartet wenn Cert-Material vorhanden ist;
|
||||
// auf einer frisch installierten Box hat die Init-Phase oben das
|
||||
// schon erledigt.
|
||||
startAgentListener(version, agentHdl)
|
||||
startAgentListener(version, agentHdl, systemHdl)
|
||||
|
||||
// Nach einem Upgrade-Neustart: wenn die State-Datei "updating-primary"
|
||||
// enthält, sind wir gerade neu gestartet → Update abgeschlossen → "done".
|
||||
handlers.FinishRollingUpdateIfPending()
|
||||
|
||||
log.Printf("edgeguard-api %s listening on %s", version, addr)
|
||||
srv := &http.Server{Addr: addr, Handler: r}
|
||||
@@ -450,7 +499,7 @@ func main() {
|
||||
// RegisterAgent — health + resources). Fehler im Cert-Load = no-op
|
||||
// + log; Fehler beim Listen.Serve loggen wir aber lassen die API
|
||||
// weiterlaufen.
|
||||
func startAgentListener(version string, clusterHdl *handlers.ClusterHandler) {
|
||||
func startAgentListener(version string, clusterHdl *handlers.ClusterHandler, sysHdl *handlers.SystemHandler) {
|
||||
store := clustertls.New("")
|
||||
serverTLS, err := store.ServerTLSConfig()
|
||||
if err != nil {
|
||||
@@ -470,7 +519,13 @@ func startAgentListener(version string, clusterHdl *handlers.ClusterHandler) {
|
||||
// hier implizit aus dem Binary (Peer-Roundtrip ist immer same-major).
|
||||
// Aggregator-Aufrufer sehen /agent/... direkt.
|
||||
root := r.Group("")
|
||||
handlers.NewSystemHandler(version).RegisterAgent(root)
|
||||
// Nutze den gewiredeten systemHdl (mit Users + Setup) damit
|
||||
// AgentAuthCheck Credentials gegen die echte DB prüfen kann.
|
||||
if sysHdl != nil {
|
||||
sysHdl.RegisterAgent(root)
|
||||
} else {
|
||||
handlers.NewSystemHandler(version).RegisterAgent(root)
|
||||
}
|
||||
if clusterHdl != nil {
|
||||
// Phase 3.5: /agent/cluster/peers (Auto-Register).
|
||||
clusterHdl.RegisterAgent(root)
|
||||
@@ -535,10 +590,18 @@ func mountUI(r *gin.Engine) {
|
||||
return
|
||||
}
|
||||
if info, err := os.Stat(full); err == nil && !info.IsDir() {
|
||||
// Vite hashed assets are immutable — cache them forever.
|
||||
// index.html must never be cached so updates take effect.
|
||||
if strings.HasPrefix(clean, "/assets/") {
|
||||
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||||
} else {
|
||||
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
}
|
||||
c.File(full)
|
||||
return
|
||||
}
|
||||
// SPA fallback — React Router renders the right page.
|
||||
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
c.File(indexPath)
|
||||
})
|
||||
}
|
||||
@@ -655,6 +718,116 @@ func runClusterHeartbeat(ctx context.Context, pool *pgxpoolPool, localID, versio
|
||||
}
|
||||
}
|
||||
|
||||
// runSecondaryConfigRender läuft auf Secondary-Nodes und re-rendert alle
|
||||
// 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.
|
||||
//
|
||||
// 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, 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
|
||||
}
|
||||
lastHash = hash
|
||||
slog.Info("cluster: secondary config changed via replication, re-rendering", "hash", hash)
|
||||
// HAProxy
|
||||
if err := haproxy.New(pool).Render(rCtx); err != nil {
|
||||
slog.Warn("cluster: secondary haproxy render failed", "error", err)
|
||||
}
|
||||
// nftables
|
||||
if err := firewallrender.New(pool).Render(rCtx); err != nil {
|
||||
slog.Warn("cluster: secondary nftables render failed", "error", err)
|
||||
}
|
||||
// WireGuard — Interface-Configs + wg-quick@<iface> 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 {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(30 * time.Second):
|
||||
render()
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
render()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runPrimaryPush periodically pushes this secondary node's config_hash to the
|
||||
// primary via mTLS. The primary's ha_nodes view only gets config_hash written
|
||||
// during join-time autoRegister — after that the primary never hears about
|
||||
// hash changes unless we push. Without this, the drift banner shows stale
|
||||
// hashes from join-time forever.
|
||||
func runPrimaryPush(ctx context.Context, pool *pgxpoolPool, nodeID, fqdn, version, primaryURL string) {
|
||||
const tick = 5 * time.Minute
|
||||
t := time.NewTicker(tick)
|
||||
defer t.Stop()
|
||||
push := func() {
|
||||
pCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
hash, _ := cluster.ComputeConfigHash(pCtx, pool)
|
||||
if err := clusterjoin.PushSelfToPrimary(primaryURL, "", nodeID, fqdn, version, hash); err != nil {
|
||||
slog.Warn("cluster: push-to-primary failed", "error", err)
|
||||
} else {
|
||||
slog.Debug("cluster: config_hash pushed to primary", "hash", hash)
|
||||
}
|
||||
}
|
||||
push() // immediate push on API startup
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
push()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func randomEphemeralSecret() []byte {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
|
||||
@@ -1,43 +1,14 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/clustertls"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/clusterjoin"
|
||||
)
|
||||
|
||||
// cmdClusterJoin: provisioniert auf diesem Node das Cluster-Cert-
|
||||
// Material durch einen Aufruf an /api/v1/cluster/issue-cert beim
|
||||
// Primary.
|
||||
//
|
||||
// Usage:
|
||||
// edgeguard-ctl cluster-join <primary-fqdn-or-url> --token <token>
|
||||
// [--insecure]
|
||||
// [--cn <fqdn>]
|
||||
//
|
||||
// --insecure: TLS-Verify überspringen (für Bootstrap wenn der
|
||||
// Primary mit self-signed Cert läuft und die CA noch
|
||||
// nicht woanders verteilt ist — der Cert-Issue-Flow
|
||||
// selbst läuft über HMAC-Token, nicht über TLS-Trust).
|
||||
// --cn: Subject-CN für unseren CSR. Default: os.Hostname().
|
||||
//
|
||||
// Output: schreibt ca.crt + peer.{crt,key} nach /var/lib/edgeguard/
|
||||
// cluster-tls/. Falls Cert-Material schon vorhanden, abort mit
|
||||
// hint auf manuellen rm — wir wollen nicht aus Versehen einen
|
||||
// laufenden Cluster-Node von seiner identity bringen.
|
||||
func cmdClusterJoin(args []string) int {
|
||||
fs := flag.NewFlagSet("cluster-join", flag.ContinueOnError)
|
||||
tokenFlag := fs.String("token", "", "cluster join token (eg-join-v1.…)")
|
||||
@@ -52,93 +23,32 @@ func cmdClusterJoin(args []string) int {
|
||||
fmt.Fprintln(os.Stderr, "usage: edgeguard-ctl cluster-join <primary-fqdn-or-url> --token <…>")
|
||||
return 2
|
||||
}
|
||||
primary := fs.Arg(0)
|
||||
if *tokenFlag == "" {
|
||||
fmt.Fprintln(os.Stderr, "edgeguard-ctl cluster-join: --token required")
|
||||
return 2
|
||||
}
|
||||
store := clustertls.New(*clusterTLSDir)
|
||||
if store.HasPeer() {
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"edgeguard-ctl cluster-join: peer cert already present under %s — "+
|
||||
"refuse to overwrite. Run 'rm -rf %s' first if this is intentional.\n",
|
||||
*clusterTLSDir, *clusterTLSDir)
|
||||
return 1
|
||||
}
|
||||
|
||||
commonName := *cn
|
||||
if commonName == "" {
|
||||
h, _ := os.Hostname()
|
||||
commonName = h
|
||||
}
|
||||
if commonName == "" {
|
||||
commonName = "edgeguard-node"
|
||||
}
|
||||
|
||||
endpoint, err := normalizePrimaryURL(primary)
|
||||
if err != nil {
|
||||
if err := clusterjoin.Join(clusterjoin.Request{
|
||||
PrimaryFQDN: fs.Arg(0),
|
||||
Token: *tokenFlag,
|
||||
CommonName: commonName,
|
||||
Insecure: *insecure,
|
||||
TLSDir: *clusterTLSDir,
|
||||
Version: version,
|
||||
}); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "edgeguard-ctl cluster-join: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
// SAN: gleicher CN + Hostname. IPs hängen wir an wenn das Host-
|
||||
// Argument eine IP war, damit der lokale Agent-Listener auch
|
||||
// gegen IP gechecked werden kann.
|
||||
dnsNames := []string{commonName}
|
||||
var ips []net.IP
|
||||
if ip := net.ParseIP(commonName); ip != nil {
|
||||
ips = append(ips, ip)
|
||||
// Wenn CN eine IP ist, lassen wir DNSNames leer — RFC 6125
|
||||
// erlaubt nicht beides als-ob-DNS.
|
||||
dnsNames = nil
|
||||
}
|
||||
|
||||
keyPEM, csrPEM, err := clustertls.NewPeerKeyAndCSR(commonName, dnsNames, ips)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "edgeguard-ctl cluster-join: gen CSR: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
caCertPEM, peerCertPEM, err := postIssueCert(endpoint, *tokenFlag, csrPEM, *insecure)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "edgeguard-ctl cluster-join: issue-cert: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(*clusterTLSDir, 0o700); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "edgeguard-ctl cluster-join: mkdir %s: %v\n", *clusterTLSDir, err)
|
||||
return 1
|
||||
}
|
||||
// Schreiben in stabiler Reihenfolge: erst CA (wird vom Peer-Cert-
|
||||
// Verify gebraucht), dann peer.{crt,key}.
|
||||
for _, w := range []struct {
|
||||
name string
|
||||
mode os.FileMode
|
||||
data string
|
||||
}{
|
||||
{"ca.crt", 0o644, caCertPEM},
|
||||
{"peer.crt", 0o644, peerCertPEM},
|
||||
{"peer.key", 0o600, keyPEM},
|
||||
} {
|
||||
path := *clusterTLSDir + "/" + w.name
|
||||
if err := os.WriteFile(path, []byte(w.data), w.mode); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "edgeguard-ctl cluster-join: write %s: %v\n", path, err)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3.5: Auto-Register beim Primary. Nutzt das frisch erhaltene
|
||||
// Peer-Cert via mTLS, damit der Primary uns in ha_nodes mit
|
||||
// status='joining' anlegt + sein peer_ipv4-Set updated.
|
||||
if err := autoRegister(endpoint, *clusterTLSDir, commonName); err != nil {
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"edgeguard-ctl cluster-join: auto-register failed (Cert-Material liegt aber schon — kannst manuell nachholen): %v\n", err)
|
||||
// Wir geben hier NICHT-NULL zurück — der Cert-Issue war ja
|
||||
// erfolgreich. Der Operator kann manuell registrieren oder
|
||||
// es funktioniert beim Service-Start (Phase 3.2 Heartbeat).
|
||||
}
|
||||
|
||||
primary, _ := clusterjoin.NormalizePrimaryURL(fs.Arg(0))
|
||||
fmt.Printf("Cluster-Join erfolgreich.\n")
|
||||
fmt.Printf(" Primary: %s\n", endpoint)
|
||||
fmt.Printf(" Primary: %s\n", primary)
|
||||
fmt.Printf(" CN: %s\n", commonName)
|
||||
fmt.Printf(" Files: %s/{ca.crt,peer.crt,peer.key}\n", *clusterTLSDir)
|
||||
fmt.Printf("\nNächste Schritte:\n")
|
||||
@@ -147,150 +57,3 @@ func cmdClusterJoin(args []string) int {
|
||||
fmt.Printf(" 3) PG-Basebackup + KeyDB-Replica-Setup folgt mit Phase 3.5 (manuell bis dahin)\n")
|
||||
return 0
|
||||
}
|
||||
|
||||
// autoRegister: POST mTLS an <primary-host>:8443/agent/cluster/peers.
|
||||
// Note: der mTLS-Agent-Port :8443 ist anders als der Public-Port
|
||||
// (3443). Wir leiten den Host aus der primary-URL ab und ersetzen
|
||||
// den Port.
|
||||
func autoRegister(primary, tlsDir, commonName string) error {
|
||||
// Primary-URL parse + Port-Override
|
||||
u, err := url.Parse(primary)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.Host = u.Hostname() + ":8443"
|
||||
u.Path = "/agent/cluster/peers"
|
||||
|
||||
// Local node-id + body bauen. node-id liegt in /var/lib/edgeguard/
|
||||
// node-id (vom Heartbeat-Subsystem persistiert); wir lesen direkt
|
||||
// statt cluster.EnsureNodeID() um den DB-Abhängigkeit-Pfad nicht
|
||||
// zu öffnen.
|
||||
nodeID, _ := os.ReadFile("/var/lib/edgeguard/node-id")
|
||||
hostname, _ := os.Hostname()
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"id": strings.TrimSpace(string(nodeID)),
|
||||
"name": hostname,
|
||||
"fqdn": commonName,
|
||||
"api_url": "https://" + commonName + ":3443",
|
||||
"version": version,
|
||||
})
|
||||
|
||||
// mTLS-Client mit gerade frisch geschriebenem Material.
|
||||
pair, err := tls.LoadX509KeyPair(tlsDir+"/peer.crt", tlsDir+"/peer.key")
|
||||
if err != nil {
|
||||
return fmt.Errorf("load peer cert: %w", err)
|
||||
}
|
||||
caPEM, err := os.ReadFile(tlsDir + "/ca.crt")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read ca: %w", err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(caPEM) {
|
||||
return errors.New("invalid ca.crt")
|
||||
}
|
||||
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
Certificates: []tls.Certificate{pair},
|
||||
RootCAs: pool,
|
||||
MinVersion: tls.VersionTLS13,
|
||||
// Hostname-Verify: wir checken gegen den CN/SAN des
|
||||
// Primary-Cert. Wenn der Primary-Cert das nicht hat
|
||||
// (Self-Signed for IP only), kann der join trotzdem
|
||||
// erfolgreich sein wenn das CA-Cert validiert.
|
||||
ServerName: u.Hostname(),
|
||||
},
|
||||
TLSHandshakeTimeout: 5 * time.Second,
|
||||
ResponseHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
client := &http.Client{Transport: tr, Timeout: 30 * time.Second}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, u.String(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizePrimaryURL: nimmt "fqdn", "host:port" oder "https://host:port"
|
||||
// und liefert immer "https://host:port" zurück. Default-Port 3443 (das
|
||||
// ist der Mgmt-UI-Listener; /cluster/issue-cert läuft dort).
|
||||
func normalizePrimaryURL(in string) (string, error) {
|
||||
in = strings.TrimSpace(in)
|
||||
if in == "" {
|
||||
return "", errors.New("empty primary fqdn/url")
|
||||
}
|
||||
if !strings.HasPrefix(in, "http://") && !strings.HasPrefix(in, "https://") {
|
||||
in = "https://" + in
|
||||
}
|
||||
u, err := url.Parse(in)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if u.Hostname() == "" {
|
||||
return "", errors.New("primary URL has no host")
|
||||
}
|
||||
if u.Port() == "" {
|
||||
u.Host = u.Hostname() + ":3443"
|
||||
}
|
||||
u.Path = ""
|
||||
u.RawQuery = ""
|
||||
u.Fragment = ""
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
// postIssueCert: POSTet {token, csr} an <primary>/api/v1/cluster/issue-cert.
|
||||
// `insecure` skippt TLS-Verify damit der Bootstrap auch wenn der Primary
|
||||
// mit self-signed Cert hört durchgeht — die Sicherheit hängt am HMAC-
|
||||
// gesigneten Token, nicht am TLS-Layer.
|
||||
func postIssueCert(primary, token, csr string, insecure bool) (caCert, peerCert string, err error) {
|
||||
body, _ := json.Marshal(map[string]string{"token": token, "csr": csr})
|
||||
req, err := http.NewRequest(http.MethodPost,
|
||||
primary+"/api/v1/cluster/issue-cert", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure, MinVersion: tls.VersionTLS12},
|
||||
TLSHandshakeTimeout: 5 * time.Second,
|
||||
ResponseHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
client := &http.Client{Transport: tr, Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
|
||||
}
|
||||
var env struct {
|
||||
Data struct {
|
||||
CACert string `json:"ca_cert"`
|
||||
PeerCert string `json:"peer_cert"`
|
||||
} `json:"data"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
return "", "", fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
if env.Error != "" {
|
||||
return "", "", fmt.Errorf("server: %s", env.Error)
|
||||
}
|
||||
if env.Data.CACert == "" || env.Data.PeerCert == "" {
|
||||
return "", "", errors.New("response missing ca_cert or peer_cert")
|
||||
}
|
||||
return env.Data.CACert, env.Data.PeerCert, nil
|
||||
}
|
||||
|
||||
573
cmd/edgeguard-ctl/cluster_replication.go
Normal file
573
cmd/edgeguard-ctl/cluster_replication.go
Normal file
@@ -0,0 +1,573 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/clustertls"
|
||||
)
|
||||
|
||||
const (
|
||||
egReplSecret = "/var/lib/edgeguard/pg-replication-secret"
|
||||
egReplUser = "edgeguard_replicator"
|
||||
egPubName = "edgeguard_shared"
|
||||
egSubName = "edgeguard_sub"
|
||||
)
|
||||
|
||||
// pgConfig hält die zur Laufzeit erkannten PG-Pfade.
|
||||
type pgConfig struct {
|
||||
Version string // z.B. "17"
|
||||
Cluster string // z.B. "main"
|
||||
DataDir string // /var/lib/postgresql/17/main
|
||||
HBAPath string // /etc/postgresql/17/main/pg_hba.conf
|
||||
ConfD string // /etc/postgresql/17/main/conf.d
|
||||
}
|
||||
|
||||
// detectPGConfig ermittelt Version, Cluster und Pfade aus der laufenden
|
||||
// PG-Instanz via SHOW hba_file / SHOW data_directory. Damit ist der Code
|
||||
// unabhängig von der PG-Hauptversion (16, 17, …).
|
||||
func detectPGConfig() (pgConfig, error) {
|
||||
hbaRaw, err := psqlRun([]string{"-tA", "-c", "SHOW hba_file;"})
|
||||
if err != nil {
|
||||
return pgConfig{}, fmt.Errorf("cannot detect pg hba_file: %w", err)
|
||||
}
|
||||
hbaPath := strings.TrimSpace(string(hbaRaw))
|
||||
|
||||
dataRaw, err := psqlRun([]string{"-tA", "-c", "SHOW data_directory;"})
|
||||
if err != nil {
|
||||
return pgConfig{}, fmt.Errorf("cannot detect pg data_directory: %w", err)
|
||||
}
|
||||
dataDir := strings.TrimSpace(string(dataRaw))
|
||||
|
||||
// hbaPath: /etc/postgresql/<version>/<cluster>/pg_hba.conf
|
||||
parts := strings.Split(filepath.ToSlash(hbaPath), "/")
|
||||
if len(parts) < 6 {
|
||||
return pgConfig{}, fmt.Errorf("unexpected hba_file path: %s", hbaPath)
|
||||
}
|
||||
version := parts[3]
|
||||
cluster := parts[4]
|
||||
confD := filepath.Join("/etc/postgresql", version, cluster, "conf.d")
|
||||
|
||||
return pgConfig{
|
||||
Version: version,
|
||||
Cluster: cluster,
|
||||
DataDir: dataDir,
|
||||
HBAPath: hbaPath,
|
||||
ConfD: confD,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// localOnlyTables listet alle Tabellen die nicht in die Replikations-
|
||||
// Publication aufgenommen werden. Alles andere wird automatisch repliziert.
|
||||
var localOnlyTables = []string{
|
||||
"ha_nodes", // Node-Identität, Status
|
||||
"network_interfaces", // Eigene Interfaces (eth0, eth1 …)
|
||||
"ip_addresses", // Eigene IP-Adressen (unterschiedlich pro Node!)
|
||||
"static_routes", // Node-spezifisches Routing
|
||||
"cluster_settings", // VIP-Interface kann pro Node unterschiedlich sein
|
||||
"dns_settings", // listen_addresses ist node-spezifisch
|
||||
"ntp_settings", // listen_addresses ist node-spezifisch
|
||||
"system_settings", // Hostname, Maintenance-Mode etc.
|
||||
"join_tokens_used", // Token-Tracking nur auf Primary relevant
|
||||
"audit_log", // Lokales Audit-Protokoll
|
||||
"alert_events", // Lokale Laufzeit-Events
|
||||
"backups", // Backup-Historie ist per-Node
|
||||
"goose_db_version", // Migration-Tracking, internes Tool-State
|
||||
}
|
||||
|
||||
// cmdClusterInitReplication richtet PG auf dieser Node als Logical-Replication-
|
||||
// Primary ein. Idempotent — kann gefahrlos mehrfach laufen.
|
||||
//
|
||||
// Ablauf:
|
||||
// 1. edgeguard_replicator-Rolle anlegen/aktualisieren
|
||||
// 2. Passwort → /var/lib/edgeguard/pg-replication-secret
|
||||
// 3. conf.d/edgeguard-replication.conf mit wal_level=logical schreiben
|
||||
// 4. pg_hba.conf für Replikations-Verbindungen aktualisieren
|
||||
// 5. SELECT-Grants auf alle geteilten Tabellen
|
||||
// 6. PUBLICATION erstellen (alle Tabellen außer localOnlyTables)
|
||||
// 7. PG reload
|
||||
func cmdClusterInitReplication(args []string) int {
|
||||
fs := flag.NewFlagSet("cluster-init-replication", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
|
||||
pg, err := detectPGConfig()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "cluster-init-replication: PG-Erkennung:", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Printf("→ PostgreSQL %s/%s erkannt\n", pg.Version, pg.Cluster)
|
||||
|
||||
// 1. Passwort generieren
|
||||
pass, err := generatePassword(32)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "cluster-init-replication: generate password:", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
// 2. edgeguard_replicator-Rolle anlegen/updaten
|
||||
roleSQL := fmt.Sprintf(`DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '%s') THEN
|
||||
CREATE ROLE %s REPLICATION LOGIN PASSWORD '%s';
|
||||
ELSE
|
||||
ALTER ROLE %s PASSWORD '%s';
|
||||
END IF;
|
||||
END
|
||||
$$`, egReplUser, egReplUser, pass, egReplUser, pass)
|
||||
if err := psqlExec(roleSQL); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "cluster-init-replication: create replication role:", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Printf("✓ Replication-Rolle %q angelegt/aktualisiert\n", egReplUser)
|
||||
|
||||
// 3. Passwort speichern
|
||||
if err := os.MkdirAll(filepath.Dir(egReplSecret), 0o750); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "cluster-init-replication: mkdir:", err)
|
||||
return 1
|
||||
}
|
||||
if err := os.WriteFile(egReplSecret, []byte(pass), 0o600); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "cluster-init-replication: write secret:", err)
|
||||
return 1
|
||||
}
|
||||
// Ownership an edgeguard-api-User übergeben damit die API lesen kann
|
||||
if u, err := user.Lookup("edgeguard"); err == nil {
|
||||
uid, _ := strconv.Atoi(u.Uid)
|
||||
gid, _ := strconv.Atoi(u.Gid)
|
||||
_ = os.Chown(egReplSecret, uid, gid)
|
||||
}
|
||||
fmt.Printf("✓ Replication-Secret gespeichert: %s\n", egReplSecret)
|
||||
|
||||
// 4. conf.d/edgeguard-replication.conf schreiben
|
||||
// wal_level=logical ist eine Obermenge von replica — unterstützt
|
||||
// sowohl Logical Replication als auch ggfs. physisches WAL-Archiving.
|
||||
if err := os.MkdirAll(pg.ConfD, 0o755); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "cluster-init-replication: conf.d mkdir:", err)
|
||||
return 1
|
||||
}
|
||||
replConf := `# EdgeGuard Logical Replication — automatisch generiert
|
||||
# Nicht manuell bearbeiten; wird von edgeguard-ctl cluster-init-replication verwaltet.
|
||||
wal_level = logical
|
||||
max_wal_senders = 10
|
||||
max_replication_slots = 20
|
||||
max_logical_replication_workers = 4
|
||||
wal_keep_size = 512MB
|
||||
# Lausche auf localhost + alle konfigurierten Interfaces damit Cluster-Peers
|
||||
# sich verbinden können. '*' ist sicher weil pg_hba.conf den Zugriff auf
|
||||
# bekannte Replikations-User beschränkt.
|
||||
listen_addresses = '*'
|
||||
`
|
||||
confPath := filepath.Join(pg.ConfD, "edgeguard-replication.conf")
|
||||
if err := os.WriteFile(confPath, []byte(replConf), 0o644); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "cluster-init-replication: write postgresql conf:", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Printf("✓ %s geschrieben (wal_level=logical)\n", confPath)
|
||||
|
||||
// 5. pg_hba.conf aktualisieren
|
||||
if err := ensureHBAReplication(pg.HBAPath); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "cluster-init-replication: pg_hba.conf:", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Printf("✓ %s aktualisiert\n", pg.HBAPath)
|
||||
|
||||
// 6. PG reload (damit wal_level + pg_hba aktiv werden)
|
||||
if out, err := exec.Command("pg_ctlcluster", pg.Version, pg.Cluster, "reload").CombinedOutput(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "cluster-init-replication: pg reload failed: %v\n%s\n", err, out)
|
||||
return 1
|
||||
}
|
||||
fmt.Printf("✓ PostgreSQL %s/%s neu geladen\n", pg.Version, pg.Cluster)
|
||||
|
||||
// 7. SELECT-Grants: edgeguard_replicator muss alle zu replizierenden
|
||||
// Tabellen lesen können. DEFAULT PRIVILEGES sichert zukünftige Tabellen.
|
||||
grantSQL := fmt.Sprintf(`
|
||||
GRANT SELECT ON ALL TABLES IN SCHEMA public TO %s;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO %s;
|
||||
`, egReplUser, egReplUser)
|
||||
if err := psqlDBExec("edgeguard", grantSQL); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "cluster-init-replication: grant SELECT:", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Printf("✓ SELECT auf alle Tabellen für %q gewährt\n", egReplUser)
|
||||
|
||||
// 8. PUBLICATION erstellen — alle public-Tabellen außer localOnlyTables.
|
||||
// Idempotent: DROP IF EXISTS + CREATE.
|
||||
if err := createPublication(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "cluster-init-replication: create publication:", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Printf("✓ PUBLICATION %q erstellt\n", egPubName)
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("Nächste Schritte:")
|
||||
fmt.Println(" 1) Auf dem Secondary: edgeguard-ctl cluster-setup-standby <primary-ip>")
|
||||
fmt.Println(" 2) Cluster-Settings (VIP) auf BEIDEN Nodes separat konfigurieren")
|
||||
fmt.Println(" → Settings → Cluster → VIP/Keepalived")
|
||||
return 0
|
||||
}
|
||||
|
||||
// createPublication baut die PUBLICATION dynamisch aus allen Tabellen
|
||||
// im public-Schema minus localOnlyTables. Idempotent: löscht eine
|
||||
// bestehende Publication gleichen Namens zuerst.
|
||||
func createPublication() error {
|
||||
// Alle Tabellen im public-Schema ermitteln
|
||||
listSQL := `SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename`
|
||||
out, err := psqlDBRun("edgeguard", []string{"-tA", "-c", listSQL})
|
||||
if err != nil {
|
||||
return fmt.Errorf("list tables: %w", err)
|
||||
}
|
||||
|
||||
excluded := make(map[string]bool)
|
||||
for _, t := range localOnlyTables {
|
||||
excluded[t] = true
|
||||
}
|
||||
|
||||
var tables []string
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
t := strings.TrimSpace(line)
|
||||
if t == "" || excluded[t] {
|
||||
continue
|
||||
}
|
||||
tables = append(tables, t)
|
||||
}
|
||||
if len(tables) == 0 {
|
||||
return fmt.Errorf("keine Tabellen für Publication gefunden")
|
||||
}
|
||||
|
||||
dropSQL := fmt.Sprintf("DROP PUBLICATION IF EXISTS %s;", egPubName)
|
||||
if err := psqlDBExec("edgeguard", dropSQL); err != nil {
|
||||
return fmt.Errorf("drop old publication: %w", err)
|
||||
}
|
||||
|
||||
createSQL := fmt.Sprintf("CREATE PUBLICATION %s FOR TABLE %s;",
|
||||
egPubName, strings.Join(tables, ", "))
|
||||
if err := psqlDBExec("edgeguard", createSQL); err != nil {
|
||||
return fmt.Errorf("create publication: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureHBAReplication fügt Einträge für die Replikations-Verbindung
|
||||
// in pg_hba.conf ein. Für Logical Replication brauchen wir einen
|
||||
// normalen "host edgeguard"-Eintrag (nicht "host replication").
|
||||
// Idempotent via Marker-Kommentar.
|
||||
func ensureHBAReplication(hbaPath string) error {
|
||||
data, err := os.ReadFile(hbaPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read: %w", err)
|
||||
}
|
||||
const marker = "# EdgeGuard replication"
|
||||
if strings.Contains(string(data), marker) {
|
||||
return nil
|
||||
}
|
||||
entry := fmt.Sprintf(`
|
||||
%s
|
||||
host edgeguard %s 0.0.0.0/0 scram-sha-256
|
||||
host edgeguard %s ::/0 scram-sha-256
|
||||
host replication %s 0.0.0.0/0 scram-sha-256
|
||||
host replication %s ::/0 scram-sha-256
|
||||
`, marker, egReplUser, egReplUser, egReplUser, egReplUser)
|
||||
f, err := os.OpenFile(hbaPath, os.O_APPEND|os.O_WRONLY, 0o640)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = f.WriteString(entry)
|
||||
return err
|
||||
}
|
||||
|
||||
// cmdClusterSetupStandby richtet diesen Node als Logical-Replication-
|
||||
// Subscriber ein. Der Secondary behält seine eigene beschreibbare PG-
|
||||
// Instanz — nur die geteilten Tabellen werden vom Primary repliziert.
|
||||
// Node-spezifische Tabellen (Interfaces, IPs, Routen, VIP-Settings …)
|
||||
// bleiben lokal und werden NICHT überschrieben. Analog zu OPNsense's
|
||||
// HA-Sync: Interface-IPs und Hostname bleiben immer per-Node konfiguriert.
|
||||
//
|
||||
// Voraussetzungen:
|
||||
// - cluster-join erfolgreich (TLS-Certs in /var/lib/edgeguard/cluster-tls/)
|
||||
// - Primary hat cluster-init-replication ausgeführt
|
||||
// - Dieser Node hat edgeguard-api schon gelaufen (Migrations ausgeführt)
|
||||
//
|
||||
// Ablauf:
|
||||
// 1. Replication-Credentials via mTLS vom Primary holen
|
||||
// 2. Bestehende Subscription löschen (idempotent)
|
||||
// 3. SUBSCRIPTION auf Primary erstellen (copy_data=true → Initialkopiierung)
|
||||
// 4. Warten bis Initialkopiierung abgeschlossen
|
||||
// 5. render-config ausführen damit Service-Configs den neuen Stand reflektieren
|
||||
func cmdClusterSetupStandby(args []string) int {
|
||||
fs := flag.NewFlagSet("cluster-setup-standby", flag.ContinueOnError)
|
||||
agentPort := fs.Int("agent-port", 8443, "mTLS agent port on primary")
|
||||
tlsDir := fs.String("tls-dir", clustertls.DefaultDir, "Verzeichnis mit ca.crt + peer.{crt,key}")
|
||||
fs.SetOutput(os.Stderr)
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
if fs.NArg() < 1 {
|
||||
fmt.Fprintln(os.Stderr, "usage: edgeguard-ctl cluster-setup-standby <primary-ip-or-host>")
|
||||
return 2
|
||||
}
|
||||
primaryHost := fs.Arg(0)
|
||||
|
||||
// 1. Replication-Credentials vom Primary holen
|
||||
creds, err := fetchReplicationCreds(primaryHost, *agentPort, *tlsDir)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "cluster-setup-standby: replication-creds: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Printf("✓ Replication-Credentials von %s:%d erhalten\n", primaryHost, *agentPort)
|
||||
|
||||
// 2. Bestehende Subscription löschen (idempotent)
|
||||
dropSQL := fmt.Sprintf(`
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (SELECT FROM pg_subscription WHERE subname = '%s') THEN
|
||||
ALTER SUBSCRIPTION %s DISABLE;
|
||||
ALTER SUBSCRIPTION %s SET (slot_name = NONE);
|
||||
DROP SUBSCRIPTION %s;
|
||||
END IF;
|
||||
END $$;`, egSubName, egSubName, egSubName, egSubName)
|
||||
if err := psqlDBExec("edgeguard", dropSQL); err != nil {
|
||||
// Nicht fatal — wenn PG noch keine Subscription kennt ist das OK
|
||||
fmt.Printf(" → keine bestehende Subscription gefunden (ok)\n")
|
||||
} else {
|
||||
fmt.Println("✓ Bestehende Subscription entfernt")
|
||||
}
|
||||
|
||||
// 3. SUBSCRIPTION erstellen
|
||||
// sslmode=require: Verbindung zwischen Cluster-Nodes soll immer verschlüsselt sein.
|
||||
// copy_data=true: Initialkopiierung aller geteilten Tabellen vom Primary.
|
||||
connStr := fmt.Sprintf(
|
||||
"host=%s port=%d user=%s password=%s dbname=edgeguard sslmode=require",
|
||||
creds.Host, creds.Port, creds.User, creds.Password,
|
||||
)
|
||||
createSQL := fmt.Sprintf(
|
||||
"CREATE SUBSCRIPTION %s CONNECTION '%s' PUBLICATION %s WITH (copy_data = true, enabled = true);",
|
||||
egSubName, connStr, egPubName,
|
||||
)
|
||||
if err := psqlDBExec("edgeguard", createSQL); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "cluster-setup-standby: create subscription: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Printf("✓ SUBSCRIPTION %q erstellt — Initialkopiierung läuft\n", egSubName)
|
||||
|
||||
// 4. Warten bis Initialkopiierung abgeschlossen
|
||||
fmt.Print("→ Warte auf Initialkopiierung")
|
||||
deadline := time.Now().Add(5 * time.Minute)
|
||||
for time.Now().Before(deadline) {
|
||||
pendingSQL := fmt.Sprintf(`
|
||||
SELECT COUNT(*) FROM pg_subscription_rel
|
||||
WHERE srsubid = (SELECT oid FROM pg_subscription WHERE subname = '%s')
|
||||
AND srsubstate != 'r';`, egSubName)
|
||||
out, err := psqlDBRun("edgeguard", []string{"-tA", "-c", pendingSQL})
|
||||
if err == nil && strings.TrimSpace(string(out)) == "0" {
|
||||
break
|
||||
}
|
||||
fmt.Print(".")
|
||||
time.Sleep(3 * time.Second)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// Finale Prüfung
|
||||
checkSQL := fmt.Sprintf(`
|
||||
SELECT COUNT(*) FROM pg_subscription_rel
|
||||
WHERE srsubid = (SELECT oid FROM pg_subscription WHERE subname = '%s')
|
||||
AND srsubstate != 'r';`, egSubName)
|
||||
if out, err := psqlDBRun("edgeguard", []string{"-tA", "-c", checkSQL}); err == nil {
|
||||
if n := strings.TrimSpace(string(out)); n != "0" {
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"cluster-setup-standby: %s Tabellen noch nicht synchronisiert — prüfe PG-Logs\n", n)
|
||||
fmt.Println(" → Subscription läuft trotzdem weiter im Hintergrund")
|
||||
} else {
|
||||
fmt.Println("✓ Alle geteilten Tabellen synchronisiert")
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Master-Key vom Primary holen — für WireGuard-Key-Entschlüsselung
|
||||
fmt.Println("→ Secrets Master-Key vom Primary synchronisieren...")
|
||||
if err := syncMasterKey(primaryHost, *agentPort, *tlsDir); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "cluster-setup-standby: master-key: %v (WireGuard-Keys können nicht entschlüsselt werden)\n", err)
|
||||
} else {
|
||||
fmt.Println("✓ Master-Key synchronisiert")
|
||||
}
|
||||
|
||||
// 6. render-config ausführen — muss als edgeguard-User laufen (DB-Zugriff)
|
||||
fmt.Println("→ Service-Configs neu rendern...")
|
||||
if out, err := exec.Command("sudo", "-u", "edgeguard", "edgeguard-ctl", "render-config").CombinedOutput(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "cluster-setup-standby: render-config: %v\n%s\n", err, out)
|
||||
fmt.Println(" → Manuell nachholen: sudo -u edgeguard edgeguard-ctl render-config")
|
||||
} else {
|
||||
fmt.Print(string(out))
|
||||
fmt.Println("✓ Service-Configs aktualisiert")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("✓ Logical Replication eingerichtet.")
|
||||
fmt.Println()
|
||||
fmt.Println("Was repliziert wird (automatisch, in Echtzeit):")
|
||||
fmt.Println(" Domains, Backends, Firewall-Rules, WireGuard, DNS-Zones,")
|
||||
fmt.Println(" TLS-Certs, Users, Forward-Proxy, NTP-Pools, ...")
|
||||
fmt.Println()
|
||||
fmt.Println("Was NICHT repliziert wird (bleibt pro Node konfiguriert):")
|
||||
fmt.Println(" Netzwerk-Interfaces, IP-Adressen, Routen,")
|
||||
fmt.Println(" Cluster-Settings (VIP-Interface!), DNS/NTP-Listen-Adressen")
|
||||
fmt.Println()
|
||||
fmt.Println("Nächste Schritte:")
|
||||
fmt.Println(" 1) sudo systemctl restart edgeguard-api")
|
||||
fmt.Println(" 2) VIP/Keepalived auf BEIDEN Nodes separat konfigurieren:")
|
||||
fmt.Println(" Settings → Cluster → VIP/Keepalived")
|
||||
fmt.Println(" 3) Bei Failover: edgeguard-ctl promote (auf dem Secondary)")
|
||||
return 0
|
||||
}
|
||||
|
||||
// pgReplicationCreds sind die Credentials die der Primary via mTLS zurückgibt.
|
||||
type pgReplicationCreds struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// fetchReplicationCreds ruft GET /agent/cluster/pg-replication-info via mTLS ab.
|
||||
func fetchReplicationCreds(host string, agentPort int, tlsDir string) (*pgReplicationCreds, error) {
|
||||
caPath := filepath.Join(tlsDir, "ca.crt")
|
||||
certPath := filepath.Join(tlsDir, "peer.crt")
|
||||
keyPath := filepath.Join(tlsDir, "peer.key")
|
||||
|
||||
caCert, err := os.ReadFile(caPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read ca.crt: %w", err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
pool.AppendCertsFromPEM(caCert)
|
||||
|
||||
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load peer cert: %w", err)
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
RootCAs: pool,
|
||||
Certificates: []tls.Certificate{cert},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://%s:%d/agent/cluster/pg-replication-info", host, agentPort)
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GET %s: %w", url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("GET %s: HTTP %d", url, resp.StatusCode)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Data pgReplicationCreds `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
return &result.Data, nil
|
||||
}
|
||||
|
||||
// syncMasterKey holt den Secrets-Master-Key vom Primary via mTLS und schreibt
|
||||
// ihn nach /var/lib/edgeguard/.master_key. Dadurch können replizierte
|
||||
// verschlüsselte WireGuard-Keys und PSKs auf dem Secondary entschlüsselt werden.
|
||||
func syncMasterKey(host string, agentPort int, tlsDir string) error {
|
||||
caPath := filepath.Join(tlsDir, "ca.crt")
|
||||
certPath := filepath.Join(tlsDir, "peer.crt")
|
||||
keyPath := filepath.Join(tlsDir, "peer.key")
|
||||
|
||||
caCert, err := os.ReadFile(caPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read ca.crt: %w", err)
|
||||
}
|
||||
rootPool := x509.NewCertPool()
|
||||
rootPool.AppendCertsFromPEM(caCert)
|
||||
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load peer cert: %w", err)
|
||||
}
|
||||
client := &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
RootCAs: rootPool,
|
||||
Certificates: []tls.Certificate{cert},
|
||||
},
|
||||
},
|
||||
}
|
||||
url := fmt.Sprintf("https://%s:%d/agent/cluster/master-key", host, agentPort)
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("GET %s: %w", url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("GET %s: HTTP %d", url, resp.StatusCode)
|
||||
}
|
||||
var result struct {
|
||||
Data struct {
|
||||
KeyHex string `json:"key_hex"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
key := make([]byte, 32)
|
||||
if _, err := fmt.Sscanf(result.Data.KeyHex, "%x", &key); err != nil {
|
||||
return fmt.Errorf("decode key_hex: %w", err)
|
||||
}
|
||||
const masterKeyPath = "/var/lib/edgeguard/.master_key"
|
||||
if err := os.WriteFile(masterKeyPath, key, 0o600); err != nil {
|
||||
return fmt.Errorf("write master key: %w", err)
|
||||
}
|
||||
if u, err := user.Lookup("edgeguard"); err == nil {
|
||||
uid, _ := strconv.Atoi(u.Uid)
|
||||
gid, _ := strconv.Atoi(u.Gid)
|
||||
_ = os.Chown(masterKeyPath, uid, gid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// generatePassword erzeugt ein kryptographisch sicheres Passwort.
|
||||
func generatePassword(n int) (string, error) {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
buf := make([]byte, n)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for i, b := range buf {
|
||||
buf[i] = charset[int(b)%len(charset)]
|
||||
}
|
||||
return string(buf), nil
|
||||
}
|
||||
|
||||
// psqlDBExec führt SQL in der angegebenen Datenbank als postgres-Superuser aus.
|
||||
func psqlDBExec(db, sql string) error {
|
||||
_, err := psqlDBRun(db, []string{"-v", "ON_ERROR_STOP=1", "-c", sql})
|
||||
return err
|
||||
}
|
||||
|
||||
// psqlDBRun führt psql-Kommandos gegen eine bestimmte Datenbank aus.
|
||||
func psqlDBRun(db string, args []string) ([]byte, error) {
|
||||
baseArgs := []string{"-d", db}
|
||||
return psqlRun(append(baseArgs, args...))
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
// Command edgeguard-ctl is the admin CLI for setup, migrations and
|
||||
// (later) cluster ops. v1 wires migrate + initdb so postinst can
|
||||
// initialise a fresh node; cluster-* and promote remain stubs until
|
||||
// Phase 3.
|
||||
// cluster ops. v1.2 implements PG streaming replication setup,
|
||||
// VIP/Keepalived config and manual failover (promote).
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -11,7 +10,7 @@ import (
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
||||
)
|
||||
|
||||
var version = "1.1.89"
|
||||
var version = "1.2.15"
|
||||
|
||||
const usage = `edgeguard-ctl — EdgeGuard CLI
|
||||
|
||||
@@ -25,20 +24,25 @@ Commands:
|
||||
migrate check Validate embedded migrations (no DB connect)
|
||||
migrate dump [dir] Write embedded SQL files to dir (default: ./migrations)
|
||||
initdb Create PostgreSQL role + database (idempotent)
|
||||
render-config Regenerate haproxy / nftables configs from PG (--no-reload, --only=)
|
||||
wg-import [--path <dir>] Import existing /etc/wireguard/*.conf files into the DB
|
||||
render-config Regenerate all configs from PG (--no-reload, --only=svc)
|
||||
Services: haproxy nftables squid wireguard unbound chrony keepalived
|
||||
wg-import [--path <dir>] [iface…]
|
||||
Import /etc/wireguard/*.conf files into the DB.
|
||||
reset-password Generate a one-time token for the /reset-password UI flow
|
||||
cluster-join <primary> --token <…>
|
||||
Provision Cluster-TLS material on this node by
|
||||
exchanging the join-token at the primary's
|
||||
/api/v1/cluster/issue-cert endpoint. Writes
|
||||
ca.crt + peer.{crt,key} into /var/lib/edgeguard/
|
||||
cluster-tls/. PG-Basebackup + KeyDB replica
|
||||
setup remain manual until Phase 3.5.
|
||||
cluster-renew-self Re-issue this node's peer.{crt,key} using the
|
||||
local cluster CA (founder/single-node only).
|
||||
1-year validity. Restart edgeguard-api after.
|
||||
promote Promote this node's PG to primary (Phase 3, not yet implemented)
|
||||
Provision Cluster-TLS material; writes ca.crt + peer.{crt,key}
|
||||
cluster-init-replication Richtet PG Logical Replication auf dem Primary ein.
|
||||
Erstellt edgeguard_replicator-Rolle, setzt wal_level=logical,
|
||||
erstellt PUBLICATION edgeguard_shared (alle geteilten Tabellen).
|
||||
Auf dem Primary ausführen bevor der Secondary joined.
|
||||
cluster-setup-standby <ip> Richtet diesen Node als Logical-Replication-Subscriber ein.
|
||||
Erstellt SUBSCRIPTION gegen den Primary (Initialkopiierung
|
||||
aller geteilten Tabellen). Node-eigene Daten (Interfaces,
|
||||
IPs, Routen, VIP-Settings) bleiben unangetastet.
|
||||
Voraussetzung: cluster-join + cluster-init-replication.
|
||||
cluster-renew-self Re-issue this node's peer.{crt,key} using the local cluster CA.
|
||||
promote Promote diesen PG-Standby zum Primary (manueller Failover).
|
||||
Kein Auto-Promote — Split-Brain-Schutz durch manuelle Entscheidung.
|
||||
dump-config Print effective config (Phase 3, not yet implemented)
|
||||
`
|
||||
|
||||
@@ -66,7 +70,13 @@ func main() {
|
||||
os.Exit(cmdClusterJoin(os.Args[2:]))
|
||||
case "cluster-renew-self":
|
||||
os.Exit(cmdClusterRenewSelf(os.Args[2:]))
|
||||
case "cluster-leave", "promote", "dump-config":
|
||||
case "cluster-init-replication":
|
||||
os.Exit(cmdClusterInitReplication(os.Args[2:]))
|
||||
case "cluster-setup-standby":
|
||||
os.Exit(cmdClusterSetupStandby(os.Args[2:]))
|
||||
case "promote":
|
||||
os.Exit(cmdPromote(os.Args[2:]))
|
||||
case "cluster-leave", "dump-config":
|
||||
fmt.Fprintf(os.Stderr, "edgeguard-ctl: %q is a Phase-3 stub — not yet implemented\n", os.Args[1])
|
||||
os.Exit(1)
|
||||
default:
|
||||
|
||||
160
cmd/edgeguard-ctl/promote.go
Normal file
160
cmd/edgeguard-ctl/promote.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/keepalived"
|
||||
)
|
||||
|
||||
// cmdPromote promotes this node's PostgreSQL instance from Hot-Standby
|
||||
// to Primary. Manual failover — keine automatische Promotion, um Split-Brain
|
||||
// in 2-Node-Clustern ohne externen Quorum zu verhindern.
|
||||
//
|
||||
// Ablauf:
|
||||
// 1. Prüfen ob standby.signal vorhanden (wir sind wirklich Standby)
|
||||
// 2. pg_ctlcluster promote → PG wird Primary
|
||||
// 3. Warten bis pg_is_in_recovery() = false
|
||||
// 4. ha_nodes.pg_role auf 'primary' setzen
|
||||
// 5. KeyDB cluster:pg-primary-url auf lokal setzen
|
||||
// 6. keepalived.conf neu rendern (Primary bekommt Priorität 200)
|
||||
// 7. keepalived reload
|
||||
func cmdPromote(args []string) int {
|
||||
pg, err := detectPGConfig()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "promote: PG-Erkennung:", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
// 1. Standby-Signal prüfen
|
||||
signalPath := filepath.Join(pg.DataDir, "standby.signal")
|
||||
if _, err := os.Stat(signalPath); os.IsNotExist(err) {
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"promote: %s nicht gefunden — diese Node ist kein PG-Standby oder wurde bereits promoted.\n",
|
||||
signalPath)
|
||||
return 1
|
||||
}
|
||||
|
||||
fmt.Printf("→ Promoting PostgreSQL %s/%s zu Primary...\n", pg.Version, pg.Cluster)
|
||||
if out, err := exec.Command("pg_ctlcluster", pg.Version, pg.Cluster, "promote").
|
||||
CombinedOutput(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "promote: pg_ctlcluster promote: %v\n%s\n", err, out)
|
||||
return 1
|
||||
}
|
||||
fmt.Println("✓ pg_ctlcluster promote gesendet")
|
||||
|
||||
// 2. Warten bis PG wirklich Primary ist (pg_is_in_recovery = false)
|
||||
fmt.Print("→ Warte auf PG Primary-Mode")
|
||||
deadline := time.Now().Add(60 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
out, err := psqlRun([]string{"-tA", "-c", "SELECT pg_is_in_recovery();"})
|
||||
if err == nil && strings.TrimSpace(string(out)) == "f" {
|
||||
break
|
||||
}
|
||||
fmt.Print(".")
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
fmt.Println()
|
||||
// Nochmal prüfen
|
||||
out, err := psqlRun([]string{"-tA", "-c", "SELECT pg_is_in_recovery();"})
|
||||
if err != nil || strings.TrimSpace(string(out)) != "f" {
|
||||
fmt.Fprintln(os.Stderr, "promote: PG ist nach 60s noch in recovery — prüfe PG-Logs")
|
||||
return 1
|
||||
}
|
||||
fmt.Println("✓ PostgreSQL ist jetzt Primary")
|
||||
|
||||
// 3. ha_nodes.pg_role + role aktualisieren
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
pool, err := database.Open(ctx, database.ConnStringFromEnv())
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "promote: db connect:", err)
|
||||
fmt.Println(" → ha_nodes manuell updaten: UPDATE ha_nodes SET pg_role='primary', role='primary' WHERE id='<local-id>';")
|
||||
} else {
|
||||
defer pool.Close()
|
||||
localID, err := loadLocalID()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "promote: local node ID:", err)
|
||||
} else {
|
||||
_, err = pool.Exec(ctx, `UPDATE ha_nodes SET pg_role='primary', role='primary', status='online', updated_at=NOW() WHERE id=$1`, localID)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "promote: update ha_nodes:", err)
|
||||
} else {
|
||||
fmt.Println("✓ ha_nodes.pg_role = 'primary' gesetzt")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. KeyDB cluster:pg-primary-url updaten
|
||||
if err := updateKeyDBPrimaryURL(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "promote: KeyDB update: %v\n", err)
|
||||
fmt.Println(" → Manuell: redis-cli SET cluster:pg-primary-url 'postgres://edgeguard@/edgeguard'")
|
||||
} else {
|
||||
fmt.Println("✓ KeyDB cluster:pg-primary-url aktualisiert")
|
||||
}
|
||||
|
||||
// 5. Keepalived.conf neu rendern (Primary = Priorität 200)
|
||||
if pool != nil {
|
||||
localID, _ := loadLocalID()
|
||||
kg := keepalived.New(pool, localID)
|
||||
renderCtx, renderCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer renderCancel()
|
||||
if err := kg.Render(renderCtx); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "promote: keepalived render: %v\n", err)
|
||||
fmt.Println(" → Manuell: edgeguard-ctl render-config --only=keepalived")
|
||||
} else {
|
||||
fmt.Println("✓ keepalived.conf neu gerendert (Priority 200)")
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("✓ Promotion abgeschlossen. Diese Node ist jetzt der primäre EdgeGuard-Knoten.")
|
||||
fmt.Println()
|
||||
fmt.Println("Empfohlene Nachschritte:")
|
||||
fmt.Println(" 1) sudo systemctl restart edgeguard-api (falls noch nicht laufend)")
|
||||
fmt.Println(" 2) Alte Primary-Node nach Recovery als neuen Standby einrichten:")
|
||||
fmt.Println(" edgeguard-ctl cluster-setup-standby <diese-node-ip>")
|
||||
return 0
|
||||
}
|
||||
|
||||
// loadLocalID liest die Node-ID aus /var/lib/edgeguard/node.conf.
|
||||
func loadLocalID() (string, error) {
|
||||
c, err := cluster.LoadLocalConfig("")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if c.NodeID == "" {
|
||||
return "", fmt.Errorf("NODE_ID in node.conf ist leer")
|
||||
}
|
||||
return c.NodeID, nil
|
||||
}
|
||||
|
||||
// updateKeyDBPrimaryURL schreibt den lokalen PG-DSN als cluster:pg-primary-url
|
||||
// in KeyDB, damit alle Nodes im Cluster Writes an diese Node schicken.
|
||||
func updateKeyDBPrimaryURL() error {
|
||||
// edgeguard-api nutzt Unix-Socket-Auth, der DSN ist immer lokal.
|
||||
const localDSN = "postgres://edgeguard@/edgeguard?host=/var/run/postgresql"
|
||||
out, err := exec.Command("redis-cli",
|
||||
"-s", "/var/run/keydb/keydb.sock",
|
||||
"SET", "cluster:pg-primary-url", localDSN,
|
||||
).CombinedOutput()
|
||||
if err != nil {
|
||||
// Fallback: Standard-Port
|
||||
out2, err2 := exec.Command("redis-cli",
|
||||
"-p", "6379",
|
||||
"SET", "cluster:pg-primary-url", localDSN,
|
||||
).CombinedOutput()
|
||||
if err2 != nil {
|
||||
return fmt.Errorf("%v: %s / %v: %s", err, out, err2, out2)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/firewall"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/haproxy"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/keepalived"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/configorch"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/squid"
|
||||
@@ -64,7 +66,16 @@ func cmdRenderConfig(args []string) int {
|
||||
fw.SkipReload = true
|
||||
}
|
||||
|
||||
// keepalived: Node-ID aus node.conf für Prioritäts-Berechnung
|
||||
var ka configgen.Generator
|
||||
if lc, err := cluster.LoadLocalConfig(""); err == nil && lc.NodeID != "" {
|
||||
ka = keepalived.New(pool, lc.NodeID)
|
||||
}
|
||||
|
||||
gens := []configgen.Generator{hap, fw, sq, wg, ub, cn}
|
||||
if ka != nil {
|
||||
gens = append(gens, ka)
|
||||
}
|
||||
|
||||
results, runErr := configorch.Run(ctx, gens, only)
|
||||
fmt.Print(configorch.Summarise(results))
|
||||
|
||||
@@ -46,7 +46,9 @@ func cmdWGImport(args []string) int {
|
||||
wireguard.NewPeersRepo(pool),
|
||||
box,
|
||||
)
|
||||
res, err := im.ImportDir(ctx, *path)
|
||||
// Positional args after flags = specific interface names to import.
|
||||
names := fs.Args()
|
||||
res, err := im.ImportSelected(ctx, *path, names)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "wg-import:", err)
|
||||
return 1
|
||||
|
||||
@@ -9,12 +9,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -27,6 +32,7 @@ import (
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/acme"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/alerts"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/backends"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/backup"
|
||||
backupremote "git.netcell-it.de/projekte/edgeguard-native/internal/services/backup/remote"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/certrenewer"
|
||||
@@ -35,7 +41,7 @@ import (
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
|
||||
)
|
||||
|
||||
var version = "1.1.89"
|
||||
var version = "1.2.35"
|
||||
|
||||
const (
|
||||
// renewTickInterval — how often we re-evaluate expiring certs.
|
||||
@@ -97,6 +103,44 @@ const (
|
||||
// ist passiert nichts.
|
||||
auditCleanupInterval = 24 * time.Hour
|
||||
auditRetentionDays = 90
|
||||
|
||||
// backendDownCheckInterval — alle 2 Minuten HAProxy-Stats lesen und
|
||||
// prüfen ob ein Backend komplett ausgefallen ist (alle Server DOWN).
|
||||
// Dedupe 12h pro Backend → kein Alert-Spam. Frischer Alert wenn das
|
||||
// Backend nach 12h immer noch unten ist.
|
||||
backendDownCheckInterval = 2 * time.Minute
|
||||
|
||||
// memCheckInterval — alle 5 Minuten /proc/meminfo lesen. Schwellen
|
||||
// warning 85%, critical 95%. Dedupe 1h pro Severity damit bei einem
|
||||
// kurzfristigen Spike nicht jeder Tick feuert.
|
||||
memCheckInterval = 5 * time.Minute
|
||||
memWarnPct = 85.0
|
||||
memCriticalPct = 95.0
|
||||
|
||||
// conntrackCheckInterval — alle 2 Minuten /proc/sys/net/netfilter/
|
||||
// nf_conntrack_count+max lesen. Eine volle conntrack-Tabelle verwirft
|
||||
// alle neuen Verbindungen ohne jegliche Rückmeldung. 2-Minuten-Takt
|
||||
// erlaubt früh zu warnen bevor die Tabelle überläuft.
|
||||
// Schwellen analog Disk: 80% Warning, 90% Critical. Dedupe 1h.
|
||||
conntrackCheckInterval = 2 * time.Minute
|
||||
conntrackWarnPct = 80.0
|
||||
conntrackCriticalPct = 90.0
|
||||
|
||||
// ntpSyncCheckInterval — alle 10 Minuten chronyc tracking aufrufen.
|
||||
// Keine Sync bedeutet: Uhr driftet → TLS-Cert-Prüfung schlägt fehl
|
||||
// wenn die Abweichung > Toleranz des Gegenstücks (i.d.R. ±1 min),
|
||||
// JWT-Ablauf inkonsistent, Cluster-Split-Brain möglich. Dedupe 1h
|
||||
// damit ein kurzer Upstream-Ausfall (Reboot, DHCP-Pause) keinen
|
||||
// Alert-Regen produziert.
|
||||
ntpSyncCheckInterval = 10 * time.Minute
|
||||
|
||||
// wgTunnelCheckInterval — alle 5 Minuten WireGuard-Client-Tunnels
|
||||
// auf Aktualität prüfen. Client-Tunnels (mode='client') haben genau
|
||||
// einen Peer; wenn dessen letzter Handshake älter als wgStaleSec ist,
|
||||
// ist der Tunnel effektiv tot — Traffic droht lautlos. Dedupe 30min
|
||||
// pro Tunnel damit schnell wiederhergestellte Tunnels nur einmal feuern.
|
||||
wgTunnelCheckInterval = 5 * time.Minute
|
||||
wgStaleSec = int64(5 * 60) // 5 Minuten ohne Handshake = tot
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -180,6 +224,31 @@ func main() {
|
||||
auditTick := time.NewTicker(auditCleanupInterval)
|
||||
defer auditTick.Stop()
|
||||
|
||||
backendDownTick := time.NewTicker(backendDownCheckInterval)
|
||||
defer backendDownTick.Stop()
|
||||
// Initial-Check direkt beim Start — wenn ein Backend seit dem letzten
|
||||
// Scheduler-Restart down ist, brauchen wir nicht 2 Minuten zu warten.
|
||||
runBackendDownCheck(ctx, pool, alertSvc, alertDedupe)
|
||||
|
||||
memTick := time.NewTicker(memCheckInterval)
|
||||
defer memTick.Stop()
|
||||
runMemoryCheck(ctx, alertSvc, alertDedupe)
|
||||
|
||||
conntrackTick := time.NewTicker(conntrackCheckInterval)
|
||||
defer conntrackTick.Stop()
|
||||
runConntrackCheck(ctx, alertSvc, alertDedupe)
|
||||
|
||||
ntpSyncTick := time.NewTicker(ntpSyncCheckInterval)
|
||||
defer ntpSyncTick.Stop()
|
||||
// Kein Initial-Check bei Boot: chrony braucht nach dem Start
|
||||
// einige Sekunden bis zur ersten Synchronisation — ein
|
||||
// sofortiger Check würde immer feuern.
|
||||
|
||||
wgTunnelTick := time.NewTicker(wgTunnelCheckInterval)
|
||||
defer wgTunnelTick.Stop()
|
||||
// Kein Initial-Check bei Boot: Tunnels brauchen nach dem Start
|
||||
// des wg-quick-Dienstes einen Moment für den ersten Handshake.
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-renewTick.C:
|
||||
@@ -203,6 +272,16 @@ func main() {
|
||||
runDiskCheck(ctx, alertSvc, alertDedupe)
|
||||
case <-auditTick.C:
|
||||
runAuditCleanup(ctx, auditRepo, setupStore)
|
||||
case <-backendDownTick.C:
|
||||
runBackendDownCheck(ctx, pool, alertSvc, alertDedupe)
|
||||
case <-memTick.C:
|
||||
runMemoryCheck(ctx, alertSvc, alertDedupe)
|
||||
case <-conntrackTick.C:
|
||||
runConntrackCheck(ctx, alertSvc, alertDedupe)
|
||||
case <-ntpSyncTick.C:
|
||||
runNTPSyncCheck(ctx, alertSvc, alertDedupe)
|
||||
case <-wgTunnelTick.C:
|
||||
runWGClientTunnelCheck(ctx, pool, alertSvc, alertDedupe)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -307,6 +386,395 @@ func runDiskCheck(ctx context.Context, a *alerts.Service, d *dedupe) {
|
||||
// remaining hat UND eine lokale CA existiert, wird automatisch neu
|
||||
// signiert. Restart-Hinweis als Info-Alert — wir starten edgeguard-api
|
||||
// nicht selbst neu, das passiert beim nächsten geplanten Update/Reboot.
|
||||
// runMemoryCheck liest /proc/meminfo und feuert bei hoher RAM-Belegung.
|
||||
// Schwellen: warning >= 85%, critical >= 95%. Dedupe 1h pro Severity
|
||||
// damit kurze Spikes (Backup, apt-Upgrade) keine Alert-Flut erzeugen.
|
||||
func runMemoryCheck(ctx context.Context, a *alerts.Service, d *dedupe) {
|
||||
if a == nil || d == nil {
|
||||
return
|
||||
}
|
||||
data, err := os.ReadFile("/proc/meminfo")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var memTotal, memAvail int64
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
var key string
|
||||
var val int64
|
||||
if _, err := fmt.Sscanf(line, "%s %d", &key, &val); err != nil {
|
||||
continue
|
||||
}
|
||||
switch key {
|
||||
case "MemTotal:":
|
||||
memTotal = val
|
||||
case "MemAvailable:":
|
||||
memAvail = val
|
||||
}
|
||||
}
|
||||
if memTotal <= 0 {
|
||||
return
|
||||
}
|
||||
usedPct := float64(memTotal-memAvail) * 100 / float64(memTotal)
|
||||
usedGB := float64(memTotal-memAvail) / 1024 / 1024
|
||||
totalGB := float64(memTotal) / 1024 / 1024
|
||||
|
||||
var key, title string
|
||||
var sev alerts.Severity
|
||||
switch {
|
||||
case usedPct >= memCriticalPct:
|
||||
key = "mem.high.critical"
|
||||
sev = alerts.SeverityError
|
||||
title = fmt.Sprintf("RAM kritisch hoch: %.0f%%", usedPct)
|
||||
case usedPct >= memWarnPct:
|
||||
key = "mem.high.warning"
|
||||
sev = alerts.SeverityWarning
|
||||
title = fmt.Sprintf("RAM-Belegung hoch: %.0f%%", usedPct)
|
||||
default:
|
||||
return
|
||||
}
|
||||
if !d.shouldFire(key) {
|
||||
return
|
||||
}
|
||||
desc := fmt.Sprintf(
|
||||
"RAM-Auslastung: %.1f%% — %.1f von %.1f GB belegt.\n\n"+
|
||||
"Häufige Ursachen:\n"+
|
||||
" • Unbound-Cache zu groß (rrset-cache-size in /etc/edgeguard/unbound/unbound.conf)\n"+
|
||||
" • Squid cache_mem zu groß (64 MB default)\n"+
|
||||
" • PostgreSQL shared_buffers (default ~128 MB)\n"+
|
||||
" • Prozesse prüfen: ps aux --sort=-%%mem | head -10",
|
||||
usedPct, usedGB, totalGB)
|
||||
if _, err := a.Fire(ctx, "mem.high", sev, title, desc); err != nil {
|
||||
slog.Warn("scheduler: memory-check alert fire failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// runConntrackCheck liest die conntrack-Tabellen-Belegung aus /proc und
|
||||
// feuert bei hoher Auslastung. Eine volle conntrack-Tabelle (100%)
|
||||
// verwirft alle neuen TCP/UDP-Verbindungen ohne ICMP-Rückmeldung —
|
||||
// der Operator sieht auf der Gegenstelle nur Timeouts.
|
||||
//
|
||||
// Schwellen: 80% Warning, 90% Critical (wie Disk, niedriger als RAM weil
|
||||
// der Impact sofortig ist). Dedupe 1h pro Severity.
|
||||
func runConntrackCheck(ctx context.Context, a *alerts.Service, d *dedupe) {
|
||||
if a == nil || d == nil {
|
||||
return
|
||||
}
|
||||
readInt := func(path string) int64 {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
v, _ := strconv.ParseInt(strings.TrimSpace(string(b)), 10, 64)
|
||||
return v
|
||||
}
|
||||
count := readInt("/proc/sys/net/netfilter/nf_conntrack_count")
|
||||
max := readInt("/proc/sys/net/netfilter/nf_conntrack_max")
|
||||
if max <= 0 {
|
||||
return
|
||||
}
|
||||
usedPct := float64(count) * 100 / float64(max)
|
||||
|
||||
var key, title string
|
||||
var sev alerts.Severity
|
||||
switch {
|
||||
case usedPct >= conntrackCriticalPct:
|
||||
key = "conntrack.high.critical"
|
||||
sev = alerts.SeverityError
|
||||
title = fmt.Sprintf("Conntrack-Tabelle kritisch voll: %.0f%%", usedPct)
|
||||
case usedPct >= conntrackWarnPct:
|
||||
key = "conntrack.high.warning"
|
||||
sev = alerts.SeverityWarning
|
||||
title = fmt.Sprintf("Conntrack-Tabelle fast voll: %.0f%%", usedPct)
|
||||
default:
|
||||
return
|
||||
}
|
||||
if !d.shouldFire(key) {
|
||||
return
|
||||
}
|
||||
desc := fmt.Sprintf(
|
||||
"Conntrack-Auslastung: %.1f%% — %d von %d Einträgen belegt.\n\n"+
|
||||
"Wenn die Tabelle auf 100%% steigt, werden alle neuen Verbindungen\n"+
|
||||
"ohne Fehlermeldung verworfen (Silent Drop).\n\n"+
|
||||
"Maßnahmen:\n"+
|
||||
" • Zeitweilige Spikes: nf_conntrack_max erhöhen\n"+
|
||||
" (sysctl net.netfilter.nf_conntrack_max)\n"+
|
||||
" • Leaks: conntrack -L | sort | head zeigt häufige Quellen\n"+
|
||||
" • Timeouts reduzieren (z.B. nf_conntrack_tcp_timeout_established)",
|
||||
usedPct, count, max)
|
||||
if _, err := a.Fire(ctx, "conntrack.high", sev, title, desc); err != nil {
|
||||
slog.Warn("scheduler: conntrack-check alert fire failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// runNTPSyncCheck ruft chronyc tracking auf und feuert einen Alert wenn
|
||||
// chrony keine synchronisierte Zeitquelle hat (Stratum 0 oder ≥ 16).
|
||||
// Zeitdrift > ~1 Minute führt zu TLS-Handshake-Fehlern, JWT-Ablauf-
|
||||
// Inkonsistenzen und möglichen Cluster-Problemen. Dedupe 1h.
|
||||
func runNTPSyncCheck(ctx context.Context, a *alerts.Service, d *dedupe) {
|
||||
if a == nil || d == nil {
|
||||
return
|
||||
}
|
||||
out, err := exec.Command("chronyc", "tracking").Output()
|
||||
if err != nil {
|
||||
// chrony nicht installiert oder nicht gestartet — kein Alert,
|
||||
// weil wir nicht wissen ob chrony hier überhaupt erwartet wird.
|
||||
return
|
||||
}
|
||||
synced, stratum, ref := parseChronyTrackingForAlert(string(out))
|
||||
if synced {
|
||||
return
|
||||
}
|
||||
const key = "ntp.unsync"
|
||||
if !d.shouldFire(key) {
|
||||
return
|
||||
}
|
||||
refStr := ref
|
||||
if refStr == "" {
|
||||
refStr = "(keine Referenz)"
|
||||
}
|
||||
title := fmt.Sprintf("NTP nicht synchronisiert (Stratum %d)", stratum)
|
||||
desc := fmt.Sprintf(
|
||||
"chrony hat keine synchronisierte Zeitquelle.\n"+
|
||||
"Referenz: %s Stratum: %d\n\n"+
|
||||
"Mögliche Ursachen:\n"+
|
||||
" • Upstream-NTP-Server nicht erreichbar (UDP/123 blockiert?)\n"+
|
||||
" • Pool-DNS-Einträge lösen nicht auf\n"+
|
||||
" • chrony läuft, braucht aber noch Zeit nach Boot (warten)\n\n"+
|
||||
"Prüfen: chronyc sources -v — chronyc tracking",
|
||||
refStr, stratum)
|
||||
if _, err := a.Fire(ctx, "ntp.unsync", alerts.SeverityWarning, title, desc); err != nil {
|
||||
slog.Warn("scheduler: ntp-sync-check alert fire failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// parseChronyTrackingForAlert ist eine schlanke Variante des NTP-Handler-
|
||||
// Parsers: liefert nur synced/stratum/reference ohne die vollen Felder.
|
||||
func parseChronyTrackingForAlert(out string) (synced bool, stratum int, reference string) {
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
key, val, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
val = strings.TrimSpace(val)
|
||||
switch key {
|
||||
case "Reference ID":
|
||||
if i := strings.Index(val, "("); i >= 0 {
|
||||
reference = strings.Trim(val[i:], "()")
|
||||
}
|
||||
if val != "00000000 ()" {
|
||||
synced = true
|
||||
}
|
||||
case "Stratum":
|
||||
fmt.Sscanf(val, "%d", &stratum)
|
||||
if stratum > 0 && stratum < 16 {
|
||||
synced = true
|
||||
} else if stratum == 0 || stratum >= 16 {
|
||||
synced = false
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// runWGClientTunnelCheck prüft alle aktiven WireGuard-Client-Tunnels
|
||||
// (mode='client') auf Handshake-Aktualität. Ein Client-Tunnel hat genau
|
||||
// einen Peer; wenn dessen letzter Handshake älter als wgStaleSec oder
|
||||
// noch nie stattgefunden hat, ist der Tunnel tot — Traffic wird lautlos
|
||||
// verworfen (kein ICMP Unreachable). Dedupe 30min pro Tunnel damit
|
||||
// nach einer Selbstheilung nicht alle paar Minuten neu gefeuert wird.
|
||||
func runWGClientTunnelCheck(ctx context.Context, pool *pgxpool.Pool, a *alerts.Service, d *dedupe) {
|
||||
if a == nil || d == nil || pool == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Alle aktiven Client-Interfaces aus DB laden.
|
||||
type wgIface struct{ name string }
|
||||
rows, err := pool.Query(ctx,
|
||||
`SELECT name FROM wg_interfaces WHERE mode = 'client' AND active = true ORDER BY name`)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
var ifaces []wgIface
|
||||
for rows.Next() {
|
||||
var n string
|
||||
if err := rows.Scan(&n); err == nil {
|
||||
ifaces = append(ifaces, wgIface{n})
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
if len(ifaces) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
for _, ifc := range ifaces {
|
||||
out, err := exec.Command("wg", "show", ifc.name, "dump").Output()
|
||||
if err != nil {
|
||||
// Interface existiert nicht mehr im Kernel (wg-quick down) —
|
||||
// das ist selbst schon ein Problem; kein separater Alert hier,
|
||||
// da systemd-Restart-Policy das abdeckt.
|
||||
continue
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||
// Zeile 0 ist die Interface-Zeile (own key / pubkey / port / fwmark).
|
||||
// Zeile 1 ist die Peer-Zeile: pubkey psk endpoint allowed-ips last-hs rx tx keepalive
|
||||
if len(lines) < 2 {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(lines[1])
|
||||
if len(fields) < 5 {
|
||||
continue
|
||||
}
|
||||
lastHS, _ := strconv.ParseInt(fields[4], 10, 64)
|
||||
|
||||
stale := lastHS == 0 || (now-lastHS) > wgStaleSec
|
||||
if !stale {
|
||||
continue
|
||||
}
|
||||
key := "wg.tunnel.down." + ifc.name
|
||||
if !d.shouldFire(key) {
|
||||
continue
|
||||
}
|
||||
var detail string
|
||||
if lastHS == 0 {
|
||||
detail = "Noch kein Handshake — Tunnel wurde nie erfolgreich aufgebaut."
|
||||
} else {
|
||||
ageMin := (now - lastHS) / 60
|
||||
detail = fmt.Sprintf("Letzter Handshake: vor %d Minuten.", ageMin)
|
||||
}
|
||||
title := fmt.Sprintf("WireGuard-Tunnel %s ausgefallen", ifc.name)
|
||||
desc := fmt.Sprintf(
|
||||
"Client-Tunnel %s hat seit >5 Minuten keinen Handshake.\n%s\n\n"+
|
||||
"Traffic zu den RemoteAllowed-Netzen wird lautlos verworfen.\n\n"+
|
||||
"Mögliche Ursachen:\n"+
|
||||
" • Remote-Peer nicht erreichbar (Firewall, Routing)\n"+
|
||||
" • Remote-Server-Keypair geändert (Public-Key stimmt nicht mehr)\n"+
|
||||
" • UDP-Port des Peers geblockt\n"+
|
||||
" • wg-quick-Dienst auf dieser Box gestoppt: systemctl status wg-quick@%s",
|
||||
ifc.name, detail, ifc.name)
|
||||
if _, err := a.Fire(ctx, "wg.tunnel.down", alerts.SeverityError, title, desc); err != nil {
|
||||
slog.Warn("scheduler: wg-tunnel-check alert fire failed", "iface", ifc.name, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var egBackendRE = regexp.MustCompile(`^eg_backend_(\d+)$`)
|
||||
|
||||
// runBackendDownCheck liest HAProxy-Stats via Admin-Socket und feuert
|
||||
// einen Error-Alert für jedes Backend bei dem alle Server DOWN sind
|
||||
// (und mind. einer einen echten Health-Check hat). Dedupe 12h pro Backend.
|
||||
func runBackendDownCheck(ctx context.Context, pool *pgxpool.Pool, a *alerts.Service, d *dedupe) {
|
||||
if a == nil || d == nil {
|
||||
return
|
||||
}
|
||||
conn, err := net.DialTimeout("unix", "/run/haproxy/admin.sock", 2*time.Second)
|
||||
if err != nil {
|
||||
// HAProxy läuft nicht oder Socket nicht erreichbar — kein Alert,
|
||||
// das ist der Dienst selbst nicht der Scheduler.
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(3 * time.Second))
|
||||
if _, err := conn.Write([]byte("show stat\n")); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
type srvEntry struct{ status string; hasCheck bool }
|
||||
byBackend := map[string][]srvEntry{}
|
||||
colIdx := map[string]int{}
|
||||
scanner := bufio.NewScanner(conn)
|
||||
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
fields := strings.Split(line, ",")
|
||||
if strings.HasPrefix(line, "# ") {
|
||||
fields[0] = strings.TrimPrefix(fields[0], "# ")
|
||||
for i, name := range fields {
|
||||
colIdx[name] = i
|
||||
}
|
||||
continue
|
||||
}
|
||||
px := fieldAt(fields, colIdx["pxname"])
|
||||
sv := fieldAt(fields, colIdx["svname"])
|
||||
if !strings.HasPrefix(px, "eg_backend_") || sv == "BACKEND" || sv == "FRONTEND" || sv == "" {
|
||||
continue
|
||||
}
|
||||
status := fieldAt(fields, colIdx["status"])
|
||||
byBackend[px] = append(byBackend[px], srvEntry{
|
||||
status: status,
|
||||
hasCheck: status != "no check",
|
||||
})
|
||||
}
|
||||
if len(byBackend) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Friendly Backend-Namen aus DB — best-effort, Fehler = anonyme ID.
|
||||
bkRepo := backends.New(pool)
|
||||
bklist, _ := bkRepo.List(ctx)
|
||||
nameOf := func(id int64) string {
|
||||
for _, b := range bklist {
|
||||
if b.ID == id {
|
||||
return b.Name
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("#%d", id)
|
||||
}
|
||||
|
||||
for haName, servers := range byBackend {
|
||||
hasRealCheck, allDown := false, true
|
||||
for _, s := range servers {
|
||||
if s.hasCheck {
|
||||
hasRealCheck = true
|
||||
}
|
||||
if s.status == "UP" {
|
||||
allDown = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasRealCheck || !allDown {
|
||||
continue
|
||||
}
|
||||
m := egBackendRE.FindStringSubmatch(haName)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
id, _ := strconv.ParseInt(m[1], 10, 64)
|
||||
name := nameOf(id)
|
||||
|
||||
key := "backend.down." + haName
|
||||
if !d.shouldFire(key) {
|
||||
continue
|
||||
}
|
||||
msg := fmt.Sprintf(
|
||||
"Alle Server in Backend \"%s\" sind DOWN — HAProxy liefert 503 für alle Requests zu diesem Backend.\n\n"+
|
||||
"HAProxy-Backend-Name: %s\n\n"+
|
||||
"Nächste Schritte:\n"+
|
||||
" • Dienst auf Backend-Host prüfen (systemctl status / docker ps)\n"+
|
||||
" • Health-Check-Pfad erreichbar? (curl http://<server>:<port><path>)\n"+
|
||||
" • Firewall-Regeln zwischen EdgeGuard und Backend-Host prüfen",
|
||||
name, haName)
|
||||
if _, err := a.Fire(ctx, "backend.down", alerts.SeverityError,
|
||||
fmt.Sprintf("Backend DOWN: %s", name), msg); err != nil {
|
||||
slog.Warn("scheduler: backend-down alert fire failed",
|
||||
"backend", name, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fieldAt(fields []string, i int) string {
|
||||
if i < 0 || i >= len(fields) {
|
||||
return ""
|
||||
}
|
||||
return fields[i]
|
||||
}
|
||||
|
||||
func runClusterCertExpiryCheck(ctx context.Context, a *alerts.Service, d *dedupe) {
|
||||
if a == nil || d == nil {
|
||||
return
|
||||
@@ -594,11 +1062,18 @@ func runRenewer(ctx context.Context, r *certrenewer.Service, a *alerts.Service,
|
||||
slog.Info("scheduler: renewer pass complete",
|
||||
"checked", res.Checked, "renewed", res.Renewed,
|
||||
"failed", res.Failed, "skipped", res.Skipped)
|
||||
if a != nil && res.Failed > 0 && d != nil && d.shouldFire("cert.renew_failed") {
|
||||
_, _ = a.Fire(ctx, "cert.renew_failed", alerts.SeverityError,
|
||||
"Cert-Renewal teilweise fehlgeschlagen",
|
||||
fmt.Sprintf("Renewer-Cycle: %d checked, %d renewed, %d failed, %d skipped",
|
||||
res.Checked, res.Renewed, res.Failed, res.Skipped))
|
||||
if a != nil && d != nil {
|
||||
for _, domain := range res.FailedDomains {
|
||||
key := "cert.renew_failed:" + domain
|
||||
if !d.shouldFire(key) {
|
||||
continue
|
||||
}
|
||||
_, _ = a.Fire(ctx, "cert.renew_failed", alerts.SeverityError,
|
||||
"Cert-Renewal fehlgeschlagen: "+domain,
|
||||
"Let's Encrypt Erneuerung für "+domain+" ist fehlgeschlagen. "+
|
||||
"Prüfe ACME-Konfiguration und DNS-Erreichbarkeit. "+
|
||||
"Nächster Versuch beim nächsten Renewer-Tick (alle 6h).")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
105
cmd/edgeguard-waf/main.go
Normal file
105
cmd/edgeguard-waf/main.go
Normal file
@@ -0,0 +1,105 @@
|
||||
// Command edgeguard-waf is the per-domain WAF SPOE agent for EdgeGuard.
|
||||
// HAProxy connects to it via the SPOE protocol (127.0.0.1:9000).
|
||||
// It loads per-domain WAF configs from PostgreSQL and uses Coraza v3
|
||||
// with the OWASP Core Rule Set to inspect HTTP requests.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/waf"
|
||||
intwaf "git.netcell-it.de/projekte/edgeguard-native/internal/waf"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
dsn := database.ConnStringFromEnv()
|
||||
pool, err := database.Open(ctx, dsn)
|
||||
if err != nil {
|
||||
slog.Error("waf: db connect", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
if err := database.Migrate(ctx, ""); err != nil {
|
||||
slog.Error("waf: migrate", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
repo := waf.New(pool)
|
||||
|
||||
crsDir := os.Getenv("EDGEGUARD_WAF_CRS_DIR")
|
||||
if crsDir == "" {
|
||||
crsDir = intwaf.DefaultCRSDir
|
||||
}
|
||||
spoeAddr := os.Getenv("EDGEGUARD_WAF_ADDR")
|
||||
if spoeAddr == "" {
|
||||
spoeAddr = intwaf.DefaultSPOEAddr
|
||||
}
|
||||
|
||||
mgr := intwaf.NewManager(crsDir)
|
||||
|
||||
// Initial load.
|
||||
if err := reload(ctx, repo, mgr); err != nil {
|
||||
slog.Error("waf: initial load", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Periodic config refresh every 30 seconds.
|
||||
go func() {
|
||||
t := time.NewTicker(30 * time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
if err := reload(ctx, repo, mgr); err != nil {
|
||||
slog.Warn("waf: reload", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
alertWriter := intwaf.NewAlertWriter(pool, 2048)
|
||||
|
||||
agent := intwaf.SPOEAgent{
|
||||
Manager: mgr,
|
||||
AlertWriter: alertWriter,
|
||||
Addr: spoeAddr,
|
||||
}
|
||||
|
||||
slog.Info("waf: SPOE agent starting", "addr", spoeAddr, "crs", crsDir)
|
||||
if err := agent.ListenAndServe(ctx); err != nil && ctx.Err() == nil {
|
||||
slog.Error("waf: SPOE agent stopped", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// reload fetches all domain+waf_config pairs from DB and rebuilds engines.
|
||||
func reload(ctx context.Context, repo *waf.Repo, mgr *intwaf.Manager) error {
|
||||
configs, err := repo.ListAllWithDomain(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
domains := make([]intwaf.DomainConfig, 0, len(configs))
|
||||
for _, c := range configs {
|
||||
domains = append(domains, intwaf.DomainConfig{
|
||||
Hostname: c.Hostname,
|
||||
Config: c.Config,
|
||||
})
|
||||
}
|
||||
return mgr.Reload(domains)
|
||||
}
|
||||
|
||||
// Ensure models package is used (imported transitively via services/waf).
|
||||
var _ = models.WafConfig{}
|
||||
40
deploy/keepalived/keepalived.conf.tpl
Normal file
40
deploy/keepalived/keepalived.conf.tpl
Normal file
@@ -0,0 +1,40 @@
|
||||
global_defs {
|
||||
router_id {{ .RouterID }}
|
||||
script_user root
|
||||
enable_script_security
|
||||
vrrp_garp_interval 0
|
||||
vrrp_gna_interval 0
|
||||
}
|
||||
|
||||
vrrp_script chk_edgeguard {
|
||||
script "/usr/lib/edgeguard/keepalived-check.sh"
|
||||
interval 2
|
||||
weight -50
|
||||
fall 3
|
||||
rise 2
|
||||
}
|
||||
|
||||
vrrp_instance VI_1 {
|
||||
state {{ .State }}
|
||||
interface {{ .Interface }}
|
||||
virtual_router_id {{ .RouterID }}
|
||||
priority {{ .Priority }}
|
||||
advert_int 1
|
||||
{{ if .SrcIP }} unicast_src_ip {{ .SrcIP }}
|
||||
unicast_peer {
|
||||
{{ .PeerIP }}
|
||||
}
|
||||
{{ end }} authentication {
|
||||
auth_type PASS
|
||||
auth_pass {{ .AuthPass }}
|
||||
}
|
||||
virtual_ipaddress {
|
||||
{{ .VIP }}
|
||||
}
|
||||
track_script {
|
||||
chk_edgeguard
|
||||
}
|
||||
notify_master "/usr/lib/edgeguard/keepalived-master.sh"
|
||||
notify_backup "/usr/lib/edgeguard/keepalived-backup.sh"
|
||||
notify_fault "/usr/lib/edgeguard/keepalived-backup.sh"
|
||||
}
|
||||
@@ -41,7 +41,7 @@ SystemCallFilter=@system-service
|
||||
# direkt in den distro-Conf-Dir (chrony+unbound) bzw. legen Symlinks
|
||||
# nach /etc/edgeguard/wireguard (wg). Ohne diese Pfade scheitern alle
|
||||
# UI-Mutationen an DNS/NTP/WireGuard-Settings still mit EROFS.
|
||||
ReadWritePaths=/etc/edgeguard /var/lib/edgeguard /var/log/edgeguard /var/backups/edgeguard /var/lib/apt /var/cache/apt /etc/apt/apt.conf.d /etc/chrony/conf.d /etc/unbound/unbound.conf.d /etc/wireguard
|
||||
ReadWritePaths=/etc/edgeguard /var/lib/edgeguard /var/log/edgeguard /var/backups/edgeguard /var/lib/apt /var/cache/apt /etc/apt/apt.conf.d /etc/chrony/conf.d /etc/unbound/unbound.conf.d /etc/wireguard /var/lib/crowdsec
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
32
deploy/systemd/edgeguard-waf.service
Normal file
32
deploy/systemd/edgeguard-waf.service
Normal file
@@ -0,0 +1,32 @@
|
||||
[Unit]
|
||||
Description=EdgeGuard WAF SPOE Agent (Coraza/OWASP CRS)
|
||||
Documentation=https://git.netcell-it.de/projekte/edgeguard-native
|
||||
After=network-online.target postgresql.service edgeguard-api.service
|
||||
Wants=network-online.target
|
||||
Requires=postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=edgeguard
|
||||
Group=edgeguard
|
||||
ExecStart=/usr/bin/edgeguard-waf
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
# Hardening — WAF agent only needs DB access and one TCP listen socket.
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
SystemCallFilter=@system-service
|
||||
# CRS rules are read from /usr/share/edgeguard/waf/crs/ (read-only, OK).
|
||||
# Alerts/logs are written to /var/log/edgeguard/.
|
||||
ReadWritePaths=/var/log/edgeguard
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
25
go.mod
25
go.mod
@@ -3,23 +3,30 @@ module git.netcell-it.de/projekte/edgeguard-native
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/corazawaf/coraza/v3 v3.7.0
|
||||
github.com/dropmorepackets/haproxy-go v0.0.8
|
||||
github.com/fsnotify/fsnotify v1.10.1
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/go-acme/lego/v4 v4.35.2
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
github.com/minio/minio-go/v7 v7.1.0
|
||||
github.com/pkg/sftp v1.13.10
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/pressly/goose/v3 v3.27.1
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
golang.org/x/crypto v0.51.0
|
||||
)
|
||||
|
||||
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
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/corazawaf/libinjection-go v0.3.2 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
@@ -28,34 +35,43 @@ require (
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.23.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gotnospirit/makeplural v0.0.0-20180622080156-a5f48d94d976 // indirect
|
||||
github.com/gotnospirit/messageformat v0.0.0-20221001023931-dfe49f1eb092 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect
|
||||
github.com/kaptinlin/go-i18n v0.1.4 // indirect
|
||||
github.com/kaptinlin/jsonschema v0.4.6 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
|
||||
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||
github.com/kr/fs v0.1.0 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/magefile/mage v1.17.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.21 // indirect
|
||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||
github.com/miekg/dns v1.1.72 // indirect
|
||||
github.com/minio/crc64nvme v1.1.1 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/minio/minio-go/v7 v7.1.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/petar-dambovaliev/aho-corasick v0.0.0-20250424160509-463d218d4745 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/pkg/sftp v1.13.10 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||
github.com/tidwall/gjson v1.18.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tinylib/msgp v1.6.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/valllabh/ocsf-schema-golang v1.0.3 // indirect
|
||||
github.com/zeebo/xxh3 v1.1.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
@@ -68,4 +84,5 @@ require (
|
||||
golang.org/x/tools v0.44.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
rsc.io/binaryregexp v0.2.0 // indirect
|
||||
)
|
||||
|
||||
60
go.sum
60
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=
|
||||
@@ -10,13 +13,23 @@ github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/corazawaf/coraza-coreruleset v0.0.0-20240226094324-415b1017abdc h1:OlJhrgI3I+FLUCTI3JJW8MoqyM78WbqJjecqMnqG+wc=
|
||||
github.com/corazawaf/coraza-coreruleset v0.0.0-20240226094324-415b1017abdc/go.mod h1:7rsocqNDkTCira5T0M7buoKR2ehh7YZiPkzxRuAgvVU=
|
||||
github.com/corazawaf/coraza/v3 v3.7.0 h1:LIQqu1r+l6e/U/gyiZeykWaNNBY1TzRLz+aaI+QYEEM=
|
||||
github.com/corazawaf/coraza/v3 v3.7.0/go.mod h1:dOSt5evqC7EstouEv6ghhui01+oVUwp9X1vybWwqTlo=
|
||||
github.com/corazawaf/libinjection-go v0.3.2 h1:9rrKt0lpg4WvUXt+lwS06GywfqRXXsa/7JcOw5cQLwI=
|
||||
github.com/corazawaf/libinjection-go v0.3.2/go.mod h1:Ik/+w3UmTWH9yn366RgS9D95K3y7Atb5m/H/gXzzPCk=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dropmorepackets/haproxy-go v0.0.8 h1:kS2Wa8+ZDcnJdRSTiuBsaPun5hpdUPIuLQ+Drp9ZxYs=
|
||||
github.com/dropmorepackets/haproxy-go v0.0.8/go.mod h1:4a2AmmVjvg2zPNdizGZrMN8ZSUpj90U43VlcdbOIBnU=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/foxcpp/go-mockdns v1.1.0 h1:jI0rD8M0wuYAxL7r/ynTrCQQq0BVqfB99Vgk7DlmewI=
|
||||
github.com/foxcpp/go-mockdns v1.1.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk=
|
||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
@@ -39,8 +52,10 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o=
|
||||
github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
@@ -48,6 +63,10 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gotnospirit/makeplural v0.0.0-20180622080156-a5f48d94d976 h1:b70jEaX2iaJSPZULSUxKtm73LBfsCrMsIlYCUgNGSIs=
|
||||
github.com/gotnospirit/makeplural v0.0.0-20180622080156-a5f48d94d976/go.mod h1:ZGQeOwybjD8lkCjIyJfqR5LD2wMVHJ31d6GdPxoTsWY=
|
||||
github.com/gotnospirit/messageformat v0.0.0-20221001023931-dfe49f1eb092 h1:c7gcNWTSr1gtLp6PyYi3wzvFCEcHJ4YRobDgqmIgf7Q=
|
||||
github.com/gotnospirit/messageformat v0.0.0-20221001023931-dfe49f1eb092/go.mod h1:ZZAN4fkkful3l1lpJwF8JbW41ZiG9TwJ2ZlqzQovBNU=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
@@ -56,14 +75,18 @@ github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jcchavezs/mergefs v0.1.1 h1:D45R17m6dHnSVZefnhynoeZvcK2Uw0oTrRfoUOQ0S5Y=
|
||||
github.com/jcchavezs/mergefs v0.1.1/go.mod h1:eRLTrsA+vFwQZ48hj8p8gki/5v9C2bFtHH5Mnn4bcGk=
|
||||
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 h1:9Nu54bhS/H/Kgo2/7xNSUuC5G28VR8ljfrLKU2G4IjU=
|
||||
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12/go.mod h1:TBzl5BIHNXfS9+C35ZyJaklL7mLDbgUkcgXzSLa8Tk0=
|
||||
github.com/kaptinlin/go-i18n v0.1.4 h1:wCiwAn1LOcvymvWIVAM4m5dUAMiHunTdEubLDk4hTGs=
|
||||
github.com/kaptinlin/go-i18n v0.1.4/go.mod h1:g1fn1GvTgT4CiLE8/fFE1hboHWJ6erivrDpiDtCcFKg=
|
||||
github.com/kaptinlin/jsonschema v0.4.6 h1:vOSFg5tjmfkOdKg+D6Oo4fVOM/pActWu/ntkPsI1T64=
|
||||
github.com/kaptinlin/jsonschema v0.4.6/go.mod h1:1DUd7r5SdyB2ZnMtyB7uLv64dE3zTFTiYytDCd+AEL0=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
|
||||
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
|
||||
@@ -77,6 +100,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/magefile/mage v1.17.0 h1:dS4tkq997Ism03akafC8509iqDjeE7TNTexI25Y7sXM=
|
||||
github.com/magefile/mage v1.17.0/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA=
|
||||
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
|
||||
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||
@@ -97,8 +122,10 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/petar-dambovaliev/aho-corasick v0.0.0-20250424160509-463d218d4745 h1:Vpr4VgAizEgEZsaMohpw6JYDP+i9Of9dmdY4ufNP6HI=
|
||||
github.com/petar-dambovaliev/aho-corasick v0.0.0-20250424160509-463d218d4745/go.mod h1:EHPiTAKtiFmrMldLUNswFwfZ2eJIYBHktdaUTZxYWRw=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
@@ -107,6 +134,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=
|
||||
@@ -122,22 +151,30 @@ github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDq
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
|
||||
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/valllabh/ocsf-schema-golang v1.0.3 h1:eR8k/3jP/OOqB8LRCtdJ4U+vlgd/gk5y3KMXoodrsrw=
|
||||
github.com/valllabh/ocsf-schema-golang v1.0.3/go.mod h1:sZ3as9xqm1SSK5feFWIR2CuGeGRhsM7TR1MbpBctzPk=
|
||||
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
@@ -155,9 +192,10 @@ golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
@@ -179,4 +217,6 @@ modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw
|
||||
modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U=
|
||||
modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/binaryregexp v0.2.0 h1:HfqmD5MEmC0zvwBuF187nq9mdnXjXsSivRiXN7SmRkE=
|
||||
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
|
||||
@@ -151,7 +151,19 @@ func (a *Aggregator) callPeer(ctx context.Context, p models.HANode, path string)
|
||||
return res
|
||||
}
|
||||
res.OK = true
|
||||
res.Data = body
|
||||
// Agent-Endpoints liefern die Standard-API-Envelope zurück
|
||||
// ({"data": {...}, "error": null, "message": "ok"}). Wir entpacken
|
||||
// das `data`-Feld so dass der Aufrufer direkt die Nutzlast bekommt —
|
||||
// konsistent mit dem Lokal-Pfad (der marshalt die Struct direkt ohne
|
||||
// Envelope).
|
||||
var env struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &env); err == nil && len(env.Data) > 0 {
|
||||
res.Data = env.Data
|
||||
} else {
|
||||
res.Data = body
|
||||
}
|
||||
res.Duration = time.Since(start).Milliseconds()
|
||||
return res
|
||||
}
|
||||
@@ -180,6 +192,80 @@ func agentURL(apiURL string, agentPort int, path string) (string, error) {
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
// PostPeer sendet einen POST-Request an einen einzelnen Peer.
|
||||
// Wird vom Rolling-Update-Orchestrator genutzt um /agent/cluster/trigger-update
|
||||
// auf dem Secondary auszulösen.
|
||||
func (a *Aggregator) PostPeer(ctx context.Context, p models.HANode, path string) PeerResult {
|
||||
start := time.Now()
|
||||
res := PeerResult{NodeID: p.ID, FQDN: p.FQDN}
|
||||
target, err := agentURL(p.APIURL, a.AgentPort, path)
|
||||
if err != nil {
|
||||
res.Err = "bad api_url: " + err.Error()
|
||||
return res
|
||||
}
|
||||
reqCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, target, nil)
|
||||
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()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted {
|
||||
res.Err = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
res.Duration = time.Since(start).Milliseconds()
|
||||
return res
|
||||
}
|
||||
res.OK = true
|
||||
res.Duration = time.Since(start).Milliseconds()
|
||||
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
|
||||
|
||||
@@ -49,6 +49,30 @@ func New(pool *pgxpool.Pool) *Generator {
|
||||
|
||||
func (g *Generator) Name() string { return "chrony" }
|
||||
|
||||
// RenderToString renders the chrony config to a string without writing
|
||||
// to disk or reloading the service. Used by the config-preview endpoint.
|
||||
func (g *Generator) RenderToString(ctx context.Context) (string, error) {
|
||||
settings, err := g.Repo.GetSettings(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("settings: %w", err)
|
||||
}
|
||||
pools, err := g.Repo.ListPools(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pools: %w", err)
|
||||
}
|
||||
view := View{
|
||||
Settings: settings,
|
||||
Pools: pools,
|
||||
ListenAddresses: filterNonLoopback(splitCSV(settings.ListenAddresses)),
|
||||
AllowACLs: splitCSV(settings.AllowACL),
|
||||
}
|
||||
var body bytes.Buffer
|
||||
if err := tpl.Execute(&body, view); err != nil {
|
||||
return "", fmt.Errorf("template: %w", err)
|
||||
}
|
||||
return body.String(), nil
|
||||
}
|
||||
|
||||
func (g *Generator) Render(ctx context.Context) error {
|
||||
settings, err := g.Repo.GetSettings(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -35,10 +35,13 @@ import (
|
||||
|
||||
// hashTable beschreibt eine Tabelle die in den config-hash einfließt.
|
||||
type hashTable struct {
|
||||
Name string
|
||||
Singleton bool // dns_settings, ntp_settings → eine row, id=1
|
||||
ExtraExclude []string // Spalten die zusätzlich aus to_jsonb gefiltert werden
|
||||
SkipUpdatedAt bool // setze true wenn updated_at semantisch relevant ist
|
||||
Name string
|
||||
Singleton bool // dns_settings, ntp_settings → eine row, id=1
|
||||
ExtraExclude []string // Spalten die zusätzlich aus to_jsonb gefiltert werden
|
||||
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
|
||||
@@ -49,15 +52,13 @@ var hashSpec = []hashTable{
|
||||
{Name: "backends"},
|
||||
{Name: "backend_servers"},
|
||||
{Name: "routing_rules"},
|
||||
{Name: "network_interfaces"},
|
||||
{Name: "ip_addresses"},
|
||||
{Name: "tls_certs", ExtraExclude: []string{"last_renewed_at", "last_error"}},
|
||||
|
||||
{Name: "firewall_zones"},
|
||||
{Name: "firewall_zones", MigrationDefault: true},
|
||||
{Name: "firewall_address_objects"},
|
||||
{Name: "firewall_address_groups"},
|
||||
{Name: "firewall_services"},
|
||||
{Name: "firewall_service_groups"},
|
||||
{Name: "firewall_services", MigrationDefault: true},
|
||||
{Name: "firewall_service_groups", MigrationDefault: true},
|
||||
{Name: "firewall_rules"},
|
||||
{Name: "firewall_nat_rules"},
|
||||
|
||||
@@ -67,12 +68,36 @@ var hashSpec = []hashTable{
|
||||
|
||||
{Name: "dns_zones"},
|
||||
{Name: "dns_records"},
|
||||
{Name: "dns_settings", Singleton: true},
|
||||
|
||||
{Name: "ntp_pools"},
|
||||
{Name: "ntp_settings", Singleton: true},
|
||||
{Name: "ntp_pools", MigrationDefault: true},
|
||||
|
||||
{Name: "static_routes"},
|
||||
// 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.
|
||||
@@ -100,22 +125,39 @@ func hashSQL(t hashTable) string {
|
||||
// ComputeConfigHash gibt den 16-hex-char-Hash über alle Spec-Tabellen
|
||||
// zurück. Fehlende Tabellen (transienter schema-flux) werden als
|
||||
// leerer Per-Table-Hash behandelt — kein Abbruch.
|
||||
//
|
||||
// Gibt "" zurück wenn alle user-konfigurierbaren Tabellen leer sind
|
||||
// (Singleton- und MigrationDefault-Tabellen zählen nicht als User-Config).
|
||||
// Das verhindert False-Positive-Drift-Banner auf frisch gejointen Secondaries.
|
||||
func ComputeConfigHash(ctx context.Context, pool *pgxpool.Pool) (string, error) {
|
||||
if pool == nil {
|
||||
return "", fmt.Errorf("nil pool")
|
||||
}
|
||||
h := sha256.New()
|
||||
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 = ""
|
||||
}
|
||||
if s != "" && !t.Singleton && !t.MigrationDefault {
|
||||
hasUserConfig = true
|
||||
}
|
||||
h.Write([]byte(t.Name))
|
||||
h.Write([]byte{0})
|
||||
h.Write([]byte(s))
|
||||
h.Write([]byte{0})
|
||||
}
|
||||
if !hasUserConfig {
|
||||
// Frisch gejoincter Secondary oder komplett leere DB →
|
||||
// leerer String signalisiert "kein Drift prüfen" im Status-Handler.
|
||||
return "", nil
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))[:16], nil
|
||||
}
|
||||
|
||||
|
||||
@@ -61,12 +61,19 @@ type Service struct {
|
||||
}
|
||||
|
||||
func New(pool *pgxpool.Pool, getCAFinger func() (string, error)) *Service {
|
||||
return &Service{
|
||||
s := &Service{
|
||||
Pool: pool,
|
||||
SecretPath: DefaultSecretPath,
|
||||
GetCAFinger: getCAFinger,
|
||||
TTL: DefaultTTL,
|
||||
}
|
||||
// Ensure the HMAC secret file exists at startup so Consume() never
|
||||
// fails with "no such file" on the first join attempt.
|
||||
if _, err := s.ensureSecret(); err != nil {
|
||||
// Non-fatal: generate won't work either, but we log and continue.
|
||||
_ = err
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// payload ist das JSON inside-the-token.
|
||||
|
||||
@@ -115,6 +115,16 @@ func (s *Store) Delete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeletePlaceholdersByFQDN removes all ha_nodes rows that share the given
|
||||
// FQDN but do NOT have the specified keepID. Used to clean up pre-registered
|
||||
// placeholder rows (both old "pre-{timestamp}" and new "prenode-{fqdn}"
|
||||
// style) after a real autoRegister arrives via mTLS.
|
||||
func (s *Store) DeletePlaceholdersByFQDN(ctx context.Context, fqdn, keepID string) error {
|
||||
_, err := s.Pool.Exec(ctx,
|
||||
`DELETE FROM ha_nodes WHERE fqdn = $1 AND id != $2`, fqdn, keepID)
|
||||
return err
|
||||
}
|
||||
|
||||
// EnsureSelfRegistered mints the node-id if needed, builds the row
|
||||
// from setup.json + os.Hostname + node.conf, and upserts it. Called
|
||||
// on edgeguard-api boot AFTER the DB pool is reachable.
|
||||
|
||||
432
internal/crowdsec/service.go
Normal file
432
internal/crowdsec/service.go
Normal file
@@ -0,0 +1,432 @@
|
||||
// Package crowdsec wraps sudo /usr/bin/cscli calls for the edgeguard
|
||||
// management API. All list operations use -o json. Mutation operations
|
||||
// (add/delete) use the appropriate cscli sub-commands.
|
||||
//
|
||||
// edgeguard runs as a non-root system user; every cscli call goes
|
||||
// through sudo (allowed entries are in /etc/sudoers.d/edgeguard).
|
||||
package crowdsec
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrNotInstalled is returned when /usr/bin/cscli is not found.
|
||||
var ErrNotInstalled = errors.New("crowdsec not installed")
|
||||
|
||||
// IsInstalled checks whether /usr/bin/cscli exists on this host.
|
||||
func IsInstalled() bool {
|
||||
_, err := os.Stat("/usr/bin/cscli")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// ---------- Types -----------------------------------------------------------
|
||||
|
||||
// Decision represents a single IP decision (ban/captcha/etc.) in CrowdSec.
|
||||
type Decision struct {
|
||||
ID int64 `json:"id"`
|
||||
Origin string `json:"origin"`
|
||||
Type string `json:"type"`
|
||||
Scope string `json:"scope"`
|
||||
Value string `json:"value"`
|
||||
Duration string `json:"duration"`
|
||||
Reason string `json:"reason"`
|
||||
Country string `json:"country,omitempty"`
|
||||
AS string `json:"as,omitempty"`
|
||||
}
|
||||
|
||||
// Alert represents a CrowdSec alert with associated decisions.
|
||||
type Alert struct {
|
||||
ID int64 `json:"id"`
|
||||
Scenario string `json:"scenario"`
|
||||
EventsCount int `json:"events_count"`
|
||||
Source AlertSource `json:"source"`
|
||||
StartAt string `json:"start_at"`
|
||||
StopAt string `json:"stop_at"`
|
||||
Decisions []Decision `json:"decisions,omitempty"`
|
||||
}
|
||||
|
||||
// AlertSource holds the source IP/range info for an alert.
|
||||
type AlertSource struct {
|
||||
IP string `json:"ip"`
|
||||
Country string `json:"cn,omitempty"`
|
||||
ASName string `json:"as_name,omitempty"`
|
||||
Range string `json:"range,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Value string `json:"value,omitempty"`
|
||||
}
|
||||
|
||||
// Bouncer represents a registered CrowdSec bouncer.
|
||||
type Bouncer struct {
|
||||
Name string `json:"name"`
|
||||
IPAddress string `json:"ip_address,omitempty"`
|
||||
Revoked bool `json:"revoked"`
|
||||
LastPull string `json:"last_pull,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
AuthType string `json:"auth_type,omitempty"`
|
||||
}
|
||||
|
||||
// Machine represents a registered CrowdSec agent/machine.
|
||||
type Machine struct {
|
||||
MachineID string `json:"machineId"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
LastPush string `json:"last_push,omitempty"`
|
||||
IsValidated bool `json:"isValidated"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// HubItem represents a CrowdSec hub item (collection, parser, scenario, etc.).
|
||||
type HubItem struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Status string `json:"status"`
|
||||
LocalVersion string `json:"local_version,omitempty"`
|
||||
LocalPath string `json:"local_path,omitempty"`
|
||||
Author string `json:"author,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
}
|
||||
|
||||
// Status summarises the runtime state of the CrowdSec stack on this node.
|
||||
type Status struct {
|
||||
Installed bool `json:"installed"`
|
||||
AgentRunning bool `json:"agent_running"`
|
||||
BouncerRunning bool `json:"bouncer_running"`
|
||||
Version string `json:"version,omitempty"`
|
||||
DecisionCount int `json:"decision_count"`
|
||||
AlertCount int `json:"alert_count"`
|
||||
BouncerCount int `json:"bouncer_count"`
|
||||
MachineCount int `json:"machine_count"`
|
||||
}
|
||||
|
||||
// ---------- Helpers ---------------------------------------------------------
|
||||
|
||||
// sudoCscli executes `sudo -n /usr/bin/cscli <args...>` and returns stdout.
|
||||
func sudoCscli(ctx context.Context, args ...string) ([]byte, error) {
|
||||
full := append([]string{"-n", "/usr/bin/cscli"}, args...)
|
||||
cmd := exec.CommandContext(ctx, "sudo", full...)
|
||||
var out, errBuf bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &errBuf
|
||||
if err := cmd.Run(); err != nil {
|
||||
slog.Error("crowdsec: sudoCscli failed", "args", args, "error", err, "stderr", errBuf.String())
|
||||
return nil, err
|
||||
}
|
||||
if errBuf.Len() > 0 {
|
||||
slog.Warn("crowdsec: sudoCscli stderr", "args", args, "stderr", errBuf.String())
|
||||
}
|
||||
slog.Debug("crowdsec: sudoCscli ok", "args", args[0], "bytes", out.Len())
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
// systemctlActive returns true when the named unit is "active".
|
||||
func systemctlActive(ctx context.Context, unit string) bool {
|
||||
cmd := exec.CommandContext(ctx, "systemctl", "is-active", "--quiet", unit)
|
||||
return cmd.Run() == nil
|
||||
}
|
||||
|
||||
// unmarshalSlice unmarshals JSON that may be "null" (cscli returns null
|
||||
// instead of [] when no items exist). Returns an empty slice in that case.
|
||||
func unmarshalSlice[T any](data []byte) ([]T, error) {
|
||||
data = bytes.TrimSpace(data)
|
||||
if bytes.Equal(data, []byte("null")) || len(data) == 0 {
|
||||
return []T{}, nil
|
||||
}
|
||||
var result []T
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ---------- ServiceStatus ---------------------------------------------------
|
||||
|
||||
// ServiceStatus returns a Status struct describing the current state of the
|
||||
// CrowdSec agent and bouncer on this node. Does NOT need cscli installed —
|
||||
// it uses systemctl for the running-state checks. Version is extracted via
|
||||
// `cscli version` when available.
|
||||
func ServiceStatus(ctx context.Context) Status {
|
||||
st := Status{
|
||||
Installed: IsInstalled(),
|
||||
AgentRunning: systemctlActive(ctx, "crowdsec"),
|
||||
BouncerRunning: systemctlActive(ctx, "crowdsec-firewall-bouncer"),
|
||||
}
|
||||
|
||||
if st.Installed {
|
||||
// Grab version from `sudo -n /usr/bin/cscli version` — first line only.
|
||||
// Output is not JSON; it looks like "version: v1.6.3-..."
|
||||
if out, err := sudoCscli(ctx, "version"); err == nil {
|
||||
scanner := bufio.NewScanner(bytes.NewReader(out))
|
||||
if scanner.Scan() {
|
||||
st.Version = strings.TrimSpace(scanner.Text())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only query cscli data endpoints when the agent is running — cscli
|
||||
// hangs on its local socket when the agent is stopped, which would
|
||||
// block the entire status response and leave the UI with no data.
|
||||
if st.AgentRunning {
|
||||
if decisions, err := Decisions(ctx); err == nil {
|
||||
st.DecisionCount = len(decisions)
|
||||
}
|
||||
if alerts, err := Alerts(ctx, 500); err == nil {
|
||||
st.AlertCount = len(alerts)
|
||||
}
|
||||
if bouncers, err := Bouncers(ctx); err == nil {
|
||||
st.BouncerCount = len(bouncers)
|
||||
}
|
||||
if machines, err := Machines(ctx); err == nil {
|
||||
st.MachineCount = len(machines)
|
||||
}
|
||||
}
|
||||
|
||||
return st
|
||||
}
|
||||
|
||||
// ---------- Decisions -------------------------------------------------------
|
||||
|
||||
// cscli decisions list -o json returns alert-level objects with nested
|
||||
// decisions[] arrays. These intermediate types are used only for parsing.
|
||||
type cscliDecisionRaw struct {
|
||||
ID int64 `json:"id"`
|
||||
Duration string `json:"duration"`
|
||||
Origin string `json:"origin"`
|
||||
Scope string `json:"scope"`
|
||||
Type string `json:"type"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type cscliAlertRaw struct {
|
||||
Scenario string `json:"scenario"`
|
||||
Decisions []cscliDecisionRaw `json:"decisions"`
|
||||
Source struct {
|
||||
IP string `json:"ip"`
|
||||
CN string `json:"cn"`
|
||||
ASName string `json:"as_name"`
|
||||
} `json:"source"`
|
||||
}
|
||||
|
||||
// Decisions lists all active decisions by flattening the alert-level JSON
|
||||
// that cscli emits (each alert contains a nested decisions[] array).
|
||||
func Decisions(ctx context.Context) ([]Decision, error) {
|
||||
if !IsInstalled() {
|
||||
return nil, ErrNotInstalled
|
||||
}
|
||||
out, err := sudoCscli(ctx, "decisions", "list", "-o", "json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
alerts, err := unmarshalSlice[cscliAlertRaw](out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result []Decision
|
||||
for _, a := range alerts {
|
||||
for _, d := range a.Decisions {
|
||||
result = append(result, Decision{
|
||||
ID: d.ID,
|
||||
Origin: d.Origin,
|
||||
Type: d.Type,
|
||||
Scope: d.Scope,
|
||||
Value: d.Value,
|
||||
Duration: d.Duration,
|
||||
Reason: a.Scenario,
|
||||
Country: a.Source.CN,
|
||||
AS: a.Source.ASName,
|
||||
})
|
||||
}
|
||||
}
|
||||
if result == nil {
|
||||
result = []Decision{}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// AddDecision creates a new ban/captcha decision for the given IP.
|
||||
func AddDecision(ctx context.Context, ip, duration, reason, typ string) error {
|
||||
if !IsInstalled() {
|
||||
return ErrNotInstalled
|
||||
}
|
||||
_, err := sudoCscli(ctx, "decisions", "add",
|
||||
"--ip", ip,
|
||||
"--duration", duration,
|
||||
"--reason", reason,
|
||||
"--type", typ,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteDecisionByIP removes all decisions for a given IP address.
|
||||
func DeleteDecisionByIP(ctx context.Context, ip string) error {
|
||||
if !IsInstalled() {
|
||||
return ErrNotInstalled
|
||||
}
|
||||
_, err := sudoCscli(ctx, "decisions", "delete", "--ip", ip)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteDecisionByID removes a single decision by its numeric ID.
|
||||
func DeleteDecisionByID(ctx context.Context, id string) error {
|
||||
if !IsInstalled() {
|
||||
return ErrNotInstalled
|
||||
}
|
||||
_, err := sudoCscli(ctx, "decisions", "delete", "--id", id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------- Alerts ----------------------------------------------------------
|
||||
|
||||
// Alerts lists recent alerts (up to limit).
|
||||
func Alerts(ctx context.Context, limit int) ([]Alert, error) {
|
||||
if !IsInstalled() {
|
||||
return nil, ErrNotInstalled
|
||||
}
|
||||
out, err := sudoCscli(ctx, "alerts", "list", "-o", "json",
|
||||
"-l", fmt.Sprintf("%d", limit))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return unmarshalSlice[Alert](out)
|
||||
}
|
||||
|
||||
// DeleteAlert discards (deletes) a single alert by its ID.
|
||||
func DeleteAlert(ctx context.Context, id string) error {
|
||||
if !IsInstalled() {
|
||||
return ErrNotInstalled
|
||||
}
|
||||
_, err := sudoCscli(ctx, "alerts", "delete", "--id", id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------- Bouncers --------------------------------------------------------
|
||||
|
||||
// Bouncers lists all registered bouncers.
|
||||
func Bouncers(ctx context.Context) ([]Bouncer, error) {
|
||||
if !IsInstalled() {
|
||||
return nil, ErrNotInstalled
|
||||
}
|
||||
out, err := sudoCscli(ctx, "bouncers", "list", "-o", "json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return unmarshalSlice[Bouncer](out)
|
||||
}
|
||||
|
||||
// DeleteBouncer removes a bouncer by name.
|
||||
func DeleteBouncer(ctx context.Context, name string) error {
|
||||
if !IsInstalled() {
|
||||
return ErrNotInstalled
|
||||
}
|
||||
_, err := sudoCscli(ctx, "bouncers", "delete", name)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------- Machines --------------------------------------------------------
|
||||
|
||||
// cscliMachineRaw mirrors the actual cscli JSON with its mixed camelCase /
|
||||
// snake_case field names. Only used inside Machines().
|
||||
type cscliMachineRaw struct {
|
||||
MachineID string `json:"machineId"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
LastPush string `json:"last_push"`
|
||||
IsValidated bool `json:"isValidated"`
|
||||
Version string `json:"version"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// Machines lists all registered machines/agents.
|
||||
func Machines(ctx context.Context) ([]Machine, error) {
|
||||
if !IsInstalled() {
|
||||
return nil, ErrNotInstalled
|
||||
}
|
||||
out, err := sudoCscli(ctx, "machines", "list", "-o", "json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := unmarshalSlice[cscliMachineRaw](out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]Machine, len(raw))
|
||||
for i, r := range raw {
|
||||
result[i] = Machine{
|
||||
MachineID: r.MachineID,
|
||||
CreatedAt: r.CreatedAt,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
LastPush: r.LastPush,
|
||||
IsValidated: r.IsValidated,
|
||||
Version: r.Version,
|
||||
Status: r.Status,
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// DeleteMachine removes a machine by its machine ID.
|
||||
func DeleteMachine(ctx context.Context, id string) error {
|
||||
if !IsInstalled() {
|
||||
return ErrNotInstalled
|
||||
}
|
||||
_, err := sudoCscli(ctx, "machines", "delete", "--machine-id", id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------- Collections -----------------------------------------------------
|
||||
|
||||
// Collections lists installed/available hub collections.
|
||||
// cscli returns {"collections": [...]} (not a flat array) — we unwrap the key.
|
||||
func Collections(ctx context.Context) ([]HubItem, error) {
|
||||
if !IsInstalled() {
|
||||
return nil, ErrNotInstalled
|
||||
}
|
||||
out, err := sudoCscli(ctx, "collections", "list", "-o", "json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = bytes.TrimSpace(out)
|
||||
if bytes.Equal(out, []byte("null")) || len(out) == 0 {
|
||||
return []HubItem{}, nil
|
||||
}
|
||||
// cscli wraps collections in {"collections": [...]}
|
||||
var wrapper struct {
|
||||
Collections []HubItem `json:"collections"`
|
||||
}
|
||||
if err := json.Unmarshal(out, &wrapper); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if wrapper.Collections == nil {
|
||||
return []HubItem{}, nil
|
||||
}
|
||||
return wrapper.Collections, nil
|
||||
}
|
||||
|
||||
// InstallCollection installs a hub collection by name (--force to upgrade).
|
||||
func InstallCollection(ctx context.Context, name string) error {
|
||||
if !IsInstalled() {
|
||||
return ErrNotInstalled
|
||||
}
|
||||
_, err := sudoCscli(ctx, "collections", "install", name, "--force")
|
||||
return err
|
||||
}
|
||||
|
||||
// RemoveCollection removes a hub collection by name.
|
||||
func RemoveCollection(ctx context.Context, name string) error {
|
||||
if !IsInstalled() {
|
||||
return ErrNotInstalled
|
||||
}
|
||||
_, err := sudoCscli(ctx, "collections", "remove", name)
|
||||
return err
|
||||
}
|
||||
@@ -15,8 +15,8 @@ CREATE TABLE IF NOT EXISTS ha_nodes (
|
||||
name TEXT NOT NULL,
|
||||
fqdn TEXT NOT NULL,
|
||||
api_url TEXT NOT NULL,
|
||||
public_ip INET,
|
||||
internal_ip INET,
|
||||
public_ip TEXT,
|
||||
internal_ip TEXT,
|
||||
role TEXT NOT NULL DEFAULT 'peer',
|
||||
last_seen TIMESTAMPTZ,
|
||||
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
ALTER TABLE ha_nodes
|
||||
ADD COLUMN IF NOT EXISTS version TEXT,
|
||||
ADD COLUMN IF NOT EXISTS config_hash TEXT,
|
||||
ADD COLUMN IF NOT EXISTS mgmt_ip INET,
|
||||
ADD COLUMN IF NOT EXISTS mgmt_ip TEXT,
|
||||
ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'unknown';
|
||||
|
||||
ALTER TABLE ha_nodes
|
||||
@@ -33,7 +33,7 @@ ALTER TABLE ha_nodes
|
||||
DROP CONSTRAINT IF EXISTS ha_nodes_status_check;
|
||||
ALTER TABLE ha_nodes
|
||||
DROP COLUMN IF EXISTS status,
|
||||
DROP COLUMN IF EXISTS mgmt_ip,
|
||||
DROP COLUMN IF EXISTS mgmt_ip, -- TEXT
|
||||
DROP COLUMN IF EXISTS config_hash,
|
||||
DROP COLUMN IF EXISTS version;
|
||||
-- +goose StatementEnd
|
||||
|
||||
20
internal/database/migrations/0028_ha_nodes_ip_text.sql
Normal file
20
internal/database/migrations/0028_ha_nodes_ip_text.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
-- pgx scannt INET-Spalten nicht direkt in *string (OID 869-Fehler).
|
||||
-- Go-Modell speichert IPs als string — TEXT ist hier korrekt.
|
||||
-- Bestehende Werte bleiben erhalten (USING public_ip::TEXT).
|
||||
ALTER TABLE ha_nodes
|
||||
ALTER COLUMN public_ip TYPE TEXT USING public_ip::TEXT,
|
||||
ALTER COLUMN internal_ip TYPE TEXT USING internal_ip::TEXT,
|
||||
ALTER COLUMN mgmt_ip TYPE TEXT USING mgmt_ip::TEXT;
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE ha_nodes
|
||||
ALTER COLUMN public_ip TYPE INET USING public_ip::INET,
|
||||
ALTER COLUMN internal_ip TYPE INET USING internal_ip::INET,
|
||||
ALTER COLUMN mgmt_ip TYPE INET USING mgmt_ip::INET;
|
||||
-- +goose StatementEnd
|
||||
36
internal/database/migrations/0029_cluster_vip.sql
Normal file
36
internal/database/migrations/0029_cluster_vip.sql
Normal file
@@ -0,0 +1,36 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
-- pg_role: Rolle dieser Node in der PG-Replikation.
|
||||
-- "standalone" = kein Streaming-Replication-Setup
|
||||
-- "primary" = WAL-Sender, repliziert an Standby(s)
|
||||
-- "standby" = Hot-Standby, liest WAL vom Primary
|
||||
ALTER TABLE ha_nodes ADD COLUMN IF NOT EXISTS pg_role TEXT NOT NULL DEFAULT 'standalone';
|
||||
|
||||
-- cluster_settings: VIP + VRRP-Konfiguration (Singleton, id=1).
|
||||
-- vip_address = die virtuelle IP-Adresse (z.B. "89.163.205.10")
|
||||
-- vip_interface = Netzwerk-Interface (z.B. "eth0")
|
||||
-- vip_auth_pass = VRRP-Authentication-Passwort (max. 8 Zeichen, Keepalived-Limit)
|
||||
-- vrrp_router_id = VRRP Virtual Router ID (1–255, muss im Subnetz eindeutig sein)
|
||||
CREATE TABLE IF NOT EXISTS cluster_settings (
|
||||
id INTEGER PRIMARY KEY DEFAULT 1,
|
||||
vip_address TEXT,
|
||||
vip_interface TEXT,
|
||||
vip_auth_pass TEXT,
|
||||
vrrp_router_id INTEGER NOT NULL DEFAULT 51,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT cluster_settings_singleton CHECK (id = 1)
|
||||
);
|
||||
|
||||
INSERT INTO cluster_settings (id) VALUES (1) ON CONFLICT DO NOTHING;
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
|
||||
DROP TABLE IF EXISTS cluster_settings;
|
||||
ALTER TABLE ha_nodes DROP COLUMN IF EXISTS pg_role;
|
||||
|
||||
-- +goose StatementEnd
|
||||
@@ -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
|
||||
25
internal/database/migrations/0031_forward_proxy_settings.sql
Normal file
25
internal/database/migrations/0031_forward_proxy_settings.sql
Normal file
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
9
internal/database/migrations/0034_totp.sql
Normal file
9
internal/database/migrations/0034_totp.sql
Normal file
@@ -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;
|
||||
17
internal/database/migrations/0035_firewall_note_labels.sql
Normal file
17
internal/database/migrations/0035_firewall_note_labels.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE firewall_rules
|
||||
ADD COLUMN IF NOT EXISTS note TEXT,
|
||||
ADD COLUMN IF NOT EXISTS labels TEXT[] NOT NULL DEFAULT '{}';
|
||||
|
||||
ALTER TABLE firewall_nat_rules
|
||||
ADD COLUMN IF NOT EXISTS note TEXT,
|
||||
ADD COLUMN IF NOT EXISTS labels TEXT[] NOT NULL DEFAULT '{}';
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE firewall_rules
|
||||
DROP COLUMN IF EXISTS note,
|
||||
DROP COLUMN IF EXISTS labels;
|
||||
|
||||
ALTER TABLE firewall_nat_rules
|
||||
DROP COLUMN IF EXISTS note,
|
||||
DROP COLUMN IF EXISTS labels;
|
||||
12
internal/database/migrations/0036_crowdsec.sql
Normal file
12
internal/database/migrations/0036_crowdsec.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE IF NOT EXISTS crowdsec_settings (
|
||||
id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
simulation_mode BOOLEAN NOT NULL DEFAULT false,
|
||||
collections TEXT[] NOT NULL DEFAULT '{"crowdsecurity/linux","crowdsecurity/haproxy"}',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
INSERT INTO crowdsec_settings (id) VALUES (1) ON CONFLICT DO NOTHING;
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS crowdsec_settings;
|
||||
18
internal/database/migrations/0037_waf.sql
Normal file
18
internal/database/migrations/0037_waf.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE IF NOT EXISTS waf_configs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
domain_id BIGINT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
||||
enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
mode TEXT NOT NULL DEFAULT 'detection'
|
||||
CHECK (mode IN ('detection','blocking')),
|
||||
paranoia_level INT NOT NULL DEFAULT 1
|
||||
CHECK (paranoia_level BETWEEN 1 AND 4),
|
||||
rule_exclusions TEXT[] NOT NULL DEFAULT '{}',
|
||||
trusted_proxies TEXT[] NOT NULL DEFAULT '{}',
|
||||
custom_rules TEXT NOT NULL DEFAULT '',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT waf_configs_domain_unique UNIQUE (domain_id)
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS waf_configs;
|
||||
20
internal/database/migrations/0038_waf_alerts.sql
Normal file
20
internal/database/migrations/0038_waf_alerts.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE IF NOT EXISTS waf_alerts (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
domain_id BIGINT REFERENCES domains(id) ON DELETE CASCADE,
|
||||
hostname TEXT NOT NULL,
|
||||
client_ip TEXT NOT NULL,
|
||||
method TEXT NOT NULL,
|
||||
uri TEXT NOT NULL,
|
||||
rule_id INT NOT NULL DEFAULT 0,
|
||||
rule_msg TEXT NOT NULL DEFAULT '',
|
||||
severity TEXT NOT NULL DEFAULT '',
|
||||
action TEXT NOT NULL, -- 'detected' | 'blocked'
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS waf_alerts_domain_created ON waf_alerts(domain_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS waf_alerts_created ON waf_alerts(created_at DESC);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS waf_alerts;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE waf_configs
|
||||
ADD COLUMN IF NOT EXISTS exclusion_notes JSONB NOT NULL DEFAULT '{}';
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE waf_configs DROP COLUMN IF EXISTS exclusion_notes;
|
||||
@@ -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 <listen_port> pro aktive iface.
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
# Source: internal/firewall/firewall.go.
|
||||
# Re-generate via `edgeguard-ctl render-config` or via API mutations.
|
||||
|
||||
flush ruleset
|
||||
add table inet edgeguard
|
||||
flush table inet edgeguard
|
||||
|
||||
table inet edgeguard {
|
||||
set peer_ipv4 {
|
||||
@@ -49,6 +50,11 @@ table inet edgeguard {
|
||||
# Cluster-internal: peers reach edgeguard-api over mTLS on :8443
|
||||
tcp dport 8443 ip saddr @peer_ipv4 accept
|
||||
tcp dport 8443 ip6 saddr @peer_ipv6 accept
|
||||
# Cluster-internal: PG Logical Replication (:5432) + KeyDB Active-Active (:6379)
|
||||
tcp dport 5432 ip saddr @peer_ipv4 accept
|
||||
tcp dport 5432 ip6 saddr @peer_ipv6 accept
|
||||
tcp dport 6379 ip saddr @peer_ipv4 accept
|
||||
tcp dport 6379 ip6 saddr @peer_ipv6 accept
|
||||
|
||||
# ── Service-Auto-Rules (DNS/Squid/WG/...) ──
|
||||
# Aus dem laufenden Service-State abgeleitet — Operator
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/clustertls"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/session"
|
||||
@@ -21,11 +27,12 @@ import (
|
||||
// the account is auto-migrated into the DB (Upsert) so it shows up in
|
||||
// user management from that point on.
|
||||
type AuthHandler struct {
|
||||
Setup *setup.Store
|
||||
Signer *session.Signer
|
||||
Audit *audit.Repo
|
||||
NodeID string
|
||||
Users *usersvc.Repo // optional — nil on first boot before DB is ready
|
||||
Setup *setup.Store
|
||||
Signer *session.Signer
|
||||
Audit *audit.Repo
|
||||
NodeID string
|
||||
Users *usersvc.Repo // optional — nil on first boot before DB is ready
|
||||
ClusterTLS *clustertls.Store // optional — enables auth federation on cluster nodes
|
||||
}
|
||||
|
||||
func NewAuthHandler(s *setup.Store, sig *session.Signer) *AuthHandler {
|
||||
@@ -46,15 +53,29 @@ func (h *AuthHandler) WithUsers(u *usersvc.Repo) *AuthHandler {
|
||||
return h
|
||||
}
|
||||
|
||||
// WithClusterTLS enables auth federation: when local auth fails on a
|
||||
// cluster node, Login tries the primary via mTLS /agent/auth/check.
|
||||
func (h *AuthHandler) WithClusterTLS(store *clustertls.Store) *AuthHandler {
|
||||
h.ClusterTLS = store
|
||||
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 {
|
||||
@@ -63,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) {
|
||||
@@ -86,38 +108,79 @@ 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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fallback: setup-store admin (backwards compat for pre-DB installs).
|
||||
if actor == "" && st.AdminEmail != "" {
|
||||
if strings.EqualFold(st.AdminEmail, email) && st.VerifyAdminPassword(req.Password) {
|
||||
actor = st.AdminEmail
|
||||
role = "admin"
|
||||
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.
|
||||
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
|
||||
role = r
|
||||
} else {
|
||||
slog.Debug("auth: primary auth check failed", "primary", st.PrimaryFQDN, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
if actor == "" {
|
||||
if !strings.EqualFold(st.AdminEmail, email) || !st.VerifyAdminPassword(req.Password) {
|
||||
response.Unauthorized(c, errors.New("invalid_credentials"))
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), email, "auth.login.failed",
|
||||
email, gin.H{"reason": "invalid_credentials", "remote": remote}, h.NodeID)
|
||||
}
|
||||
response.Unauthorized(c, errors.New("invalid_credentials"))
|
||||
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
|
||||
}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
@@ -127,6 +190,10 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
}
|
||||
setSessionCookie(c, raw, tok.Exp)
|
||||
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), actor, "auth.login.success",
|
||||
actor, gin.H{"role": role, "remote": remote}, h.NodeID)
|
||||
}
|
||||
response.OK(c, loginResponse{
|
||||
Actor: tok.Actor,
|
||||
Role: tok.Role,
|
||||
@@ -134,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})
|
||||
@@ -194,12 +401,49 @@ type changePasswordRequest struct {
|
||||
// braucht das hier das current_password als Confirmation — verhindert
|
||||
// dass eine kompromittierte Session den Account übernimmt ohne dass
|
||||
// das alte Passwort bekannt ist.
|
||||
//
|
||||
// Lookup-Reihenfolge: 1) DB users-Tabelle (alle multi-user-Accounts),
|
||||
// 2) setup-store Admin-Fallback (Legacy / pre-DB). Beim Setup-Admin
|
||||
// werden beide Stores synchron gehalten.
|
||||
func (h *AuthHandler) ChangePassword(c *gin.Context) {
|
||||
var req changePasswordRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
tok := CurrentToken(c)
|
||||
if tok == nil {
|
||||
response.Unauthorized(c, nil)
|
||||
return
|
||||
}
|
||||
|
||||
// 1. DB-backed user (alle via User-Management erstellten Accounts).
|
||||
if h.Users != nil {
|
||||
u, hash, dbErr := h.Users.FindByEmail(c.Request.Context(), tok.Actor)
|
||||
if dbErr == nil {
|
||||
if !usersvc.VerifyPassword(hash, req.CurrentPassword) {
|
||||
response.Unauthorized(c, errors.New("invalid_current_password"))
|
||||
return
|
||||
}
|
||||
if err := h.Users.SetPassword(c.Request.Context(), u.ID, req.NewPassword); err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
// Setup-Store-Admin synchron halten, falls gleiche E-Mail.
|
||||
if st, _ := h.Setup.Load(); st != nil && strings.EqualFold(st.AdminEmail, tok.Actor) {
|
||||
_ = h.Setup.SetAdminPassword(req.NewPassword)
|
||||
}
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "auth.password.change",
|
||||
tok.Actor, gin.H{"actor": actorOf(c)}, h.NodeID)
|
||||
}
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fallback: setup-store Admin (vor DB-Migration oder nicht migriert).
|
||||
st, err := h.Setup.Load()
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
@@ -209,10 +453,6 @@ func (h *AuthHandler) ChangePassword(c *gin.Context) {
|
||||
response.Err(c, http.StatusServiceUnavailable, errors.New("setup_required"))
|
||||
return
|
||||
}
|
||||
// Authorisierte Session ist nicht automatisch der Admin (Phase 4
|
||||
// admin_users-Tabelle könnte mehrere Rollen haben). v1: aktuell
|
||||
// nur der eine Admin-User; trotzdem prüfen wir das current_password
|
||||
// gegen die persistierte Hash.
|
||||
if !st.VerifyAdminPassword(req.CurrentPassword) {
|
||||
response.Unauthorized(c, errors.New("invalid_current_password"))
|
||||
return
|
||||
@@ -225,13 +465,51 @@ func (h *AuthHandler) ChangePassword(c *gin.Context) {
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "auth.password.change",
|
||||
st.AdminEmail, gin.H{"actor": actorOf(c)}, h.NodeID)
|
||||
}
|
||||
// Neue Session ausstellen — alte Cookie zeigt auf ein Token das
|
||||
// noch gültig ist; das ist OK für UX (kein erzwungener Logout),
|
||||
// sicherheitsbewusster: clearSession + force re-login. Wir
|
||||
// halten's hier ruhig.
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// checkWithPrimary verifies credentials against the primary node via mTLS.
|
||||
// Returns actor+role on success, error on failure.
|
||||
func (h *AuthHandler) checkWithPrimary(ctx context.Context, primaryFQDN, email, password string) (string, string, error) {
|
||||
clientTLS, err := h.ClusterTLS.ClientTLSConfig()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
tr := &http.Transport{TLSClientConfig: clientTLS, TLSHandshakeTimeout: 5 * time.Second}
|
||||
client := &http.Client{Transport: tr, Timeout: 8 * time.Second}
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"email": email, "password": password})
|
||||
reqURL := "https://" + primaryFQDN + ":8443/agent/auth/check"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", "", errors.New("primary: " + strings.TrimSpace(string(raw)))
|
||||
}
|
||||
var env struct {
|
||||
Data struct {
|
||||
Actor string `json:"actor"`
|
||||
Role string `json:"role"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if env.Data.Actor == "" {
|
||||
return "", "", errors.New("primary returned empty actor")
|
||||
}
|
||||
return env.Data.Actor, env.Data.Role, nil
|
||||
}
|
||||
|
||||
func setSessionCookie(c *gin.Context, raw string, expUnix int64) {
|
||||
maxAge := int(time.Until(time.Unix(expUnix, 0)).Seconds())
|
||||
if maxAge < 0 {
|
||||
|
||||
@@ -117,6 +117,10 @@ func (h *BackendsHandler) Delete(c *gin.Context) {
|
||||
response.NotFound(c, err)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, backends.ErrInUse) {
|
||||
response.Conflict(c, err)
|
||||
return
|
||||
}
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2,8 +2,15 @@ package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -14,6 +21,7 @@ import (
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/jointoken"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
|
||||
)
|
||||
|
||||
// ClusterHandler exposes cluster-state endpoints. /status ist die
|
||||
@@ -27,6 +35,7 @@ type ClusterHandler struct {
|
||||
Store *cluster.Store
|
||||
LocalID string
|
||||
Aggregator *aggregator.Aggregator
|
||||
Version string // laufende Binary-Version, für Rolling-Update-Koordination
|
||||
|
||||
// TLSStore + Tokens: optional, gesetzt bei Phase 3.4. Erlauben das
|
||||
// Generieren von Join-Tokens und das Issue-Cert für joining Peers.
|
||||
@@ -36,6 +45,11 @@ type ClusterHandler struct {
|
||||
// PeerReloader: optional, gesetzt bei Phase 3.5. Nach Auto-Register
|
||||
// triggert das den firewall-Render damit peer_ipv4 frisch ist.
|
||||
PeerReloader PeerReloader
|
||||
|
||||
// Audit + NodeID: optional, gesetzt via WithAudit. Nötig für
|
||||
// protokollierte, mutierende Aktionen wie den Replication-Repair.
|
||||
Audit *audit.Repo
|
||||
NodeID string
|
||||
}
|
||||
|
||||
func NewClusterHandler(store *cluster.Store, localID string) *ClusterHandler {
|
||||
@@ -57,12 +71,27 @@ func (h *ClusterHandler) WithJoinFlow(store *clustertls.Store, tokens *jointoken
|
||||
return h
|
||||
}
|
||||
|
||||
// WithAudit setzt den Audit-Repo + NodeID für protokollierte Aktionen.
|
||||
func (h *ClusterHandler) WithAudit(a *audit.Repo, nodeID string) *ClusterHandler {
|
||||
h.Audit = a
|
||||
h.NodeID = nodeID
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *ClusterHandler) Register(rg *gin.RouterGroup) {
|
||||
g := rg.Group("/cluster")
|
||||
g.GET("/nodes", h.ListNodes)
|
||||
g.GET("/status", h.Status)
|
||||
g.GET("/system/load", h.SystemLoad)
|
||||
g.DELETE("/nodes/:id", h.DeleteNode)
|
||||
g.GET("/vip-settings", h.GetVIPSettings)
|
||||
g.PUT("/vip-settings", h.UpdateVIPSettings)
|
||||
g.POST("/rolling-update", h.RollingUpdate)
|
||||
g.GET("/rolling-update/status", h.RollingUpdateStatus)
|
||||
g.POST("/repair-replication", h.RepairReplication)
|
||||
g.GET("/repair-replication/status", h.RepairReplicationStatus)
|
||||
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)
|
||||
@@ -111,6 +140,85 @@ func (h *ClusterHandler) DeleteNode(c *gin.Context) {
|
||||
response.NoContent(c)
|
||||
}
|
||||
|
||||
// GetVIPSettings liest die cluster_settings-Singleton-Row (VIP/VRRP-Config).
|
||||
func (h *ClusterHandler) GetVIPSettings(c *gin.Context) {
|
||||
if h.Store == nil {
|
||||
response.NotFound(c, simpleError("cluster store not available"))
|
||||
return
|
||||
}
|
||||
var cs vipSettingsRow
|
||||
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
|
||||
}
|
||||
response.OK(c, cs)
|
||||
}
|
||||
|
||||
// UpdateVIPSettings speichert die VIP/VRRP-Konfiguration und triggert
|
||||
// einen Keepalived-Config-Render. Viewer-Schutz via RequireAdminForMutations-
|
||||
// Middleware auf der authed-Group — kein Extra-Check nötig.
|
||||
func (h *ClusterHandler) UpdateVIPSettings(c *gin.Context) {
|
||||
var req vipSettingsRow
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if h.Store == nil {
|
||||
response.NotFound(c, simpleError("cluster store not available"))
|
||||
return
|
||||
}
|
||||
_, 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,
|
||||
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.HBInterface), nullIfEmpty(req.HBSrcIP),
|
||||
nullIfEmpty(req.HBPeerIP), req.HBRouterID, nullIfEmpty(req.GWCheckIP))
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
slog.Info("cluster: VIP settings updated", "vip", req.VIPAddress, "actor", actorOf(c))
|
||||
// Keepalived-Config asynchron neu rendern
|
||||
if h.PeerReloader != nil {
|
||||
go func() {
|
||||
rctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
if err := h.PeerReloader(rctx); err != nil {
|
||||
slog.Warn("cluster: keepalived render after VIP update failed", "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
response.NoContent(c)
|
||||
}
|
||||
|
||||
type vipSettingsRow struct {
|
||||
VIPAddress *string `json:"vip_address"`
|
||||
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 {
|
||||
if s == nil || *s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// RegisterPublic mountet die public (unauth) Endpoints — joining Peers
|
||||
// haben noch keine Session/Cert, deshalb läuft /issue-cert vor der
|
||||
// requireAuth-Middleware. Aufrufer muss diesen Group auf /api/v1 setzen
|
||||
@@ -134,6 +242,89 @@ func (h *ClusterHandler) RegisterPublic(rg *gin.RouterGroup) {
|
||||
func (h *ClusterHandler) RegisterAgent(rg *gin.RouterGroup) {
|
||||
g := rg.Group("/agent/cluster")
|
||||
g.POST("/peers", h.AgentRegisterPeer)
|
||||
g.GET("/identity", h.AgentIdentity)
|
||||
g.GET("/pg-replication-info", h.AgentPGReplicationInfo)
|
||||
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)
|
||||
g.POST("/repair-replication", h.AgentRepairReplication)
|
||||
g.GET("/repair-replication/status", h.AgentRepairReplicationStatus)
|
||||
}
|
||||
|
||||
// AgentIdentity gibt die eigene ha_nodes-Row zurück. Wird vom Primary
|
||||
// genutzt um joining-Peers aktiv zu reconcilen wenn autoRegister (Push)
|
||||
// fehlgeschlagen ist — Pull-Fallback.
|
||||
func (h *ClusterHandler) AgentIdentity(c *gin.Context) {
|
||||
if h.Store == nil || h.LocalID == "" {
|
||||
response.NotFound(c, simpleError("node not registered"))
|
||||
return
|
||||
}
|
||||
node, err := h.Store.Get(c.Request.Context(), h.LocalID)
|
||||
if err != nil {
|
||||
if err == cluster.ErrNotFound {
|
||||
response.NotFound(c, simpleError("local node not in ha_nodes yet"))
|
||||
return
|
||||
}
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, node)
|
||||
}
|
||||
|
||||
// AgentPGReplicationInfo gibt die Replication-Credentials für pg_basebackup
|
||||
// zurück. Nur über den mTLS-Agent-Listener erreichbar. Liest das Passwort
|
||||
// aus /var/lib/edgeguard/pg-replication-secret. Gibt 404 zurück wenn die
|
||||
// Datei fehlt (cluster-init-replication noch nicht ausgeführt).
|
||||
func (h *ClusterHandler) AgentPGReplicationInfo(c *gin.Context) {
|
||||
const secretPath = "/var/lib/edgeguard/pg-replication-secret"
|
||||
pass, err := readFileString(secretPath)
|
||||
if err != nil {
|
||||
response.NotFound(c, simpleError("pg-replication-secret nicht gefunden — cluster-init-replication auf dem Primary ausführen"))
|
||||
return
|
||||
}
|
||||
// Host = eigene Public-IP aus ha_nodes (oder Fallback: FQDN)
|
||||
host := ""
|
||||
if h.Store != nil && h.LocalID != "" {
|
||||
if node, err := h.Store.Get(c.Request.Context(), h.LocalID); err == nil {
|
||||
if node.PublicIP != nil && *node.PublicIP != "" {
|
||||
host = *node.PublicIP
|
||||
}
|
||||
if host == "" {
|
||||
host = node.FQDN
|
||||
}
|
||||
}
|
||||
}
|
||||
response.OK(c, gin.H{
|
||||
"host": host,
|
||||
"port": 5432,
|
||||
"user": "edgeguard_replicator",
|
||||
"password": strings.TrimSpace(pass),
|
||||
})
|
||||
}
|
||||
|
||||
// AgentMasterKey gibt den Secrets-Master-Key zurück, damit cluster-setup-standby
|
||||
// ihn auf dem Secondary synchronisieren kann. Nur über den mTLS-Agent-Listener
|
||||
// erreichbar. Ohne gemeinsamen Master-Key können replizierte verschlüsselte
|
||||
// Felder (WireGuard private keys, PSKs) auf dem Secondary nicht entschlüsselt werden.
|
||||
func (h *ClusterHandler) AgentMasterKey(c *gin.Context) {
|
||||
const keyPath = "/var/lib/edgeguard/.master_key"
|
||||
data, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
response.NotFound(c, simpleError("master key nicht gefunden"))
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"key_hex": fmt.Sprintf("%x", data)})
|
||||
}
|
||||
|
||||
func readFileString(path string) (string, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// PeerReloader: optionale Funktion die nach einem Auto-Register
|
||||
@@ -147,6 +338,12 @@ func (h *ClusterHandler) WithPeerReloader(r PeerReloader) *ClusterHandler {
|
||||
return h
|
||||
}
|
||||
|
||||
// WithVersion: setzt die laufende Binary-Version für Rolling-Update-Koordination.
|
||||
func (h *ClusterHandler) WithVersion(v string) *ClusterHandler {
|
||||
h.Version = v
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *ClusterHandler) ListNodes(c *gin.Context) {
|
||||
nodes, err := h.Store.List(c.Request.Context())
|
||||
if err != nil {
|
||||
@@ -208,6 +405,23 @@ func (h *ClusterHandler) Status(c *gin.Context) {
|
||||
if len(out.Peers) > 0 {
|
||||
out.Mode = "cluster"
|
||||
}
|
||||
|
||||
// Pull-Reconcile für joining-Peers: wenn ein Peer via Aggregator
|
||||
// erreichbar ist aber noch mit Placeholder-ID in ha_nodes steht,
|
||||
// holen wir seine echte Identity aktiv ab. Best-effort goroutine —
|
||||
// blockiert die Status-Response nicht.
|
||||
if h.Aggregator != nil && h.Store != nil {
|
||||
var joining []models.HANode
|
||||
for _, p := range out.Peers {
|
||||
if p.Status == "joining" || p.Status == "pending" {
|
||||
joining = append(joining, p)
|
||||
}
|
||||
}
|
||||
if len(joining) > 0 {
|
||||
go h.reconcileJoiningPeers(joining)
|
||||
}
|
||||
}
|
||||
|
||||
// Drift-Detection: jeder peer mit anderem config_hash als unser
|
||||
// lokaler → Banner-Trigger im UI.
|
||||
if localHash != nil && *localHash != "" {
|
||||
@@ -289,29 +503,64 @@ func localSystemLoad() any {
|
||||
// ── Phase 3.4: Cluster-Join Token Flow ────────────────────────────────
|
||||
|
||||
// GenerateJoinToken — Admin generiert einen one-shot Bootstrap-Token
|
||||
// für einen neuen Peer. Token wird NUR EINMAL zurückgegeben; Server
|
||||
// speichert keinen Klartext, beim Re-Use blockt der nonce-Tracker.
|
||||
// für einen neuen Peer. Der FQDN des neuen Nodes wird vorab übergeben
|
||||
// so dass er sofort in ha_nodes vorregistriert und im UI angezeigt werden
|
||||
// kann. Token wird NUR EINMAL zurückgegeben.
|
||||
func (h *ClusterHandler) GenerateJoinToken(c *gin.Context) {
|
||||
var req struct {
|
||||
NodeFQDN string `json:"node_fqdn"`
|
||||
}
|
||||
// Body optional — wenn leer, läuft der Flow ohne Pre-Register.
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
|
||||
token, exp, err := h.Tokens.Generate()
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
// Wir liefern auch die primary-fqdn + ca-fingerprint mit, damit
|
||||
// das UI den Join-Befehl als kompletten curl/CLI-String anzeigen
|
||||
// kann.
|
||||
caCert, _, err := h.TLSStore.LoadCA()
|
||||
caFP := ""
|
||||
if err == nil && caCert != nil {
|
||||
caFP = jointoken.CAFingerprint16(caCert.Raw)
|
||||
}
|
||||
|
||||
// Pre-register: wenn ein FQDN übergeben wurde, schon jetzt in
|
||||
// ha_nodes anlegen (status='pending') + Firewall-Reload. So ist
|
||||
// der Node bekannt bevor er überhaupt antwortet, und @peer_ipv4
|
||||
// wird beim issue-cert (wo wir die IP haben) nur noch updaten.
|
||||
if req.NodeFQDN != "" && h.Store != nil {
|
||||
go h.preRegisterByFQDN(req.NodeFQDN)
|
||||
}
|
||||
|
||||
response.OK(c, gin.H{
|
||||
"token": token,
|
||||
"expires_at": exp.UTC().Format(time.RFC3339),
|
||||
"ca_fingerprint": caFP,
|
||||
"node_fqdn": req.NodeFQDN,
|
||||
})
|
||||
}
|
||||
|
||||
// preRegisterByFQDN legt einen ha_nodes-Eintrag mit status='pending' an
|
||||
// bevor der Joiner überhaupt die Verbindung aufbaut. Idempotent dank
|
||||
// ON CONFLICT. Kein Firewall-Reload hier — die IP ist noch unbekannt;
|
||||
// das erledigt preRegisterJoiner wenn die issue-cert-Anfrage eintrifft.
|
||||
func (h *ClusterHandler) preRegisterByFQDN(fqdn string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
nodeID := fmt.Sprintf("prenode-%s", strings.ReplaceAll(fqdn, ".", "-"))
|
||||
n := models.HANode{
|
||||
ID: nodeID,
|
||||
Name: fqdn,
|
||||
FQDN: fqdn,
|
||||
APIURL: "https://" + fqdn + ":3443",
|
||||
Role: "peer",
|
||||
Status: "pending",
|
||||
}
|
||||
if _, err := h.Store.UpsertSelf(ctx, n); err != nil {
|
||||
slog.Warn("cluster: pre-register by FQDN failed", "fqdn", fqdn, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// IssueCert — Joining Peer POSTet seinen CSR + den Token. Wir verifizieren
|
||||
// + konsumieren den Token, signieren den CSR mit unserer Cluster-CA und
|
||||
// liefern {ca_cert, peer_cert} zurück. PUBLIC Endpoint — keine Session-
|
||||
@@ -337,8 +586,8 @@ func (h *ClusterHandler) IssueCert(c *gin.Context) {
|
||||
}
|
||||
// consumedBy → Remote-IP. Audit-Trail wenn jemand Tokens stiehlt
|
||||
// und vom falschen Host einlöst.
|
||||
consumedBy := c.ClientIP()
|
||||
if _, err := h.Tokens.Consume(c.Request.Context(), req.Token, consumedBy); err != nil {
|
||||
clientIP := c.ClientIP()
|
||||
if _, err := h.Tokens.Consume(c.Request.Context(), req.Token, clientIP); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
@@ -353,12 +602,193 @@ func (h *ClusterHandler) IssueCert(c *gin.Context) {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Pre-register the joining node SYNCHRONOUSLY before returning the
|
||||
// cert so that nftables @peer_ipv4 already contains the joiner's IP
|
||||
// by the time they call autoRegister on port 8443. A goroutine here
|
||||
// caused a race: cert returned → joiner calls autoRegister → nftables
|
||||
// not updated yet → connection refused → status stays "joining".
|
||||
if h.Store != nil && h.PeerReloader != nil {
|
||||
h.preRegisterJoiner(c.Request.Context(), clientIP, req.CSR)
|
||||
}
|
||||
|
||||
response.OK(c, issueCertResponse{
|
||||
CACert: caPEM,
|
||||
PeerCert: peerCert,
|
||||
})
|
||||
}
|
||||
|
||||
// preRegisterJoiner inserts a minimal ha_nodes row for the joining peer
|
||||
// (using the CSR CN as FQDN and the HTTP client IP as public_ip), then
|
||||
// triggers a firewall reload so @peer_ipv4 contains the new IP before
|
||||
// the peer tries to call /agent/cluster/peers on port 8443.
|
||||
// Uses a stable deterministic ID so re-joins are idempotent.
|
||||
func (h *ClusterHandler) preRegisterJoiner(parent context.Context, clientIP, csrPEM string) {
|
||||
ctx, cancel := context.WithTimeout(parent, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
fqdn := cnFromCSR(csrPEM)
|
||||
if fqdn == "" {
|
||||
fqdn = "joining-" + clientIP
|
||||
}
|
||||
nodeID := fmt.Sprintf("prenode-%s", strings.ReplaceAll(fqdn, ".", "-"))
|
||||
|
||||
n := models.HANode{
|
||||
ID: nodeID,
|
||||
Name: fqdn,
|
||||
FQDN: fqdn,
|
||||
APIURL: "https://" + fqdn + ":3443",
|
||||
Role: "peer",
|
||||
Status: "joining",
|
||||
}
|
||||
n.PublicIP = &clientIP
|
||||
|
||||
if _, err := h.Store.UpsertSelf(ctx, n); err != nil {
|
||||
slog.Warn("cluster: pre-register joiner failed", "fqdn", fqdn, "ip", clientIP, "error", err)
|
||||
return
|
||||
}
|
||||
if err := h.PeerReloader(ctx); err != nil {
|
||||
slog.Warn("cluster: PeerReloader failed after pre-register", "error", err)
|
||||
return
|
||||
}
|
||||
slog.Info("cluster: joiner pre-registered, firewall updated", "fqdn", fqdn, "ip", clientIP)
|
||||
}
|
||||
|
||||
// ptrStr dereferences a *string safely for comparison; nil → "".
|
||||
func ptrStr(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
// cnFromCSR extracts the Subject Common Name from a PEM-encoded CSR.
|
||||
// Returns empty string on any parse error.
|
||||
func cnFromCSR(csrPEM string) string {
|
||||
block, _ := pem.Decode([]byte(csrPEM))
|
||||
if block == nil {
|
||||
return ""
|
||||
}
|
||||
csr, err := x509.ParseCertificateRequest(block.Bytes)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return csr.Subject.CommonName
|
||||
}
|
||||
|
||||
// reconcileJoiningPeers versucht für jeden Peer im Status "joining" oder
|
||||
// "pending" die echte Node-ID via /agent/cluster/identity zu holen und
|
||||
// ihn in ha_nodes mit der richtigen ID einzutragen. Self-Healing-
|
||||
// Fallback wenn autoRegister (Push von joining-Peer zu Primary) wegen
|
||||
// eines temporären Netzwerkproblems fehlgeschlagen ist.
|
||||
//
|
||||
// Die public_ip des Placeholder-Rows wird in der neuen Row übernommen
|
||||
// damit @peer_ipv4 (nftables) korrekt bleibt.
|
||||
func (h *ClusterHandler) reconcileJoiningPeers(placeholders []models.HANode) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
results := h.Aggregator.FanOut(ctx, placeholders, "/agent/cluster/identity", h.LocalID)
|
||||
changed := false
|
||||
for i, res := range results {
|
||||
if !res.OK || len(res.Data) == 0 {
|
||||
continue
|
||||
}
|
||||
var identity models.HANode
|
||||
if err := json.Unmarshal(res.Data, &identity); err != nil || identity.ID == "" {
|
||||
continue
|
||||
}
|
||||
placeholder := placeholders[i]
|
||||
if identity.ID == placeholder.ID {
|
||||
continue // ID bereits korrekt
|
||||
}
|
||||
// Echte ID gefunden — row mit realer ID anlegen, public_ip aus
|
||||
// dem Placeholder-Row übernehmen damit nftables korrekt bleibt.
|
||||
n := identity
|
||||
n.Status = "online"
|
||||
if n.PublicIP == nil {
|
||||
n.PublicIP = placeholder.PublicIP
|
||||
}
|
||||
if n.InternalIP == nil {
|
||||
n.InternalIP = placeholder.InternalIP
|
||||
}
|
||||
// Placeholder zuerst löschen: ha_nodes hat UNIQUE(fqdn). Ohne
|
||||
// dieses Delete würde UpsertSelf (ON CONFLICT(id)) mit fqdn-
|
||||
// unique-Violation scheitern.
|
||||
_ = h.Store.DeletePlaceholdersByFQDN(ctx, n.FQDN, n.ID)
|
||||
out, err := h.Store.UpsertSelf(ctx, n)
|
||||
if err != nil {
|
||||
slog.Warn("cluster: reconcile joining peer: upsert failed",
|
||||
"fqdn", n.FQDN, "real_id", n.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
changed = true
|
||||
slog.Info("cluster: joining peer reconciled via identity pull",
|
||||
"id", out.ID, "fqdn", out.FQDN, "placeholder_id", placeholder.ID)
|
||||
}
|
||||
if changed && h.PeerReloader != nil {
|
||||
rctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := h.PeerReloader(rctx); err != nil {
|
||||
slog.Warn("cluster: PeerReloader failed after reconcile", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AgentVersion gibt die laufende Binary-Version zurück. Wird vom Rolling-
|
||||
// Update-Orchestrator gepollt um zu erkennen wann der Secondary die neue
|
||||
// Version hat.
|
||||
func (h *ClusterHandler) AgentVersion(c *gin.Context) {
|
||||
response.OK(c, gin.H{"version": h.Version})
|
||||
}
|
||||
|
||||
// AgentTriggerUpdate startet den Upgrade-Prozess auf diesem Node via
|
||||
// systemd-run (detached). Wird vom Primary via mTLS aufgerufen um den
|
||||
// Secondary zuerst zu aktualisieren (Rolling-Update). Pattern identisch
|
||||
// zu /system/upgrade — nutzt dieselbe Sudoers-Whitelist aus dem postinst.
|
||||
func (h *ClusterHandler) AgentTriggerUpdate(c *gin.Context) {
|
||||
const scriptPath = "/var/lib/edgeguard/upgrade.sh"
|
||||
const script = `#!/bin/bash
|
||||
set -e
|
||||
sleep 2
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
dpkg --configure -a || true
|
||||
retry_apt() {
|
||||
local attempt=0 max=3 wait_for=15
|
||||
while [ $attempt -lt $max ]; do
|
||||
attempt=$((attempt + 1))
|
||||
apt-get update -qq || true
|
||||
if apt-get install -y -qq -o Dpkg::Options::=--force-confold \
|
||||
edgeguard-api edgeguard-ui edgeguard; then return 0; fi
|
||||
[ $attempt -lt $max ] && sleep $wait_for && wait_for=$((wait_for * 2))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
retry_apt
|
||||
echo "[upgrade] complete"
|
||||
rm -f /var/lib/edgeguard/upgrade.sh
|
||||
`
|
||||
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
const unitName = "edgeguard-upgrade.service"
|
||||
_ = exec.Command("sudo", "-n", "/usr/bin/systemctl", "reset-failed", unitName).Run()
|
||||
cmd := exec.Command("sudo", "-n", "/usr/bin/systemd-run",
|
||||
"--unit="+unitName,
|
||||
"--description=EdgeGuard self-upgrade",
|
||||
"--collect",
|
||||
"bash", scriptPath)
|
||||
if err := cmd.Run(); err != nil {
|
||||
slog.Warn("cluster: AgentTriggerUpdate: systemd-run failed", "error", err)
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
slog.Info("cluster: rolling update triggered on this node by primary mTLS call",
|
||||
"client", c.ClientIP())
|
||||
c.JSON(http.StatusAccepted, gin.H{"status": "upgrading"})
|
||||
}
|
||||
|
||||
var errInvalidJoinRequest = simpleError("missing token or csr")
|
||||
|
||||
type simpleError string
|
||||
@@ -427,14 +857,15 @@ func (h *ClusterHandler) RenewSelf(c *gin.Context) {
|
||||
// die wir wirklich brauchen — sonst kann ein joining Peer beliebige
|
||||
// ha_nodes-Felder überschreiben.
|
||||
type registerPeerRequest struct {
|
||||
ID string `json:"id"` // Joiner's eigene node-id
|
||||
Name string `json:"name"` // hostname
|
||||
FQDN string `json:"fqdn"` // sollte mit Client-Cert-CN matchen
|
||||
APIURL string `json:"api_url"` // https://<fqdn>
|
||||
PublicIP string `json:"public_ip"` // optional
|
||||
InternalIP string `json:"internal_ip"` // mTLS-Listener-IP (für peer_ipv4-Set)
|
||||
MgmtIP string `json:"mgmt_ip"` // optional
|
||||
Version string `json:"version"`
|
||||
ID string `json:"id"` // Joiner's eigene node-id
|
||||
Name string `json:"name"` // hostname
|
||||
FQDN string `json:"fqdn"` // sollte mit Client-Cert-CN matchen
|
||||
APIURL string `json:"api_url"` // https://<fqdn>
|
||||
PublicIP string `json:"public_ip"` // optional
|
||||
InternalIP string `json:"internal_ip"` // mTLS-Listener-IP (für peer_ipv4-Set)
|
||||
MgmtIP string `json:"mgmt_ip"` // optional
|
||||
Version string `json:"version"`
|
||||
ConfigHash *string `json:"config_hash"` // nil=absent (don't change), ""=no user config
|
||||
}
|
||||
|
||||
// AgentRegisterPeer: vom Joiner nach issue-cert via mTLS aufgerufen.
|
||||
@@ -479,7 +910,7 @@ func (h *ClusterHandler) AgentRegisterPeer(c *gin.Context) {
|
||||
FQDN: req.FQDN,
|
||||
APIURL: req.APIURL,
|
||||
Role: "peer",
|
||||
Status: "joining",
|
||||
Status: "online", // peer IS online — it just connected via mTLS
|
||||
}
|
||||
if req.PublicIP != "" {
|
||||
v := req.PublicIP
|
||||
@@ -497,17 +928,34 @@ func (h *ClusterHandler) AgentRegisterPeer(c *gin.Context) {
|
||||
v := req.Version
|
||||
n.Version = &v
|
||||
}
|
||||
if req.ConfigHash != nil {
|
||||
n.ConfigHash = req.ConfigHash
|
||||
}
|
||||
// Placeholder zuerst löschen: ha_nodes hat UNIQUE(fqdn). Der INSERT
|
||||
// in UpsertSelf verwendet ON CONFLICT(id) — greift NICHT bei fqdn-
|
||||
// Konflikten. Ohne das Delete würde der INSERT mit "duplicate key on
|
||||
// ha_nodes_fqdn_unique" scheitern und der Peer bliebe ewig "joining".
|
||||
_ = h.Store.DeletePlaceholdersByFQDN(c.Request.Context(), req.FQDN, req.ID)
|
||||
|
||||
// Snapshot der aktuellen IPs VOR dem Upsert — zum Vergleich danach.
|
||||
// Nur wenn sich public_ip oder internal_ip ändert, müssen wir nftables
|
||||
// neu laden (@peer_ipv4-Set). Periodische Pushes vom Secondary (alle
|
||||
// 5 min) ändern nur version/config_hash, nicht die IPs → kein Reset.
|
||||
existing, _ := h.Store.Get(c.Request.Context(), req.ID)
|
||||
|
||||
out, err := h.Store.UpsertSelf(c.Request.Context(), n)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Firewall-Reload damit peer_ipv4-Set die neue IP aufnimmt. Best-
|
||||
// effort: Fehler loggen, Response weiter durchreichen — der Peer
|
||||
// hat seine Identity erfolgreich registriert, Operator kann manuell
|
||||
// nachrendern.
|
||||
if h.PeerReloader != nil {
|
||||
// Firewall-Reload nur wenn sich die Peer-IP geändert hat oder der
|
||||
// Peer neu eingetragen wurde. Verhindert Counter-Reset alle 5 min
|
||||
// durch den periodischen Secondary-Push (runPrimaryPush).
|
||||
ipChanged := existing == nil ||
|
||||
ptrStr(existing.PublicIP) != ptrStr(out.PublicIP) ||
|
||||
ptrStr(existing.InternalIP) != ptrStr(out.InternalIP)
|
||||
if ipChanged && h.PeerReloader != nil {
|
||||
go func() {
|
||||
rctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
118
internal/handlers/cluster_certsync.go
Normal file
118
internal/handlers/cluster_certsync.go
Normal file
@@ -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
|
||||
}
|
||||
389
internal/handlers/cluster_repair.go
Normal file
389
internal/handlers/cluster_repair.go
Normal file
@@ -0,0 +1,389 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
)
|
||||
|
||||
// Replication-Repair ("Resync erzwingen") für das Config-Drift-Banner.
|
||||
//
|
||||
// Drift entsteht, wenn ein Peer einen anderen config_hash hat als dieser
|
||||
// Node — entweder weil die Logical-Replication-Subscription gestört ist
|
||||
// oder weil direkt in die DB des Subscribers geschrieben wurde. Die
|
||||
// Reparatur baut die Subscription neu auf und kopiert alle geteilten
|
||||
// Tabellen frisch vom Primary (einseitig: Primary = Source of Truth).
|
||||
//
|
||||
// Rollen-Erkennung: NICHT über ha_nodes.role/pg_role — die sind je Node
|
||||
// lokal und unzuverlässig (jede Node markiert sich selbst, pg_role bleibt
|
||||
// 'standalone' bis `promote`). Verlässlich ist die PUBLICATION: nur der
|
||||
// Primary hat `edgeguard_shared` (pg_publication ist für jeden DB-User
|
||||
// lesbar). Der Subscriber hat sie nicht → er ist das Resync-Ziel.
|
||||
//
|
||||
// Ablauf:
|
||||
// - Klick auf dem Primary → Dispatch via mTLS an den Peer
|
||||
// (POST /agent/cluster/repair-replication) mit der eigenen Adresse als
|
||||
// primary_host; der Peer resynct von dort.
|
||||
// - Klick direkt auf dem Subscriber → läuft lokal (Quelle = der Peer).
|
||||
//
|
||||
// Die eigentliche Arbeit läuft — analog zum Rolling-Update — in einer
|
||||
// transienten systemd-Unit, die `edgeguard-ctl cluster-setup-standby
|
||||
// <primary>` ausführt.
|
||||
|
||||
const (
|
||||
repairUnitName = "edgeguard-repair-replication.service"
|
||||
repairScriptPath = "/var/lib/edgeguard/repair-replication.sh"
|
||||
repairAgentPath = "/agent/cluster/repair-replication"
|
||||
repairPubName = "edgeguard_shared" // muss zu cmd/edgeguard-ctl egPubName passen
|
||||
)
|
||||
|
||||
// validRepairHost erlaubt nur IPv4/IPv6/Hostnamen — der Wert landet in
|
||||
// einem Bash-Script das als root läuft, also strikt validieren.
|
||||
var validRepairHost = regexp.MustCompile(`^[A-Za-z0-9._:-]{1,253}$`)
|
||||
|
||||
// repairDispatchBody ist der Body des Agent-Dispatch: der Primary teilt
|
||||
// dem Subscriber seine Adresse mit, von der resynct werden soll.
|
||||
type repairDispatchBody struct {
|
||||
PrimaryHost string `json:"primary_host"`
|
||||
}
|
||||
|
||||
// RepairReplication ist der UI-Endpoint. Hat dieser Node die Publication
|
||||
// (= Primary), wird der Resync an den Peer delegiert; sonst (Subscriber)
|
||||
// läuft er lokal mit dem Peer als Quelle.
|
||||
func (h *ClusterHandler) RepairReplication(c *gin.Context) {
|
||||
if h.Store == nil {
|
||||
response.Internal(c, errors.New("cluster store unavailable"))
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
all, err := h.Store.List(ctx)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
local := findNode(all, h.LocalID)
|
||||
peer := findOtherPeer(all, h.LocalID)
|
||||
if peer == nil {
|
||||
response.BadRequest(c, errors.New("kein Peer-Node im Cluster — nichts zu resyncen"))
|
||||
return
|
||||
}
|
||||
|
||||
if h.nodeHasPublication(ctx) {
|
||||
// Primary → an den Subscriber-Peer delegieren, mit eigener Adresse.
|
||||
if h.Aggregator == nil {
|
||||
response.BadRequest(c, errors.New("kein mTLS-Aggregator verfügbar — Resync nicht delegierbar"))
|
||||
return
|
||||
}
|
||||
primaryHost := pickPrimaryHost(local)
|
||||
if primaryHost == "" || !validRepairHost.MatchString(primaryHost) {
|
||||
response.BadRequest(c, errors.New("eigene Primary-Adresse (Mgmt/Internal/Public-IP/FQDN) fehlt oder ist ungültig"))
|
||||
return
|
||||
}
|
||||
body, _ := json.Marshal(repairDispatchBody{PrimaryHost: primaryHost})
|
||||
res := h.Aggregator.PostPeerWithBody(ctx, *peer, repairAgentPath, body)
|
||||
if !res.OK {
|
||||
response.Internal(c, fmt.Errorf("Resync auf %s anstoßen: %s", peer.FQDN, res.Err))
|
||||
return
|
||||
}
|
||||
slog.Info("cluster: replication repair delegated", "target", peer.FQDN, "primary_host", primaryHost)
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(ctx, actorOf(c), "cluster.repair-replication",
|
||||
peer.FQDN, gin.H{"target": "peer", "peer": peer.FQDN, "primary_host": primaryHost}, h.NodeID)
|
||||
}
|
||||
response.Accepted(c, gin.H{"dispatched": true, "target": "peer", "peer_fqdn": peer.FQDN})
|
||||
return
|
||||
}
|
||||
|
||||
// Subscriber → lokal ausführen, Quelle = der Peer (Primary).
|
||||
host := pickPrimaryHost(peer)
|
||||
if err := h.startResync(ctx, host); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(ctx, actorOf(c), "cluster.repair-replication",
|
||||
host, gin.H{"target": "local", "primary": host}, h.NodeID)
|
||||
}
|
||||
response.Accepted(c, gin.H{"dispatched": true, "target": "local", "primary": host})
|
||||
}
|
||||
|
||||
// AgentRepairReplication wird vom Primary via mTLS auf dem Subscriber
|
||||
// aufgerufen und startet dort den lokalen Resync von primary_host.
|
||||
func (h *ClusterHandler) AgentRepairReplication(c *gin.Context) {
|
||||
if h.Store == nil {
|
||||
response.Internal(c, errors.New("cluster store unavailable"))
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
var body repairDispatchBody
|
||||
_ = c.ShouldBindJSON(&body) // best-effort; Fallback unten
|
||||
|
||||
host := strings.TrimSpace(body.PrimaryHost)
|
||||
if host == "" {
|
||||
// Fallback: Quelle aus ha_nodes (der andere Node).
|
||||
if all, err := h.Store.List(ctx); err == nil {
|
||||
host = pickPrimaryHost(findOtherPeer(all, h.LocalID))
|
||||
}
|
||||
}
|
||||
if err := h.startResync(ctx, host); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
slog.Info("cluster: replication repair triggered by peer", "primary", host, "node", h.LocalID)
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(ctx, "cluster-peer", "cluster.repair-replication",
|
||||
host, gin.H{"target": "local", "primary": host, "via": "agent"}, h.NodeID)
|
||||
}
|
||||
response.Accepted(c, gin.H{"dispatched": true, "primary": host})
|
||||
}
|
||||
|
||||
// startResync schreibt das Repair-Script und startet die transiente
|
||||
// systemd-Unit. Safety-Guard: läuft NIE auf dem Publication-Primary.
|
||||
func (h *ClusterHandler) startResync(ctx context.Context, primaryHost string) error {
|
||||
primaryHost = strings.TrimSpace(primaryHost)
|
||||
if primaryHost == "" {
|
||||
return errors.New("keine Primary-Adresse für den Resync ermittelbar")
|
||||
}
|
||||
if !validRepairHost.MatchString(primaryHost) {
|
||||
return fmt.Errorf("ungültige Primary-Adresse: %q", primaryHost)
|
||||
}
|
||||
// Niemals auf dem Primary (Publication-Quelle) resyncen — würde die
|
||||
// eigene Config mit sich selbst überschreiben bzw. ist sinnlos.
|
||||
if h.nodeHasPublication(ctx) {
|
||||
return errors.New("dieser Node ist der Publication-Primary — Resync läuft nur auf einem Subscriber")
|
||||
}
|
||||
if st := repairUnitState(); st == "activating" || st == "active" {
|
||||
return errors.New("Resync läuft bereits")
|
||||
}
|
||||
|
||||
script := fmt.Sprintf(`#!/bin/bash
|
||||
set -uo pipefail
|
||||
echo "[repair] resync der Logical-Replication-Subscription von Primary %[1]s"
|
||||
/usr/bin/edgeguard-ctl cluster-setup-standby %[1]s
|
||||
rc=$?
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
echo "[repair] cluster-setup-standby fehlgeschlagen (rc=$rc)"
|
||||
exit "$rc"
|
||||
fi
|
||||
echo "[repair] abgeschlossen — config_hash wird beim nächsten Cluster-Status neu berechnet"
|
||||
rm -f %[2]s
|
||||
`, primaryHost, repairScriptPath)
|
||||
|
||||
if err := os.WriteFile(repairScriptPath, []byte(script), 0o755); err != nil {
|
||||
return fmt.Errorf("write repair script: %w", err)
|
||||
}
|
||||
_ = exec.Command("sudo", "-n", "/usr/bin/systemctl", "reset-failed", repairUnitName).Run()
|
||||
cmd := exec.Command("sudo", "-n", "/usr/bin/systemd-run",
|
||||
"--unit="+repairUnitName,
|
||||
"--description=EdgeGuard replication repair",
|
||||
"--collect",
|
||||
"bash", repairScriptPath)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("systemd-run failed: %w", err)
|
||||
}
|
||||
slog.Info("cluster: replication repair dispatched (local)", "primary", primaryHost, "node", h.LocalID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// nodeHasPublication prüft, ob dieser Node die Replikations-Publication
|
||||
// besitzt — das verlässliche Primary-Signal. pg_publication ist für jeden
|
||||
// DB-User lesbar (anders als pg_subscription).
|
||||
func (h *ClusterHandler) nodeHasPublication(ctx context.Context) bool {
|
||||
if h.Store == nil || h.Store.Pool == nil {
|
||||
return false
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
var exists bool
|
||||
if err := h.Store.Pool.QueryRow(cctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM pg_publication WHERE pubname = $1)`, repairPubName,
|
||||
).Scan(&exists); err != nil {
|
||||
return false
|
||||
}
|
||||
return exists
|
||||
}
|
||||
|
||||
// repairStatusResponse spiegelt den Zustand der transienten Repair-Unit.
|
||||
type repairStatusResponse struct {
|
||||
Phase string `json:"phase"` // idle | running | success | failed
|
||||
State string `json:"state"`
|
||||
Result string `json:"result"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
StartedAt string `json:"started_at,omitempty"`
|
||||
FinishedAt string `json:"finished_at,omitempty"`
|
||||
Log []string `json:"log"`
|
||||
}
|
||||
|
||||
// RepairReplicationStatus liest den Job-Zustand. Auf dem Primary wird der
|
||||
// Status vom Subscriber-Peer geholt (dort läuft der Job); sonst lokal.
|
||||
func (h *ClusterHandler) RepairReplicationStatus(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
if h.Store != nil && h.nodeHasPublication(ctx) && h.Aggregator != nil {
|
||||
if all, err := h.Store.List(ctx); err == nil {
|
||||
if peer := findOtherPeer(all, h.LocalID); peer != nil {
|
||||
results := h.Aggregator.FanOut(ctx,
|
||||
[]models.HANode{*peer}, repairAgentPath+"/status", h.LocalID)
|
||||
if len(results) == 1 && results[0].OK && len(results[0].Data) > 0 {
|
||||
c.Data(200, "application/json", wrapEnvelope(results[0].Data))
|
||||
return
|
||||
}
|
||||
// Peer nicht erreichbar → idle statt Fehler, damit das
|
||||
// UI-Polling nicht hart abbricht.
|
||||
response.OK(c, repairStatusResponse{Phase: "idle", Log: []string{}})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
response.OK(c, localRepairStatus())
|
||||
}
|
||||
|
||||
// AgentRepairReplicationStatus liefert den lokalen Job-Zustand an den
|
||||
// abfragenden Primary.
|
||||
func (h *ClusterHandler) AgentRepairReplicationStatus(c *gin.Context) {
|
||||
response.OK(c, localRepairStatus())
|
||||
}
|
||||
|
||||
// wrapEnvelope verpackt eine bereits entpackte data-Payload wieder in die
|
||||
// Standard-Envelope, damit das UI (isEnvelope) sie konsistent liest.
|
||||
func wrapEnvelope(data []byte) []byte {
|
||||
out := []byte(`{"data":`)
|
||||
out = append(out, data...)
|
||||
out = append(out, []byte(`,"error":null,"message":"ok"}`)...)
|
||||
return out
|
||||
}
|
||||
|
||||
// localRepairStatus liest den Zustand der lokalen Repair-Unit aus systemd
|
||||
// (analog UpgradeStatus). Quelle der Wahrheit für Job-Ende ist die Unit.
|
||||
func localRepairStatus() repairStatusResponse {
|
||||
out := repairStatusResponse{Phase: "idle", Log: []string{}}
|
||||
|
||||
if data, err := exec.Command("systemctl", "show", repairUnitName,
|
||||
"--no-page",
|
||||
"-p", "ActiveState",
|
||||
"-p", "Result",
|
||||
"-p", "ExecMainStatus",
|
||||
"-p", "ExecMainStartTimestamp",
|
||||
"-p", "ExecMainExitTimestamp",
|
||||
).CombinedOutput(); err == nil {
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
kv := strings.SplitN(strings.TrimSpace(line), "=", 2)
|
||||
if len(kv) != 2 {
|
||||
continue
|
||||
}
|
||||
switch kv[0] {
|
||||
case "ActiveState":
|
||||
out.State = kv[1]
|
||||
case "Result":
|
||||
out.Result = kv[1]
|
||||
case "ExecMainStatus":
|
||||
out.ExitCode, _ = strconv.Atoi(kv[1])
|
||||
case "ExecMainStartTimestamp":
|
||||
if t, err := time.Parse("Mon 2006-01-02 15:04:05 MST", kv[1]); err == nil {
|
||||
out.StartedAt = t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
case "ExecMainExitTimestamp":
|
||||
if t, err := time.Parse("Mon 2006-01-02 15:04:05 MST", kv[1]); err == nil {
|
||||
out.FinishedAt = t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch out.State {
|
||||
case "activating", "active", "deactivating":
|
||||
out.Phase = "running"
|
||||
case "failed":
|
||||
out.Phase = "failed"
|
||||
case "inactive":
|
||||
if out.Result == "success" && out.ExitCode == 0 && out.FinishedAt != "" {
|
||||
out.Phase = "success"
|
||||
} else if out.Result != "" && out.Result != "success" {
|
||||
out.Phase = "failed"
|
||||
}
|
||||
}
|
||||
|
||||
if data, err := exec.Command("journalctl",
|
||||
"-u", repairUnitName,
|
||||
"--no-pager", "-n", "100", "-o", "cat",
|
||||
).CombinedOutput(); err == nil {
|
||||
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
|
||||
if !(len(lines) == 1 && (lines[0] == "" || strings.HasPrefix(lines[0], "-- No entries"))) {
|
||||
out.Log = lines
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// findNode liefert die ha_nodes-Row mit der gegebenen ID.
|
||||
func findNode(nodes []models.HANode, id string) *models.HANode {
|
||||
for i := range nodes {
|
||||
if nodes[i].ID == id {
|
||||
return &nodes[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// findOtherPeer liefert den (einen) anderen Node im 2-Node-Cluster.
|
||||
// Bevorzugt einen online erreichbaren Peer.
|
||||
func findOtherPeer(nodes []models.HANode, localID string) *models.HANode {
|
||||
var fallback *models.HANode
|
||||
for i := range nodes {
|
||||
n := &nodes[i]
|
||||
if n.ID == localID {
|
||||
continue
|
||||
}
|
||||
if n.Status == "online" {
|
||||
return n
|
||||
}
|
||||
if fallback == nil {
|
||||
fallback = n
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// pickPrimaryHost wählt die beste erreichbare Adresse eines Node:
|
||||
// Mgmt-IP → Internal-IP → Public-IP → FQDN. Strippt eine etwaige
|
||||
// CIDR-Maske (inet-Spalten können "10.0.0.5/32" liefern).
|
||||
func pickPrimaryHost(n *models.HANode) string {
|
||||
if n == nil {
|
||||
return ""
|
||||
}
|
||||
for _, cand := range []*string{n.MgmtIP, n.InternalIP, n.PublicIP} {
|
||||
if cand != nil {
|
||||
if h := strings.TrimSpace(strings.SplitN(*cand, "/", 2)[0]); h != "" {
|
||||
return h
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(n.FQDN)
|
||||
}
|
||||
|
||||
// repairUnitState gibt den ActiveState der Repair-Unit zurück ("" wenn
|
||||
// unbekannt). Für den Doppelstart-Schutz.
|
||||
func repairUnitState() string {
|
||||
out, err := exec.Command("systemctl", "show", repairUnitName, "--no-page", "-p", "ActiveState").CombinedOutput()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
if kv := strings.SplitN(strings.TrimSpace(line), "=", 2); len(kv) == 2 && kv[0] == "ActiveState" {
|
||||
return kv[1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
258
internal/handlers/cluster_rollingupdate.go
Normal file
258
internal/handlers/cluster_rollingupdate.go
Normal file
@@ -0,0 +1,258 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
)
|
||||
|
||||
const rollingUpdateStateFile = "/var/lib/edgeguard/rolling-update-state.json"
|
||||
|
||||
const (
|
||||
phaseIdle = "idle"
|
||||
phaseUpdatingSecondary = "updating-secondary"
|
||||
phaseWaitingSecondary = "waiting-secondary"
|
||||
phaseUpdatingPrimary = "updating-primary"
|
||||
phaseDone = "done"
|
||||
phaseFailed = "failed"
|
||||
)
|
||||
|
||||
// FinishRollingUpdateIfPending wird beim API-Start aufgerufen. Wenn die
|
||||
// State-Datei "updating-primary" enthält, bedeutet das dass der Primary
|
||||
// gerade erfolgreich neugestartet ist → Update abgeschlossen → "done" schreiben.
|
||||
func FinishRollingUpdateIfPending() {
|
||||
st := readRollingUpdateState()
|
||||
if st.Phase == phaseUpdatingPrimary {
|
||||
writeRollingUpdateState(RollingUpdateState{
|
||||
Phase: phaseDone,
|
||||
SecondaryID: st.SecondaryID,
|
||||
SecondaryFQDN: st.SecondaryFQDN,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RollingUpdateState hält den Fortschritt des Rolling-Updates.
|
||||
// Persistiert in rollingUpdateStateFile damit der Status über
|
||||
// einen kurzen API-Neustart hinaus lesbar bleibt.
|
||||
type RollingUpdateState struct {
|
||||
Phase string `json:"phase"`
|
||||
SecondaryID string `json:"secondary_id,omitempty"`
|
||||
SecondaryFQDN string `json:"secondary_fqdn,omitempty"`
|
||||
StartedAt time.Time `json:"started_at,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func readRollingUpdateState() RollingUpdateState {
|
||||
data, err := os.ReadFile(rollingUpdateStateFile)
|
||||
if err != nil {
|
||||
return RollingUpdateState{Phase: phaseIdle, UpdatedAt: time.Now()}
|
||||
}
|
||||
var s RollingUpdateState
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return RollingUpdateState{Phase: phaseIdle, UpdatedAt: time.Now()}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func writeRollingUpdateState(s RollingUpdateState) {
|
||||
s.UpdatedAt = time.Now()
|
||||
data, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
slog.Warn("rolling-update: failed to marshal state", "error", err)
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(rollingUpdateStateFile, data, 0o600); err != nil {
|
||||
slog.Warn("rolling-update: failed to write state file", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// RollingUpdate startet den Rolling-Update-Prozess:
|
||||
// 1. Secondary aktualisieren (via mTLS /agent/cluster/trigger-update)
|
||||
// 2. Warten bis Secondary neue Version meldet
|
||||
// 3. Primary (dieser Node) aktualisieren (wie /system/upgrade)
|
||||
//
|
||||
// Kein Cluster vorhanden → 409 zurück damit der Client auf /system/upgrade
|
||||
// ausweichen kann. Wenn bereits ein Rolling-Update läuft → aktuellen State.
|
||||
func (h *ClusterHandler) RollingUpdate(c *gin.Context) {
|
||||
if h.Aggregator == nil || h.Store == nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "no cluster — use /system/upgrade"})
|
||||
return
|
||||
}
|
||||
|
||||
st := readRollingUpdateState()
|
||||
if st.Phase != phaseIdle && st.Phase != phaseFailed && st.Phase != phaseDone {
|
||||
response.OK(c, st)
|
||||
return
|
||||
}
|
||||
|
||||
nodes, err := h.Store.List(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
var secondary *models.HANode
|
||||
for i := range nodes {
|
||||
if nodes[i].ID != h.LocalID {
|
||||
secondary = &nodes[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if secondary == nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "no peer node — use /system/upgrade"})
|
||||
return
|
||||
}
|
||||
|
||||
newState := RollingUpdateState{
|
||||
Phase: phaseUpdatingSecondary,
|
||||
SecondaryID: secondary.ID,
|
||||
SecondaryFQDN: secondary.FQDN,
|
||||
StartedAt: time.Now(),
|
||||
}
|
||||
writeRollingUpdateState(newState)
|
||||
slog.Info("rolling-update: started", "secondary", secondary.FQDN)
|
||||
|
||||
go h.runRollingUpdate(secondary)
|
||||
|
||||
c.JSON(http.StatusAccepted, newState)
|
||||
}
|
||||
|
||||
// RollingUpdateStatus gibt den aktuellen Rolling-Update-State zurück.
|
||||
// Bei phase == "done" wird nach Auslieferung sofort auf idle zurückgesetzt
|
||||
// damit der nächste Pageload keinen Stale-done vorfindet.
|
||||
func (h *ClusterHandler) RollingUpdateStatus(c *gin.Context) {
|
||||
st := readRollingUpdateState()
|
||||
response.OK(c, st)
|
||||
if st.Phase == phaseDone {
|
||||
writeRollingUpdateState(RollingUpdateState{Phase: phaseIdle})
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ClusterHandler) runRollingUpdate(secondary *models.HANode) {
|
||||
ctx := context.Background()
|
||||
|
||||
// 1. Secondary triggern
|
||||
slog.Info("rolling-update: posting trigger-update to secondary", "fqdn", secondary.FQDN)
|
||||
result := h.Aggregator.PostPeer(ctx, *secondary, "/agent/cluster/trigger-update")
|
||||
if !result.OK {
|
||||
writeRollingUpdateState(RollingUpdateState{
|
||||
Phase: phaseFailed,
|
||||
SecondaryID: secondary.ID,
|
||||
SecondaryFQDN: secondary.FQDN,
|
||||
Error: "trigger-update failed: " + result.Err,
|
||||
})
|
||||
slog.Warn("rolling-update: secondary trigger failed", "error", result.Err)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Secondary-Version pollen — der Secondary restartet nach dem
|
||||
// Upgrade, danach zeigt /agent/cluster/version eine neue Version.
|
||||
writeRollingUpdateState(RollingUpdateState{
|
||||
Phase: phaseWaitingSecondary,
|
||||
SecondaryID: secondary.ID,
|
||||
SecondaryFQDN: secondary.FQDN,
|
||||
})
|
||||
slog.Info("rolling-update: waiting for secondary version flip")
|
||||
|
||||
// Kurze Wartezeit damit apt auf dem Secondary erst losläuft
|
||||
time.Sleep(20 * time.Second)
|
||||
|
||||
deadline := time.Now().Add(10 * time.Minute)
|
||||
versionFlipped := false
|
||||
for time.Now().Before(deadline) {
|
||||
results := h.Aggregator.FanOut(ctx, []models.HANode{*secondary}, "/agent/cluster/version", h.LocalID)
|
||||
if len(results) > 0 && results[0].OK {
|
||||
var ver struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if err := json.Unmarshal(results[0].Data, &ver); err == nil {
|
||||
slog.Info("rolling-update: secondary version", "version", ver.Version, "primary", h.Version)
|
||||
if ver.Version != h.Version {
|
||||
versionFlipped = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
time.Sleep(10 * time.Second)
|
||||
}
|
||||
|
||||
if !versionFlipped {
|
||||
writeRollingUpdateState(RollingUpdateState{
|
||||
Phase: phaseFailed,
|
||||
SecondaryID: secondary.ID,
|
||||
SecondaryFQDN: secondary.FQDN,
|
||||
Error: "timeout (10 min) waiting for secondary version flip",
|
||||
})
|
||||
slog.Warn("rolling-update: secondary version flip timeout")
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Primary (uns selbst) aktualisieren — identisch zu /system/upgrade
|
||||
writeRollingUpdateState(RollingUpdateState{
|
||||
Phase: phaseUpdatingPrimary,
|
||||
SecondaryID: secondary.ID,
|
||||
SecondaryFQDN: secondary.FQDN,
|
||||
})
|
||||
slog.Info("rolling-update: triggering primary self-upgrade")
|
||||
|
||||
const scriptPath = "/var/lib/edgeguard/upgrade.sh"
|
||||
const script = `#!/bin/bash
|
||||
set -e
|
||||
sleep 2
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
dpkg --configure -a || true
|
||||
retry_apt() {
|
||||
local attempt=0 max=3 wait_for=15
|
||||
while [ $attempt -lt $max ]; do
|
||||
attempt=$((attempt + 1))
|
||||
apt-get update -qq || true
|
||||
if apt-get install -y -qq -o Dpkg::Options::=--force-confold \
|
||||
edgeguard-api edgeguard-ui edgeguard; then return 0; fi
|
||||
[ $attempt -lt $max ] && sleep $wait_for && wait_for=$((wait_for * 2))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
retry_apt
|
||||
echo "[upgrade] complete"
|
||||
rm -f /var/lib/edgeguard/upgrade.sh
|
||||
`
|
||||
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
|
||||
writeRollingUpdateState(RollingUpdateState{
|
||||
Phase: phaseFailed,
|
||||
SecondaryID: secondary.ID,
|
||||
SecondaryFQDN: secondary.FQDN,
|
||||
Error: "write upgrade script: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const unitName = "edgeguard-upgrade.service"
|
||||
_ = exec.Command("sudo", "-n", "/usr/bin/systemctl", "reset-failed", unitName).Run()
|
||||
cmd := exec.Command("sudo", "-n", "/usr/bin/systemd-run",
|
||||
"--unit="+unitName,
|
||||
"--description=EdgeGuard self-upgrade",
|
||||
"--collect",
|
||||
"bash", scriptPath)
|
||||
if err := cmd.Run(); err != nil {
|
||||
writeRollingUpdateState(RollingUpdateState{
|
||||
Phase: phaseFailed,
|
||||
SecondaryID: secondary.ID,
|
||||
SecondaryFQDN: secondary.FQDN,
|
||||
Error: "systemd-run failed: " + err.Error(),
|
||||
})
|
||||
slog.Warn("rolling-update: primary systemd-run failed", "error", err)
|
||||
return
|
||||
}
|
||||
// State bleibt "updating-primary" — der Primary restartet gleich.
|
||||
// UI erkennt Version-Flip via /system/health und schließt den Flow.
|
||||
slog.Info("rolling-update: primary upgrade dispatched, process will restart")
|
||||
}
|
||||
319
internal/handlers/cluster_viptest.go
Normal file
319
internal/handlers/cluster_viptest.go
Normal file
@@ -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
|
||||
}
|
||||
285
internal/handlers/crowdsec.go
Normal file
285
internal/handlers/crowdsec.go
Normal file
@@ -0,0 +1,285 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
crowdsec "git.netcell-it.de/projekte/edgeguard-native/internal/crowdsec"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
|
||||
)
|
||||
|
||||
// CrowdSecHandler exposes the CrowdSec IDS/IPS management REST API:
|
||||
//
|
||||
// GET /crowdsec/status
|
||||
// GET /crowdsec/decisions
|
||||
// POST /crowdsec/decisions
|
||||
// DELETE /crowdsec/decisions (?ip=<ip> or ?id=<id>)
|
||||
// GET /crowdsec/alerts
|
||||
// DELETE /crowdsec/alerts/:id
|
||||
// GET /crowdsec/bouncers
|
||||
// DELETE /crowdsec/bouncers/:name
|
||||
// GET /crowdsec/machines
|
||||
// DELETE /crowdsec/machines/:id
|
||||
// GET /crowdsec/collections
|
||||
// POST /crowdsec/collections/:name/install
|
||||
// DELETE /crowdsec/collections/:name
|
||||
type CrowdSecHandler struct {
|
||||
Audit *audit.Repo
|
||||
NodeID string
|
||||
}
|
||||
|
||||
// NewCrowdSecHandler returns a CrowdSecHandler wired with audit and node-id.
|
||||
func NewCrowdSecHandler(a *audit.Repo, nodeID string) *CrowdSecHandler {
|
||||
return &CrowdSecHandler{Audit: a, NodeID: nodeID}
|
||||
}
|
||||
|
||||
// Register mounts all CrowdSec routes onto the provided authenticated router
|
||||
// group.
|
||||
func (h *CrowdSecHandler) Register(rg *gin.RouterGroup) {
|
||||
g := rg.Group("/crowdsec")
|
||||
g.GET("/status", h.Status)
|
||||
g.GET("/decisions", h.ListDecisions)
|
||||
g.POST("/decisions", h.AddDecision)
|
||||
g.DELETE("/decisions", h.DeleteDecision)
|
||||
g.GET("/alerts", h.ListAlerts)
|
||||
g.DELETE("/alerts/:id", h.DeleteAlert)
|
||||
g.GET("/bouncers", h.ListBouncers)
|
||||
g.DELETE("/bouncers/:name", h.DeleteBouncer)
|
||||
g.GET("/machines", h.ListMachines)
|
||||
g.DELETE("/machines/:id", h.DeleteMachine)
|
||||
g.GET("/collections", h.ListCollections)
|
||||
g.POST("/collections/:name/install", h.InstallCollection)
|
||||
g.DELETE("/collections/:name", h.RemoveCollection)
|
||||
}
|
||||
|
||||
// csNotInstalled responds with 503 when cscli is absent.
|
||||
func csNotInstalled(c *gin.Context) {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "crowdsec not installed"})
|
||||
}
|
||||
|
||||
// ---------- Status ----------------------------------------------------------
|
||||
|
||||
// Status returns live status of the CrowdSec agent + bouncer.
|
||||
// Does NOT require cscli — uses systemctl for running-state checks.
|
||||
func (h *CrowdSecHandler) Status(c *gin.Context) {
|
||||
st := crowdsec.ServiceStatus(c.Request.Context())
|
||||
response.OK(c, st)
|
||||
}
|
||||
|
||||
// ---------- Decisions -------------------------------------------------------
|
||||
|
||||
// ListDecisions returns all active decisions.
|
||||
func (h *CrowdSecHandler) ListDecisions(c *gin.Context) {
|
||||
if !crowdsec.IsInstalled() {
|
||||
csNotInstalled(c)
|
||||
return
|
||||
}
|
||||
list, err := crowdsec.Decisions(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"decisions": list})
|
||||
}
|
||||
|
||||
// addDecisionBody is the expected JSON body for POST /crowdsec/decisions.
|
||||
type addDecisionBody struct {
|
||||
IP string `json:"ip" binding:"required"`
|
||||
Duration string `json:"duration" binding:"required"`
|
||||
Reason string `json:"reason"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// AddDecision creates a new ban/captcha decision.
|
||||
func (h *CrowdSecHandler) AddDecision(c *gin.Context) {
|
||||
if !crowdsec.IsInstalled() {
|
||||
csNotInstalled(c)
|
||||
return
|
||||
}
|
||||
var body addDecisionBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if body.Reason == "" {
|
||||
body.Reason = "manual ban"
|
||||
}
|
||||
if body.Type == "" {
|
||||
body.Type = "ban"
|
||||
}
|
||||
if err := crowdsec.AddDecision(c.Request.Context(), body.IP, body.Duration, body.Reason, body.Type); err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "crowdsec.decision.add", body.IP,
|
||||
gin.H{"duration": body.Duration, "type": body.Type, "reason": body.Reason}, h.NodeID)
|
||||
response.Created(c, gin.H{"ip": body.IP, "duration": body.Duration, "type": body.Type})
|
||||
}
|
||||
|
||||
// DeleteDecision removes a decision by IP (?ip=) or by ID (?id=).
|
||||
func (h *CrowdSecHandler) DeleteDecision(c *gin.Context) {
|
||||
if !crowdsec.IsInstalled() {
|
||||
csNotInstalled(c)
|
||||
return
|
||||
}
|
||||
ip := c.Query("ip")
|
||||
id := c.Query("id")
|
||||
if ip == "" && id == "" {
|
||||
response.BadRequest(c, errors.New("query parameter 'ip' or 'id' required"))
|
||||
return
|
||||
}
|
||||
var err error
|
||||
var target string
|
||||
if ip != "" {
|
||||
err = crowdsec.DeleteDecisionByIP(c.Request.Context(), ip)
|
||||
target = ip
|
||||
} else {
|
||||
err = crowdsec.DeleteDecisionByID(c.Request.Context(), id)
|
||||
target = id
|
||||
}
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "crowdsec.decision.delete", target, nil, h.NodeID)
|
||||
response.OK(c, gin.H{"deleted": target})
|
||||
}
|
||||
|
||||
// ---------- Alerts ----------------------------------------------------------
|
||||
|
||||
// ListAlerts returns recent CrowdSec alerts.
|
||||
func (h *CrowdSecHandler) ListAlerts(c *gin.Context) {
|
||||
if !crowdsec.IsInstalled() {
|
||||
csNotInstalled(c)
|
||||
return
|
||||
}
|
||||
list, err := crowdsec.Alerts(c.Request.Context(), 200)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"alerts": list})
|
||||
}
|
||||
|
||||
// DeleteAlert discards a single alert.
|
||||
func (h *CrowdSecHandler) DeleteAlert(c *gin.Context) {
|
||||
if !crowdsec.IsInstalled() {
|
||||
csNotInstalled(c)
|
||||
return
|
||||
}
|
||||
id := c.Param("id")
|
||||
if err := crowdsec.DeleteAlert(c.Request.Context(), id); err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"deleted": id})
|
||||
}
|
||||
|
||||
// ---------- Bouncers --------------------------------------------------------
|
||||
|
||||
// ListBouncers returns all registered bouncers.
|
||||
func (h *CrowdSecHandler) ListBouncers(c *gin.Context) {
|
||||
if !crowdsec.IsInstalled() {
|
||||
csNotInstalled(c)
|
||||
return
|
||||
}
|
||||
list, err := crowdsec.Bouncers(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"bouncers": list})
|
||||
}
|
||||
|
||||
// DeleteBouncer removes a bouncer by name.
|
||||
func (h *CrowdSecHandler) DeleteBouncer(c *gin.Context) {
|
||||
if !crowdsec.IsInstalled() {
|
||||
csNotInstalled(c)
|
||||
return
|
||||
}
|
||||
name := c.Param("name")
|
||||
if err := crowdsec.DeleteBouncer(c.Request.Context(), name); err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "crowdsec.bouncer.delete", name, nil, h.NodeID)
|
||||
response.OK(c, gin.H{"deleted": name})
|
||||
}
|
||||
|
||||
// ---------- Machines --------------------------------------------------------
|
||||
|
||||
// ListMachines returns all registered machines.
|
||||
func (h *CrowdSecHandler) ListMachines(c *gin.Context) {
|
||||
if !crowdsec.IsInstalled() {
|
||||
csNotInstalled(c)
|
||||
return
|
||||
}
|
||||
list, err := crowdsec.Machines(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"machines": list})
|
||||
}
|
||||
|
||||
// DeleteMachine removes a machine by ID.
|
||||
func (h *CrowdSecHandler) DeleteMachine(c *gin.Context) {
|
||||
if !crowdsec.IsInstalled() {
|
||||
csNotInstalled(c)
|
||||
return
|
||||
}
|
||||
id := c.Param("id")
|
||||
if err := crowdsec.DeleteMachine(c.Request.Context(), id); err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "crowdsec.machine.delete", id, nil, h.NodeID)
|
||||
response.OK(c, gin.H{"deleted": id})
|
||||
}
|
||||
|
||||
// ---------- Collections -----------------------------------------------------
|
||||
|
||||
// ListCollections returns all hub collections and their install status.
|
||||
func (h *CrowdSecHandler) ListCollections(c *gin.Context) {
|
||||
if !crowdsec.IsInstalled() {
|
||||
csNotInstalled(c)
|
||||
return
|
||||
}
|
||||
list, err := crowdsec.Collections(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"collections": list})
|
||||
}
|
||||
|
||||
// InstallCollection installs a hub collection by name.
|
||||
func (h *CrowdSecHandler) InstallCollection(c *gin.Context) {
|
||||
if !crowdsec.IsInstalled() {
|
||||
csNotInstalled(c)
|
||||
return
|
||||
}
|
||||
name := c.Param("name")
|
||||
if err := crowdsec.InstallCollection(c.Request.Context(), name); err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.Created(c, gin.H{"installed": name})
|
||||
}
|
||||
|
||||
// RemoveCollection removes a hub collection by name.
|
||||
func (h *CrowdSecHandler) RemoveCollection(c *gin.Context) {
|
||||
if !crowdsec.IsInstalled() {
|
||||
csNotInstalled(c)
|
||||
return
|
||||
}
|
||||
name := c.Param("name")
|
||||
if err := crowdsec.RemoveCollection(c.Request.Context(), name); err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"removed": name})
|
||||
}
|
||||
@@ -3,7 +3,12 @@ package handlers
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -54,6 +59,8 @@ func (h *DNSHandler) Register(rg *gin.RouterGroup) {
|
||||
|
||||
g.GET("/settings", h.GetSettings)
|
||||
g.PUT("/settings", h.UpdateSettings)
|
||||
g.GET("/stats", h.Stats)
|
||||
g.POST("/flush-cache", h.FlushCache)
|
||||
}
|
||||
|
||||
// ── Zones ──────────────────────────────────────────────────────
|
||||
@@ -145,6 +152,8 @@ func (h *DNSHandler) DeleteZone(c *gin.Context) {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dns.zone.delete",
|
||||
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
|
||||
response.NoContent(c)
|
||||
h.reload(c.Request.Context(), "zone.delete")
|
||||
}
|
||||
@@ -256,6 +265,8 @@ func (h *DNSHandler) DeleteRecord(c *gin.Context) {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dns.record.delete",
|
||||
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
|
||||
response.NoContent(c)
|
||||
h.reload(c.Request.Context(), "record.delete")
|
||||
}
|
||||
@@ -277,6 +288,10 @@ func (h *DNSHandler) UpdateSettings(c *gin.Context) {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if err := validateSettings(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
out, err := h.Repo.UpdateSettings(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
@@ -287,8 +302,68 @@ func (h *DNSHandler) UpdateSettings(c *gin.Context) {
|
||||
h.reload(c.Request.Context(), "settings.update")
|
||||
}
|
||||
|
||||
// FlushCache runs `unbound-control flush_zone .` which discards all
|
||||
// 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(), "/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)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dns.flush-cache", "unbound", nil, h.NodeID)
|
||||
response.OK(c, gin.H{"message": "cache flushed", "output": string(out)})
|
||||
}
|
||||
|
||||
// ── Validation ─────────────────────────────────────────────────
|
||||
|
||||
// validateSettings checks user-supplied DNS global settings before they
|
||||
// reach unbound. A malformed upstream IP or CIDR would cause unbound to
|
||||
// fail on the next reload without any visible error.
|
||||
func validateSettings(s *models.DNSSettings) error {
|
||||
if s.ListenPort < 1 || s.ListenPort > 65535 {
|
||||
return fmt.Errorf("listen_port %d out of range (1-65535)", s.ListenPort)
|
||||
}
|
||||
if s.CacheMaxTTL < s.CacheMinTTL {
|
||||
return fmt.Errorf("cache_max_ttl (%d) must be ≥ cache_min_ttl (%d)", s.CacheMaxTTL, s.CacheMinTTL)
|
||||
}
|
||||
for _, raw := range strings.Split(s.UpstreamForwards, ",") {
|
||||
entry := strings.TrimSpace(raw)
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
// strip optional @port suffix (e.g. 1.1.1.1@853)
|
||||
host, _, _ := strings.Cut(entry, "@")
|
||||
if net.ParseIP(host) == nil {
|
||||
return fmt.Errorf("invalid upstream forwarder IP: %q", host)
|
||||
}
|
||||
}
|
||||
for _, raw := range strings.Split(s.AccessACL, ",") {
|
||||
entry := strings.TrimSpace(raw)
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(entry, "/") {
|
||||
if _, _, err := net.ParseCIDR(entry); err != nil {
|
||||
return fmt.Errorf("invalid access ACL CIDR: %q", entry)
|
||||
}
|
||||
} else if net.ParseIP(entry) == nil {
|
||||
return fmt.Errorf("invalid access ACL IP: %q", entry)
|
||||
}
|
||||
}
|
||||
for _, raw := range strings.Split(s.ListenAddresses, ",") {
|
||||
addr := strings.TrimSpace(raw)
|
||||
if addr == "" {
|
||||
continue
|
||||
}
|
||||
if net.ParseIP(addr) == nil {
|
||||
return fmt.Errorf("invalid listen address: %q", addr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateZone(z *models.DNSZone) error {
|
||||
if z.Name == "" {
|
||||
return errors.New("name required")
|
||||
@@ -309,6 +384,71 @@ func validateZone(z *models.DNSZone) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stats liefert Unbound-Resolver-Statistiken via `unbound-control stats_noreset`.
|
||||
// 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("/usr/sbin/unbound-control", "stats_noreset").Output()
|
||||
if err != nil {
|
||||
response.OK(c, gin.H{
|
||||
"error": "unbound-control nicht verfügbar: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"stats": parseUnboundStats(string(out))})
|
||||
}
|
||||
|
||||
type unboundStats struct {
|
||||
TotalQueries int64 `json:"total_queries"`
|
||||
CacheHits int64 `json:"cache_hits"`
|
||||
CacheMiss int64 `json:"cache_miss"`
|
||||
CacheHitPct float64 `json:"cache_hit_pct"`
|
||||
RecursiveReplies int64 `json:"recursive_replies"`
|
||||
Prefetch int64 `json:"prefetch"`
|
||||
RateLimited int64 `json:"rate_limited"`
|
||||
RRSetCacheBytes int64 `json:"rrset_cache_bytes"`
|
||||
MsgCacheBytes int64 `json:"msg_cache_bytes"`
|
||||
TCPUsage int64 `json:"tcp_usage"`
|
||||
Unwanted int64 `json:"unwanted"`
|
||||
}
|
||||
|
||||
func parseUnboundStats(out string) unboundStats {
|
||||
s := unboundStats{}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
k, v, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
n, _ := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
|
||||
switch strings.TrimSpace(k) {
|
||||
case "total.num.queries":
|
||||
s.TotalQueries = n
|
||||
case "total.num.cachehits":
|
||||
s.CacheHits = n
|
||||
case "total.num.cachemiss":
|
||||
s.CacheMiss = n
|
||||
case "total.num.recursivereplies":
|
||||
s.RecursiveReplies = n
|
||||
case "total.num.prefetch":
|
||||
s.Prefetch = n
|
||||
case "total.num.queries_ip_ratelimited":
|
||||
s.RateLimited = n
|
||||
case "mem.cache.rrset":
|
||||
s.RRSetCacheBytes = n
|
||||
case "mem.cache.message":
|
||||
s.MsgCacheBytes = n
|
||||
case "total.tcpusage":
|
||||
s.TCPUsage = n
|
||||
case "unwanted.queries":
|
||||
s.Unwanted = n
|
||||
}
|
||||
}
|
||||
if s.TotalQueries > 0 {
|
||||
s.CacheHitPct = float64(s.CacheHits) / float64(s.TotalQueries) * 100
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func validateRecord(r *models.DNSRecord) error {
|
||||
if r.Name == "" {
|
||||
return errors.New("name required")
|
||||
|
||||
@@ -138,6 +138,7 @@ func (h *FirewallHandler) Register(rg *gin.RouterGroup) {
|
||||
rl.POST("", h.CreateRule)
|
||||
rl.GET("/:id", h.GetRule)
|
||||
rl.PUT("/:id", h.UpdateRule)
|
||||
rl.PATCH("/:id", h.PatchRule)
|
||||
rl.DELETE("/:id", h.DeleteRule)
|
||||
|
||||
nat := g.Group("/nat-rules")
|
||||
@@ -145,6 +146,7 @@ func (h *FirewallHandler) Register(rg *gin.RouterGroup) {
|
||||
nat.POST("", h.CreateNAT)
|
||||
nat.GET("/:id", h.GetNAT)
|
||||
nat.PUT("/:id", h.UpdateNAT)
|
||||
nat.PATCH("/:id", h.PatchNAT)
|
||||
nat.DELETE("/:id", h.DeleteNAT)
|
||||
}
|
||||
|
||||
@@ -758,6 +760,48 @@ func (h *FirewallHandler) DeleteRule(c *gin.Context) {
|
||||
response.NoContent(c); h.reload(c.Request.Context(), "delete")
|
||||
}
|
||||
|
||||
func (h *FirewallHandler) PatchRule(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Note *string `json:"note"`
|
||||
Labels []string `json:"labels"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
if body.Note != nil {
|
||||
if err := h.Rules.PatchNote(ctx, id, *body.Note); err != nil {
|
||||
if errors.Is(err, firewall.ErrRuleNotFound) {
|
||||
response.NotFound(c, err)
|
||||
return
|
||||
}
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if body.Labels != nil {
|
||||
if err := h.Rules.PatchLabels(ctx, id, body.Labels); err != nil {
|
||||
if errors.Is(err, firewall.ErrRuleNotFound) {
|
||||
response.NotFound(c, err)
|
||||
return
|
||||
}
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
out, err := h.Rules.Get(ctx, id)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, out)
|
||||
}
|
||||
|
||||
// ── NAT Rules ──────────────────────────────────────────────────────────
|
||||
|
||||
func (h *FirewallHandler) ListNAT(c *gin.Context) {
|
||||
@@ -858,6 +902,48 @@ func (h *FirewallHandler) DeleteNAT(c *gin.Context) {
|
||||
response.NoContent(c); h.reload(c.Request.Context(), "delete")
|
||||
}
|
||||
|
||||
func (h *FirewallHandler) PatchNAT(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Note *string `json:"note"`
|
||||
Labels []string `json:"labels"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
if body.Note != nil {
|
||||
if err := h.NATRules.PatchNote(ctx, id, *body.Note); err != nil {
|
||||
if errors.Is(err, firewall.ErrNATRuleNotFound) {
|
||||
response.NotFound(c, err)
|
||||
return
|
||||
}
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if body.Labels != nil {
|
||||
if err := h.NATRules.PatchLabels(ctx, id, body.Labels); err != nil {
|
||||
if errors.Is(err, firewall.ErrNATRuleNotFound) {
|
||||
response.NotFound(c, err)
|
||||
return
|
||||
}
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
out, err := h.NATRules.Get(ctx, id)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, out)
|
||||
}
|
||||
|
||||
// ── Validators ─────────────────────────────────────────────────────────
|
||||
|
||||
func validateAddrObjValue(kind, value string) error {
|
||||
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -35,7 +37,12 @@ func (h *ForwardProxyHandler) reload(ctx context.Context, op string) {
|
||||
}
|
||||
|
||||
func (h *ForwardProxyHandler) Register(rg *gin.RouterGroup) {
|
||||
g := rg.Group("/forward-proxy/acls")
|
||||
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)
|
||||
g.POST("", h.Create)
|
||||
g.GET("/:id", h.Get)
|
||||
@@ -43,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 {
|
||||
@@ -135,6 +170,68 @@ func (h *ForwardProxyHandler) Delete(c *gin.Context) {
|
||||
h.reload(c.Request.Context(), "delete")
|
||||
}
|
||||
|
||||
// Stats liefert Squid-Cache-Statistiken via `squidclient mgr:counters`.
|
||||
// Die Ausgabe enthält HTTP-Header gefolgt von key = value Zeilen.
|
||||
func (h *ForwardProxyHandler) Stats(c *gin.Context) {
|
||||
out, err := exec.Command("squidclient", "-h", "127.0.0.1", "-p", "3128", "mgr:counters").Output()
|
||||
if err != nil {
|
||||
response.OK(c, gin.H{
|
||||
"error": "squidclient nicht verfügbar: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"stats": parseSquidCounters(string(out))})
|
||||
}
|
||||
|
||||
type squidStats struct {
|
||||
ClientRequests int64 `json:"client_requests"`
|
||||
CacheHits int64 `json:"cache_hits"`
|
||||
CacheHitPct float64 `json:"cache_hit_pct"`
|
||||
ClientErrors int64 `json:"client_errors"`
|
||||
BytesIn int64 `json:"bytes_in"`
|
||||
BytesOut int64 `json:"bytes_out"`
|
||||
ServerRequests int64 `json:"server_requests"`
|
||||
ServerErrors int64 `json:"server_errors"`
|
||||
}
|
||||
|
||||
func parseSquidCounters(out string) squidStats {
|
||||
// squidclient prefixes an HTTP response header block — skip it.
|
||||
body := out
|
||||
if idx := strings.Index(out, "\r\n\r\n"); idx >= 0 {
|
||||
body = out[idx+4:]
|
||||
} else if idx := strings.Index(out, "\n\n"); idx >= 0 {
|
||||
body = out[idx+2:]
|
||||
}
|
||||
s := squidStats{}
|
||||
for _, line := range strings.Split(body, "\n") {
|
||||
k, v, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
n, _ := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
|
||||
switch strings.TrimSpace(k) {
|
||||
case "client_http.requests":
|
||||
s.ClientRequests = n
|
||||
case "client_http.hits":
|
||||
s.CacheHits = n
|
||||
case "client_http.errors":
|
||||
s.ClientErrors = n
|
||||
case "client_http.kbytes_in":
|
||||
s.BytesIn = n * 1024
|
||||
case "client_http.kbytes_out":
|
||||
s.BytesOut = n * 1024
|
||||
case "server.all.requests":
|
||||
s.ServerRequests = n
|
||||
case "server.all.errors":
|
||||
s.ServerErrors = n
|
||||
}
|
||||
}
|
||||
if s.ClientRequests > 0 {
|
||||
s.CacheHitPct = float64(s.CacheHits) / float64(s.ClientRequests) * 100
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// validateACL prüft Name (squid-konform), action, acl_type. Squid
|
||||
// nimmt viele Typen — wir whitelisten die, die in einem Forward-
|
||||
// Proxy-Setup üblich sind, damit Tippfehler nicht beim reload
|
||||
|
||||
@@ -98,8 +98,10 @@ func (h *HAProxyStatsHandler) Stats(c *gin.Context) {
|
||||
svname := safeAt(fields, colIdx["svname"])
|
||||
pxname := safeAt(fields, colIdx["pxname"])
|
||||
|
||||
// Skip our internal stats listener and the BACKEND summary row.
|
||||
if pxname == "internal_stats" || svname == "BACKEND" || svname == "" {
|
||||
// Skip internal infrastructure rows and the BACKEND summary row.
|
||||
// api_backend = management API; rl_* = rate-limit stick-tables (no servers).
|
||||
if pxname == "internal_stats" || pxname == "api_backend" ||
|
||||
strings.HasPrefix(pxname, "rl_") || svname == "BACKEND" || svname == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -135,7 +137,7 @@ func (h *HAProxyStatsHandler) Stats(c *gin.Context) {
|
||||
}
|
||||
|
||||
func safeAt(fields []string, i int) string {
|
||||
if i <= 0 || i >= len(fields) {
|
||||
if i < 0 || i >= len(fields) {
|
||||
return ""
|
||||
}
|
||||
return fields[i]
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -13,13 +15,27 @@ import (
|
||||
)
|
||||
|
||||
type IPAddressesHandler struct {
|
||||
Repo *ipaddresses.Repo
|
||||
Audit *audit.Repo
|
||||
NodeID string
|
||||
Repo *ipaddresses.Repo
|
||||
Generator *ipaddresses.Generator
|
||||
Audit *audit.Repo
|
||||
NodeID string
|
||||
}
|
||||
|
||||
func NewIPAddressesHandler(repo *ipaddresses.Repo, a *audit.Repo, nodeID string) *IPAddressesHandler {
|
||||
return &IPAddressesHandler{Repo: repo, Audit: a, NodeID: nodeID}
|
||||
return &IPAddressesHandler{
|
||||
Repo: repo,
|
||||
Generator: ipaddresses.NewGenerator(repo),
|
||||
Audit: a,
|
||||
NodeID: nodeID,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *IPAddressesHandler) applyAsync() {
|
||||
go func() {
|
||||
if err := h.Generator.Render(context.Background()); err != nil {
|
||||
slog.Warn("ip-addresses: apply failed", "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (h *IPAddressesHandler) Register(rg *gin.RouterGroup) {
|
||||
@@ -70,6 +86,7 @@ func (h *IPAddressesHandler) Create(c *gin.Context) {
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "ip_address.create",
|
||||
req.Address, out, h.NodeID)
|
||||
h.applyAsync()
|
||||
response.Created(c, out)
|
||||
}
|
||||
|
||||
@@ -94,6 +111,7 @@ func (h *IPAddressesHandler) Update(c *gin.Context) {
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "ip_address.update",
|
||||
out.Address, out, h.NodeID)
|
||||
h.applyAsync()
|
||||
response.OK(c, out)
|
||||
}
|
||||
|
||||
@@ -112,5 +130,6 @@ func (h *IPAddressesHandler) Delete(c *gin.Context) {
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "ip_address.delete",
|
||||
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
|
||||
h.applyAsync()
|
||||
response.NoContent(c)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -15,18 +17,34 @@ import (
|
||||
)
|
||||
|
||||
type NetworksHandler struct {
|
||||
Repo *networkifs.Repo
|
||||
IPs *ipaddresses.Repo
|
||||
Zones *firewall.ZonesRepo
|
||||
Audit *audit.Repo
|
||||
NodeID string
|
||||
Repo *networkifs.Repo
|
||||
Generator *networkifs.Generator
|
||||
IPs *ipaddresses.Repo
|
||||
Zones *firewall.ZonesRepo
|
||||
Audit *audit.Repo
|
||||
NodeID string
|
||||
}
|
||||
|
||||
func NewNetworksHandler(
|
||||
repo *networkifs.Repo, ips *ipaddresses.Repo,
|
||||
zones *firewall.ZonesRepo, a *audit.Repo, nodeID string,
|
||||
) *NetworksHandler {
|
||||
return &NetworksHandler{Repo: repo, IPs: ips, Zones: zones, Audit: a, NodeID: nodeID}
|
||||
return &NetworksHandler{
|
||||
Repo: repo,
|
||||
Generator: networkifs.NewGenerator(repo),
|
||||
IPs: ips,
|
||||
Zones: zones,
|
||||
Audit: a,
|
||||
NodeID: nodeID,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *NetworksHandler) applyAsync() {
|
||||
go func() {
|
||||
if err := h.Generator.Render(context.Background()); err != nil {
|
||||
slog.Warn("network-interfaces: apply failed", "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (h *NetworksHandler) Register(rg *gin.RouterGroup) {
|
||||
@@ -88,6 +106,7 @@ func (h *NetworksHandler) Create(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "network_interface.create", req.Name, out, h.NodeID)
|
||||
h.applyAsync()
|
||||
response.Created(c, out)
|
||||
}
|
||||
|
||||
@@ -122,6 +141,7 @@ func (h *NetworksHandler) Update(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "network_interface.update", out.Name, out, h.NodeID)
|
||||
h.applyAsync()
|
||||
response.OK(c, out)
|
||||
}
|
||||
|
||||
@@ -140,6 +160,7 @@ func (h *NetworksHandler) Delete(c *gin.Context) {
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "network_interface.delete",
|
||||
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
|
||||
h.applyAsync()
|
||||
response.NoContent(c)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -41,6 +42,8 @@ func (h *NTPHandler) Register(rg *gin.RouterGroup) {
|
||||
g.GET("/settings", h.GetSettings)
|
||||
g.PUT("/settings", h.UpdateSettings)
|
||||
g.GET("/status", h.Status)
|
||||
g.GET("/sources", h.Sources)
|
||||
g.POST("/force-sync", h.ForceSync)
|
||||
|
||||
p := g.Group("/pools")
|
||||
p.GET("", h.ListPools)
|
||||
@@ -230,10 +233,95 @@ func (h *NTPHandler) DeletePool(c *gin.Context) {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "ntp.pool.delete",
|
||||
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
|
||||
response.NoContent(c)
|
||||
h.reload(c.Request.Context(), "pool.delete")
|
||||
}
|
||||
|
||||
// ForceSync runs `chronyc makestep` which immediately adjusts the
|
||||
// system clock to the current NTP reference. Useful after a long
|
||||
// outage or VM migration where the clock has drifted by more than
|
||||
// the 1ms default slew threshold.
|
||||
func (h *NTPHandler) ForceSync(c *gin.Context) {
|
||||
out, err := exec.Command("chronyc", "makestep").CombinedOutput()
|
||||
if err != nil {
|
||||
slog.Error("ntp force-sync failed", "err", err, "out", string(out))
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "ntp.force-sync", "chrony", nil, h.NodeID)
|
||||
response.OK(c, gin.H{"message": "clock stepped", "output": string(out)})
|
||||
}
|
||||
|
||||
// Sources liefert die aktuellen NTP-Quellen via `chronyc sources`.
|
||||
// Jede Zeile wird in ein NTPSource-Objekt geparst und als Array zurückgegeben.
|
||||
func (h *NTPHandler) Sources(c *gin.Context) {
|
||||
out, err := exec.Command("chronyc", "sources").Output()
|
||||
if err != nil {
|
||||
response.OK(c, gin.H{
|
||||
"sources": []any{},
|
||||
"error": "chronyc nicht verfügbar: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"sources": parseChronymSources(string(out))})
|
||||
}
|
||||
|
||||
type ntpSource struct {
|
||||
Mode string `json:"mode"`
|
||||
State string `json:"state"`
|
||||
Active bool `json:"active"`
|
||||
Name string `json:"name"`
|
||||
Stratum int `json:"stratum"`
|
||||
Poll int `json:"poll"`
|
||||
Reach string `json:"reach"`
|
||||
LastRx string `json:"last_rx"`
|
||||
Sample string `json:"sample"`
|
||||
}
|
||||
|
||||
func parseChronymSources(out string) []ntpSource {
|
||||
modeMap := map[byte]string{'^': "server", '=': "peer", '#': "local"}
|
||||
stateMap := map[byte]string{
|
||||
'*': "synced", '+': "combined", '-': "not_combined",
|
||||
'?': "unreachable", 'x': "error", '~': "variable",
|
||||
}
|
||||
var srcs []ntpSource
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if len(line) < 2 {
|
||||
continue
|
||||
}
|
||||
mode, ok := modeMap[line[0]]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
stateChar := line[1]
|
||||
stateStr, ok := stateMap[stateChar]
|
||||
if !ok {
|
||||
stateStr = string(stateChar)
|
||||
}
|
||||
fields := strings.Fields(strings.TrimSpace(line[2:]))
|
||||
if len(fields) < 5 {
|
||||
continue
|
||||
}
|
||||
src := ntpSource{
|
||||
Mode: mode,
|
||||
State: stateStr,
|
||||
Active: stateChar == '*' || stateChar == '+',
|
||||
Name: fields[0],
|
||||
Reach: fields[3],
|
||||
LastRx: fields[4],
|
||||
}
|
||||
fmt.Sscanf(fields[1], "%d", &src.Stratum)
|
||||
fmt.Sscanf(fields[2], "%d", &src.Poll)
|
||||
if len(fields) >= 6 {
|
||||
src.Sample = strings.Join(fields[5:], " ")
|
||||
}
|
||||
srcs = append(srcs, src)
|
||||
}
|
||||
return srcs
|
||||
}
|
||||
|
||||
func validateNTPPool(p *models.NTPPool) error {
|
||||
if p.Address == "" {
|
||||
return errors.New("address required")
|
||||
|
||||
@@ -82,6 +82,10 @@ func Forbidden(c *gin.Context, err error) {
|
||||
Err(c, http.StatusForbidden, err)
|
||||
}
|
||||
|
||||
func Conflict(c *gin.Context, err error) {
|
||||
Err(c, http.StatusConflict, err)
|
||||
}
|
||||
|
||||
func Internal(c *gin.Context, err error) {
|
||||
Err(c, http.StatusInternalServerError, err)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/clusterjoin"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
||||
)
|
||||
|
||||
@@ -12,9 +22,12 @@ import (
|
||||
// are mounted before SetupGate so they remain reachable while the API
|
||||
// is in setup mode.
|
||||
type SetupHandler struct {
|
||||
Store *setup.Store
|
||||
Audit *audit.Repo
|
||||
NodeID string
|
||||
Store *setup.Store
|
||||
Audit *audit.Repo
|
||||
NodeID string
|
||||
Version string
|
||||
ClusterStore *cluster.Store
|
||||
PeerReloader PeerReloader
|
||||
}
|
||||
|
||||
func NewSetupHandler(store *setup.Store) *SetupHandler {
|
||||
@@ -30,10 +43,27 @@ func (h *SetupHandler) WithAudit(a *audit.Repo, nodeID string) *SetupHandler {
|
||||
return h
|
||||
}
|
||||
|
||||
// WithVersion macht die laufende Version für auto-register verfügbar.
|
||||
func (h *SetupHandler) WithVersion(v string) *SetupHandler {
|
||||
h.Version = v
|
||||
return h
|
||||
}
|
||||
|
||||
// WithClusterSupport erlaubt dem JoinCluster-Handler nach dem Join den
|
||||
// Primary in der lokalen ha_nodes zu registrieren + nftables neu zu laden,
|
||||
// damit Port 8443 bidirektional offen ist.
|
||||
func (h *SetupHandler) WithClusterSupport(store *cluster.Store, reloader PeerReloader) *SetupHandler {
|
||||
h.ClusterStore = store
|
||||
h.PeerReloader = reloader
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *SetupHandler) Register(rg *gin.RouterGroup) {
|
||||
g := rg.Group("/setup")
|
||||
g.GET("/status", h.Status)
|
||||
g.POST("/complete", h.Complete)
|
||||
g.POST("/complete-node", h.CompleteAsNode)
|
||||
g.POST("/join-cluster", h.JoinCluster)
|
||||
}
|
||||
|
||||
// RegisterAuthed mountet die Endpoints die nach abgeschlossenem Setup
|
||||
@@ -111,3 +141,130 @@ func (h *SetupHandler) Complete(c *gin.Context) {
|
||||
"fqdn": st.FQDN,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *SetupHandler) CompleteAsNode(c *gin.Context) {
|
||||
var req setup.NodeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
st, err := h.Store.CompleteAsNode(req)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{
|
||||
"completed": st.Completed,
|
||||
"is_cluster_node": st.IsClusterNode,
|
||||
"fqdn": st.FQDN,
|
||||
})
|
||||
}
|
||||
|
||||
// JoinCluster performs the full cluster-join flow from the setup wizard:
|
||||
// fetches certs from the primary, writes them to disk, then marks setup
|
||||
// as completed. No CLI required.
|
||||
func (h *SetupHandler) JoinCluster(c *gin.Context) {
|
||||
var body struct {
|
||||
FQDN string `json:"fqdn" binding:"required"`
|
||||
ACMEEmail string `json:"acme_email" binding:"required,email"`
|
||||
PrimaryFQDN string `json:"primary_fqdn" binding:"required"`
|
||||
Token string `json:"token" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
fqdn := strings.ToLower(strings.TrimSpace(body.FQDN))
|
||||
|
||||
if err := clusterjoin.Join(clusterjoin.Request{
|
||||
PrimaryFQDN: body.PrimaryFQDN,
|
||||
Token: strings.TrimSpace(body.Token),
|
||||
CommonName: fqdn,
|
||||
Insecure: true, // security comes from the HMAC token, not TLS cert trust
|
||||
Force: true, // bootstrap self-signed cert must be replaced
|
||||
Version: h.Version,
|
||||
NodeID: h.NodeID,
|
||||
}); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
st, err := h.Store.CompleteAsNode(setup.NodeRequest{
|
||||
FQDN: fqdn,
|
||||
ACMEEmail: body.ACMEEmail,
|
||||
PrimaryFQDN: strings.ToLower(strings.TrimSpace(body.PrimaryFQDN)),
|
||||
})
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Pre-register the primary in our local ha_nodes so its IP lands in
|
||||
// @peer_ipv4 and port 8443 is open bidirectionally.
|
||||
if h.ClusterStore != nil && h.PeerReloader != nil {
|
||||
go h.preRegisterPrimary(body.PrimaryFQDN)
|
||||
}
|
||||
|
||||
response.OK(c, gin.H{
|
||||
"completed": st.Completed,
|
||||
"is_cluster_node": st.IsClusterNode,
|
||||
"fqdn": st.FQDN,
|
||||
})
|
||||
}
|
||||
|
||||
// StartupPeerSync is called once after the DB pool and ClusterStore are
|
||||
// ready. On cluster nodes it re-registers the primary in the local ha_nodes
|
||||
// and reloads nftables so @peer_ipv4 is correct after a package update or
|
||||
// reboot — without requiring a new join.
|
||||
func (h *SetupHandler) StartupPeerSync() {
|
||||
if h.ClusterStore == nil || h.PeerReloader == nil {
|
||||
return
|
||||
}
|
||||
st, err := h.Store.Load()
|
||||
if err != nil || st == nil || !st.IsClusterNode || st.PrimaryFQDN == "" {
|
||||
return
|
||||
}
|
||||
h.preRegisterPrimary(st.PrimaryFQDN)
|
||||
}
|
||||
|
||||
// preRegisterPrimary inserts the primary node into the local ha_nodes with
|
||||
// its resolved IP so nftables @peer_ipv4 allows port 8443 from the primary.
|
||||
// Uses a stable ID derived from the FQDN so repeated calls are idempotent.
|
||||
func (h *SetupHandler) preRegisterPrimary(primaryFQDN string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
primaryFQDN = strings.ToLower(strings.TrimSpace(primaryFQDN))
|
||||
|
||||
addrs, err := net.DefaultResolver.LookupHost(ctx, primaryFQDN)
|
||||
if err != nil || len(addrs) == 0 {
|
||||
slog.Warn("setup: could not resolve primary FQDN for pre-registration",
|
||||
"fqdn", primaryFQDN, "error", err)
|
||||
return
|
||||
}
|
||||
ip := addrs[0]
|
||||
|
||||
// Stable ID so repeated calls (join + startup) don't accumulate rows.
|
||||
nodeID := fmt.Sprintf("prenode-%s", strings.ReplaceAll(primaryFQDN, ".", "-"))
|
||||
n := models.HANode{
|
||||
ID: nodeID,
|
||||
Name: primaryFQDN,
|
||||
FQDN: primaryFQDN,
|
||||
APIURL: "https://" + primaryFQDN + ":3443",
|
||||
Role: "primary",
|
||||
Status: "online",
|
||||
}
|
||||
n.PublicIP = &ip
|
||||
|
||||
if _, err := h.ClusterStore.UpsertSelf(ctx, n); err != nil {
|
||||
slog.Warn("setup: pre-register primary in ha_nodes failed", "fqdn", primaryFQDN, "error", err)
|
||||
return
|
||||
}
|
||||
if err := h.PeerReloader(ctx); err != nil {
|
||||
slog.Warn("setup: PeerReloader failed after primary pre-register", "error", err)
|
||||
return
|
||||
}
|
||||
slog.Info("setup: primary pre-registered locally, firewall updated",
|
||||
"fqdn", primaryFQDN, "ip", ip)
|
||||
}
|
||||
|
||||
@@ -3,11 +3,13 @@ package handlers
|
||||
import (
|
||||
"bufio"
|
||||
stdcontext "context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
@@ -21,6 +23,7 @@ import (
|
||||
aptsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/apt"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
||||
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
|
||||
)
|
||||
|
||||
// SystemHandler covers /system/health, /system/package-versions,
|
||||
@@ -40,6 +43,12 @@ type SystemHandler struct {
|
||||
// in main.go after generators are constructed. Read-only access —
|
||||
// no writes, no reloads.
|
||||
ConfigPreviewers map[string]func(stdcontext.Context) (string, error)
|
||||
// ExtraReloaders: additional service renderers triggered by
|
||||
// RenderConfigs. Keyed by service name (nftables, wireguard, etc.).
|
||||
ExtraReloaders map[string]func(stdcontext.Context) error
|
||||
// Users: optional — wired after DB pool opens. Used by AgentAuthCheck
|
||||
// so cluster peers can verify credentials against this node's DB.
|
||||
Users *usersvc.Repo
|
||||
}
|
||||
|
||||
func NewSystemHandler(version string) *SystemHandler {
|
||||
@@ -75,6 +84,20 @@ func (h *SystemHandler) WithConfigPreviewers(previewers map[string]func(stdconte
|
||||
return h
|
||||
}
|
||||
|
||||
// WithAllReloaders injectet alle Service-Reloader für RenderConfigs.
|
||||
// Reihenfolge: haproxy ist bereits in HAProxyReloader; extras sind die
|
||||
// restlichen Dienste (nftables, wireguard, squid, unbound, chrony).
|
||||
func (h *SystemHandler) WithAllReloaders(extras map[string]func(stdcontext.Context) error) *SystemHandler {
|
||||
h.ExtraReloaders = extras
|
||||
return h
|
||||
}
|
||||
|
||||
// WithUsers injectet das Users-Repo für AgentAuthCheck.
|
||||
func (h *SystemHandler) WithUsers(u *usersvc.Repo) *SystemHandler {
|
||||
h.Users = u
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *SystemHandler) Register(rg *gin.RouterGroup) {
|
||||
g := rg.Group("/system")
|
||||
g.GET("/health", h.Health)
|
||||
@@ -95,10 +118,12 @@ func (h *SystemHandler) Register(rg *gin.RouterGroup) {
|
||||
g.POST("/haproxy-reload", h.HAProxyReload)
|
||||
g.POST("/render-configs", h.RenderConfigs)
|
||||
g.POST("/service-restart", h.ServiceRestart)
|
||||
g.POST("/service-toggle", h.ServiceToggle)
|
||||
g.GET("/upgrade-status", h.UpgradeStatus)
|
||||
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-
|
||||
@@ -112,8 +137,50 @@ func (h *SystemHandler) RegisterAgent(rg *gin.RouterGroup) {
|
||||
g := rg.Group("/agent/system")
|
||||
g.GET("/health", h.Health)
|
||||
g.GET("/resources", h.Resources)
|
||||
// Auth-Federation: Cluster-Nodes verifizieren Credentials gegen diesen
|
||||
// Node via mTLS. Nur über den Agent-Listener (:8443) erreichbar.
|
||||
rg.POST("/agent/auth/check", h.AgentAuthCheck)
|
||||
}
|
||||
|
||||
// AgentAuthCheck verifies email+password against the local users table
|
||||
// and setup.json. Called by cluster nodes over mTLS when local auth fails
|
||||
// so users can log in on any cluster node with the primary's credentials.
|
||||
func (h *SystemHandler) AgentAuthCheck(c *gin.Context) {
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
email := strings.TrimSpace(strings.ToLower(req.Email))
|
||||
if email == "" || req.Password == "" {
|
||||
response.Unauthorized(c, errInvalidCreds)
|
||||
return
|
||||
}
|
||||
|
||||
// Check local users table first.
|
||||
if h.Users != nil {
|
||||
u, hash, err := h.Users.FindByEmail(c.Request.Context(), email)
|
||||
if err == nil && u.Active && usersvc.VerifyPassword(hash, req.Password) {
|
||||
response.OK(c, gin.H{"actor": u.Email, "role": u.Role})
|
||||
return
|
||||
}
|
||||
}
|
||||
// Fallback: setup.json admin.
|
||||
if h.Setup != nil {
|
||||
st, _ := h.Setup.Load()
|
||||
if st != nil && strings.EqualFold(st.AdminEmail, email) && st.VerifyAdminPassword(req.Password) {
|
||||
response.OK(c, gin.H{"actor": st.AdminEmail, "role": "admin"})
|
||||
return
|
||||
}
|
||||
}
|
||||
response.Unauthorized(c, errInvalidCreds)
|
||||
}
|
||||
|
||||
var errInvalidCreds = errors.New("invalid_credentials")
|
||||
|
||||
// servicesToCheck is the curated list shown on the dashboard
|
||||
// service-health-grid. Order matters (UI renders in this sequence).
|
||||
// Each entry is a (label, systemd-unit) pair — label is what the
|
||||
@@ -123,10 +190,14 @@ var servicesToCheck = []struct{ Label, Unit string }{
|
||||
{"edgeguard-scheduler", "edgeguard-scheduler"},
|
||||
{"haproxy", "haproxy"},
|
||||
{"nftables", "nftables"},
|
||||
{"keepalived", "keepalived"},
|
||||
{"unbound", "unbound"},
|
||||
{"chrony", "chrony"},
|
||||
{"squid", "squid"},
|
||||
{"postgresql", "postgresql"},
|
||||
{"crowdsec", "crowdsec"},
|
||||
{"crowdsec-firewall-bouncer", "crowdsec-firewall-bouncer"},
|
||||
{"edgeguard-waf", "edgeguard-waf"},
|
||||
}
|
||||
|
||||
type serviceStatus struct {
|
||||
@@ -200,6 +271,7 @@ type resources struct {
|
||||
LoadAvg1 float64 `json:"load_avg_1"`
|
||||
LoadAvg5 float64 `json:"load_avg_5"`
|
||||
LoadAvg15 float64 `json:"load_avg_15"`
|
||||
NumCPUs int `json:"num_cpus"`
|
||||
MemTotalKB int64 `json:"mem_total_kb"`
|
||||
MemAvailKB int64 `json:"mem_avail_kb"`
|
||||
MemUsedPct float64 `json:"mem_used_pct"`
|
||||
@@ -223,7 +295,7 @@ func (h *SystemHandler) Resources(c *gin.Context) {
|
||||
// gin.Context erstellen kann. Best-effort: fehlende Quellen lassen
|
||||
// die jeweiligen Felder einfach auf 0.
|
||||
func computeLocalSystemResources() resources {
|
||||
r := resources{}
|
||||
r := resources{NumCPUs: runtime.NumCPU()}
|
||||
if data, err := os.ReadFile("/proc/loadavg"); err == nil {
|
||||
f := strings.Fields(string(data))
|
||||
if len(f) >= 3 {
|
||||
@@ -564,31 +636,94 @@ func (h *SystemHandler) ServiceRestart(c *gin.Context) {
|
||||
response.OK(c, gin.H{"ok": true, "service": svc})
|
||||
}
|
||||
|
||||
// RenderConfigs erzwingt ein Re-Render aller Service-Configs aus dem
|
||||
// aktuellen DB-State. Wenn der HAProxyReloader gesetzt ist, läuft der
|
||||
// (rendert haproxy.cfg + reload). Praktisch wenn ein Operator denkt
|
||||
// dass die generierte Config nicht mehr mit der DB übereinstimmt
|
||||
// (Drift, Manual-Edit, etc.).
|
||||
//
|
||||
// v1 macht NUR haproxy — weitere Renderer (firewall, dns, ntp, wg,
|
||||
// squid) sind per Handler an die jeweiligen Mutations-Endpoints
|
||||
// gekoppelt; für die fehlt aktuell ein generisches "render all".
|
||||
func (h *SystemHandler) RenderConfigs(c *gin.Context) {
|
||||
if h.HAProxyReloader == nil {
|
||||
response.Err(c, http.StatusServiceUnavailable, simpleErr("renderer not wired"))
|
||||
// toggleAllowlist defines which services may be started/stopped via the UI.
|
||||
var toggleAllowlist = map[string]bool{
|
||||
"crowdsec": true,
|
||||
"crowdsec-firewall-bouncer": true,
|
||||
"edgeguard-waf": true,
|
||||
"squid": true,
|
||||
"unbound": true,
|
||||
}
|
||||
|
||||
// ServiceToggle starts or stops (and enables/disables) a service.
|
||||
// Body: {"service": "crowdsec", "enabled": true}
|
||||
func (h *SystemHandler) ServiceToggle(c *gin.Context) {
|
||||
var req struct {
|
||||
Service string `json:"service" binding:"required"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Err(c, http.StatusBadRequest, simpleErr("service and enabled required"))
|
||||
return
|
||||
}
|
||||
ctx, cancel := stdcontext.WithTimeout(c.Request.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := h.HAProxyReloader(ctx); err != nil {
|
||||
response.Internal(c, err)
|
||||
svc := strings.TrimSpace(req.Service)
|
||||
if !toggleAllowlist[svc] {
|
||||
response.Err(c, http.StatusBadRequest, simpleErr("service not in toggle allowlist: "+svc))
|
||||
return
|
||||
}
|
||||
unit := svc + ".service"
|
||||
action := "stop"
|
||||
sysdAction := "disable"
|
||||
if req.Enabled {
|
||||
action = "start"
|
||||
sysdAction = "enable"
|
||||
}
|
||||
if out, err := exec.Command("sudo", "-n", "/usr/bin/systemctl", sysdAction, unit).CombinedOutput(); err != nil {
|
||||
response.Err(c, http.StatusInternalServerError, simpleErr(strings.TrimSpace(string(out))+": "+err.Error()))
|
||||
return
|
||||
}
|
||||
if out, err := exec.Command("sudo", "-n", "/usr/bin/systemctl", action, unit).CombinedOutput(); err != nil {
|
||||
response.Err(c, http.StatusInternalServerError, simpleErr(strings.TrimSpace(string(out))+": "+err.Error()))
|
||||
return
|
||||
}
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "system.render_configs",
|
||||
"", gin.H{}, h.NodeID)
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "system.service_toggle",
|
||||
svc, gin.H{"service": svc, "enabled": req.Enabled}, h.NodeID)
|
||||
}
|
||||
response.OK(c, gin.H{"ok": true, "rendered": []string{"haproxy"}})
|
||||
response.OK(c, gin.H{"ok": true, "service": svc, "enabled": req.Enabled})
|
||||
}
|
||||
|
||||
// RenderConfigs erzwingt ein Re-Render aller Service-Configs aus dem
|
||||
// aktuellen DB-State. Läuft haproxy + alle ExtraReloaders (nftables,
|
||||
// wireguard, squid, unbound, chrony) durch. Fehler werden gesammelt
|
||||
// und als partial-Antwort zurückgegeben — erfolgreich gerenderte
|
||||
// Dienste stehen in "rendered", fehlgeschlagene in "errors".
|
||||
func (h *SystemHandler) RenderConfigs(c *gin.Context) {
|
||||
if h.HAProxyReloader == nil && len(h.ExtraReloaders) == 0 {
|
||||
response.Err(c, http.StatusServiceUnavailable, simpleErr("renderer not wired"))
|
||||
return
|
||||
}
|
||||
ctx, cancel := stdcontext.WithTimeout(c.Request.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
rendered := []string{}
|
||||
errs := map[string]string{}
|
||||
|
||||
if h.HAProxyReloader != nil {
|
||||
if err := h.HAProxyReloader(ctx); err != nil {
|
||||
errs["haproxy"] = err.Error()
|
||||
} else {
|
||||
rendered = append(rendered, "haproxy")
|
||||
}
|
||||
}
|
||||
// Defined order so the audit log is deterministic.
|
||||
order := []string{"nftables", "wireguard", "squid", "unbound", "chrony"}
|
||||
for _, name := range order {
|
||||
fn, ok := h.ExtraReloaders[name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if err := fn(ctx); err != nil {
|
||||
errs[name] = err.Error()
|
||||
} else {
|
||||
rendered = append(rendered, name)
|
||||
}
|
||||
}
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "system.render_configs",
|
||||
"", gin.H{"rendered": rendered, "errors": errs}, h.NodeID)
|
||||
}
|
||||
response.OK(c, gin.H{"ok": len(errs) == 0, "rendered": rendered, "errors": errs})
|
||||
}
|
||||
|
||||
// UpgradeStatus liefert den Status des letzten Self-Upgrade-Versuchs.
|
||||
@@ -725,10 +860,31 @@ func (h *SystemHandler) ConfigPreview(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *SystemHandler) Health(c *gin.Context) {
|
||||
response.OK(c, gin.H{
|
||||
resp := gin.H{
|
||||
"status": "ok",
|
||||
"version": h.Version,
|
||||
})
|
||||
}
|
||||
if hn, err := os.Hostname(); err == nil {
|
||||
resp["hostname"] = hn
|
||||
}
|
||||
if data, err := os.ReadFile("/proc/version"); err == nil {
|
||||
// /proc/version: "Linux version 6.x.y (...)" — Kurzform: alles
|
||||
// bis zum ersten '(' kürzen.
|
||||
line := strings.TrimSpace(string(data))
|
||||
if idx := strings.Index(line, " ("); idx > 0 {
|
||||
line = strings.TrimSpace(line[:idx])
|
||||
}
|
||||
resp["kernel"] = line
|
||||
}
|
||||
if data, err := os.ReadFile("/etc/os-release"); err == nil {
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if k, v, ok := strings.Cut(line, "="); ok && k == "PRETTY_NAME" {
|
||||
resp["os"] = strings.Trim(v, `"`)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
response.OK(c, resp)
|
||||
}
|
||||
|
||||
// PackageVersions reports installed and available versions for the
|
||||
@@ -831,6 +987,10 @@ rm -f /var/lib/edgeguard/upgrade.sh
|
||||
_ = fallback.Process.Release()
|
||||
}
|
||||
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "system.upgrade",
|
||||
"", gin.H{"unit": unitName}, h.NodeID)
|
||||
}
|
||||
c.JSON(http.StatusAccepted, response.Envelope{
|
||||
Data: gin.H{"status": "upgrading", "unit": unitName},
|
||||
Error: nil,
|
||||
@@ -970,6 +1130,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 {
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
199
internal/handlers/waf.go
Normal file
199
internal/handlers/waf.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
|
||||
wafsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/waf"
|
||||
)
|
||||
|
||||
// WafHandler exposes the per-domain WAF configuration REST API:
|
||||
//
|
||||
// GET /waf/configs — list all configs (one per domain)
|
||||
// GET /waf/configs/:domain_id — get config for a domain
|
||||
// PUT /waf/configs/:domain_id — upsert config for a domain
|
||||
type WafHandler struct {
|
||||
Repo *wafsvc.Repo
|
||||
Audit *audit.Repo
|
||||
NodeID string
|
||||
Reloader func(ctx context.Context) error
|
||||
}
|
||||
|
||||
func NewWafHandler(repo *wafsvc.Repo, a *audit.Repo, nodeID string, reloader func(context.Context) error) *WafHandler {
|
||||
return &WafHandler{Repo: repo, Audit: a, NodeID: nodeID, Reloader: reloader}
|
||||
}
|
||||
|
||||
func (h *WafHandler) Register(rg *gin.RouterGroup) {
|
||||
g := rg.Group("/waf")
|
||||
g.GET("/configs", h.List)
|
||||
g.GET("/configs/:domain_id", h.Get)
|
||||
g.PUT("/configs/:domain_id", h.Upsert)
|
||||
g.GET("/alerts", h.ListAlerts)
|
||||
g.DELETE("/alerts", h.PurgeAlerts)
|
||||
}
|
||||
|
||||
// List returns all WAF configs.
|
||||
func (h *WafHandler) List(c *gin.Context) {
|
||||
configs, err := h.Repo.List(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"configs": configs})
|
||||
}
|
||||
|
||||
// Get returns the WAF config for a single domain.
|
||||
// Returns a default (disabled) config when none exists yet.
|
||||
func (h *WafHandler) Get(c *gin.Context) {
|
||||
domainID, err := strconv.ParseInt(c.Param("domain_id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, errors.New("invalid domain_id"))
|
||||
return
|
||||
}
|
||||
cfg, err := h.Repo.GetByDomain(c.Request.Context(), domainID)
|
||||
if err != nil {
|
||||
if errors.Is(err, wafsvc.ErrNotFound) {
|
||||
// Return a default config so the UI always gets a usable object.
|
||||
response.OK(c, gin.H{"config": defaultConfig(domainID)})
|
||||
return
|
||||
}
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"config": cfg})
|
||||
}
|
||||
|
||||
// upsertBody is the accepted JSON for PUT /waf/configs/:domain_id.
|
||||
type upsertBody struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Mode string `json:"mode"`
|
||||
ParanoiaLevel int `json:"paranoia_level"`
|
||||
RuleExclusions []string `json:"rule_exclusions"`
|
||||
ExclusionNotes map[string]string `json:"exclusion_notes"`
|
||||
TrustedProxies []string `json:"trusted_proxies"`
|
||||
CustomRules string `json:"custom_rules"`
|
||||
}
|
||||
|
||||
// Upsert creates or updates the WAF config for a domain.
|
||||
func (h *WafHandler) Upsert(c *gin.Context) {
|
||||
domainID, err := strconv.ParseInt(c.Param("domain_id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, errors.New("invalid domain_id"))
|
||||
return
|
||||
}
|
||||
var body upsertBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if body.Mode == "" {
|
||||
body.Mode = "detection"
|
||||
}
|
||||
if body.ParanoiaLevel < 1 || body.ParanoiaLevel > 4 {
|
||||
body.ParanoiaLevel = 1
|
||||
}
|
||||
if body.RuleExclusions == nil {
|
||||
body.RuleExclusions = []string{}
|
||||
}
|
||||
if body.TrustedProxies == nil {
|
||||
body.TrustedProxies = []string{}
|
||||
}
|
||||
|
||||
if body.ExclusionNotes == nil {
|
||||
body.ExclusionNotes = map[string]string{}
|
||||
}
|
||||
cfg := models.WafConfig{
|
||||
DomainID: domainID,
|
||||
Enabled: body.Enabled,
|
||||
Mode: body.Mode,
|
||||
ParanoiaLevel: body.ParanoiaLevel,
|
||||
RuleExclusions: body.RuleExclusions,
|
||||
ExclusionNotes: body.ExclusionNotes,
|
||||
TrustedProxies: body.TrustedProxies,
|
||||
CustomRules: body.CustomRules,
|
||||
}
|
||||
result, err := h.Repo.Upsert(c.Request.Context(), cfg)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "waf.config.upsert",
|
||||
strconv.FormatInt(domainID, 10),
|
||||
gin.H{"enabled": body.Enabled, "mode": body.Mode, "paranoia_level": body.ParanoiaLevel},
|
||||
h.NodeID)
|
||||
// Reload HAProxy so the SPOE filter is added/removed based on
|
||||
// whether any domain now has WAF enabled.
|
||||
if h.Reloader != nil {
|
||||
go func() {
|
||||
if err := h.Reloader(context.Background()); err != nil {
|
||||
slog.Warn("waf: haproxy reload after config change failed", "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"config": result})
|
||||
}
|
||||
|
||||
// ListAlerts returns recent WAF alerts. Optional: ?domain_id=X&limit=N
|
||||
func (h *WafHandler) ListAlerts(c *gin.Context) {
|
||||
var domainID *int64
|
||||
if v := c.Query("domain_id"); v != "" {
|
||||
id, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, errors.New("invalid domain_id"))
|
||||
return
|
||||
}
|
||||
domainID = &id
|
||||
}
|
||||
limit := 200
|
||||
if v := c.Query("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
alerts, err := h.Repo.ListAlerts(c.Request.Context(), domainID, limit)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"alerts": alerts})
|
||||
}
|
||||
|
||||
// PurgeAlerts deletes old WAF alerts. Optional: ?days=N (default 30)
|
||||
func (h *WafHandler) PurgeAlerts(c *gin.Context) {
|
||||
days := 30
|
||||
if v := c.Query("days"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
days = n
|
||||
}
|
||||
}
|
||||
if err := h.Repo.PurgeAlerts(c.Request.Context(), days); err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "waf.alerts.purge",
|
||||
"", gin.H{"days": days}, h.NodeID)
|
||||
response.OK(c, gin.H{"ok": true, "days": days})
|
||||
}
|
||||
|
||||
// defaultConfig returns a sensible disabled default for a domain
|
||||
// that has no WAF config row yet.
|
||||
func defaultConfig(domainID int64) models.WafConfig {
|
||||
return models.WafConfig{
|
||||
DomainID: domainID,
|
||||
Enabled: false,
|
||||
Mode: "detection",
|
||||
ParanoiaLevel: 1,
|
||||
RuleExclusions: []string{},
|
||||
ExclusionNotes: map[string]string{},
|
||||
TrustedProxies: []string{},
|
||||
CustomRules: "",
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,8 @@ func (h *WireguardHandler) Register(rg *gin.RouterGroup) {
|
||||
// row per (iface, peer) with last_handshake + transfer counters.
|
||||
// Polled by the UI every 10s; no DB write.
|
||||
g.GET("/status", h.Status)
|
||||
g.GET("/importable", h.ListImportable)
|
||||
g.POST("/import", h.Import)
|
||||
}
|
||||
|
||||
// ── Live wg-show status ─────────────────────────────────────────────
|
||||
@@ -142,6 +144,45 @@ func (h *WireguardHandler) Status(c *gin.Context) {
|
||||
response.OK(c, gin.H{"status": rows})
|
||||
}
|
||||
|
||||
// ── Import ────────────────────────────────────────────────────────
|
||||
|
||||
const wgImportDir = "/etc/wireguard"
|
||||
|
||||
// ListImportable scans /etc/wireguard for *.conf files and reports
|
||||
// which are new (not yet in the DB) vs. already imported.
|
||||
func (h *WireguardHandler) ListImportable(c *gin.Context) {
|
||||
im := wireguard.NewImporter(h.Ifaces, h.Peers, h.Box)
|
||||
entries, err := im.ListImportable(c.Request.Context(), wgImportDir)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"entries": entries})
|
||||
}
|
||||
|
||||
// Import imports selected (or all) WireGuard .conf files from
|
||||
// /etc/wireguard into the DB. Body: { "names": ["wg0","wg1"] }.
|
||||
// Empty or missing names → import all.
|
||||
func (h *WireguardHandler) Import(c *gin.Context) {
|
||||
var req struct {
|
||||
Names []string `json:"names"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
im := wireguard.NewImporter(h.Ifaces, h.Peers, h.Box)
|
||||
res, err := im.ImportSelected(c.Request.Context(), wgImportDir, req.Names)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "wireguard.import",
|
||||
fmt.Sprintf("%d ifaces", res.IfacesAdded), res, h.NodeID)
|
||||
h.reload(c.Request.Context(), "import")
|
||||
response.OK(c, res)
|
||||
}
|
||||
|
||||
// ── Keygen ────────────────────────────────────────────────────────
|
||||
|
||||
func (h *WireguardHandler) GenerateKeypair(c *gin.Context) {
|
||||
|
||||
@@ -15,6 +15,9 @@ global
|
||||
# Ohne dieses Flag verweigert HAProxy `bind quic4@…` mit "this SSL
|
||||
# library does not support the QUIC protocol".
|
||||
limited-quic
|
||||
# Runtime-API-Socket für edgeguard-api (show stat, show info, …).
|
||||
# mode 0660 + group haproxy → edgeguard-user (in haproxy-group) kann lesen.
|
||||
stats socket /run/haproxy/admin.sock mode 0660 level admin expose-fd listeners
|
||||
|
||||
defaults
|
||||
log global
|
||||
@@ -77,6 +80,11 @@ frontend public_https
|
||||
bind [::]:443 ssl crt /etc/edgeguard/tls/ alpn h2,http/1.1
|
||||
bind quic6@:443 ssl crt /etc/edgeguard/tls/ alpn h3
|
||||
{{- end}}
|
||||
{{- if .WAFEnabled}}
|
||||
# WAF: SPOE-Filter — edgeguard-waf inspiziert jeden Request.
|
||||
# filter muss vor allen http-request/http-response-Direktiven stehen.
|
||||
filter spoe engine edgeguard-waf config /etc/edgeguard/haproxy/coraza-spoe.cfg
|
||||
{{- end}}
|
||||
|
||||
# Alt-Svc: signalisiert dass h3 auf demselben Port verfügbar ist.
|
||||
# ma=86400 = Browser darf den Hinweis 24h cachen.
|
||||
@@ -88,6 +96,10 @@ frontend public_https
|
||||
# echte Source-IP ohne XFF-Chain-Parsing brauchen.
|
||||
http-request set-header X-Forwarded-Proto https
|
||||
http-request set-header X-Real-IP %[src]
|
||||
{{- if .WAFEnabled}}
|
||||
# WAF: Request blockieren wenn edgeguard-waf txn.waf.status gesetzt hat.
|
||||
http-request deny deny_status 403 if { var(txn.waf.status) -m found }
|
||||
{{- end}}
|
||||
|
||||
{{- if .GlobalMaintenance}}
|
||||
# Whole-Box-Maintenance — Settings → Maintenance-Mode aktiv. Dieser
|
||||
@@ -192,6 +204,16 @@ backend rl_{{$d.ID}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
|
||||
{{- if .WAFEnabled}}
|
||||
|
||||
# SPOE-Backend für edgeguard-waf (TCP, kein HTTP-Parsing).
|
||||
backend spoe-edgeguard-waf
|
||||
mode tcp
|
||||
timeout connect 100ms
|
||||
timeout server 1s
|
||||
server spoe-waf-1 127.0.0.1:9000
|
||||
{{- end}}
|
||||
|
||||
{{- range $b := .Backends}}
|
||||
|
||||
backend eg_backend_{{$b.ID}}
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/domains"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/routingrules"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
||||
wafsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/waf"
|
||||
)
|
||||
|
||||
//go:embed haproxy.cfg.tpl
|
||||
@@ -64,25 +65,29 @@ type Generator struct {
|
||||
ServersRepo *backendservers.Repo
|
||||
RoutingRepo *routingrules.Repo
|
||||
HeadersRepo *domainheaders.Repo
|
||||
WafRepo *wafsvc.Repo
|
||||
|
||||
// SetupStore (optional): wenn gesetzt, lesen wir Whole-Box-
|
||||
// Maintenance-Status hieraus und reichen ihn als View.GlobalMaintenance
|
||||
// ans Template weiter.
|
||||
SetupStore *setup.Store
|
||||
|
||||
OutputPath string
|
||||
SkipReload bool
|
||||
OutputPath string
|
||||
SPOEConfigPath string
|
||||
SkipReload bool
|
||||
}
|
||||
|
||||
func New(pool *pgxpool.Pool) *Generator {
|
||||
return &Generator{
|
||||
Pool: pool,
|
||||
DomainsRepo: domains.New(pool),
|
||||
BackendsRepo: backends.New(pool),
|
||||
ServersRepo: backendservers.New(pool),
|
||||
RoutingRepo: routingrules.New(pool),
|
||||
HeadersRepo: domainheaders.New(pool),
|
||||
SetupStore: setup.NewStore(setup.DefaultDir),
|
||||
Pool: pool,
|
||||
DomainsRepo: domains.New(pool),
|
||||
BackendsRepo: backends.New(pool),
|
||||
ServersRepo: backendservers.New(pool),
|
||||
RoutingRepo: routingrules.New(pool),
|
||||
HeadersRepo: domainheaders.New(pool),
|
||||
WafRepo: wafsvc.New(pool),
|
||||
SetupStore: setup.NewStore(setup.DefaultDir),
|
||||
SPOEConfigPath: filepath.Join(configgen.EtcEdgeguard, "haproxy", "coraza-spoe.cfg"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +121,17 @@ func (g *Generator) Render(ctx context.Context) error {
|
||||
if err := configgen.AtomicWrite(out, buf.Bytes(), 0o644); err != nil {
|
||||
return fmt.Errorf("haproxy: write: %w", err)
|
||||
}
|
||||
// Write SPOE config whenever WAF is enabled; remove it when disabled
|
||||
// so HAProxy doesn't fail on a missing backend reference.
|
||||
if view.WAFEnabled {
|
||||
spoeOut := g.SPOEConfigPath
|
||||
if spoeOut == "" {
|
||||
spoeOut = filepath.Join(configgen.EtcEdgeguard, "haproxy", "coraza-spoe.cfg")
|
||||
}
|
||||
if err := configgen.AtomicWrite(spoeOut, []byte(spoeCfg), 0o644); err != nil {
|
||||
return fmt.Errorf("haproxy: write spoe config: %w", err)
|
||||
}
|
||||
}
|
||||
if g.SkipReload {
|
||||
return nil
|
||||
}
|
||||
@@ -125,6 +141,29 @@ func (g *Generator) Render(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// spoeCfg is the static SPOE configuration for edgeguard-waf.
|
||||
// HAProxy 3.x format: [<engine-name>] section + spoe-agent / spoe-message
|
||||
// (no square brackets around spoe-agent/spoe-message keywords).
|
||||
// spoeCfg uses `option continue-on-error` so that HAProxy never blocks
|
||||
// a request when the SPOE agent is slow or unavailable. Without this,
|
||||
// a timeout during CRS engine initialization would block all traffic,
|
||||
// including domains without WAF configured.
|
||||
const spoeCfg = `# Generated by edgeguard-api. DO NOT EDIT.
|
||||
[edgeguard-waf]
|
||||
spoe-agent edgeguard-waf-agent
|
||||
messages edgeguard-waf-req
|
||||
option var-prefix waf
|
||||
option continue-on-error
|
||||
timeout hello 100ms
|
||||
timeout idle 30s
|
||||
timeout processing 1s
|
||||
use-backend spoe-edgeguard-waf
|
||||
|
||||
spoe-message edgeguard-waf-req
|
||||
args src=src method=method uri=url ver=req.ver headers=req.hdrs host=req.hdr(host)
|
||||
event on-frontend-http-request
|
||||
`
|
||||
|
||||
// View is what the template consumes. Routes per domain are pre-
|
||||
// joined here so the template can stay declarative; Servers leben pro
|
||||
// BackendView, damit das Template einen `backend …`-Block mit den N
|
||||
@@ -148,6 +187,11 @@ type View struct {
|
||||
// IPv6Enabled: wenn true fügt das Template zusätzliche
|
||||
// bind-Direktiven für [::]:80, [::]:443 und [::]:3443 hinzu.
|
||||
IPv6Enabled bool
|
||||
|
||||
// WAFEnabled: wenn true wird der SPOE-Filter für edgeguard-waf
|
||||
// in public_https eingebunden und das spoe-Backend gerendert.
|
||||
// Wird gesetzt sobald mindestens eine Domain WAF enabled hat.
|
||||
WAFEnabled bool
|
||||
}
|
||||
|
||||
type DomainView struct {
|
||||
@@ -289,6 +333,14 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) {
|
||||
}
|
||||
}
|
||||
v := &View{Domains: domViews, Backends: activeBackends, HTTPDomains: httpDomains}
|
||||
|
||||
// Check whether any domain has WAF enabled.
|
||||
if g.WafRepo != nil {
|
||||
if wafEnabled, err := g.WafRepo.ListEnabled(ctx); err == nil {
|
||||
v.WAFEnabled = len(wafEnabled) > 0
|
||||
}
|
||||
}
|
||||
|
||||
if g.SetupStore != nil {
|
||||
if st, err := g.SetupStore.Load(); err == nil && st != nil {
|
||||
v.GlobalMaintenance = st.MaintenanceMode
|
||||
|
||||
72
internal/keepalived/keepalived.conf.tpl
Normal file
72
internal/keepalived/keepalived.conf.tpl
Normal file
@@ -0,0 +1,72 @@
|
||||
global_defs {
|
||||
router_id {{ .RouterID }}
|
||||
script_user root
|
||||
enable_script_security
|
||||
}
|
||||
|
||||
vrrp_script chk_edgeguard {
|
||||
script "/usr/lib/edgeguard/keepalived-check.sh"
|
||||
interval 2
|
||||
weight -50
|
||||
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 }}
|
||||
virtual_router_id {{ .RouterID }}
|
||||
priority {{ .Priority }}
|
||||
advert_int 1
|
||||
{{ if .SrcIP }} unicast_src_ip {{ .SrcIP }}
|
||||
unicast_peer {
|
||||
{{ .PeerIP }}
|
||||
}
|
||||
{{ end }} authentication {
|
||||
auth_type PASS
|
||||
auth_pass {{ .AuthPass }}
|
||||
}
|
||||
virtual_ipaddress {
|
||||
{{ 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 }}
|
||||
210
internal/keepalived/keepalived.go
Normal file
210
internal/keepalived/keepalived.go
Normal file
@@ -0,0 +1,210 @@
|
||||
// Package keepalived rendert /etc/keepalived/keepalived.conf aus
|
||||
// cluster_settings (VIP/VRRP-Config) und ha_nodes (local vs. peer).
|
||||
//
|
||||
// Split-Brain-Strategie: kein Auto-Promote. notify_master loggt nur
|
||||
// und sendet einen internen Alert. Promotion ist immer manuell via
|
||||
// "edgeguard-ctl promote" — das ist die einzig sichere Option ohne
|
||||
// externes Quorum in einem 2-Node-Cluster.
|
||||
package keepalived
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
)
|
||||
|
||||
const ConfPath = "/etc/keepalived/keepalived.conf"
|
||||
|
||||
//go:embed keepalived.conf.tpl
|
||||
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 // 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 {
|
||||
pool *pgxpool.Pool
|
||||
localID string
|
||||
}
|
||||
|
||||
func New(pool *pgxpool.Pool, localID string) configgen.Generator {
|
||||
return &generator{pool: pool, localID: localID}
|
||||
}
|
||||
|
||||
func (g *generator) Name() string { return "keepalived" }
|
||||
|
||||
func (g *generator) Render(ctx context.Context) error {
|
||||
cs, vips, local, peer, err := g.loadData(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("keepalived: load: %w", err)
|
||||
}
|
||||
if len(vips) == 0 {
|
||||
// Keine VIPs konfiguriert → keepalived.conf nicht schreiben.
|
||||
return nil
|
||||
}
|
||||
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)
|
||||
}
|
||||
if err := configgen.AtomicWrite(ConfPath, buf.Bytes(), 0o640); err != nil {
|
||||
return fmt.Errorf("keepalived: write: %w", err)
|
||||
}
|
||||
if err := reloadKeepalived(); err != nil {
|
||||
return fmt.Errorf("keepalived: reload: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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,
|
||||
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)
|
||||
}
|
||||
|
||||
// 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, nil, fmt.Errorf("ip_addresses: %w", err)
|
||||
}
|
||||
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 nodeRows.Next() {
|
||||
n := &models.HANode{}
|
||||
if err := nodeRows.Scan(&n.ID, &n.FQDN, &n.Role, &n.PGRole, &n.PublicIP, &n.Status); err != nil {
|
||||
continue
|
||||
}
|
||||
if n.ID == g.localID {
|
||||
local = n
|
||||
} else {
|
||||
peer = n
|
||||
}
|
||||
}
|
||||
if local == nil {
|
||||
return nil, nil, nil, nil, fmt.Errorf("local node %s not in ha_nodes", g.localID)
|
||||
}
|
||||
return &cs, vips, local, peer, nil
|
||||
}
|
||||
|
||||
func (g *generator) buildView(cs *models.ClusterSettings, vips []VIPEntry, local, peer *models.HANode) View {
|
||||
v := View{
|
||||
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"
|
||||
}
|
||||
if v.AuthPass == "" {
|
||||
v.AuthPass = "edgeguard"
|
||||
}
|
||||
if v.HBRouterID == 0 {
|
||||
v.HBRouterID = 52
|
||||
}
|
||||
|
||||
// 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 {
|
||||
v.State = "BACKUP"
|
||||
v.Priority = 100
|
||||
}
|
||||
|
||||
if local.PublicIP != nil {
|
||||
v.SrcIP = *local.PublicIP
|
||||
}
|
||||
if peer != nil && peer.PublicIP != nil {
|
||||
v.PeerIP = *peer.PublicIP
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func reloadKeepalived() error {
|
||||
if _, err := os.Stat("/run/keepalived.pid"); os.IsNotExist(err) {
|
||||
// keepalived läuft noch nicht — erster Render beim Start.
|
||||
return nil
|
||||
}
|
||||
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 {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
@@ -365,23 +365,16 @@ func (c *Client) Trial() (*Result, error) {
|
||||
daysLeft := int(remaining.Hours()/24) + 1
|
||||
expiresAt := info.StartedAt.Add(TrialDuration)
|
||||
|
||||
// Trial schaltet alle Pro-Features frei, Limits bleiben 0 (= Skip-
|
||||
// Enforcement-Konvention). nmg-Backend prüft `lim > 0 && count >= lim`
|
||||
// und blockiert in Trial-Mode niemanden.
|
||||
// Trial schaltet alle Features frei, Limits bleiben 0 (= Skip-
|
||||
// Enforcement-Konvention). Enforcement: `lim > 0 && count >= lim`.
|
||||
return &Result{
|
||||
Valid: true,
|
||||
Type: "trial",
|
||||
Status: "active",
|
||||
Product: "NetCell MailGuard",
|
||||
Product: "NetCell EdgeGuard",
|
||||
ExpiresAt: &expiresAt,
|
||||
GracePeriod: daysLeft <= 2,
|
||||
Features: map[string]bool{
|
||||
"reporting": true,
|
||||
"eu_portal": true,
|
||||
"digest": true,
|
||||
"whitelabel": true,
|
||||
"rest_api_write": true,
|
||||
},
|
||||
Features: map[string]bool{},
|
||||
Limits: map[string]int64{
|
||||
"max_domains": 0, // 0 = Skip-Enforcement (Trial-Konvention)
|
||||
"max_nodes": 0,
|
||||
|
||||
24
internal/models/cluster_settings.go
Normal file
24
internal/models/cluster_settings.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package models
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
func (ClusterSettings) TableName() string { return "cluster_settings" }
|
||||
@@ -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" }
|
||||
|
||||
@@ -33,6 +33,8 @@ type FirewallNATRule struct {
|
||||
TargetPortEnd *int `gorm:"column:target_port_end" json:"target_port_end,omitempty"`
|
||||
|
||||
Comment *string `gorm:"column:comment" json:"comment,omitempty"`
|
||||
Note *string `gorm:"column:note" json:"note,omitempty"`
|
||||
Labels []string `gorm:"column:labels" json:"labels"`
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ type FirewallRule struct {
|
||||
|
||||
Log bool `gorm:"column:log" json:"log"`
|
||||
Comment *string `gorm:"column:comment" json:"comment,omitempty"`
|
||||
Note *string `gorm:"column:note" json:"note,omitempty"`
|
||||
Labels []string `gorm:"column:labels;serializer:json" json:"labels"`
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
19
internal/models/forward_proxy_settings.go
Normal file
19
internal/models/forward_proxy_settings.go
Normal file
@@ -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" }
|
||||
@@ -4,7 +4,7 @@ import "time"
|
||||
|
||||
// HANode mirrort eine Row der ha_nodes-Tabelle. Erweitert in Migration
|
||||
// 0020 um version/config_hash/mgmt_ip/status für Cluster-Phase-3-
|
||||
// Drift-Detection + Health-State.
|
||||
// Drift-Detection + Health-State. Migration 0029 fügt PGRole hinzu.
|
||||
type HANode struct {
|
||||
ID string `gorm:"column:id;primaryKey" json:"id"`
|
||||
Name string `gorm:"column:name" json:"name"`
|
||||
@@ -14,6 +14,7 @@ type HANode struct {
|
||||
InternalIP *string `gorm:"column:internal_ip;type:inet" json:"internal_ip,omitempty"`
|
||||
MgmtIP *string `gorm:"column:mgmt_ip;type:inet" json:"mgmt_ip,omitempty"`
|
||||
Role string `gorm:"column:role" json:"role"`
|
||||
PGRole string `gorm:"column:pg_role" json:"pg_role"`
|
||||
Version *string `gorm:"column:version" json:"version,omitempty"`
|
||||
ConfigHash *string `gorm:"column:config_hash" json:"config_hash,omitempty"`
|
||||
Status string `gorm:"column:status" json:"status"`
|
||||
|
||||
20
internal/models/waf.go
Normal file
20
internal/models/waf.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// WafConfig holds the per-domain WAF policy.
|
||||
// Default on creation: enabled=false, mode=detection, paranoia_level=1.
|
||||
type WafConfig struct {
|
||||
ID int64 `gorm:"primaryKey" json:"id"`
|
||||
DomainID int64 `gorm:"column:domain_id;uniqueIndex" json:"domain_id"`
|
||||
Enabled bool `gorm:"column:enabled" json:"enabled"`
|
||||
Mode string `gorm:"column:mode" json:"mode"` // "detection" | "blocking"
|
||||
ParanoiaLevel int `gorm:"column:paranoia_level" json:"paranoia_level"` // 1–4
|
||||
RuleExclusions []string `gorm:"column:rule_exclusions;type:text[]" json:"rule_exclusions"`
|
||||
ExclusionNotes map[string]string `gorm:"column:exclusion_notes;type:jsonb" json:"exclusion_notes"` // rule_id → note
|
||||
TrustedProxies []string `gorm:"column:trusted_proxies;type:text[]" json:"trusted_proxies"`
|
||||
CustomRules string `gorm:"column:custom_rules" json:"custom_rules"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (WafConfig) TableName() string { return "waf_configs" }
|
||||
@@ -14,7 +14,10 @@ import (
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("backend not found")
|
||||
var (
|
||||
ErrNotFound = errors.New("backend not found")
|
||||
ErrInUse = errors.New("backend is referenced by one or more routing rules")
|
||||
)
|
||||
|
||||
type Repo struct {
|
||||
Pool *pgxpool.Pool
|
||||
@@ -99,6 +102,15 @@ RETURNING id, name, scheme, health_check_path, lb_algorithm, websocket, force_ht
|
||||
}
|
||||
|
||||
func (r *Repo) Delete(ctx context.Context, id int64) error {
|
||||
var n int
|
||||
if err := r.Pool.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM routing_rules WHERE backend_id = $1`, id,
|
||||
).Scan(&n); err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
return ErrInUse
|
||||
}
|
||||
tag, err := r.Pool.Exec(ctx, `DELETE FROM backends WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -62,12 +62,14 @@ func (s *Service) log() *slog.Logger {
|
||||
}
|
||||
|
||||
// Result counts what one Run pass did so the scheduler can log /
|
||||
// surface it.
|
||||
// surface it. FailedDomains holds the domain names that failed so
|
||||
// the scheduler can fire per-domain alerts.
|
||||
type Result struct {
|
||||
Checked int
|
||||
Renewed int
|
||||
Failed int
|
||||
Skipped int
|
||||
Checked int
|
||||
Renewed int
|
||||
Failed int
|
||||
Skipped int
|
||||
FailedDomains []string
|
||||
}
|
||||
|
||||
// Run scans tls_certs for LE-issued rows that expire within the
|
||||
@@ -95,6 +97,7 @@ func (s *Service) Run(ctx context.Context) (Result, error) {
|
||||
certPEM, chainPEM, keyPEM, err := s.Issuer.Issue(row.Domain)
|
||||
if err != nil {
|
||||
res.Failed++
|
||||
res.FailedDomains = append(res.FailedDomains, row.Domain)
|
||||
s.log().Error("certrenewer: issue failed", "domain", row.Domain, "error", err)
|
||||
_ = s.Repo.MarkError(ctx, row.Domain, err.Error())
|
||||
continue
|
||||
@@ -102,12 +105,14 @@ func (s *Service) Run(ctx context.Context) (Result, error) {
|
||||
info, err := certstore.Parse(certPEM)
|
||||
if err != nil {
|
||||
res.Failed++
|
||||
res.FailedDomains = append(res.FailedDomains, row.Domain)
|
||||
_ = s.Repo.MarkError(ctx, row.Domain, "parse: "+err.Error())
|
||||
continue
|
||||
}
|
||||
path, err := certstore.WriteCombined(s.CertDir, row.Domain, certPEM, chainPEM, keyPEM)
|
||||
if err != nil {
|
||||
res.Failed++
|
||||
res.FailedDomains = append(res.FailedDomains, row.Domain)
|
||||
_ = s.Repo.MarkError(ctx, row.Domain, "write: "+err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
293
internal/services/clusterjoin/join.go
Normal file
293
internal/services/clusterjoin/join.go
Normal file
@@ -0,0 +1,293 @@
|
||||
// Package clusterjoin encapsulates the cluster-join flow so it can be
|
||||
// driven from both the edgeguard-ctl CLI and the setup-wizard HTTP
|
||||
// handler without duplicating the HTTP + cert logic.
|
||||
package clusterjoin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/clustertls"
|
||||
)
|
||||
|
||||
// Request describes what the joining node needs.
|
||||
type Request struct {
|
||||
// PrimaryFQDN is the FQDN (or https://…:port URL) of the primary.
|
||||
// NormalizePrimaryURL() is applied automatically.
|
||||
PrimaryFQDN string
|
||||
|
||||
// Token is the eg-join-v1.… value generated on the primary.
|
||||
Token string
|
||||
|
||||
// CommonName becomes the Subject-CN of the peer cert and the
|
||||
// lookup key in ha_nodes. Usually the FQDN of this node.
|
||||
CommonName string
|
||||
|
||||
// Insecure skips TLS verify when contacting the primary. Safe
|
||||
// because security depends on the HMAC-signed token, not TLS trust.
|
||||
Insecure bool
|
||||
|
||||
// Force overwrites existing peer cert material. The CLI keeps this
|
||||
// false (explicit rm required); the setup-wizard handler sets it
|
||||
// true because the bootstrap self-signed cert must be replaced.
|
||||
Force bool
|
||||
|
||||
// TLSDir is where ca.crt + peer.{crt,key} are written.
|
||||
// Zero value → clustertls.DefaultDir.
|
||||
TLSDir string
|
||||
|
||||
// Version + NodeID are reported to the primary during auto-register.
|
||||
// Both are optional metadata — join succeeds even if empty.
|
||||
Version string
|
||||
NodeID string
|
||||
}
|
||||
|
||||
// Join performs the full cluster-join:
|
||||
// 1. Generates a local Ed25519 key + CSR.
|
||||
// 2. POSTs to primary /api/v1/cluster/issue-cert to get ca.crt + peer.crt.
|
||||
// 3. Writes ca.crt, peer.crt, peer.key to TLSDir.
|
||||
// 4. Calls auto-register on the primary via mTLS (best-effort).
|
||||
func Join(req Request) error {
|
||||
if req.PrimaryFQDN == "" {
|
||||
return errors.New("primary_fqdn required")
|
||||
}
|
||||
if req.Token == "" {
|
||||
return errors.New("token required")
|
||||
}
|
||||
if req.CommonName == "" {
|
||||
h, _ := os.Hostname()
|
||||
req.CommonName = h
|
||||
}
|
||||
if req.CommonName == "" {
|
||||
req.CommonName = "edgeguard-node"
|
||||
}
|
||||
tlsDir := req.TLSDir
|
||||
if tlsDir == "" {
|
||||
tlsDir = clustertls.DefaultDir
|
||||
}
|
||||
|
||||
store := clustertls.New(tlsDir)
|
||||
if store.HasPeer() && !req.Force {
|
||||
return fmt.Errorf("peer cert already present under %s — remove it first if this is intentional", tlsDir)
|
||||
}
|
||||
|
||||
primary, err := NormalizePrimaryURL(req.PrimaryFQDN)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dnsNames := []string{req.CommonName}
|
||||
var ips []net.IP
|
||||
if ip := net.ParseIP(req.CommonName); ip != nil {
|
||||
ips = append(ips, ip)
|
||||
dnsNames = nil
|
||||
}
|
||||
|
||||
keyPEM, csrPEM, err := clustertls.NewPeerKeyAndCSR(req.CommonName, dnsNames, ips)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate CSR: %w", err)
|
||||
}
|
||||
|
||||
caCertPEM, peerCertPEM, err := issueCert(primary, req.Token, csrPEM, req.Insecure)
|
||||
if err != nil {
|
||||
return fmt.Errorf("issue-cert: %w", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(tlsDir, 0o700); err != nil {
|
||||
return fmt.Errorf("mkdir %s: %w", tlsDir, err)
|
||||
}
|
||||
for _, w := range []struct {
|
||||
name string
|
||||
mode os.FileMode
|
||||
data string
|
||||
}{
|
||||
{"ca.crt", 0o644, caCertPEM},
|
||||
{"peer.crt", 0o644, peerCertPEM},
|
||||
{"peer.key", 0o600, keyPEM},
|
||||
} {
|
||||
path := tlsDir + "/" + w.name
|
||||
if err := os.WriteFile(path, []byte(w.data), w.mode); err != nil {
|
||||
return fmt.Errorf("write %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-register: retry a few times because the primary's nftables may
|
||||
// need a moment to reload even though preRegisterJoiner is now
|
||||
// synchronous on the primary side.
|
||||
var autoRegErr error
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := autoRegister(primary, tlsDir, req.CommonName, req.Version, req.NodeID, ""); err == nil {
|
||||
autoRegErr = nil
|
||||
break
|
||||
} else {
|
||||
autoRegErr = err
|
||||
if i < 2 {
|
||||
slog.Warn("clusterjoin: autoRegister failed, retrying", "attempt", i+1, "error", err)
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
}
|
||||
}
|
||||
if autoRegErr != nil {
|
||||
slog.Warn("clusterjoin: autoRegister failed after all retries — primary will reconcile via identity pull",
|
||||
"error", autoRegErr)
|
||||
}
|
||||
// Certs are saved; primary will reconcile via /agent/cluster/identity pull
|
||||
// even if autoRegister failed. Not returning the error — join is
|
||||
// structurally complete (certs issued), only the ha_nodes update is pending.
|
||||
return nil
|
||||
}
|
||||
|
||||
// NormalizePrimaryURL accepts "fqdn", "host:port" or "https://host:port"
|
||||
// and always returns "https://host:port". Default port is 3443.
|
||||
func NormalizePrimaryURL(in string) (string, error) {
|
||||
in = strings.TrimSpace(in)
|
||||
if in == "" {
|
||||
return "", errors.New("empty primary fqdn/url")
|
||||
}
|
||||
if !strings.HasPrefix(in, "http://") && !strings.HasPrefix(in, "https://") {
|
||||
in = "https://" + in
|
||||
}
|
||||
u, err := url.Parse(in)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if u.Hostname() == "" {
|
||||
return "", errors.New("primary URL has no host")
|
||||
}
|
||||
if u.Port() == "" {
|
||||
u.Host = u.Hostname() + ":3443"
|
||||
}
|
||||
u.Path = ""
|
||||
u.RawQuery = ""
|
||||
u.Fragment = ""
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func issueCert(primary, token, csr string, insecure bool) (caCert, peerCert string, err error) {
|
||||
body, _ := json.Marshal(map[string]string{"token": token, "csr": csr})
|
||||
req, err := http.NewRequest(http.MethodPost,
|
||||
primary+"/api/v1/cluster/issue-cert", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure, MinVersion: tls.VersionTLS12}, //nolint:gosec
|
||||
TLSHandshakeTimeout: 5 * time.Second,
|
||||
ResponseHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
client := &http.Client{Transport: tr, Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
|
||||
}
|
||||
var env struct {
|
||||
Data struct {
|
||||
CACert string `json:"ca_cert"`
|
||||
PeerCert string `json:"peer_cert"`
|
||||
} `json:"data"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
return "", "", fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
if env.Error != "" {
|
||||
return "", "", fmt.Errorf("server: %s", env.Error)
|
||||
}
|
||||
if env.Data.CACert == "" || env.Data.PeerCert == "" {
|
||||
return "", "", errors.New("response missing ca_cert or peer_cert")
|
||||
}
|
||||
return env.Data.CACert, env.Data.PeerCert, nil
|
||||
}
|
||||
|
||||
// PushSelfToPrimary sends this node's current identity + configHash to the
|
||||
// primary via mTLS. Exported for use by the API server's periodic push
|
||||
// goroutine so the primary's ha_nodes always reflects the secondary's actual
|
||||
// config_hash (not the stale join-time value).
|
||||
func PushSelfToPrimary(primaryURL, tlsDir, nodeID, fqdn, version, configHash string) error {
|
||||
if tlsDir == "" {
|
||||
tlsDir = clustertls.DefaultDir
|
||||
}
|
||||
return autoRegister(primaryURL, tlsDir, fqdn, version, nodeID, configHash)
|
||||
}
|
||||
|
||||
func autoRegister(primary, tlsDir, commonName, version, nodeID, configHash string) error {
|
||||
u, err := url.Parse(primary)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.Host = u.Hostname() + ":8443"
|
||||
u.Path = "/agent/cluster/peers"
|
||||
|
||||
if nodeID == "" {
|
||||
raw, _ := os.ReadFile("/var/lib/edgeguard/node-id")
|
||||
nodeID = strings.TrimSpace(string(raw))
|
||||
}
|
||||
hostname, _ := os.Hostname()
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"id": nodeID,
|
||||
"name": hostname,
|
||||
"fqdn": commonName,
|
||||
"api_url": "https://" + commonName + ":3443",
|
||||
"version": version,
|
||||
"config_hash": configHash,
|
||||
})
|
||||
|
||||
pair, err := tls.LoadX509KeyPair(tlsDir+"/peer.crt", tlsDir+"/peer.key")
|
||||
if err != nil {
|
||||
return fmt.Errorf("load peer cert: %w", err)
|
||||
}
|
||||
caPEM, err := os.ReadFile(tlsDir + "/ca.crt")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read ca: %w", err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(caPEM) {
|
||||
return errors.New("invalid ca.crt")
|
||||
}
|
||||
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
Certificates: []tls.Certificate{pair},
|
||||
RootCAs: pool,
|
||||
MinVersion: tls.VersionTLS13,
|
||||
ServerName: u.Hostname(),
|
||||
},
|
||||
TLSHandshakeTimeout: 5 * time.Second,
|
||||
ResponseHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
client := &http.Client{Transport: tr, Timeout: 30 * time.Second}
|
||||
|
||||
httpReq, err := http.NewRequest(http.MethodPost, u.String(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -23,7 +23,7 @@ SELECT id, name, priority, enabled, kind,
|
||||
in_zone, out_zone, proto,
|
||||
match_src_cidr, match_dst_cidr, match_dport_start, match_dport_end,
|
||||
target_addr, target_port_start, target_port_end,
|
||||
comment, created_at, updated_at
|
||||
comment, note, labels, created_at, updated_at
|
||||
FROM firewall_nat_rules
|
||||
`
|
||||
|
||||
@@ -57,52 +57,58 @@ func (r *NATRulesRepo) Get(ctx context.Context, id int64) (*models.FirewallNATRu
|
||||
}
|
||||
|
||||
func (r *NATRulesRepo) Create(ctx context.Context, x models.FirewallNATRule) (*models.FirewallNATRule, error) {
|
||||
if x.Labels == nil {
|
||||
x.Labels = []string{}
|
||||
}
|
||||
row := r.Pool.QueryRow(ctx, `
|
||||
INSERT INTO firewall_nat_rules (
|
||||
name, priority, enabled, kind,
|
||||
in_zone, out_zone, proto,
|
||||
match_src_cidr, match_dst_cidr, match_dport_start, match_dport_end,
|
||||
target_addr, target_port_start, target_port_end,
|
||||
comment
|
||||
comment, note, labels
|
||||
) VALUES (
|
||||
$1, $2, $3, $4,
|
||||
$5, $6, $7,
|
||||
$8, $9, $10, $11,
|
||||
$12, $13, $14,
|
||||
$15
|
||||
$15, $16, $17
|
||||
)
|
||||
RETURNING id, name, priority, enabled, kind,
|
||||
in_zone, out_zone, proto,
|
||||
match_src_cidr, match_dst_cidr, match_dport_start, match_dport_end,
|
||||
target_addr, target_port_start, target_port_end,
|
||||
comment, created_at, updated_at`,
|
||||
comment, note, labels, created_at, updated_at`,
|
||||
x.Name, x.Priority, x.Enabled, x.Kind,
|
||||
x.InZone, x.OutZone, x.Proto,
|
||||
x.MatchSrcCIDR, x.MatchDstCIDR, x.MatchDPortStart, x.MatchDPortEnd,
|
||||
x.TargetAddr, x.TargetPortStart, x.TargetPortEnd,
|
||||
x.Comment)
|
||||
x.Comment, x.Note, x.Labels)
|
||||
return scanNATRule(row)
|
||||
}
|
||||
|
||||
func (r *NATRulesRepo) Update(ctx context.Context, id int64, x models.FirewallNATRule) (*models.FirewallNATRule, error) {
|
||||
if x.Labels == nil {
|
||||
x.Labels = []string{}
|
||||
}
|
||||
row := r.Pool.QueryRow(ctx, `
|
||||
UPDATE firewall_nat_rules SET
|
||||
name = $1, priority = $2, enabled = $3, kind = $4,
|
||||
in_zone = $5, out_zone = $6, proto = $7,
|
||||
match_src_cidr = $8, match_dst_cidr = $9, match_dport_start = $10, match_dport_end = $11,
|
||||
target_addr = $12, target_port_start = $13, target_port_end = $14,
|
||||
comment = $15, updated_at = NOW()
|
||||
WHERE id = $16
|
||||
comment = $15, note = $16, labels = $17, updated_at = NOW()
|
||||
WHERE id = $18
|
||||
RETURNING id, name, priority, enabled, kind,
|
||||
in_zone, out_zone, proto,
|
||||
match_src_cidr, match_dst_cidr, match_dport_start, match_dport_end,
|
||||
target_addr, target_port_start, target_port_end,
|
||||
comment, created_at, updated_at`,
|
||||
comment, note, labels, created_at, updated_at`,
|
||||
x.Name, x.Priority, x.Enabled, x.Kind,
|
||||
x.InZone, x.OutZone, x.Proto,
|
||||
x.MatchSrcCIDR, x.MatchDstCIDR, x.MatchDPortStart, x.MatchDPortEnd,
|
||||
x.TargetAddr, x.TargetPortStart, x.TargetPortEnd,
|
||||
x.Comment, id)
|
||||
x.Comment, x.Note, x.Labels, id)
|
||||
out, err := scanNATRule(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
@@ -124,6 +130,37 @@ func (r *NATRulesRepo) Delete(ctx context.Context, id int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// PatchNote updates only the note field of a NAT rule.
|
||||
func (r *NATRulesRepo) PatchNote(ctx context.Context, id int64, note string) error {
|
||||
var n *string
|
||||
if note != "" {
|
||||
n = ¬e
|
||||
}
|
||||
tag, err := r.Pool.Exec(ctx, `UPDATE firewall_nat_rules SET note = $1, updated_at = NOW() WHERE id = $2`, n, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNATRuleNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PatchLabels replaces the labels array of a NAT rule.
|
||||
func (r *NATRulesRepo) PatchLabels(ctx context.Context, id int64, labels []string) error {
|
||||
if labels == nil {
|
||||
labels = []string{}
|
||||
}
|
||||
tag, err := r.Pool.Exec(ctx, `UPDATE firewall_nat_rules SET labels = $1, updated_at = NOW() WHERE id = $2`, labels, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrNATRuleNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanNATRule(row interface{ Scan(...any) error }) (*models.FirewallNATRule, error) {
|
||||
var x models.FirewallNATRule
|
||||
if err := row.Scan(
|
||||
@@ -131,9 +168,12 @@ func scanNATRule(row interface{ Scan(...any) error }) (*models.FirewallNATRule,
|
||||
&x.InZone, &x.OutZone, &x.Proto,
|
||||
&x.MatchSrcCIDR, &x.MatchDstCIDR, &x.MatchDPortStart, &x.MatchDPortEnd,
|
||||
&x.TargetAddr, &x.TargetPortStart, &x.TargetPortEnd,
|
||||
&x.Comment, &x.CreatedAt, &x.UpdatedAt,
|
||||
&x.Comment, &x.Note, &x.Labels, &x.CreatedAt, &x.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if x.Labels == nil {
|
||||
x.Labels = []string{}
|
||||
}
|
||||
return &x, nil
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ SELECT id, name, priority, enabled, action,
|
||||
src_zone, src_address_object_id, src_address_group_id, src_cidr,
|
||||
dst_zone, dst_address_object_id, dst_address_group_id, dst_cidr,
|
||||
service_object_id, service_group_id,
|
||||
log, comment, created_at, updated_at
|
||||
log, comment, note, labels, created_at, updated_at
|
||||
FROM firewall_rules
|
||||
`
|
||||
|
||||
@@ -57,52 +57,58 @@ func (r *RulesRepo) Get(ctx context.Context, id int64) (*models.FirewallRule, er
|
||||
}
|
||||
|
||||
func (r *RulesRepo) Create(ctx context.Context, x models.FirewallRule) (*models.FirewallRule, error) {
|
||||
if x.Labels == nil {
|
||||
x.Labels = []string{}
|
||||
}
|
||||
row := r.Pool.QueryRow(ctx, `
|
||||
INSERT INTO firewall_rules (
|
||||
name, priority, enabled, action,
|
||||
src_zone, src_address_object_id, src_address_group_id, src_cidr,
|
||||
dst_zone, dst_address_object_id, dst_address_group_id, dst_cidr,
|
||||
service_object_id, service_group_id,
|
||||
log, comment
|
||||
log, comment, note, labels
|
||||
) VALUES (
|
||||
$1, $2, $3, $4,
|
||||
$5, $6, $7, $8,
|
||||
$9, $10, $11, $12,
|
||||
$13, $14,
|
||||
$15, $16
|
||||
$15, $16, $17, $18
|
||||
)
|
||||
RETURNING id, name, priority, enabled, action,
|
||||
src_zone, src_address_object_id, src_address_group_id, src_cidr,
|
||||
dst_zone, dst_address_object_id, dst_address_group_id, dst_cidr,
|
||||
service_object_id, service_group_id,
|
||||
log, comment, created_at, updated_at`,
|
||||
log, comment, note, labels, created_at, updated_at`,
|
||||
x.Name, x.Priority, x.Enabled, x.Action,
|
||||
x.SrcZone, x.SrcAddressObjectID, x.SrcAddressGroupID, x.SrcCIDR,
|
||||
x.DstZone, x.DstAddressObjectID, x.DstAddressGroupID, x.DstCIDR,
|
||||
x.ServiceObjectID, x.ServiceGroupID,
|
||||
x.Log, x.Comment)
|
||||
x.Log, x.Comment, x.Note, x.Labels)
|
||||
return scanRule(row)
|
||||
}
|
||||
|
||||
func (r *RulesRepo) Update(ctx context.Context, id int64, x models.FirewallRule) (*models.FirewallRule, error) {
|
||||
if x.Labels == nil {
|
||||
x.Labels = []string{}
|
||||
}
|
||||
row := r.Pool.QueryRow(ctx, `
|
||||
UPDATE firewall_rules SET
|
||||
name = $1, priority = $2, enabled = $3, action = $4,
|
||||
src_zone = $5, src_address_object_id = $6, src_address_group_id = $7, src_cidr = $8,
|
||||
dst_zone = $9, dst_address_object_id = $10, dst_address_group_id = $11, dst_cidr = $12,
|
||||
service_object_id = $13, service_group_id = $14,
|
||||
log = $15, comment = $16, updated_at = NOW()
|
||||
WHERE id = $17
|
||||
log = $15, comment = $16, note = $17, labels = $18, updated_at = NOW()
|
||||
WHERE id = $19
|
||||
RETURNING id, name, priority, enabled, action,
|
||||
src_zone, src_address_object_id, src_address_group_id, src_cidr,
|
||||
dst_zone, dst_address_object_id, dst_address_group_id, dst_cidr,
|
||||
service_object_id, service_group_id,
|
||||
log, comment, created_at, updated_at`,
|
||||
log, comment, note, labels, created_at, updated_at`,
|
||||
x.Name, x.Priority, x.Enabled, x.Action,
|
||||
x.SrcZone, x.SrcAddressObjectID, x.SrcAddressGroupID, x.SrcCIDR,
|
||||
x.DstZone, x.DstAddressObjectID, x.DstAddressGroupID, x.DstCIDR,
|
||||
x.ServiceObjectID, x.ServiceGroupID,
|
||||
x.Log, x.Comment, id)
|
||||
x.Log, x.Comment, x.Note, x.Labels, id)
|
||||
out, err := scanRule(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
@@ -124,6 +130,37 @@ func (r *RulesRepo) Delete(ctx context.Context, id int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// PatchNote updates only the note field of a rule.
|
||||
func (r *RulesRepo) PatchNote(ctx context.Context, id int64, note string) error {
|
||||
var n *string
|
||||
if note != "" {
|
||||
n = ¬e
|
||||
}
|
||||
tag, err := r.Pool.Exec(ctx, `UPDATE firewall_rules SET note = $1, updated_at = NOW() WHERE id = $2`, n, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrRuleNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PatchLabels replaces the labels array of a rule.
|
||||
func (r *RulesRepo) PatchLabels(ctx context.Context, id int64, labels []string) error {
|
||||
if labels == nil {
|
||||
labels = []string{}
|
||||
}
|
||||
tag, err := r.Pool.Exec(ctx, `UPDATE firewall_rules SET labels = $1, updated_at = NOW() WHERE id = $2`, labels, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrRuleNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanRule(row interface{ Scan(...any) error }) (*models.FirewallRule, error) {
|
||||
var x models.FirewallRule
|
||||
if err := row.Scan(
|
||||
@@ -131,9 +168,12 @@ func scanRule(row interface{ Scan(...any) error }) (*models.FirewallRule, error)
|
||||
&x.SrcZone, &x.SrcAddressObjectID, &x.SrcAddressGroupID, &x.SrcCIDR,
|
||||
&x.DstZone, &x.DstAddressObjectID, &x.DstAddressGroupID, &x.DstCIDR,
|
||||
&x.ServiceObjectID, &x.ServiceGroupID,
|
||||
&x.Log, &x.Comment, &x.CreatedAt, &x.UpdatedAt,
|
||||
&x.Log, &x.Comment, &x.Note, &x.Labels, &x.CreatedAt, &x.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if x.Labels == nil {
|
||||
x.Labels = []string{}
|
||||
}
|
||||
return &x, nil
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
105
internal/services/ipaddresses/apply.go
Normal file
105
internal/services/ipaddresses/apply.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package ipaddresses
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
|
||||
)
|
||||
|
||||
// ConfPath wird von edgeguard-apply-ipaddresses gelesen.
|
||||
const ConfPath = "/etc/edgeguard/ip-addresses.conf"
|
||||
|
||||
type Generator struct {
|
||||
Repo *Repo
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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`
|
||||
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)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var entries []addrRow
|
||||
for rows.Next() {
|
||||
var r addrRow
|
||||
if err := rows.Scan(&r.dev, &r.addr, &r.prefix); err != nil {
|
||||
return fmt.Errorf("scan: %w", err)
|
||||
}
|
||||
entries = append(entries, r)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString("# Generated by edgeguard-api — DO NOT EDIT.\n")
|
||||
buf.WriteString("# Read by edgeguard-apply-ipaddresses. Format: dev|address/prefix\n")
|
||||
for _, e := range entries {
|
||||
fmt.Fprintf(&buf, "%s|%s/%d\n",
|
||||
sanitize(e.dev), sanitize(e.addr), e.prefix)
|
||||
}
|
||||
|
||||
if err := configgen.AtomicWrite(ConfPath, buf.Bytes(), 0o644); err != nil {
|
||||
return fmt.Errorf("write %s: %w", ConfPath, err)
|
||||
}
|
||||
if err := applyIPAddresses(); err != nil {
|
||||
return fmt.Errorf("apply: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyIPAddresses() error {
|
||||
cmd := exec.Command("sudo", "-n", "/usr/bin/systemctl",
|
||||
"restart", "edgeguard-ipaddresses.service")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("systemctl restart edgeguard-ipaddresses.service: %s: %w",
|
||||
strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sanitize(s string) string {
|
||||
s = strings.ReplaceAll(s, "|", "")
|
||||
s = strings.ReplaceAll(s, "\n", "")
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
103
internal/services/networkifs/apply.go
Normal file
103
internal/services/networkifs/apply.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package networkifs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
|
||||
)
|
||||
|
||||
// ConfPath is read by edgeguard-apply-interfaces.
|
||||
const ConfPath = "/etc/edgeguard/interfaces.conf"
|
||||
|
||||
type Generator struct {
|
||||
Repo *Repo
|
||||
}
|
||||
|
||||
func NewGenerator(repo *Repo) *Generator { return &Generator{Repo: repo} }
|
||||
|
||||
// Render writes /etc/edgeguard/interfaces.conf (format: type|name|parent|vlan_id|mtu|members)
|
||||
// for VLAN/bridge/bond interfaces and triggers edgeguard-interfaces.service.
|
||||
// Ethernet and WireGuard interfaces are managed by the OS / wg-quick and are excluded.
|
||||
func (g *Generator) Render(ctx context.Context) error {
|
||||
rows, err := g.Repo.Pool.Query(ctx, `
|
||||
SELECT type, name,
|
||||
COALESCE(parent, ''),
|
||||
COALESCE(vlan_id::text, ''),
|
||||
COALESCE(mtu::text, ''),
|
||||
members
|
||||
FROM network_interfaces
|
||||
WHERE active = true
|
||||
AND type IN ('vlan', 'bridge', 'bond')
|
||||
AND (type = 'vlan' OR jsonb_array_length(members) > 0)
|
||||
ORDER BY type, name`,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("query: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type ifRow struct {
|
||||
typ string
|
||||
name string
|
||||
parent string
|
||||
vlanID string
|
||||
mtu string
|
||||
members []string
|
||||
}
|
||||
|
||||
var entries []ifRow
|
||||
for rows.Next() {
|
||||
var r ifRow
|
||||
var membersRaw []byte
|
||||
if err := rows.Scan(&r.typ, &r.name, &r.parent, &r.vlanID, &r.mtu, &membersRaw); err != nil {
|
||||
return fmt.Errorf("scan: %w", err)
|
||||
}
|
||||
if len(membersRaw) > 0 {
|
||||
_ = json.Unmarshal(membersRaw, &r.members)
|
||||
}
|
||||
entries = append(entries, r)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString("# Generated by edgeguard-api — DO NOT EDIT.\n")
|
||||
buf.WriteString("# Read by edgeguard-apply-interfaces. Format: type|name|parent|vlan_id|mtu|members\n")
|
||||
for _, e := range entries {
|
||||
fmt.Fprintf(&buf, "%s|%s|%s|%s|%s|%s\n",
|
||||
sanitizeIf(e.typ), sanitizeIf(e.name), sanitizeIf(e.parent),
|
||||
sanitizeIf(e.vlanID), sanitizeIf(e.mtu),
|
||||
sanitizeIf(strings.Join(e.members, ",")))
|
||||
}
|
||||
|
||||
if err := configgen.AtomicWrite(ConfPath, buf.Bytes(), 0o644); err != nil {
|
||||
return fmt.Errorf("write %s: %w", ConfPath, err)
|
||||
}
|
||||
if err := applyInterfaces(); err != nil {
|
||||
return fmt.Errorf("apply: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyInterfaces() error {
|
||||
cmd := exec.Command("sudo", "-n", "/usr/bin/systemctl",
|
||||
"restart", "edgeguard-interfaces.service")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("systemctl restart edgeguard-interfaces.service: %s: %w",
|
||||
strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sanitizeIf(s string) string {
|
||||
s = strings.ReplaceAll(s, "|", "")
|
||||
s = strings.ReplaceAll(s, "\n", "")
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -38,6 +38,9 @@ type State struct {
|
||||
LicenseKey string `json:"license_key,omitempty"`
|
||||
Completed bool `json:"completed"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
// IsClusterNode marks this node as a secondary cluster member.
|
||||
// Admin credentials are absent — they arrive via PG replication.
|
||||
IsClusterNode bool `json:"is_cluster_node,omitempty"`
|
||||
|
||||
// MaintenanceMode (whole-box): wenn true rendert HAProxy auf
|
||||
// :443 nur einen 503-Block für ALLE Customer-Domains. Mgmt-UI
|
||||
@@ -62,6 +65,12 @@ type State struct {
|
||||
// [::]:443 und [::]:3443. Default false weil nicht alle Deployments
|
||||
// IPv6 haben. Nach Änderung wird HAProxy neu geladen.
|
||||
IPv6Enabled bool `json:"ipv6_enabled,omitempty"`
|
||||
|
||||
// PrimaryFQDN: FQDN des Cluster-Primary, den dieser Node beim Join
|
||||
// angegeben hat. Wird bei jedem API-Start genutzt um den Primary in
|
||||
// der lokalen ha_nodes vorab zu registrieren (Firewall @peer_ipv4),
|
||||
// ohne dass der Join erneut durchlaufen werden muss.
|
||||
PrimaryFQDN string `json:"primary_fqdn,omitempty"`
|
||||
}
|
||||
|
||||
// Request is the JSON body POST /api/v1/setup/complete accepts.
|
||||
@@ -75,6 +84,14 @@ type Request struct {
|
||||
LicenseKey string `json:"license_key,omitempty"`
|
||||
}
|
||||
|
||||
// NodeRequest is the JSON body POST /api/v1/setup/complete-node accepts.
|
||||
// No admin credentials — they are replicated from the primary via PG.
|
||||
type NodeRequest struct {
|
||||
FQDN string `json:"fqdn" binding:"required"`
|
||||
ACMEEmail string `json:"acme_email" binding:"required,email"`
|
||||
PrimaryFQDN string `json:"primary_fqdn,omitempty"`
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
Dir string
|
||||
}
|
||||
@@ -155,6 +172,52 @@ func (s *Store) Complete(req Request) (*State, error) {
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// CompleteAsNode configures this node as a secondary cluster member.
|
||||
// No admin credentials are created; login becomes available after PG
|
||||
// replication brings users from the primary.
|
||||
//
|
||||
// Idempotent: if setup was already completed (e.g. the node was
|
||||
// previously configured as standalone), it is converted to cluster-node
|
||||
// mode. Calling again after a successful join is a no-op.
|
||||
func (s *Store) CompleteAsNode(req NodeRequest) (*State, error) {
|
||||
if !looksLikeFQDN(req.FQDN) {
|
||||
return nil, fmt.Errorf("fqdn %q does not look like a fully-qualified hostname", req.FQDN)
|
||||
}
|
||||
if _, err := mail.ParseAddress(req.ACMEEmail); err != nil {
|
||||
return nil, fmt.Errorf("invalid acme_email: %w", err)
|
||||
}
|
||||
prev, err := s.Load()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
completedAt := &now
|
||||
if prev.CompletedAt != nil {
|
||||
completedAt = prev.CompletedAt
|
||||
}
|
||||
primaryFQDN := strings.ToLower(strings.TrimSpace(req.PrimaryFQDN))
|
||||
if primaryFQDN == "" {
|
||||
primaryFQDN = prev.PrimaryFQDN // carry over if not re-supplied
|
||||
}
|
||||
st := &State{
|
||||
FQDN: strings.TrimSpace(req.FQDN),
|
||||
ACMEEmail: strings.ToLower(strings.TrimSpace(req.ACMEEmail)),
|
||||
IsClusterNode: true,
|
||||
Completed: true,
|
||||
CompletedAt: completedAt,
|
||||
PrimaryFQDN: primaryFQDN,
|
||||
// Carry over non-auth fields from previous state.
|
||||
LicenseKey: prev.LicenseKey,
|
||||
IPv6Enabled: prev.IPv6Enabled,
|
||||
BackupRetentionKeep: prev.BackupRetentionKeep,
|
||||
AuditRetentionDays: prev.AuditRetentionDays,
|
||||
}
|
||||
if err := s.Save(st); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// VerifyAdminPassword does constant-time bcrypt comparison.
|
||||
func (st *State) VerifyAdminPassword(plaintext string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(st.AdminPasswordHash), []byte(plaintext)) == nil
|
||||
|
||||
@@ -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)
|
||||
|
||||
227
internal/services/waf/waf.go
Normal file
227
internal/services/waf/waf.go
Normal file
@@ -0,0 +1,227 @@
|
||||
// Package waf implements CRUD for per-domain WAF policies (waf_configs).
|
||||
package waf
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("waf config not found")
|
||||
|
||||
type Repo struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} }
|
||||
|
||||
const baseSelect = `
|
||||
SELECT id, domain_id, enabled, mode, paranoia_level,
|
||||
rule_exclusions, exclusion_notes, trusted_proxies, custom_rules, updated_at
|
||||
FROM waf_configs
|
||||
`
|
||||
|
||||
func scan(row pgx.Row) (*models.WafConfig, error) {
|
||||
var c models.WafConfig
|
||||
err := row.Scan(
|
||||
&c.ID, &c.DomainID, &c.Enabled, &c.Mode, &c.ParanoiaLevel,
|
||||
&c.RuleExclusions, &c.ExclusionNotes, &c.TrustedProxies, &c.CustomRules, &c.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.ExclusionNotes == nil {
|
||||
c.ExclusionNotes = map[string]string{}
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// List returns all WAF configs ordered by domain_id.
|
||||
func (r *Repo) List(ctx context.Context) ([]models.WafConfig, error) {
|
||||
rows, err := r.Pool.Query(ctx, baseSelect+" ORDER BY domain_id ASC")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]models.WafConfig, 0, 16)
|
||||
for rows.Next() {
|
||||
c, err := scan(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetByDomain returns the WAF config for a domain, or ErrNotFound.
|
||||
func (r *Repo) GetByDomain(ctx context.Context, domainID int64) (*models.WafConfig, error) {
|
||||
row := r.Pool.QueryRow(ctx, baseSelect+" WHERE domain_id = $1", domainID)
|
||||
c, err := scan(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Upsert inserts or updates the WAF config for a domain.
|
||||
// Returns the resulting row.
|
||||
func (r *Repo) Upsert(ctx context.Context, c models.WafConfig) (*models.WafConfig, error) {
|
||||
c.UpdatedAt = time.Now()
|
||||
if c.ExclusionNotes == nil {
|
||||
c.ExclusionNotes = map[string]string{}
|
||||
}
|
||||
row := r.Pool.QueryRow(ctx, `
|
||||
INSERT INTO waf_configs
|
||||
(domain_id, enabled, mode, paranoia_level,
|
||||
rule_exclusions, exclusion_notes, trusted_proxies, custom_rules, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||||
ON CONFLICT (domain_id) DO UPDATE SET
|
||||
enabled = EXCLUDED.enabled,
|
||||
mode = EXCLUDED.mode,
|
||||
paranoia_level = EXCLUDED.paranoia_level,
|
||||
rule_exclusions = EXCLUDED.rule_exclusions,
|
||||
exclusion_notes = EXCLUDED.exclusion_notes,
|
||||
trusted_proxies = EXCLUDED.trusted_proxies,
|
||||
custom_rules = EXCLUDED.custom_rules,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
RETURNING id, domain_id, enabled, mode, paranoia_level,
|
||||
rule_exclusions, exclusion_notes, trusted_proxies, custom_rules, updated_at
|
||||
`,
|
||||
c.DomainID, c.Enabled, c.Mode, c.ParanoiaLevel,
|
||||
c.RuleExclusions, c.ExclusionNotes, c.TrustedProxies, c.CustomRules, c.UpdatedAt,
|
||||
)
|
||||
return scan(row)
|
||||
}
|
||||
|
||||
// ListEnabled returns only configs with enabled=true (used by the WAF agent).
|
||||
func (r *Repo) ListEnabled(ctx context.Context) ([]models.WafConfig, error) {
|
||||
rows, err := r.Pool.Query(ctx, baseSelect+" WHERE enabled = true ORDER BY domain_id ASC")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]models.WafConfig, 0, 8)
|
||||
for rows.Next() {
|
||||
c, err := scan(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// WafAlert mirrors the waf_alerts DB row.
|
||||
type WafAlert struct {
|
||||
ID int64 `json:"id"`
|
||||
DomainID *int64 `json:"domain_id,omitempty"`
|
||||
Hostname string `json:"hostname"`
|
||||
ClientIP string `json:"client_ip"`
|
||||
Method string `json:"method"`
|
||||
URI string `json:"uri"`
|
||||
RuleID int `json:"rule_id"`
|
||||
RuleMsg string `json:"rule_msg"`
|
||||
Severity string `json:"severity"`
|
||||
Action string `json:"action"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ListAlerts returns recent WAF alerts, optionally filtered by domain_id.
|
||||
func (r *Repo) ListAlerts(ctx context.Context, domainID *int64, limit int) ([]WafAlert, error) {
|
||||
if limit <= 0 || limit > 1000 {
|
||||
limit = 200
|
||||
}
|
||||
var rows interface{ Next() bool; Scan(...any) error; Close(); Err() error }
|
||||
var err error
|
||||
if domainID != nil {
|
||||
rows2, e := r.Pool.Query(ctx, `
|
||||
SELECT id, domain_id, hostname, client_ip, method, uri,
|
||||
rule_id, rule_msg, severity, action, created_at
|
||||
FROM waf_alerts
|
||||
WHERE domain_id = $1
|
||||
ORDER BY created_at DESC LIMIT $2
|
||||
`, *domainID, limit)
|
||||
rows, err = rows2, e
|
||||
} else {
|
||||
rows2, e := r.Pool.Query(ctx, `
|
||||
SELECT id, domain_id, hostname, client_ip, method, uri,
|
||||
rule_id, rule_msg, severity, action, created_at
|
||||
FROM waf_alerts
|
||||
ORDER BY created_at DESC LIMIT $1
|
||||
`, limit)
|
||||
rows, err = rows2, e
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]WafAlert, 0, limit)
|
||||
for rows.Next() {
|
||||
var a WafAlert
|
||||
if err := rows.Scan(
|
||||
&a.ID, &a.DomainID, &a.Hostname, &a.ClientIP, &a.Method, &a.URI,
|
||||
&a.RuleID, &a.RuleMsg, &a.Severity, &a.Action, &a.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// PurgeAlerts removes alerts older than the given number of days.
|
||||
func (r *Repo) PurgeAlerts(ctx context.Context, olderThanDays int) error {
|
||||
_, err := r.Pool.Exec(ctx,
|
||||
`DELETE FROM waf_alerts WHERE created_at < NOW() - ($1 || ' days')::interval`,
|
||||
olderThanDays,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// DomainConfigPair combines a domain hostname with its WAF config.
|
||||
type DomainConfigPair struct {
|
||||
Hostname string
|
||||
Config models.WafConfig
|
||||
}
|
||||
|
||||
// ListAllWithDomain returns all WAF configs joined with their domain name.
|
||||
// Used by the WAF agent to build the hostname→engine mapping.
|
||||
func (r *Repo) ListAllWithDomain(ctx context.Context) ([]DomainConfigPair, error) {
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT d.name,
|
||||
w.id, w.domain_id, w.enabled, w.mode, w.paranoia_level,
|
||||
w.rule_exclusions, w.trusted_proxies, w.custom_rules, w.updated_at
|
||||
FROM waf_configs w
|
||||
JOIN domains d ON d.id = w.domain_id
|
||||
WHERE d.active = true
|
||||
ORDER BY d.name ASC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]DomainConfigPair, 0, 16)
|
||||
for rows.Next() {
|
||||
var p DomainConfigPair
|
||||
var c models.WafConfig
|
||||
if err := rows.Scan(
|
||||
&p.Hostname,
|
||||
&c.ID, &c.DomainID, &c.Enabled, &c.Mode, &c.ParanoiaLevel,
|
||||
&c.RuleExclusions, &c.TrustedProxies, &c.CustomRules, &c.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Config = c
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -47,6 +47,68 @@ func NewImporter(ifaces *InterfacesRepo, peers *PeersRepo, box *secrets.Box) *Im
|
||||
return &Importer{Ifaces: ifaces, Peers: peers, Box: box}
|
||||
}
|
||||
|
||||
// ImportableEntry describes one .conf file that can be imported.
|
||||
type ImportableEntry struct {
|
||||
Name string `json:"name"` // interface name without .conf
|
||||
AlreadyIn bool `json:"already_in"` // already present in the DB
|
||||
}
|
||||
|
||||
// ListImportable scans dir for *.conf files and reports which are new
|
||||
// (not yet in the DB) and which are already imported.
|
||||
func (im *Importer) ListImportable(ctx context.Context, dir string) ([]ImportableEntry, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
existing, err := im.Ifaces.List(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inDB := make(map[string]bool, len(existing))
|
||||
for _, x := range existing {
|
||||
inDB[x.Name] = true
|
||||
}
|
||||
var out []ImportableEntry
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".conf") {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSuffix(e.Name(), ".conf")
|
||||
if !validIfaceName(name) {
|
||||
continue
|
||||
}
|
||||
out = append(out, ImportableEntry{Name: name, AlreadyIn: inDB[name]})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ImportSelected imports only the named interfaces from dir.
|
||||
// An empty names slice is treated as "import all" (same as ImportDir).
|
||||
func (im *Importer) ImportSelected(ctx context.Context, dir string, names []string) (*ImportResult, error) {
|
||||
if len(names) == 0 {
|
||||
return im.ImportDir(ctx, dir)
|
||||
}
|
||||
res := &ImportResult{}
|
||||
want := make(map[string]bool, len(names))
|
||||
for _, n := range names {
|
||||
want[n] = true
|
||||
}
|
||||
for name := range want {
|
||||
if !validIfaceName(name) {
|
||||
res.Skipped = append(res.Skipped, name+" (invalid name)")
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(dir, name+".conf")
|
||||
if err := im.importFile(ctx, name, path, res); err != nil {
|
||||
res.Skipped = append(res.Skipped, name+": "+err.Error())
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (im *Importer) ImportDir(ctx context.Context, dir string) (*ImportResult, error) {
|
||||
res := &ImportResult{}
|
||||
entries, err := os.ReadDir(dir)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
84
internal/waf/alerts.go
Normal file
84
internal/waf/alerts.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package waf
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Alert represents a single WAF rule match that was logged.
|
||||
type Alert struct {
|
||||
ID int64 `json:"id"`
|
||||
DomainID *int64 `json:"domain_id,omitempty"`
|
||||
Hostname string `json:"hostname"`
|
||||
ClientIP string `json:"client_ip"`
|
||||
Method string `json:"method"`
|
||||
URI string `json:"uri"`
|
||||
RuleID int `json:"rule_id"`
|
||||
RuleMsg string `json:"rule_msg"`
|
||||
Severity string `json:"severity"`
|
||||
Action string `json:"action"` // "detected" | "blocked"
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// AlertWriter accepts Alert values via a buffered channel and writes
|
||||
// them to PostgreSQL asynchronously so SPOE handling stays low-latency.
|
||||
type AlertWriter struct {
|
||||
pool *pgxpool.Pool
|
||||
ch chan Alert
|
||||
}
|
||||
|
||||
// NewAlertWriter creates an AlertWriter and starts its background goroutine.
|
||||
// bufSize is the number of unwritten alerts that can queue before drops.
|
||||
func NewAlertWriter(pool *pgxpool.Pool, bufSize int) *AlertWriter {
|
||||
aw := &AlertWriter{
|
||||
pool: pool,
|
||||
ch: make(chan Alert, bufSize),
|
||||
}
|
||||
go aw.run()
|
||||
return aw
|
||||
}
|
||||
|
||||
// Send enqueues an alert. Drops silently if the channel is full to
|
||||
// avoid slowing down SPOE request handling.
|
||||
func (aw *AlertWriter) Send(a Alert) {
|
||||
select {
|
||||
case aw.ch <- a:
|
||||
default:
|
||||
slog.Warn("waf: alert channel full — dropping alert", "host", a.Hostname, "rule", a.RuleID)
|
||||
}
|
||||
}
|
||||
|
||||
func (aw *AlertWriter) run() {
|
||||
for a := range aw.ch {
|
||||
aw.write(a)
|
||||
}
|
||||
}
|
||||
|
||||
func (aw *AlertWriter) write(a Alert) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Resolve domain_id from hostname (best-effort).
|
||||
var domainID *int64
|
||||
var id int64
|
||||
if err := aw.pool.QueryRow(ctx,
|
||||
`SELECT id FROM domains WHERE name = $1 AND active = true LIMIT 1`,
|
||||
a.Hostname,
|
||||
).Scan(&id); err == nil {
|
||||
domainID = &id
|
||||
}
|
||||
|
||||
if _, err := aw.pool.Exec(ctx, `
|
||||
INSERT INTO waf_alerts
|
||||
(domain_id, hostname, client_ip, method, uri,
|
||||
rule_id, rule_msg, severity, action)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||||
`, domainID, a.Hostname, a.ClientIP, a.Method, a.URI,
|
||||
a.RuleID, a.RuleMsg, a.Severity, a.Action,
|
||||
); err != nil {
|
||||
slog.Warn("waf: write alert to db failed", "error", err)
|
||||
}
|
||||
}
|
||||
110
internal/waf/engine.go
Normal file
110
internal/waf/engine.go
Normal file
@@ -0,0 +1,110 @@
|
||||
// Package waf implements the per-domain WAF engine for EdgeGuard.
|
||||
// It wraps Coraza v3 (OWASP Core Rule Set) and exposes a simple
|
||||
// hostname-keyed engine manager that the SPOE agent uses.
|
||||
package waf
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/corazawaf/coraza/v3"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultCRSDir = "/usr/share/edgeguard/waf/crs"
|
||||
DefaultSPOEAddr = "127.0.0.1:9000"
|
||||
)
|
||||
|
||||
// BuildEngine creates a Coraza WAF instance for the given domain config.
|
||||
// crsDir is the path to the OWASP CRS directory (may be empty — engine
|
||||
// works without CRS, using only the basic Coraza core rules).
|
||||
func BuildEngine(cfg models.WafConfig, crsDir string) (coraza.WAF, error) {
|
||||
directives := buildDirectives(cfg, crsDir)
|
||||
wafCfg := coraza.NewWAFConfig().
|
||||
WithRequestBodyAccess().
|
||||
WithDirectives(directives)
|
||||
return coraza.NewWAF(wafCfg)
|
||||
}
|
||||
|
||||
// buildDirectives assembles the SecLang directives for a domain config.
|
||||
func buildDirectives(cfg models.WafConfig, crsDir string) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("SecRequestBodyAccess On\n")
|
||||
sb.WriteString("SecResponseBodyAccess Off\n")
|
||||
sb.WriteString("SecRequestBodyLimit 13107200\n") // 12.5 MB
|
||||
sb.WriteString("SecRequestBodyInMemoryLimit 131072\n") // 128 KB
|
||||
|
||||
sb.WriteString(fmt.Sprintf("SecRuleEngine %s\n", ruleEngineMode(cfg.Mode)))
|
||||
|
||||
if crsDir != "" && crsAvailable(crsDir) {
|
||||
// Paranoia level MUST be set before CRS rules are included.
|
||||
pl := cfg.ParanoiaLevel
|
||||
if pl < 1 || pl > 4 {
|
||||
pl = 1
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(
|
||||
"SecAction \"id:900000,phase:1,nolog,pass,t:none,setvar:tx.paranoia_level=%d\"\n", pl,
|
||||
))
|
||||
setupConf := filepath.Join(crsDir, "crs-setup.conf")
|
||||
if _, err := os.Stat(setupConf); err == nil {
|
||||
sb.WriteString(fmt.Sprintf("Include %s\n", setupConf))
|
||||
}
|
||||
rulesGlob := filepath.Join(crsDir, "rules", "*.conf")
|
||||
sb.WriteString(fmt.Sprintf("Include %s\n", rulesGlob))
|
||||
}
|
||||
|
||||
// Rule exclusions (applied after CRS load so they override CRS).
|
||||
for _, id := range cfg.RuleExclusions {
|
||||
id = strings.TrimSpace(id)
|
||||
if id != "" {
|
||||
sb.WriteString(fmt.Sprintf("SecRuleRemoveById %s\n", id))
|
||||
}
|
||||
}
|
||||
|
||||
// Trusted proxies: tell Coraza to trust X-Forwarded-For from these IPs.
|
||||
for _, ip := range cfg.TrustedProxies {
|
||||
ip = strings.TrimSpace(ip)
|
||||
if ip != "" {
|
||||
sb.WriteString(fmt.Sprintf("SecRemoteRulesFailAction Abort\n"))
|
||||
_ = ip // used in custom rules below if needed
|
||||
}
|
||||
}
|
||||
|
||||
// Custom rules (appended last so they can override CRS).
|
||||
if strings.TrimSpace(cfg.CustomRules) != "" {
|
||||
sb.WriteString(cfg.CustomRules)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func ruleEngineMode(mode string) string {
|
||||
switch mode {
|
||||
case "blocking":
|
||||
return "On"
|
||||
default: // "detection"
|
||||
return "DetectionOnly"
|
||||
}
|
||||
}
|
||||
|
||||
// crsAvailable returns true when the CRS rules directory exists and
|
||||
// contains at least one .conf file.
|
||||
func crsAvailable(crsDir string) bool {
|
||||
rulesDir := filepath.Join(crsDir, "rules")
|
||||
entries, err := os.ReadDir(rulesDir)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, e := range entries {
|
||||
if strings.HasSuffix(e.Name(), ".conf") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
157
internal/waf/manager.go
Normal file
157
internal/waf/manager.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package waf
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
|
||||
"github.com/corazawaf/coraza/v3"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
)
|
||||
|
||||
// DomainEngine bundles a Coraza WAF with its operating mode.
|
||||
type DomainEngine struct {
|
||||
WAF coraza.WAF
|
||||
Mode string // "detection" | "blocking"
|
||||
}
|
||||
|
||||
// Manager holds per-domain Coraza engine instances. Engines are
|
||||
// rebuilt only when their configuration changes (UpdatedAt differs).
|
||||
// All public methods are safe for concurrent use.
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
engines map[string]*DomainEngine // hostname → engine (nil entry = disabled)
|
||||
configKeys map[string]configKey // hostname → last-seen config fingerprint
|
||||
crsDir string
|
||||
}
|
||||
|
||||
// configKey identifies a specific WAF config snapshot so we only
|
||||
// rebuild the engine when something actually changed.
|
||||
type configKey struct {
|
||||
enabled bool
|
||||
mode string
|
||||
paranoiaLevel int
|
||||
updatedAt int64 // unix nano
|
||||
}
|
||||
|
||||
// NewManager creates an empty Manager with the given CRS directory.
|
||||
func NewManager(crsDir string) *Manager {
|
||||
if crsDir == "" {
|
||||
crsDir = DefaultCRSDir
|
||||
}
|
||||
return &Manager{
|
||||
engines: make(map[string]*DomainEngine),
|
||||
configKeys: make(map[string]configKey),
|
||||
crsDir: crsDir,
|
||||
}
|
||||
}
|
||||
|
||||
// DomainConfig pairs a domain hostname with its WAF policy.
|
||||
type DomainConfig struct {
|
||||
Hostname string
|
||||
Config models.WafConfig
|
||||
}
|
||||
|
||||
// Reload refreshes engines from the given list, rebuilding only when
|
||||
// the config has actually changed since the last call.
|
||||
func (m *Manager) Reload(domains []DomainConfig) error {
|
||||
m.mu.RLock()
|
||||
prevEngines := m.engines
|
||||
prevKeys := m.configKeys
|
||||
m.mu.RUnlock()
|
||||
|
||||
newEngines := make(map[string]*DomainEngine, len(domains))
|
||||
newKeys := make(map[string]configKey, len(domains))
|
||||
|
||||
for _, dc := range domains {
|
||||
ck := configKey{
|
||||
enabled: dc.Config.Enabled,
|
||||
mode: dc.Config.Mode,
|
||||
paranoiaLevel: dc.Config.ParanoiaLevel,
|
||||
updatedAt: dc.Config.UpdatedAt.UnixNano(),
|
||||
}
|
||||
newKeys[dc.Hostname] = ck
|
||||
|
||||
if !dc.Config.Enabled {
|
||||
newEngines[dc.Hostname] = nil
|
||||
continue
|
||||
}
|
||||
|
||||
// Reuse existing engine if config hasn't changed.
|
||||
if prev, ok := prevKeys[dc.Hostname]; ok && prev == ck {
|
||||
if existing := prevEngines[dc.Hostname]; existing != nil {
|
||||
newEngines[dc.Hostname] = existing
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
waf, err := BuildEngine(dc.Config, m.crsDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("waf: build engine for %s: %w", dc.Hostname, err)
|
||||
}
|
||||
newEngines[dc.Hostname] = &DomainEngine{WAF: waf, Mode: dc.Config.Mode}
|
||||
slog.Info("waf: engine (re)loaded",
|
||||
"host", dc.Hostname,
|
||||
"mode", dc.Config.Mode,
|
||||
"paranoia_level", dc.Config.ParanoiaLevel,
|
||||
"crs", crsAvailable(m.crsDir),
|
||||
)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.engines = newEngines
|
||||
m.configKeys = newKeys
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetForHost returns the DomainEngine for the given hostname, or
|
||||
// (nil, false) when the domain has no WAF or WAF is disabled.
|
||||
func (m *Manager) GetForHost(host string) (*DomainEngine, bool) {
|
||||
// Strip port if present (e.g. "example.com:443" → "example.com").
|
||||
if i := lastColon(host); i >= 0 {
|
||||
host = host[:i]
|
||||
}
|
||||
m.mu.RLock()
|
||||
de, ok := m.engines[host]
|
||||
m.mu.RUnlock()
|
||||
if !ok || de == nil {
|
||||
return nil, false
|
||||
}
|
||||
return de, true
|
||||
}
|
||||
|
||||
// lastColon returns the index of the last ':' in s that looks like a
|
||||
// port separator (after the final ']' for IPv6), or -1.
|
||||
func lastColon(s string) int {
|
||||
// IPv6 addresses in brackets: "[::1]:443"
|
||||
if len(s) > 0 && s[0] == '[' {
|
||||
if rb := lastByte(s, ']'); rb >= 0 && rb < len(s)-1 && s[rb+1] == ':' {
|
||||
return rb + 1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
// Plain host — only strip port if there's exactly one colon.
|
||||
count := 0
|
||||
idx := -1
|
||||
for i, c := range s {
|
||||
if c == ':' {
|
||||
count++
|
||||
idx = i
|
||||
}
|
||||
}
|
||||
if count == 1 {
|
||||
return idx
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func lastByte(s string, b byte) int {
|
||||
for i := len(s) - 1; i >= 0; i-- {
|
||||
if s[i] == b {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
186
internal/waf/spoe.go
Normal file
186
internal/waf/spoe.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package waf
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/corazawaf/coraza/v3/types"
|
||||
"github.com/dropmorepackets/haproxy-go/pkg/encoding"
|
||||
"github.com/dropmorepackets/haproxy-go/spop"
|
||||
)
|
||||
|
||||
// SPOEAgent wraps the haproxy-go SPOE server and dispatches each
|
||||
// inspected request to the appropriate per-domain Coraza engine.
|
||||
type SPOEAgent struct {
|
||||
Manager *Manager
|
||||
AlertWriter *AlertWriter
|
||||
Addr string
|
||||
}
|
||||
|
||||
// ListenAndServe starts the SPOE agent. Blocks until ctx is cancelled.
|
||||
func (a *SPOEAgent) ListenAndServe(ctx context.Context) error {
|
||||
agent := spop.Agent{
|
||||
Addr: a.Addr,
|
||||
Handler: spop.HandlerFunc(a.handle),
|
||||
BaseContext: ctx,
|
||||
}
|
||||
return agent.ListenAndServe()
|
||||
}
|
||||
|
||||
// handle is called by the haproxy-go SPOE library for every NOTIFY
|
||||
// frame HAProxy sends. It extracts the request data, runs Coraza,
|
||||
// and optionally sets a txn.waf.status variable to trigger a deny ACL.
|
||||
func (a *SPOEAgent) handle(ctx context.Context, w *encoding.ActionWriter, m *encoding.Message) {
|
||||
var (
|
||||
clientIP string
|
||||
method string
|
||||
uri string // full request URI (path + optional ?query)
|
||||
httpVer string
|
||||
host string
|
||||
rawHdrs string
|
||||
)
|
||||
|
||||
// Iterate over the key-value pairs HAProxy sent with this message.
|
||||
entry := encoding.AcquireKVEntry()
|
||||
defer encoding.ReleaseKVEntry(entry)
|
||||
for m.KV.Next(entry) {
|
||||
switch {
|
||||
case entry.NameEquals("src"):
|
||||
addr := entry.ValueAddr()
|
||||
if addr.IsValid() {
|
||||
clientIP = addr.String()
|
||||
}
|
||||
case entry.NameEquals("method"):
|
||||
method = string(entry.ValueBytes())
|
||||
case entry.NameEquals("uri"):
|
||||
uri = string(entry.ValueBytes())
|
||||
case entry.NameEquals("ver"):
|
||||
httpVer = string(entry.ValueBytes())
|
||||
case entry.NameEquals("host"):
|
||||
host = string(entry.ValueBytes())
|
||||
case entry.NameEquals("headers"):
|
||||
rawHdrs = string(entry.ValueBytes())
|
||||
}
|
||||
entry.Reset()
|
||||
}
|
||||
|
||||
if host == "" {
|
||||
return
|
||||
}
|
||||
|
||||
de, ok := a.Manager.GetForHost(host)
|
||||
if !ok {
|
||||
return // WAF not configured or disabled for this domain
|
||||
}
|
||||
|
||||
tx := de.WAF.NewTransaction()
|
||||
defer func() {
|
||||
tx.ProcessLogging()
|
||||
if err := tx.Close(); err != nil {
|
||||
slog.Warn("waf: tx.Close", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Feed connection metadata.
|
||||
if clientIP != "" {
|
||||
tx.ProcessConnection(clientIP, 0, "", 0)
|
||||
}
|
||||
|
||||
if uri == "" {
|
||||
uri = "/"
|
||||
}
|
||||
if httpVer == "" {
|
||||
httpVer = "HTTP/1.1"
|
||||
}
|
||||
tx.ProcessURI(uri, method, httpVer)
|
||||
|
||||
// Feed Host header first (required by many CRS rules).
|
||||
tx.AddRequestHeader("Host", host)
|
||||
|
||||
// Parse and feed all raw headers.
|
||||
parseHeaders(rawHdrs, func(name, val string) {
|
||||
if !strings.EqualFold(name, "host") { // already added above
|
||||
tx.AddRequestHeader(name, val)
|
||||
}
|
||||
})
|
||||
|
||||
// Evaluate request headers.
|
||||
interruption := tx.ProcessRequestHeaders()
|
||||
|
||||
// Log all matched rules (detection + blocking).
|
||||
for _, mr := range tx.MatchedRules() {
|
||||
a.sendAlert(host, clientIP, method, uri, mr, interruption != nil)
|
||||
}
|
||||
|
||||
if interruption != nil {
|
||||
status := interruption.Status
|
||||
if status == 0 {
|
||||
status = http.StatusForbidden
|
||||
}
|
||||
slog.Info("waf: request blocked",
|
||||
"host", host, "method", method, "uri", uri,
|
||||
"client", clientIP, "status", status, "rule", interruption.RuleID,
|
||||
)
|
||||
if de.Mode == "blocking" {
|
||||
if err := w.SetInt64(encoding.VarScopeTransaction, "status", int64(status)); err != nil {
|
||||
slog.Warn("waf: SetInt64 status", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sendAlert enqueues a WAF alert for async DB write.
|
||||
// Control-flow rules (pass+nolog with empty message) are skipped —
|
||||
// they are CRS paranoia-level skip-markers, not real detections.
|
||||
func (a *SPOEAgent) sendAlert(host, clientIP, method, uri string, mr types.MatchedRule, blocked bool) {
|
||||
if a.AlertWriter == nil {
|
||||
return
|
||||
}
|
||||
ruleID := mr.Rule().ID()
|
||||
// Skip CRS setup/initialization rules (900xxx–909xxx) — they fire on
|
||||
// every request as part of CRS init and are not security events.
|
||||
// Real detection rules start at 910xxx (IP reputation) and above.
|
||||
if ruleID > 0 && ruleID < 910000 {
|
||||
return
|
||||
}
|
||||
// Skip control-flow rules with no message (PL-skip markers).
|
||||
if mr.Message() == "" {
|
||||
return
|
||||
}
|
||||
action := "detected"
|
||||
if blocked && mr.Disruptive() {
|
||||
action = "blocked"
|
||||
}
|
||||
a.AlertWriter.Send(Alert{
|
||||
Hostname: host,
|
||||
ClientIP: clientIP,
|
||||
Method: method,
|
||||
URI: uri,
|
||||
RuleID: mr.Rule().ID(),
|
||||
RuleMsg: mr.Message(),
|
||||
Severity: mr.Rule().Severity().String(),
|
||||
Action: action,
|
||||
})
|
||||
}
|
||||
|
||||
// parseHeaders splits HAProxy raw headers ("Name: value\r\n…") and
|
||||
// calls fn for each valid header line.
|
||||
func parseHeaders(raw string, fn func(name, val string)) {
|
||||
for _, line := range strings.Split(raw, "\n") {
|
||||
line = strings.TrimRight(line, "\r")
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
idx := strings.IndexByte(line, ':')
|
||||
if idx <= 0 {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(line[:idx])
|
||||
val := strings.TrimSpace(line[idx+1:])
|
||||
if name != "" {
|
||||
fn(name, val)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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/<iface>.conf → target via sudo. /etc/wireguard/ is
|
||||
// owned root:root 700 so the edgeguard user cannot write to it directly;
|
||||
|
||||
@@ -44,6 +44,78 @@ func New(pool *pgxpool.Pool, box *secrets.Box) *Generator {
|
||||
|
||||
func (g *Generator) Name() string { return "wireguard" }
|
||||
|
||||
// RenderToString renders all active interface configs to a combined
|
||||
// string for the config-preview endpoint. Private keys are redacted
|
||||
// so the output is safe to display in the management UI.
|
||||
func (g *Generator) RenderToString(ctx context.Context) (string, error) {
|
||||
ifs, err := g.Ifaces.List(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("list ifaces: %w", err)
|
||||
}
|
||||
var combined strings.Builder
|
||||
for _, ifc := range ifs {
|
||||
if !ifc.Active {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&combined, "# ── %s (%s) ────────────────────────────────\n", ifc.Name, ifc.Mode)
|
||||
combined.WriteString("[Interface]\n")
|
||||
fmt.Fprintf(&combined, "Address = %s\n", ifc.AddressCIDR)
|
||||
combined.WriteString("PrivateKey = <redacted>\n")
|
||||
if ifc.ListenPort != nil {
|
||||
fmt.Fprintf(&combined, "ListenPort = %d\n", *ifc.ListenPort)
|
||||
}
|
||||
if ifc.MTU != nil {
|
||||
fmt.Fprintf(&combined, "MTU = %d\n", *ifc.MTU)
|
||||
}
|
||||
combined.WriteString("\n")
|
||||
switch ifc.Mode {
|
||||
case "client":
|
||||
if ifc.PeerPublicKey != nil && ifc.PeerEndpoint != nil {
|
||||
combined.WriteString("[Peer]\n")
|
||||
fmt.Fprintf(&combined, "PublicKey = %s\n", *ifc.PeerPublicKey)
|
||||
fmt.Fprintf(&combined, "Endpoint = %s\n", *ifc.PeerEndpoint)
|
||||
if ifc.AllowedIPs != nil && *ifc.AllowedIPs != "" {
|
||||
fmt.Fprintf(&combined, "AllowedIPs = %s\n", *ifc.AllowedIPs)
|
||||
} else {
|
||||
combined.WriteString("AllowedIPs = 0.0.0.0/0,::/0\n")
|
||||
}
|
||||
if ifc.PersistentKeepalive != nil {
|
||||
fmt.Fprintf(&combined, "PersistentKeepalive = %d\n", *ifc.PersistentKeepalive)
|
||||
}
|
||||
if len(ifc.PeerPSKEnc) > 0 {
|
||||
combined.WriteString("PresharedKey = <redacted>\n")
|
||||
}
|
||||
}
|
||||
case "server":
|
||||
peers, err := g.Peers.ListForInterface(ctx, ifc.ID)
|
||||
if err == nil {
|
||||
sort.Slice(peers, func(i, j int) bool { return peers[i].Name < peers[j].Name })
|
||||
for _, p := range peers {
|
||||
if !p.Enabled {
|
||||
continue
|
||||
}
|
||||
combined.WriteString("[Peer]\n")
|
||||
fmt.Fprintf(&combined, "# %s\n", p.Name)
|
||||
fmt.Fprintf(&combined, "PublicKey = %s\n", p.PublicKey)
|
||||
fmt.Fprintf(&combined, "AllowedIPs = %s\n", p.AllowedIPs)
|
||||
if p.Keepalive != nil {
|
||||
fmt.Fprintf(&combined, "PersistentKeepalive = %d\n", *p.Keepalive)
|
||||
}
|
||||
if len(p.PSKEnc) > 0 {
|
||||
combined.WriteString("PresharedKey = <redacted>\n")
|
||||
}
|
||||
combined.WriteString("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
combined.WriteString("\n")
|
||||
}
|
||||
if combined.Len() == 0 {
|
||||
return "# No active WireGuard interfaces configured.\n", nil
|
||||
}
|
||||
return combined.String(), nil
|
||||
}
|
||||
|
||||
func (g *Generator) Render(ctx context.Context) error {
|
||||
if err := os.MkdirAll(ConfDir, 0o700); err != nil {
|
||||
return fmt.Errorf("mkdir %s: %w", ConfDir, err)
|
||||
@@ -80,6 +152,7 @@ func (g *Generator) Render(ctx context.Context) error {
|
||||
}
|
||||
_ = os.Remove(filepath.Join(ConfDir, e.Name()))
|
||||
_ = stopWGQuick(ifaceName)
|
||||
_ = disableWGQuick(ifaceName)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -163,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)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Suspense, lazy, useEffect, type ReactNode } from 'react'
|
||||
import { BrowserRouter, Navigate, Route, Routes, useLocation } from 'react-router-dom'
|
||||
import ErrorBoundary from './components/ErrorBoundary'
|
||||
import { ConfigProvider, Spin } from 'antd'
|
||||
import deDE from 'antd/locale/de_DE'
|
||||
import enUS from 'antd/locale/en_US'
|
||||
@@ -37,6 +38,8 @@ const AlertsPage = lazy(() => import('./pages/Alerts'))
|
||||
const LicensePage = lazy(() => import('./pages/License'))
|
||||
const SettingsPage = lazy(() => import('./pages/Settings'))
|
||||
const UsersPage = lazy(() => import('./pages/Users'))
|
||||
const CrowdSecPage = lazy(() => import('./pages/CrowdSec'))
|
||||
const WAFPage = lazy(() => import('./pages/WAF'))
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -62,6 +65,16 @@ const antdTheme = {
|
||||
colorTextSecondary: '#64748B',
|
||||
controlHeight: 34,
|
||||
},
|
||||
components: {
|
||||
Tabs: {
|
||||
itemColor: '#334155',
|
||||
itemHoverColor: '#0F172A',
|
||||
itemSelectedColor: '#0EA5E9',
|
||||
inkBarColor: '#0EA5E9',
|
||||
cardBg: '#F1F5F9',
|
||||
titleFontSize: 13,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
function RequireAuth({ children }: { children: ReactNode }) {
|
||||
@@ -99,6 +112,7 @@ export default function App() {
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<SetupGate>
|
||||
<LocationKeyBoundary>
|
||||
<Suspense fallback={<div className="loader-center"><Spin size="large" /></div>}>
|
||||
<Routes>
|
||||
<Route path="/setup" element={<SetupPage onComplete={(u: SessionUser) => useAuthStore.getState().set(u)} />} />
|
||||
@@ -131,14 +145,24 @@ export default function App() {
|
||||
<Route path="/license" element={<LicensePage />} />
|
||||
<Route path="/users" element={<UsersPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/crowdsec" element={<CrowdSecPage />} />
|
||||
<Route path="/waf" element={<WAFPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</LocationKeyBoundary>
|
||||
</SetupGate>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</ConfigProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// Resets the ErrorBoundary on every route change so a render error on
|
||||
// one page never permanently blocks navigation to another page.
|
||||
function LocationKeyBoundary({ children }: { children: ReactNode }) {
|
||||
const { pathname } = useLocation()
|
||||
return <ErrorBoundary key={pathname}>{children}</ErrorBoundary>
|
||||
}
|
||||
|
||||
@@ -1,35 +1,65 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react'
|
||||
|
||||
// Top-level ErrorBoundary. Catches throws aus dem React-Tree (inkl.
|
||||
// Lazy-Chunk-Loadfehler, die auf flakigem Mobilfunk häufig sind) und
|
||||
// rendert eine sichtbare Fehlerseite statt #root leer zu lassen.
|
||||
// Ohne diese Boundary endet jeder Render-Throw als „blank page".
|
||||
//
|
||||
// Wir loggen den Fehler in die Browser-Console (für Remote-Debug via
|
||||
// Safari-Inspector/Chrome-Remote) und zeigen dem Operator die
|
||||
// Fehlermeldung wörtlich — kein Translation-Layer, weil i18n selbst
|
||||
// schon kaputt sein kann.
|
||||
import { isStaleChunkError, reloadForStaleChunkOnce } from '../lib/staleChunkReload'
|
||||
|
||||
interface State { error: Error | null }
|
||||
// Top-level ErrorBoundary. Catches throws aus dem React-Tree (inkl.
|
||||
// Lazy-Chunk-Loadfehler nach einem Deploy) und rendert eine sichtbare
|
||||
// Fehlerseite statt #root leer zu lassen. Ohne diese Boundary endet
|
||||
// jeder Render-Throw als „blank page".
|
||||
//
|
||||
// Stale-Chunk-Fehler (alter Tab referenziert nicht mehr existierende
|
||||
// gehashte Chunks nach einem Deploy) werden automatisch per einmaligem
|
||||
// Reload behoben — der Operator sieht dann nur kurz „Aktualisiere…".
|
||||
// Erst wenn auch der Reload nicht hilft (giveUp) zeigen wir die manuelle
|
||||
// Fehlerkarte. Andere Fehler werden wörtlich angezeigt — kein
|
||||
// Translation-Layer, weil i18n selbst kaputt sein kann.
|
||||
|
||||
interface State { error: Error | null; giveUp: boolean }
|
||||
|
||||
export default class ErrorBoundary extends Component<{ children: ReactNode }, State> {
|
||||
state: State = { error: null }
|
||||
state: State = { error: null, giveUp: false }
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
static getDerivedStateFromError(error: Error): Partial<State> {
|
||||
return { error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[ErrorBoundary]', error, info.componentStack)
|
||||
// Stale-Chunk → einmalig neu laden. Schlägt der Loop-Schutz an
|
||||
// (Reload half nicht), auf die manuelle Karte zurückfallen.
|
||||
if (isStaleChunkError(error) && !reloadForStaleChunkOnce()) {
|
||||
this.setState({ giveUp: true })
|
||||
}
|
||||
}
|
||||
|
||||
reset = () => { this.setState({ error: null }) }
|
||||
reset = () => { this.setState({ error: null, giveUp: false }) }
|
||||
|
||||
render() {
|
||||
const err = this.state.error
|
||||
if (!err) return this.props.children
|
||||
const isChunkErr = /Loading chunk|Failed to fetch dynamically imported module|Importing a module script failed/i.test(err.message)
|
||||
const isChunkErr = isStaleChunkError(err)
|
||||
|
||||
// Auto-Reload läuft (Chunk-Fehler, Loop-Schutz noch nicht erreicht):
|
||||
// neutralen Lade-Hinweis zeigen statt der Fehlerkarte.
|
||||
if (isChunkErr && !this.state.giveUp) {
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 24,
|
||||
background: '#F8FAFC',
|
||||
color: '#64748B',
|
||||
fontSize: 14,
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
|
||||
}}>
|
||||
Aktualisiere EdgeGuard…
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
@@ -54,7 +84,7 @@ export default class ErrorBoundary extends Component<{ children: ReactNode }, St
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: '#64748B', marginBottom: 16 }}>
|
||||
{isChunkErr
|
||||
? 'Ein Teil der App konnte nicht aus dem Netz geladen werden. Das passiert häufig bei wechselndem Mobilfunk-Empfang. Versuche es mit einem Reload.'
|
||||
? 'Ein Teil der App konnte nicht geladen werden — auch ein automatischer Reload hat nicht geholfen. Bitte lade die Seite manuell neu (ggf. mit Strg+F5), oder prüfe die Verbindung zum Server.'
|
||||
: 'Beim Initialisieren der Oberfläche ist ein Fehler aufgetreten.'}
|
||||
</div>
|
||||
<pre style={{
|
||||
|
||||
@@ -15,11 +15,16 @@ const PAGE_TITLES: Record<string, string> = {
|
||||
'/domains': 'nav.domains',
|
||||
'/backends': 'nav.backends',
|
||||
'/routing-rules': 'nav.routing',
|
||||
'/networks': 'nav.networks',
|
||||
'/ip-addresses': 'nav.ipAddresses',
|
||||
'/firewall/live': 'nav.firewallLive',
|
||||
'/firewall': 'nav.firewall',
|
||||
'/cluster': 'nav.cluster',
|
||||
'/networks': 'nav.networks',
|
||||
'/ip-addresses': 'nav.ipAddresses',
|
||||
'/ssl': 'nav.ssl',
|
||||
'/dns': 'nav.dns',
|
||||
'/ntp': 'nav.ntp',
|
||||
'/vpn/wireguard': 'nav.wireguard',
|
||||
'/forward-proxy': 'nav.forwardProxy',
|
||||
'/firewall/live': 'nav.firewallLive',
|
||||
'/firewall': 'nav.firewall',
|
||||
'/cluster': 'nav.cluster',
|
||||
'/logs': 'nav.logs',
|
||||
'/audit': 'nav.audit',
|
||||
'/backups': 'nav.backups',
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
FireOutlined,
|
||||
GlobalOutlined,
|
||||
NodeIndexOutlined,
|
||||
RadarChartOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
SettingOutlined,
|
||||
TeamOutlined,
|
||||
@@ -76,6 +77,8 @@ const NAV: NavSection[] = [
|
||||
{ path: '/firewall/live', labelKey: 'nav.firewallLive', icon: <EyeOutlined />, child: true },
|
||||
{ path: '/vpn/wireguard', labelKey: 'nav.wireguard', icon: <ThunderboltOutlined /> },
|
||||
{ path: '/forward-proxy', labelKey: 'nav.forwardProxy', icon: <CloudServerOutlined /> },
|
||||
{ path: '/crowdsec', labelKey: 'nav.crowdsec', icon: <RadarChartOutlined /> },
|
||||
{ path: '/waf', labelKey: 'nav.waf', icon: <SafetyCertificateOutlined /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -112,13 +115,16 @@ export default function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||
queryKey: ['system', 'health'],
|
||||
queryFn: async () => {
|
||||
const r = await apiClient.get('/system/health')
|
||||
return isEnvelope(r.data) ? (r.data.data as { version?: string }) : { version: '' }
|
||||
return isEnvelope(r.data)
|
||||
? (r.data.data as { version?: string; hostname?: string })
|
||||
: { version: '' }
|
||||
},
|
||||
refetchInterval: 60_000,
|
||||
refetchOnWindowFocus: true,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
const version = health?.version || '…'
|
||||
const version = health?.version || '…'
|
||||
const hostname = health?.hostname || null
|
||||
|
||||
return (
|
||||
<nav className={`sidebar${isOpen ? ' open' : ''}`}>
|
||||
@@ -127,9 +133,9 @@ export default function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||
<span className="sidebar-logo-text">{t('app.title')}</span>
|
||||
</div>
|
||||
|
||||
{NAV.map((section) => (
|
||||
{NAV.map((section, idx) => (
|
||||
<div key={section.labelKey}>
|
||||
<div className="sidebar-section">
|
||||
<div className={`sidebar-section${idx > 0 ? ' sidebar-section--bordered' : ''}`}>
|
||||
<div className="sidebar-section-label">{t(section.labelKey)}</div>
|
||||
</div>
|
||||
<ul className="sidebar-menu">
|
||||
@@ -162,7 +168,14 @@ export default function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="sidebar-version">v{version}</div>
|
||||
<div className="sidebar-version">
|
||||
{hostname && (
|
||||
<div style={{ fontSize: 10, opacity: 0.6, marginBottom: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{hostname}
|
||||
</div>
|
||||
)}
|
||||
v{version}
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Alert, Button, Popconfirm, Space, Tooltip, message } from 'antd'
|
||||
import { CloudDownloadOutlined, ReloadOutlined, RocketOutlined } from '@ant-design/icons'
|
||||
import { Alert, Button, Popconfirm, Tooltip, message } from 'antd'
|
||||
import { CloudDownloadOutlined, ReloadOutlined, RocketOutlined, ClusterOutlined } from '@ant-design/icons'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -20,6 +20,19 @@ interface SystemHealth { status: string; version: string }
|
||||
|
||||
interface PendingUpdate { pkg: string; installed: string; available: string }
|
||||
|
||||
interface ClusterStatus {
|
||||
mode: string // "single-node" | "cluster"
|
||||
peers: Array<{ id: string; fqdn: string }>
|
||||
}
|
||||
|
||||
interface RollingUpdateState {
|
||||
phase: string // idle | updating-secondary | waiting-secondary | updating-primary | failed
|
||||
secondary_fqdn: string
|
||||
secondary_id: string
|
||||
error?: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// allUpdates parsed das flache map-Format ({pkg_installed,pkg_available})
|
||||
// das /system/package-versions zurückliefert. Eines davon ist meist
|
||||
// das meta-Paket "edgeguard" → die "Ziel-Version".
|
||||
@@ -53,6 +66,46 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}
|
||||
gcTime: 0,
|
||||
})
|
||||
|
||||
const clusterStatus = useQuery({
|
||||
queryKey: ['cluster', 'status-update-banner'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const r = await apiClient.get('/cluster/status')
|
||||
return isEnvelope(r.data) ? (r.data.data as ClusterStatus) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
refetchInterval: 60_000,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
const rollingStatus = useQuery({
|
||||
queryKey: ['cluster', 'rolling-update-status'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const r = await apiClient.get('/cluster/rolling-update/status')
|
||||
return isEnvelope(r.data) ? (r.data.data as RollingUpdateState) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
refetchInterval: 5_000,
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
})
|
||||
|
||||
const isCluster = clusterStatus.data?.mode === 'cluster'
|
||||
const rollingPhase = rollingStatus.data?.phase ?? 'idle'
|
||||
const rollingActive = rollingPhase !== 'idle' && rollingPhase !== 'failed' && rollingPhase !== 'done'
|
||||
const secondaryFQDN = rollingStatus.data?.secondary_fqdn ?? ''
|
||||
|
||||
// Verhindert dass ein stale "done" aus einer vorherigen Session sofort
|
||||
// einen Reload auslöst. Nur wenn rollingActive in DIESER Session true
|
||||
// war, reagieren wir auf "done".
|
||||
const wasRollingActiveRef = useRef(false)
|
||||
|
||||
// Normal single-node upgrade state
|
||||
const [upgrading, setUpgrading] = useState(false)
|
||||
const [upgradeElapsed, setUpgradeElapsed] = useState(0)
|
||||
const [forceChecking, setForceChecking] = useState(false)
|
||||
@@ -61,11 +114,62 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}
|
||||
const installedRef = useRef<string>('')
|
||||
const targetRef = useRef<string>('')
|
||||
|
||||
// Rolling update elapsed counter
|
||||
const [rollingElapsed, setRollingElapsed] = useState(0)
|
||||
const rollingTickRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
useEffect(() => () => {
|
||||
if (upgradePollRef.current) clearInterval(upgradePollRef.current)
|
||||
if (upgradeTickRef.current) clearInterval(upgradeTickRef.current)
|
||||
if (rollingTickRef.current) clearInterval(rollingTickRef.current)
|
||||
}, [])
|
||||
|
||||
// Start rolling elapsed timer when rolling becomes active
|
||||
useEffect(() => {
|
||||
if (rollingActive) {
|
||||
wasRollingActiveRef.current = true
|
||||
if (!rollingTickRef.current) {
|
||||
setRollingElapsed(0)
|
||||
rollingTickRef.current = setInterval(() => setRollingElapsed(e => e + 1), 1000)
|
||||
}
|
||||
} else if (!rollingActive && rollingTickRef.current) {
|
||||
clearInterval(rollingTickRef.current)
|
||||
rollingTickRef.current = null
|
||||
}
|
||||
}, [rollingActive])
|
||||
|
||||
// "done": nur reagieren wenn wir in DIESER Session rollingActive gesehen
|
||||
// haben — sonst würde ein stale "done" sofort einen Reload auslösen.
|
||||
useEffect(() => {
|
||||
if (rollingPhase === 'done' && wasRollingActiveRef.current) {
|
||||
msg.success(t('update.success', { version: targetRef.current || '…' }))
|
||||
setTimeout(() => window.location.reload(), 1500)
|
||||
}
|
||||
}, [rollingPhase, msg, t])
|
||||
|
||||
// Fallback: wenn "updating-primary" und die API noch antwortet (Primary
|
||||
// schon neu gestartet bevor das UI die Phase gesehen hat), poll auf "done".
|
||||
useEffect(() => {
|
||||
if (rollingPhase === 'updating-primary') {
|
||||
let sawDown = false
|
||||
const poll = setInterval(async () => {
|
||||
try {
|
||||
const res = await apiClient.get('/system/health')
|
||||
const newV = isEnvelope(res.data) ? (res.data.data as SystemHealth).version : ''
|
||||
if (sawDown && newV) {
|
||||
clearInterval(poll)
|
||||
void rollingStatus.refetch()
|
||||
}
|
||||
} catch {
|
||||
sawDown = true
|
||||
}
|
||||
}, 3000)
|
||||
// Safety: nach 2 Min einfach reload
|
||||
const safety = setTimeout(() => { clearInterval(poll); window.location.reload() }, 120_000)
|
||||
return () => { clearInterval(poll); clearTimeout(safety) }
|
||||
}
|
||||
}, [rollingPhase, rollingStatus])
|
||||
|
||||
const data = pkgVersions.data ?? {}
|
||||
const updates = allUpdates(data)
|
||||
const updateAvailable = updates.length > 0
|
||||
@@ -76,14 +180,8 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}
|
||||
const forceCheck = async () => {
|
||||
setForceChecking(true)
|
||||
try {
|
||||
// ?force=1: bypassed den Server-seitigen 5-min-Throttle für
|
||||
// apt-get update. Ohne den Force-Hint würde der Endpoint
|
||||
// einfach den letzten Cache zurückliefern (max. 5 min alt) und
|
||||
// der Button fühlt sich kaputt an. Pattern aus mail-gateway.
|
||||
const r = await apiClient.get('/system/package-versions?force=1')
|
||||
const fresh = (isEnvelope(r.data) ? (r.data.data as PackageVersions) : {})
|
||||
// useQuery-Cache mit dem frischen Wert füttern damit der Banner
|
||||
// sofort umschaltet, ohne auf die nächste 30s-Welle zu warten.
|
||||
void pkgVersions.refetch()
|
||||
const found = allUpdates(fresh).length > 0
|
||||
msg[found ? 'success' : 'info'](
|
||||
@@ -105,8 +203,6 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}
|
||||
|
||||
apiClient.post('/system/upgrade')
|
||||
.then(() => {
|
||||
// Poll /healthz (kein Auth, robust auch wenn die API gerade
|
||||
// restartet und Cookie ihre Session nicht erkennt).
|
||||
let sawDown = false
|
||||
upgradePollRef.current = setInterval(async () => {
|
||||
try {
|
||||
@@ -121,14 +217,9 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}
|
||||
setTimeout(() => window.location.reload(), 1500)
|
||||
}
|
||||
} catch {
|
||||
// Connection refused / 502 → API restartet. Beim nächsten
|
||||
// erfolgreichen Poll erkennen wir den Version-Flip.
|
||||
sawDown = true
|
||||
}
|
||||
}, 3000)
|
||||
// Sicherheits-Timeout: nach 2 Min einfach reload — falls der
|
||||
// Restart länger braucht als erwartet, kommt die UI in jedem
|
||||
// Fall wieder hoch.
|
||||
setTimeout(() => {
|
||||
if (upgradePollRef.current) clearInterval(upgradePollRef.current)
|
||||
if (upgradeTickRef.current) clearInterval(upgradeTickRef.current)
|
||||
@@ -144,13 +235,19 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}
|
||||
})
|
||||
}
|
||||
|
||||
const startRollingUpdate = () => {
|
||||
installedRef.current = installedVersion
|
||||
targetRef.current = targetVersion
|
||||
apiClient.post('/cluster/rolling-update')
|
||||
.then(() => {
|
||||
void rollingStatus.refetch()
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
msg.error(t('update.failed') + ': ' + e.message)
|
||||
})
|
||||
}
|
||||
|
||||
if (compact) {
|
||||
// Compact-Variante: Force-Check-Button für "ich will jetzt prüfen",
|
||||
// wenn aktuell NICHTS ausstehendes da ist. Sobald ein Update
|
||||
// verfügbar ist, übernimmt der gelbe Full-Mode-Banner (in
|
||||
// AppLayout) die Sichtbarkeit — wir blenden den Compact-Button
|
||||
// dann komplett aus, sonst doppelt-doppelt Info (Befund 2026-05-15:
|
||||
// "die roten Banner können weg, der gelbe Banner reicht").
|
||||
if (updateAvailable) {
|
||||
return <>{msgCtx}</>
|
||||
}
|
||||
@@ -171,7 +268,7 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}
|
||||
)
|
||||
}
|
||||
|
||||
if (!updateAvailable && !upgrading) {
|
||||
if (!updateAvailable && !upgrading && !rollingActive) {
|
||||
return <>{msgCtx}</>
|
||||
}
|
||||
|
||||
@@ -179,38 +276,43 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}
|
||||
<>
|
||||
{msgCtx}
|
||||
|
||||
{updateAvailable && !upgrading && (
|
||||
{updateAvailable && !upgrading && !rollingActive && (
|
||||
<Alert
|
||||
type="warning"
|
||||
banner
|
||||
showIcon
|
||||
icon={<CloudDownloadOutlined />}
|
||||
message={t('update.available', { version: targetVersion })}
|
||||
description={updates.length > 1
|
||||
? t('update.multiPackageHint', { count: updates.length })
|
||||
: undefined}
|
||||
action={
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
loading={forceChecking}
|
||||
onClick={forceCheck}
|
||||
>
|
||||
{t('update.checkNow')}
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title={t('update.confirmTitle')}
|
||||
description={t('update.confirmDesc', { version: targetVersion })}
|
||||
okText={t('update.applyNow')}
|
||||
cancelText={t('common.cancel')}
|
||||
onConfirm={startUpgrade}
|
||||
>
|
||||
<Button size="small" type="primary" icon={<CloudDownloadOutlined />}>
|
||||
{t('update.applyNow')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
message={
|
||||
<div className="update-banner-row">
|
||||
<span>{t('update.available', { version: targetVersion })}</span>
|
||||
{isCluster ? (
|
||||
<Popconfirm
|
||||
title={t('update.rollingConfirmTitle')}
|
||||
description={t('update.rollingConfirmDesc', {
|
||||
secondary: clusterStatus.data?.peers?.[0]?.fqdn ?? 'secondary',
|
||||
})}
|
||||
okText={t('update.rollingUpdate')}
|
||||
cancelText={t('common.cancel')}
|
||||
onConfirm={startRollingUpdate}
|
||||
>
|
||||
<Button size="small" type="primary" icon={<ClusterOutlined />}>
|
||||
Rolling Update
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : (
|
||||
<Popconfirm
|
||||
title={t('update.confirmTitle')}
|
||||
description={t('update.confirmDesc', { version: targetVersion })}
|
||||
okText={t('update.applyNow')}
|
||||
cancelText={t('common.cancel')}
|
||||
onConfirm={startUpgrade}
|
||||
>
|
||||
<Button size="small" type="primary" icon={<CloudDownloadOutlined />}>
|
||||
{t('update.applyNow')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
@@ -234,26 +336,10 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}
|
||||
</div>
|
||||
|
||||
<div className="update-modal__steps">
|
||||
<Step
|
||||
done={upgradeElapsed >= 5}
|
||||
active={upgradeElapsed < 5}
|
||||
label={t('update.stepDownload')}
|
||||
/>
|
||||
<Step
|
||||
done={upgradeElapsed >= 15}
|
||||
active={upgradeElapsed >= 5 && upgradeElapsed < 15}
|
||||
label={t('update.stepInstall')}
|
||||
/>
|
||||
<Step
|
||||
done={upgradeElapsed >= 25}
|
||||
active={upgradeElapsed >= 15 && upgradeElapsed < 25}
|
||||
label={t('update.stepRestart')}
|
||||
/>
|
||||
<Step
|
||||
done={false}
|
||||
active={upgradeElapsed >= 25}
|
||||
label={t('update.stepVerify')}
|
||||
/>
|
||||
<Step done={upgradeElapsed >= 5} active={upgradeElapsed < 5} label={t('update.stepDownload')} />
|
||||
<Step done={upgradeElapsed >= 15} active={upgradeElapsed >= 5 && upgradeElapsed < 15} label={t('update.stepInstall')} />
|
||||
<Step done={upgradeElapsed >= 25} active={upgradeElapsed >= 15 && upgradeElapsed < 25} label={t('update.stepRestart')} />
|
||||
<Step done={false} active={upgradeElapsed >= 25} label={t('update.stepVerify')} />
|
||||
</div>
|
||||
|
||||
<div className="update-modal__timer">{upgradeElapsed}s</div>
|
||||
@@ -261,6 +347,54 @@ export default function UpdateBanner({ compact = false }: UpdateBannerProps = {}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rollingActive && (
|
||||
<div className="update-modal-overlay">
|
||||
<div className="update-modal">
|
||||
<div className="update-modal__orbit">
|
||||
<div className="update-modal__ring" />
|
||||
<div className="update-modal__ring update-modal__ring--2" />
|
||||
<div className="update-modal__dot" />
|
||||
<div className="update-modal__dot update-modal__dot--2" />
|
||||
<div className="update-modal__center">
|
||||
<ClusterOutlined className="update-modal__icon" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="update-modal__title">{t('update.rollingRunning')}</div>
|
||||
<div className="update-modal__version">
|
||||
v{installedRef.current || '…'} → v{targetRef.current || '…'}
|
||||
</div>
|
||||
|
||||
<div className="update-modal__steps">
|
||||
<Step
|
||||
done={rollingPhase === 'waiting-secondary' || rollingPhase === 'updating-primary'}
|
||||
active={rollingPhase === 'updating-secondary'}
|
||||
label={t('update.rollingStepSecondary', { fqdn: secondaryFQDN })}
|
||||
/>
|
||||
<Step
|
||||
done={rollingPhase === 'updating-primary'}
|
||||
active={rollingPhase === 'waiting-secondary'}
|
||||
label={t('update.rollingStepWaiting')}
|
||||
/>
|
||||
<Step
|
||||
done={false}
|
||||
active={rollingPhase === 'updating-primary'}
|
||||
label={t('update.rollingStepPrimary')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="update-modal__timer">{rollingElapsed}s</div>
|
||||
<div className="update-modal__hint">{t('update.waitHint')}</div>
|
||||
|
||||
{rollingStatus.data?.error && (
|
||||
<div className="update-modal__hint" style={{ color: '#ff4d4f' }}>
|
||||
{rollingStatus.data.error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user