Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3dda81b49 | ||
|
|
b20ace8763 | ||
|
|
053b38e46c | ||
|
|
df31bfa720 |
@@ -194,6 +194,12 @@ func main() {
|
|||||||
}
|
}
|
||||||
// runSecondaryConfigRender wird weiter unten gestartet sobald
|
// runSecondaryConfigRender wird weiter unten gestartet sobald
|
||||||
// clusterAggregator verfügbar ist (braucht mTLS-Client für Cert-Sync).
|
// clusterAggregator verfügbar ist (braucht mTLS-Client für Cert-Sync).
|
||||||
|
} else if nodeID != "" && st != nil && st.Completed && st.FQDN != "" {
|
||||||
|
// Primary/Founder (kein joined Secondary): self (role=primary) an
|
||||||
|
// alle Peers pushen, damit deren lokale ha_nodes den Primary frisch
|
||||||
|
// hält — sonst zeigt die vom Secondary ausgelieferte UI den Primary
|
||||||
|
// als offline. No-op solange keine Peers existieren (Single-Node).
|
||||||
|
go runPeerPush(context.Background(), pool, clusterStore, nodeID, st.FQDN, version)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 3.3: Cluster-CA + Peer-Cert. Founder-Pfad — auf einem
|
// Phase 3.3: Cluster-CA + Peer-Cert. Founder-Pfad — auf einem
|
||||||
@@ -826,12 +832,20 @@ func runSecondaryConfigRender(ctx context.Context, pool *pgxpoolPool, box *secre
|
|||||||
}
|
}
|
||||||
|
|
||||||
// runPrimaryPush periodically pushes this secondary node's config_hash to the
|
// 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
|
// primary via mTLS. The primary's ha_nodes view only gets config_hash + last_seen
|
||||||
// during join-time autoRegister — after that the primary never hears about
|
// written during join-time autoRegister — after that the primary never hears about
|
||||||
// hash changes unless we push. Without this, the drift banner shows stale
|
// the secondary unless we push. Without this, the drift banner shows stale hashes
|
||||||
// hashes from join-time forever.
|
// from join-time forever AND the secondary's last_seen freezes → SweepStaleNodes
|
||||||
|
// marks it offline.
|
||||||
|
//
|
||||||
|
// WICHTIG: tick MUSS deutlich unter dem Stale-Threshold (4× 30s = 2 min, siehe
|
||||||
|
// scheduler.staleThreshold / cluster.SweepStaleNodes) liegen. Sonst flippt der
|
||||||
|
// Secondary zwischen den Pushes zwangsläufig auf "offline" (bei 5-min-Tick:
|
||||||
|
// 2 min online, 3 min offline). 30s = 4 Pushes pro Stale-Fenster → ein
|
||||||
|
// verpasster Push (Netz-Glitch) ist unkritisch. Der Receiver (AgentRegisterPeer)
|
||||||
|
// lädt nftables nur bei IP-Änderung neu → kein Reload-Sturm durch häufige Pushes.
|
||||||
func runPrimaryPush(ctx context.Context, pool *pgxpoolPool, nodeID, fqdn, version, primaryURL string) {
|
func runPrimaryPush(ctx context.Context, pool *pgxpoolPool, nodeID, fqdn, version, primaryURL string) {
|
||||||
const tick = 5 * time.Minute
|
const tick = 30 * time.Second
|
||||||
t := time.NewTicker(tick)
|
t := time.NewTicker(tick)
|
||||||
defer t.Stop()
|
defer t.Stop()
|
||||||
push := func() {
|
push := func() {
|
||||||
@@ -855,6 +869,51 @@ func runPrimaryPush(ctx context.Context, pool *pgxpoolPool, nodeID, fqdn, versio
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runPeerPush läuft auf dem Primary/Founder und pusht alle 30s die eigene
|
||||||
|
// Identität (role=primary) an jeden Peer via mTLS — das Gegenstück zu
|
||||||
|
// runPrimaryPush (Secondary→Primary). Zusammen ergibt das einen
|
||||||
|
// bidirektionalen Cross-Node-Heartbeat: beide Nodes sehen sich gegenseitig
|
||||||
|
// als online, egal von welchem Node die UI ausgeliefert wird. Tick wie
|
||||||
|
// runPrimaryPush deutlich unter dem 2-min-Stale-Threshold. No-op solange
|
||||||
|
// keine Peers existieren (Single-Node) bzw. wenn ein Peer down ist (Debug-Log).
|
||||||
|
func runPeerPush(ctx context.Context, pool *pgxpoolPool, store *cluster.Store, nodeID, fqdn, version string) {
|
||||||
|
const tick = 30 * time.Second
|
||||||
|
t := time.NewTicker(tick)
|
||||||
|
defer t.Stop()
|
||||||
|
push := func() {
|
||||||
|
pCtx, cancel := context.WithTimeout(ctx, 25*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
peers, err := store.List(pCtx)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("cluster: peer-push list failed", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hash, _ := cluster.ComputeConfigHash(pCtx, pool)
|
||||||
|
for i := range peers {
|
||||||
|
p := peers[i]
|
||||||
|
if p.ID == nodeID {
|
||||||
|
continue // nicht an sich selbst pushen
|
||||||
|
}
|
||||||
|
target := p.APIURL
|
||||||
|
if target == "" {
|
||||||
|
target = "https://" + p.FQDN
|
||||||
|
}
|
||||||
|
if err := clusterjoin.PushSelfToPeer(target, "", nodeID, fqdn, version, hash, "primary"); err != nil {
|
||||||
|
slog.Debug("cluster: push-to-peer failed", "peer", p.FQDN, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
push() // immediate push on API startup
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
push()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func randomEphemeralSecret() []byte {
|
func randomEphemeralSecret() []byte {
|
||||||
b := make([]byte, 32)
|
b := make([]byte, 32)
|
||||||
if _, err := rand.Read(b); err != nil {
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -359,7 +360,9 @@ END $$;`, egSubName, egSubName, egSubName, egSubName)
|
|||||||
"CREATE SUBSCRIPTION %s CONNECTION '%s' PUBLICATION %s WITH (copy_data = true, enabled = true);",
|
"CREATE SUBSCRIPTION %s CONNECTION '%s' PUBLICATION %s WITH (copy_data = true, enabled = true);",
|
||||||
egSubName, connStr, egPubName,
|
egSubName, connStr, egPubName,
|
||||||
)
|
)
|
||||||
if err := psqlDBExec("edgeguard", createSQL); err != nil {
|
// Via stdin (nicht -c), damit das Replikations-Passwort nicht in der
|
||||||
|
// Prozess-Argv (ps/proc) oder in PG-log_statement landet.
|
||||||
|
if err := psqlDBExecStdin("edgeguard", createSQL); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "cluster-setup-standby: create subscription: %v\n", err)
|
fmt.Fprintf(os.Stderr, "cluster-setup-standby: create subscription: %v\n", err)
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
@@ -470,7 +473,7 @@ func fetchReplicationCreds(host string, agentPort int, tlsDir string) (*pgReplic
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
url := fmt.Sprintf("https://%s:%d/agent/cluster/pg-replication-info", host, agentPort)
|
url := "https://" + net.JoinHostPort(host, strconv.Itoa(agentPort)) + "/agent/cluster/pg-replication-info"
|
||||||
resp, err := client.Get(url)
|
resp, err := client.Get(url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("GET %s: %w", url, err)
|
return nil, fmt.Errorf("GET %s: %w", url, err)
|
||||||
@@ -516,7 +519,7 @@ func syncMasterKey(host string, agentPort int, tlsDir string) error {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
url := fmt.Sprintf("https://%s:%d/agent/cluster/master-key", host, agentPort)
|
url := "https://" + net.JoinHostPort(host, strconv.Itoa(agentPort)) + "/agent/cluster/master-key"
|
||||||
resp, err := client.Get(url)
|
resp, err := client.Get(url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("GET %s: %w", url, err)
|
return fmt.Errorf("GET %s: %w", url, err)
|
||||||
@@ -568,6 +571,17 @@ func psqlDBExec(db, sql string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// psqlDBExecStdin führt SQL über stdin (`-f -`) aus statt `-c`, damit
|
||||||
|
// Secrets im SQL nicht in der Prozess-Argv / PG-Statement-Logs erscheinen.
|
||||||
|
func psqlDBExecStdin(db, sql string) error {
|
||||||
|
cmd := buildPsqlCmd([]string{"-d", db, "-v", "ON_ERROR_STOP=1", "-f", "-"})
|
||||||
|
cmd.Stdin = strings.NewReader(sql)
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// psqlDBRun führt psql-Kommandos gegen eine bestimmte Datenbank aus.
|
// psqlDBRun führt psql-Kommandos gegen eine bestimmte Datenbank aus.
|
||||||
func psqlDBRun(db string, args []string) ([]byte, error) {
|
func psqlDBRun(db string, args []string) ([]byte, error) {
|
||||||
baseArgs := []string{"-d", db}
|
baseArgs := []string{"-d", db}
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ func cmdRenderConfig(args []string) int {
|
|||||||
if skipReload {
|
if skipReload {
|
||||||
hap.SkipReload = true
|
hap.SkipReload = true
|
||||||
fw.SkipReload = true
|
fw.SkipReload = true
|
||||||
|
sq.SkipReload = true
|
||||||
|
wg.SkipReload = true
|
||||||
|
ub.SkipReload = true
|
||||||
|
cn.SkipReload = true
|
||||||
ke.SkipReload = true
|
ke.SkipReload = true
|
||||||
fr.SkipReload = true
|
fr.SkipReload = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ func main() {
|
|||||||
slog.Error("waf: SPOE agent stopped", "error", err)
|
slog.Error("waf: SPOE agent stopped", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
// Graceful shutdown (ctx cancelled): gepufferte Alerts flushen.
|
||||||
|
alertWriter.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// reload fetches all domain+waf_config pairs from DB and rebuilds engines.
|
// reload fetches all domain+waf_config pairs from DB and rebuilds engines.
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ type AutoFWRule struct {
|
|||||||
Proto string
|
Proto string
|
||||||
Port int
|
Port int
|
||||||
DstIP string
|
DstIP string
|
||||||
|
L3 string // "ip"/"ip6" — gesetzt für DstIP-Rules (Familie); leer = agnostisch
|
||||||
Iface string // optional: scope auf ein iifname (z.B. DHCP udp/67 nur auf LAN)
|
Iface string // optional: scope auf ein iifname (z.B. DHCP udp/67 nur auf LAN)
|
||||||
Comment string
|
Comment string
|
||||||
}
|
}
|
||||||
@@ -457,7 +458,21 @@ func (g *Generator) loadAutoRules(ctx context.Context) []AutoFWRule {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return out
|
// Familien-Tag (ip/ip6) für DstIP-basierte Auto-Rules setzen; eine
|
||||||
|
// IPv6-Listen-Adresse muss `ip6 daddr` ergeben (sonst lehnt nft das
|
||||||
|
// gesamte Ruleset ab). Unparsebare DstIPs werden verworfen.
|
||||||
|
tagged := out[:0]
|
||||||
|
for _, r := range out {
|
||||||
|
if r.DstIP != "" {
|
||||||
|
fam := addrFamily(r.DstIP)
|
||||||
|
if fam == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r.L3 = fam
|
||||||
|
}
|
||||||
|
tagged = append(tagged, r)
|
||||||
|
}
|
||||||
|
return tagged
|
||||||
}
|
}
|
||||||
|
|
||||||
// splitCSV — wie in den Service-renderern.
|
// splitCSV — wie in den Service-renderern.
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ func TestTemplate_autoRuleIface(t *testing.T) {
|
|||||||
view := &View{
|
view := &View{
|
||||||
AutoRules: []AutoFWRule{
|
AutoRules: []AutoFWRule{
|
||||||
{Proto: "udp", Port: 67, Iface: "eth1", Comment: "DHCP (Kea) auf eth1"},
|
{Proto: "udp", Port: 67, Iface: "eth1", Comment: "DHCP (Kea) auf eth1"},
|
||||||
{Proto: "udp", Port: 53, DstIP: "10.0.0.1", Comment: "DNS"},
|
{Proto: "udp", Port: 53, DstIP: "10.0.0.1", L3: "ip", Comment: "DNS"},
|
||||||
|
{Proto: "udp", Port: 53, DstIP: "2001:db8::1", L3: "ip6", Comment: "DNS v6"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
@@ -27,9 +28,13 @@ func TestTemplate_autoRuleIface(t *testing.T) {
|
|||||||
if !strings.Contains(out, `iifname "eth1" udp dport 67 accept comment "auto: DHCP (Kea) auf eth1"`) {
|
if !strings.Contains(out, `iifname "eth1" udp dport 67 accept comment "auto: DHCP (Kea) auf eth1"`) {
|
||||||
t.Errorf("missing iface-scoped DHCP auto-rule\n----\n%s", out)
|
t.Errorf("missing iface-scoped DHCP auto-rule\n----\n%s", out)
|
||||||
}
|
}
|
||||||
// Regression: DstIP-Auto-Rule ohne Iface bleibt unverändert.
|
// v4-DstIP-Auto-Rule: ip daddr.
|
||||||
if !strings.Contains(out, `ip daddr 10.0.0.1 udp dport 53 accept`) {
|
if !strings.Contains(out, `ip daddr 10.0.0.1 udp dport 53 accept`) {
|
||||||
t.Errorf("DstIP auto-rule changed\n----\n%s", out)
|
t.Errorf("v4 DstIP auto-rule wrong\n----\n%s", out)
|
||||||
|
}
|
||||||
|
// Fix #5: v6-DstIP muss `ip6 daddr` ergeben (sonst bricht nft das Ruleset).
|
||||||
|
if !strings.Contains(out, `ip6 daddr 2001:db8::1 udp dport 53 accept`) {
|
||||||
|
t.Errorf("v6 DstIP auto-rule must use ip6 daddr\n----\n%s", out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Echte nft-Syntaxvalidierung (braucht root → via sudo, sonst skip).
|
// Echte nft-Syntaxvalidierung (braucht root → via sudo, sonst skip).
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ table inet edgeguard {
|
|||||||
# editiert diese nicht. Wenn der Service entfernt/disabled
|
# editiert diese nicht. Wenn der Service entfernt/disabled
|
||||||
# wird, ist die Rule beim nächsten Render weg.
|
# wird, ist die Rule beim nächsten Render weg.
|
||||||
{{range .AutoRules}}
|
{{range .AutoRules}}
|
||||||
{{if .Iface}}iifname "{{.Iface}}" {{end}}{{if .DstIP}}ip daddr {{.DstIP}} {{end}}{{.Proto}} dport {{.Port}} accept comment "auto: {{.Comment}}"
|
{{if .Iface}}iifname "{{.Iface}}" {{end}}{{if .DstIP}}{{.L3}} daddr {{.DstIP}} {{end}}{{.Proto}} dport {{.Port}} accept comment "auto: {{.Comment}}"
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
# ── Operator-defined rules ──
|
# ── Operator-defined rules ──
|
||||||
|
|||||||
@@ -43,8 +43,12 @@ func New(pool *pgxpool.Pool, box *secrets.Box) *Generator {
|
|||||||
|
|
||||||
func (g *Generator) Name() string { return "freeradius" }
|
func (g *Generator) Name() string { return "freeradius" }
|
||||||
|
|
||||||
// confEscape escaped FreeRADIUS-double-quoted-Strings (Backslash + Quote).
|
// confEscape escaped FreeRADIUS-double-quoted-Strings (Backslash + Quote)
|
||||||
|
// und strippt Steuerzeichen (CR/LF) als Defense-in-Depth gegen Zeilen-
|
||||||
|
// Injection — die Werte werden zwar schon im Handler validiert.
|
||||||
func confEscape(s string) string {
|
func confEscape(s string) string {
|
||||||
|
s = strings.ReplaceAll(s, "\r", "")
|
||||||
|
s = strings.ReplaceAll(s, "\n", "")
|
||||||
s = strings.ReplaceAll(s, `\`, `\\`)
|
s = strings.ReplaceAll(s, `\`, `\\`)
|
||||||
s = strings.ReplaceAll(s, `"`, `\"`)
|
s = strings.ReplaceAll(s, `"`, `\"`)
|
||||||
return s
|
return s
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
|||||||
actor, role := "", "admin"
|
actor, role := "", "admin"
|
||||||
remote := c.ClientIP()
|
remote := c.ClientIP()
|
||||||
var totpEnabled bool
|
var totpEnabled bool
|
||||||
|
var viaDB bool // true wenn Rolle/TOTP bereits aus der DB-Row stammen
|
||||||
|
|
||||||
// 1. Try DB users table first.
|
// 1. Try DB users table first.
|
||||||
if h.Users != nil {
|
if h.Users != nil {
|
||||||
@@ -134,6 +135,7 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
|||||||
actor = ai.Email
|
actor = ai.Email
|
||||||
role = ai.Role
|
role = ai.Role
|
||||||
totpEnabled = ai.TOTPEnabled
|
totpEnabled = ai.TOTPEnabled
|
||||||
|
viaDB = true
|
||||||
h.Users.RecordLogin(c.Request.Context(), ai.ID)
|
h.Users.RecordLogin(c.Request.Context(), ai.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,6 +170,18 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bei Fallback (Setup-Store) / Federation (Primary) stammen role/TOTP
|
||||||
|
// NICHT aus der DB. Rolle + TOTP-Status autoritativ aus der lokalen
|
||||||
|
// (replizierten) users-Row ableiten — damit 2FA greift und die Rolle
|
||||||
|
// nie aus einer Remote-Payload kommt. Ist der User lokal (noch) nicht
|
||||||
|
// vorhanden (Replikations-Lag/DB aus), bleibt es beim Fallback-Wert.
|
||||||
|
if actor != "" && !viaDB && h.Users != nil {
|
||||||
|
if ai, err := h.Users.FindForAuth(c.Request.Context(), actor); err == nil {
|
||||||
|
role = ai.Role
|
||||||
|
totpEnabled = ai.TOTPEnabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TOTP gate: password OK but 2FA required → issue a short-lived pending
|
// TOTP gate: password OK but 2FA required → issue a short-lived pending
|
||||||
// cookie and tell the UI to show the TOTP input.
|
// cookie and tell the UI to show the TOTP input.
|
||||||
if totpEnabled {
|
if totpEnabled {
|
||||||
|
|||||||
@@ -866,6 +866,7 @@ type registerPeerRequest struct {
|
|||||||
MgmtIP string `json:"mgmt_ip"` // optional
|
MgmtIP string `json:"mgmt_ip"` // optional
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
ConfigHash *string `json:"config_hash"` // nil=absent (don't change), ""=no user config
|
ConfigHash *string `json:"config_hash"` // nil=absent (don't change), ""=no user config
|
||||||
|
Role string `json:"role"` // "" → "peer" (joining peer); "primary" beim Push des Primary
|
||||||
}
|
}
|
||||||
|
|
||||||
// AgentRegisterPeer: vom Joiner nach issue-cert via mTLS aufgerufen.
|
// AgentRegisterPeer: vom Joiner nach issue-cert via mTLS aufgerufen.
|
||||||
@@ -904,12 +905,21 @@ func (h *ClusterHandler) AgentRegisterPeer(c *gin.Context) {
|
|||||||
// Node, hier ist der „Self" der joining-Peer auf dieser Primary-Seite.
|
// Node, hier ist der „Self" der joining-Peer auf dieser Primary-Seite.
|
||||||
// Der Name passt nicht 100% semantisch, aber das SQL ist exakt das was
|
// Der Name passt nicht 100% semantisch, aber das SQL ist exakt das was
|
||||||
// wir brauchen.)
|
// wir brauchen.)
|
||||||
|
// Rolle aus dem Request (default "peer"). Ein joining-Peer sendet keine
|
||||||
|
// Rolle → "peer". Der Primary-Push sendet "primary", damit die vom
|
||||||
|
// Secondary ausgelieferte UI den Primary korrekt als primary zeigt.
|
||||||
|
// Cert-CN authentifiziert die FQDN; role ist node-lokal/Anzeige (echte
|
||||||
|
// Rollenerkennung läuft über pg_publication).
|
||||||
|
role := strings.TrimSpace(req.Role)
|
||||||
|
if role == "" {
|
||||||
|
role = "peer"
|
||||||
|
}
|
||||||
n := models.HANode{
|
n := models.HANode{
|
||||||
ID: req.ID,
|
ID: req.ID,
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
FQDN: req.FQDN,
|
FQDN: req.FQDN,
|
||||||
APIURL: req.APIURL,
|
APIURL: req.APIURL,
|
||||||
Role: "peer",
|
Role: role,
|
||||||
Status: "online", // peer IS online — it just connected via mTLS
|
Status: "online", // peer IS online — it just connected via mTLS
|
||||||
}
|
}
|
||||||
if req.PublicIP != "" {
|
if req.PublicIP != "" {
|
||||||
@@ -965,7 +975,14 @@ func (h *ClusterHandler) AgentRegisterPeer(c *gin.Context) {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("cluster: peer registered via mTLS",
|
// Bei neuem Peer / IP-Wechsel als Info loggen (relevantes Ereignis),
|
||||||
|
// sonst Debug — die periodischen 30s-Pushes (runPrimaryPush/runPeerPush)
|
||||||
|
// würden sonst das Log fluten.
|
||||||
|
logFn := slog.Debug
|
||||||
|
if ipChanged {
|
||||||
|
logFn = slog.Info
|
||||||
|
}
|
||||||
|
logFn("cluster: peer registered via mTLS",
|
||||||
"id", out.ID, "fqdn", out.FQDN, "role", out.Role, "status", out.Status,
|
"id", out.ID, "fqdn", out.FQDN, "role", out.Role, "status", out.Status,
|
||||||
"client_cn", cn, "remote", c.ClientIP())
|
"client_cn", cn, "remote", c.ClientIP())
|
||||||
response.OK(c, out)
|
response.OK(c, out)
|
||||||
|
|||||||
@@ -81,7 +81,14 @@ func (h *ClusterHandler) RepairReplication(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if h.nodeHasPublication(ctx) {
|
isPrimary, err := h.nodeHasPublication(ctx)
|
||||||
|
if err != nil {
|
||||||
|
// Primary/Subscriber-Status nicht ermittelbar → NICHT raten
|
||||||
|
// (sonst Resync auf dem falschen Node). Abbrechen.
|
||||||
|
response.Internal(c, fmt.Errorf("primary-status nicht ermittelbar: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if isPrimary {
|
||||||
// Primary → an den Subscriber-Peer delegieren, mit eigener Adresse.
|
// Primary → an den Subscriber-Peer delegieren, mit eigener Adresse.
|
||||||
if h.Aggregator == nil {
|
if h.Aggregator == nil {
|
||||||
response.BadRequest(c, errors.New("kein mTLS-Aggregator verfügbar — Resync nicht delegierbar"))
|
response.BadRequest(c, errors.New("kein mTLS-Aggregator verfügbar — Resync nicht delegierbar"))
|
||||||
@@ -162,7 +169,12 @@ func (h *ClusterHandler) startResync(ctx context.Context, primaryHost string) er
|
|||||||
}
|
}
|
||||||
// Niemals auf dem Primary (Publication-Quelle) resyncen — würde die
|
// Niemals auf dem Primary (Publication-Quelle) resyncen — würde die
|
||||||
// eigene Config mit sich selbst überschreiben bzw. ist sinnlos.
|
// eigene Config mit sich selbst überschreiben bzw. ist sinnlos.
|
||||||
if h.nodeHasPublication(ctx) {
|
// Bei Statusfehler fail-closed (NICHT resyncen).
|
||||||
|
isPrimary, err := h.nodeHasPublication(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("publication-status nicht ermittelbar: %w", err)
|
||||||
|
}
|
||||||
|
if isPrimary {
|
||||||
return errors.New("dieser Node ist der Publication-Primary — Resync läuft nur auf einem Subscriber")
|
return errors.New("dieser Node ist der Publication-Primary — Resync läuft nur auf einem Subscriber")
|
||||||
}
|
}
|
||||||
if st := repairUnitState(); st == "activating" || st == "active" {
|
if st := repairUnitState(); st == "activating" || st == "active" {
|
||||||
@@ -201,9 +213,9 @@ rm -f %[2]s
|
|||||||
// nodeHasPublication prüft, ob dieser Node die Replikations-Publication
|
// nodeHasPublication prüft, ob dieser Node die Replikations-Publication
|
||||||
// besitzt — das verlässliche Primary-Signal. pg_publication ist für jeden
|
// besitzt — das verlässliche Primary-Signal. pg_publication ist für jeden
|
||||||
// DB-User lesbar (anders als pg_subscription).
|
// DB-User lesbar (anders als pg_subscription).
|
||||||
func (h *ClusterHandler) nodeHasPublication(ctx context.Context) bool {
|
func (h *ClusterHandler) nodeHasPublication(ctx context.Context) (bool, error) {
|
||||||
if h.Store == nil || h.Store.Pool == nil {
|
if h.Store == nil || h.Store.Pool == nil {
|
||||||
return false
|
return false, errors.New("no db pool")
|
||||||
}
|
}
|
||||||
cctx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
cctx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -211,9 +223,9 @@ func (h *ClusterHandler) nodeHasPublication(ctx context.Context) bool {
|
|||||||
if err := h.Store.Pool.QueryRow(cctx,
|
if err := h.Store.Pool.QueryRow(cctx,
|
||||||
`SELECT EXISTS(SELECT 1 FROM pg_publication WHERE pubname = $1)`, repairPubName,
|
`SELECT EXISTS(SELECT 1 FROM pg_publication WHERE pubname = $1)`, repairPubName,
|
||||||
).Scan(&exists); err != nil {
|
).Scan(&exists); err != nil {
|
||||||
return false
|
return false, err
|
||||||
}
|
}
|
||||||
return exists
|
return exists, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// repairStatusResponse spiegelt den Zustand der transienten Repair-Unit.
|
// repairStatusResponse spiegelt den Zustand der transienten Repair-Unit.
|
||||||
@@ -231,7 +243,9 @@ type repairStatusResponse struct {
|
|||||||
// Status vom Subscriber-Peer geholt (dort läuft der Job); sonst lokal.
|
// Status vom Subscriber-Peer geholt (dort läuft der Job); sonst lokal.
|
||||||
func (h *ClusterHandler) RepairReplicationStatus(c *gin.Context) {
|
func (h *ClusterHandler) RepairReplicationStatus(c *gin.Context) {
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
if h.Store != nil && h.nodeHasPublication(ctx) && h.Aggregator != nil {
|
// Status-Poll: bei Fehler kein 500 — einfach lokalen Status liefern.
|
||||||
|
isPrimary, _ := h.nodeHasPublication(ctx)
|
||||||
|
if h.Store != nil && isPrimary && h.Aggregator != nil {
|
||||||
if all, err := h.Store.List(ctx); err == nil {
|
if all, err := h.Store.List(ctx); err == nil {
|
||||||
if peer := findOtherPeer(all, h.LocalID); peer != nil {
|
if peer := findOtherPeer(all, h.LocalID); peer != nil {
|
||||||
results := h.Aggregator.FanOut(ctx,
|
results := h.Aggregator.FanOut(ctx,
|
||||||
|
|||||||
@@ -7,14 +7,21 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
"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/models"
|
||||||
|
aptsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/apt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ruStateMu serialisiert Lesen/Schreiben der Rolling-Update-State-Datei
|
||||||
|
// (HTTP-Handler + Hintergrund-Goroutine greifen gleichzeitig zu).
|
||||||
|
var ruStateMu sync.Mutex
|
||||||
|
|
||||||
const rollingUpdateStateFile = "/var/lib/edgeguard/rolling-update-state.json"
|
const rollingUpdateStateFile = "/var/lib/edgeguard/rolling-update-state.json"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -26,17 +33,24 @@ const (
|
|||||||
phaseFailed = "failed"
|
phaseFailed = "failed"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FinishRollingUpdateIfPending wird beim API-Start aufgerufen. Wenn die
|
// FinishRollingUpdateIfPending wird beim API-Start aufgerufen.
|
||||||
// State-Datei "updating-primary" enthält, bedeutet das dass der Primary
|
// - "updating-primary": der Primary ist gerade erfolgreich neugestartet →
|
||||||
// gerade erfolgreich neugestartet ist → Update abgeschlossen → "done" schreiben.
|
// Update abgeschlossen → "done".
|
||||||
|
// - "updating-secondary"/"waiting-secondary": die orchestrierende Goroutine
|
||||||
|
// lief in DIESEM (jetzt neu gestarteten) Prozess und ist mit ihm gestorben.
|
||||||
|
// Die Phase kann nicht weiterlaufen → auf "idle" zurücksetzen, sonst zeigt
|
||||||
|
// die UI ewig "Rolling Update läuft". (Vorher blieb so ein Stand hängen.)
|
||||||
func FinishRollingUpdateIfPending() {
|
func FinishRollingUpdateIfPending() {
|
||||||
st := readRollingUpdateState()
|
st := readRollingUpdateState()
|
||||||
if st.Phase == phaseUpdatingPrimary {
|
switch st.Phase {
|
||||||
|
case phaseUpdatingPrimary:
|
||||||
writeRollingUpdateState(RollingUpdateState{
|
writeRollingUpdateState(RollingUpdateState{
|
||||||
Phase: phaseDone,
|
Phase: phaseDone,
|
||||||
SecondaryID: st.SecondaryID,
|
SecondaryID: st.SecondaryID,
|
||||||
SecondaryFQDN: st.SecondaryFQDN,
|
SecondaryFQDN: st.SecondaryFQDN,
|
||||||
})
|
})
|
||||||
|
case phaseUpdatingSecondary, phaseWaitingSecondary:
|
||||||
|
writeRollingUpdateState(RollingUpdateState{Phase: phaseIdle})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +67,8 @@ type RollingUpdateState struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func readRollingUpdateState() RollingUpdateState {
|
func readRollingUpdateState() RollingUpdateState {
|
||||||
|
ruStateMu.Lock()
|
||||||
|
defer ruStateMu.Unlock()
|
||||||
data, err := os.ReadFile(rollingUpdateStateFile)
|
data, err := os.ReadFile(rollingUpdateStateFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return RollingUpdateState{Phase: phaseIdle, UpdatedAt: time.Now()}
|
return RollingUpdateState{Phase: phaseIdle, UpdatedAt: time.Now()}
|
||||||
@@ -61,6 +77,13 @@ func readRollingUpdateState() RollingUpdateState {
|
|||||||
if err := json.Unmarshal(data, &s); err != nil {
|
if err := json.Unmarshal(data, &s); err != nil {
|
||||||
return RollingUpdateState{Phase: phaseIdle, UpdatedAt: time.Now()}
|
return RollingUpdateState{Phase: phaseIdle, UpdatedAt: time.Now()}
|
||||||
}
|
}
|
||||||
|
// Terminale Zustände altern aus (statt Mutation-on-GET): nach 10 min
|
||||||
|
// gilt done/failed als idle — so verliert kein paralleler Poller das
|
||||||
|
// Ergebnis und ein alter Stand bleibt nicht hängen.
|
||||||
|
if (s.Phase == phaseDone || s.Phase == phaseFailed) && !s.UpdatedAt.IsZero() &&
|
||||||
|
time.Since(s.UpdatedAt) > 10*time.Minute {
|
||||||
|
return RollingUpdateState{Phase: phaseIdle, UpdatedAt: time.Now()}
|
||||||
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +94,10 @@ func writeRollingUpdateState(s RollingUpdateState) {
|
|||||||
slog.Warn("rolling-update: failed to marshal state", "error", err)
|
slog.Warn("rolling-update: failed to marshal state", "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(rollingUpdateStateFile, data, 0o600); err != nil {
|
ruStateMu.Lock()
|
||||||
|
defer ruStateMu.Unlock()
|
||||||
|
// AtomicWrite (temp+rename) → Leser sehen nie einen partiellen Stand.
|
||||||
|
if err := configgen.AtomicWrite(rollingUpdateStateFile, data, 0o600); err != nil {
|
||||||
slog.Warn("rolling-update: failed to write state file", "error", err)
|
slog.Warn("rolling-update: failed to write state file", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,19 +153,30 @@ func (h *ClusterHandler) RollingUpdate(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RollingUpdateStatus gibt den aktuellen Rolling-Update-State zurück.
|
// RollingUpdateStatus gibt den aktuellen Rolling-Update-State zurück.
|
||||||
// Bei phase == "done" wird nach Auslieferung sofort auf idle zurückgesetzt
|
// Read-only — terminale Zustände altern in readRollingUpdateState aus
|
||||||
// damit der nächste Pageload keinen Stale-done vorfindet.
|
// (kein Reset-on-GET mehr, das parallelen Pollern das "done" wegnahm).
|
||||||
func (h *ClusterHandler) RollingUpdateStatus(c *gin.Context) {
|
func (h *ClusterHandler) RollingUpdateStatus(c *gin.Context) {
|
||||||
st := readRollingUpdateState()
|
response.OK(c, readRollingUpdateState())
|
||||||
response.OK(c, st)
|
|
||||||
if st.Phase == phaseDone {
|
|
||||||
writeRollingUpdateState(RollingUpdateState{Phase: phaseIdle})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *ClusterHandler) runRollingUpdate(secondary *models.HANode) {
|
func (h *ClusterHandler) runRollingUpdate(secondary *models.HANode) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Zielversion = das verfügbare apt-Candidate (worauf wir hochziehen) und
|
||||||
|
// die aktuelle Secondary-Version als Baseline. Beides steuert, ob der
|
||||||
|
// Secondary überhaupt etwas zu tun hat.
|
||||||
|
candidate := rollingCandidateVersion(ctx)
|
||||||
|
baseline := secondaryVersion(ctx, h, secondary)
|
||||||
|
|
||||||
|
// Ist der Secondary bereits auf der Zielversion, gibt es nichts
|
||||||
|
// hochzuziehen — KEIN Trigger, KEIN Warten. Sonst würde auf einen
|
||||||
|
// Version-Flip gewartet, der nie kommt → 10-min-Timeout (der frühere Bug,
|
||||||
|
// wenn beide Nodes schon aktuell waren).
|
||||||
|
secondaryUpToDate := candidate != "" && baseline != "" && baseline == candidate
|
||||||
|
if secondaryUpToDate {
|
||||||
|
slog.Info("rolling-update: secondary already at target — skipping secondary step",
|
||||||
|
"version", candidate)
|
||||||
|
} else {
|
||||||
// 1. Secondary triggern
|
// 1. Secondary triggern
|
||||||
slog.Info("rolling-update: posting trigger-update to secondary", "fqdn", secondary.FQDN)
|
slog.Info("rolling-update: posting trigger-update to secondary", "fqdn", secondary.FQDN)
|
||||||
result := h.Aggregator.PostPeer(ctx, *secondary, "/agent/cluster/trigger-update")
|
result := h.Aggregator.PostPeer(ctx, *secondary, "/agent/cluster/trigger-update")
|
||||||
@@ -161,7 +198,8 @@ func (h *ClusterHandler) runRollingUpdate(secondary *models.HANode) {
|
|||||||
SecondaryID: secondary.ID,
|
SecondaryID: secondary.ID,
|
||||||
SecondaryFQDN: secondary.FQDN,
|
SecondaryFQDN: secondary.FQDN,
|
||||||
})
|
})
|
||||||
slog.Info("rolling-update: waiting for secondary version flip")
|
slog.Info("rolling-update: waiting for secondary version flip",
|
||||||
|
"baseline", baseline, "candidate", candidate)
|
||||||
|
|
||||||
// Kurze Wartezeit damit apt auf dem Secondary erst losläuft
|
// Kurze Wartezeit damit apt auf dem Secondary erst losläuft
|
||||||
time.Sleep(20 * time.Second)
|
time.Sleep(20 * time.Second)
|
||||||
@@ -175,8 +213,13 @@ func (h *ClusterHandler) runRollingUpdate(secondary *models.HANode) {
|
|||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(results[0].Data, &ver); err == nil {
|
if err := json.Unmarshal(results[0].Data, &ver); err == nil {
|
||||||
slog.Info("rolling-update: secondary version", "version", ver.Version, "primary", h.Version)
|
slog.Info("rolling-update: secondary version", "version", ver.Version,
|
||||||
if ver.Version != h.Version {
|
"baseline", baseline, "candidate", candidate)
|
||||||
|
// Erfolg = Secondary hat die Zielversion erreicht (candidate)
|
||||||
|
// ODER hat sich gegenüber der Baseline überhaupt bewegt
|
||||||
|
// (Fallback, wenn candidate nicht ermittelbar war).
|
||||||
|
if ver.Version != "" &&
|
||||||
|
((candidate != "" && ver.Version == candidate) || ver.Version != baseline) {
|
||||||
versionFlipped = true
|
versionFlipped = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -195,8 +238,23 @@ func (h *ClusterHandler) runRollingUpdate(secondary *models.HANode) {
|
|||||||
slog.Warn("rolling-update: secondary version flip timeout")
|
slog.Warn("rolling-update: secondary version flip timeout")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Primary (uns selbst) aktualisieren — identisch zu /system/upgrade.
|
||||||
|
// Ist der Primary bereits auf der Zielversion (z. B. beide Nodes schon
|
||||||
|
// aktuell), gibt es nichts zu tun → direkt "done". Sonst liefe ein
|
||||||
|
// apt-Lauf ohne Paket-Wechsel → kein Restart → Phase hinge ewig in
|
||||||
|
// "updating-primary".
|
||||||
|
if candidate != "" && h.Version == candidate {
|
||||||
|
slog.Info("rolling-update: primary already at target — nothing to upgrade", "version", candidate)
|
||||||
|
writeRollingUpdateState(RollingUpdateState{
|
||||||
|
Phase: phaseDone,
|
||||||
|
SecondaryID: secondary.ID,
|
||||||
|
SecondaryFQDN: secondary.FQDN,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Primary (uns selbst) aktualisieren — identisch zu /system/upgrade
|
|
||||||
writeRollingUpdateState(RollingUpdateState{
|
writeRollingUpdateState(RollingUpdateState{
|
||||||
Phase: phaseUpdatingPrimary,
|
Phase: phaseUpdatingPrimary,
|
||||||
SecondaryID: secondary.ID,
|
SecondaryID: secondary.ID,
|
||||||
@@ -256,3 +314,26 @@ rm -f /var/lib/edgeguard/upgrade.sh
|
|||||||
// UI erkennt Version-Flip via /system/health und schließt den Flow.
|
// UI erkennt Version-Flip via /system/health und schließt den Flow.
|
||||||
slog.Info("rolling-update: primary upgrade dispatched, process will restart")
|
slog.Info("rolling-update: primary upgrade dispatched, process will restart")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rollingCandidateVersion liefert best-effort die verfügbare apt-Candidate-
|
||||||
|
// Version des Meta-Pakets "edgeguard" — also die Version, auf die das Rolling-
|
||||||
|
// Update hochzieht. Leerer String, wenn apt sie nicht ermitteln kann (dann
|
||||||
|
// fällt runRollingUpdate auf reine Baseline-Flip-Erkennung zurück).
|
||||||
|
func rollingCandidateVersion(ctx context.Context) string {
|
||||||
|
vers := aptsvc.PackageVersions(ctx, false)
|
||||||
|
return vers["edgeguard_available"]
|
||||||
|
}
|
||||||
|
|
||||||
|
// secondaryVersion holt best-effort die laufende Version des Peers via mTLS.
|
||||||
|
func secondaryVersion(ctx context.Context, h *ClusterHandler, secondary *models.HANode) string {
|
||||||
|
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 json.Unmarshal(results[0].Data, &ver) == nil {
|
||||||
|
return ver.Version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -148,11 +148,16 @@ func (b *clientBody) validate(creating bool) error {
|
|||||||
return errors.New("ipaddr ist keine gültige IP/CIDR: " + b.IPAddr)
|
return errors.New("ipaddr ist keine gültige IP/CIDR: " + b.IPAddr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if creating && (b.Secret == nil || len(*b.Secret) < 6) {
|
if creating && b.Secret == nil {
|
||||||
return errors.New("secret ist erforderlich (mind. 6 Zeichen)")
|
return errors.New("secret ist erforderlich")
|
||||||
|
}
|
||||||
|
if b.Secret != nil {
|
||||||
|
if len(*b.Secret) < 6 {
|
||||||
|
return errors.New("secret muss mind. 6 Zeichen haben (leer löscht es nicht)")
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(*b.Secret, "\r\n") {
|
||||||
|
return errors.New("secret darf keine Zeilenumbrüche enthalten")
|
||||||
}
|
}
|
||||||
if b.Secret != nil && *b.Secret != "" && len(*b.Secret) < 6 {
|
|
||||||
return errors.New("secret muss mind. 6 Zeichen haben")
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -266,6 +271,14 @@ func (b *userBody) validate(creating bool) error {
|
|||||||
if creating && (b.Password == nil || *b.Password == "") {
|
if creating && (b.Password == nil || *b.Password == "") {
|
||||||
return errors.New("password ist erforderlich")
|
return errors.New("password ist erforderlich")
|
||||||
}
|
}
|
||||||
|
if b.Password != nil {
|
||||||
|
if *b.Password == "" {
|
||||||
|
return errors.New("password darf nicht leer sein (löscht es nicht)")
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(*b.Password, "\r\n") {
|
||||||
|
return errors.New("password darf keine Zeilenumbrüche enthalten")
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,11 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
@@ -15,6 +18,10 @@ import (
|
|||||||
wafsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/waf"
|
wafsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/waf"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// wafRuleIDRe erlaubt nur einzelne CRS-Rule-IDs oder Ranges ("942100" /
|
||||||
|
// "942100-942999") als Exclusion — verhindert SecLang-Direktiven-Injection.
|
||||||
|
var wafRuleIDRe = regexp.MustCompile(`^[0-9]{1,9}(-[0-9]{1,9})?$`)
|
||||||
|
|
||||||
// WafHandler exposes the per-domain WAF configuration REST API:
|
// WafHandler exposes the per-domain WAF configuration REST API:
|
||||||
//
|
//
|
||||||
// GET /waf/configs — list all configs (one per domain)
|
// GET /waf/configs — list all configs (one per domain)
|
||||||
@@ -110,6 +117,27 @@ func (h *WafHandler) Upsert(c *gin.Context) {
|
|||||||
if body.ExclusionNotes == nil {
|
if body.ExclusionNotes == nil {
|
||||||
body.ExclusionNotes = map[string]string{}
|
body.ExclusionNotes = map[string]string{}
|
||||||
}
|
}
|
||||||
|
// Exclusions müssen reine Rule-IDs/Ranges sein (sonst Direktiven-Injection
|
||||||
|
// in die SecLang-Config via Newline).
|
||||||
|
for _, ex := range body.RuleExclusions {
|
||||||
|
if !wafRuleIDRe.MatchString(strings.TrimSpace(ex)) {
|
||||||
|
response.BadRequest(c, errors.New("ungültige Rule-Exclusion (nur IDs/Ranges erlaubt): "+ex))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Trusted-Proxies müssen gültige IPs/CIDRs sein.
|
||||||
|
for _, p := range body.TrustedProxies {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if net.ParseIP(p) == nil {
|
||||||
|
if _, _, err := net.ParseCIDR(p); err != nil {
|
||||||
|
response.BadRequest(c, errors.New("ungültiger Trusted-Proxy (IP/CIDR): "+p))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
cfg := models.WafConfig{
|
cfg := models.WafConfig{
|
||||||
DomainID: domainID,
|
DomainID: domainID,
|
||||||
Enabled: body.Enabled,
|
Enabled: body.Enabled,
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ func (g *Generator) buildConfig(ctx context.Context) (*keaConfig, *bool, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
ifaceSet := map[string]bool{}
|
ifaceSet := map[string]bool{}
|
||||||
var ifaces []string
|
ifaces := []string{} // nie nil → JSON "[]" statt "null" (Kea lehnt null ab)
|
||||||
var sn4 []subnet4
|
var sn4 []subnet4
|
||||||
|
|
||||||
for _, s := range subnets {
|
for _, s := range subnets {
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ func Join(req Request) error {
|
|||||||
// synchronous on the primary side.
|
// synchronous on the primary side.
|
||||||
var autoRegErr error
|
var autoRegErr error
|
||||||
for i := 0; i < 3; i++ {
|
for i := 0; i < 3; i++ {
|
||||||
if err := autoRegister(primary, tlsDir, req.CommonName, req.Version, req.NodeID, ""); err == nil {
|
if err := autoRegister(primary, tlsDir, req.CommonName, req.Version, req.NodeID, "", "peer"); err == nil {
|
||||||
autoRegErr = nil
|
autoRegErr = nil
|
||||||
break
|
break
|
||||||
} else {
|
} else {
|
||||||
@@ -222,13 +222,21 @@ func issueCert(primary, token, csr string, insecure bool) (caCert, peerCert stri
|
|||||||
// goroutine so the primary's ha_nodes always reflects the secondary's actual
|
// goroutine so the primary's ha_nodes always reflects the secondary's actual
|
||||||
// config_hash (not the stale join-time value).
|
// config_hash (not the stale join-time value).
|
||||||
func PushSelfToPrimary(primaryURL, tlsDir, nodeID, fqdn, version, configHash string) error {
|
func PushSelfToPrimary(primaryURL, tlsDir, nodeID, fqdn, version, configHash string) error {
|
||||||
|
return PushSelfToPeer(primaryURL, tlsDir, nodeID, fqdn, version, configHash, "peer")
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushSelfToPeer sendet die eigene Identität an einen beliebigen Peer (mTLS,
|
||||||
|
// /agent/cluster/peers). role bestimmt, mit welcher Rolle sich dieser Node
|
||||||
|
// beim Empfänger einträgt: ein Secondary pusht "peer" an den Primary, der
|
||||||
|
// Primary pusht "primary" an jeden Secondary (bidirektionaler Heartbeat).
|
||||||
|
func PushSelfToPeer(peerURL, tlsDir, nodeID, fqdn, version, configHash, role string) error {
|
||||||
if tlsDir == "" {
|
if tlsDir == "" {
|
||||||
tlsDir = clustertls.DefaultDir
|
tlsDir = clustertls.DefaultDir
|
||||||
}
|
}
|
||||||
return autoRegister(primaryURL, tlsDir, fqdn, version, nodeID, configHash)
|
return autoRegister(peerURL, tlsDir, fqdn, version, nodeID, configHash, role)
|
||||||
}
|
}
|
||||||
|
|
||||||
func autoRegister(primary, tlsDir, commonName, version, nodeID, configHash string) error {
|
func autoRegister(primary, tlsDir, commonName, version, nodeID, configHash, role string) error {
|
||||||
u, err := url.Parse(primary)
|
u, err := url.Parse(primary)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -241,6 +249,9 @@ func autoRegister(primary, tlsDir, commonName, version, nodeID, configHash strin
|
|||||||
nodeID = strings.TrimSpace(string(raw))
|
nodeID = strings.TrimSpace(string(raw))
|
||||||
}
|
}
|
||||||
hostname, _ := os.Hostname()
|
hostname, _ := os.Hostname()
|
||||||
|
if role == "" {
|
||||||
|
role = "peer"
|
||||||
|
}
|
||||||
body, _ := json.Marshal(map[string]string{
|
body, _ := json.Marshal(map[string]string{
|
||||||
"id": nodeID,
|
"id": nodeID,
|
||||||
"name": hostname,
|
"name": hostname,
|
||||||
@@ -248,6 +259,7 @@ func autoRegister(primary, tlsDir, commonName, version, nodeID, configHash strin
|
|||||||
"api_url": "https://" + commonName + ":3443",
|
"api_url": "https://" + commonName + ":3443",
|
||||||
"version": version,
|
"version": version,
|
||||||
"config_hash": configHash,
|
"config_hash": configHash,
|
||||||
|
"role": role,
|
||||||
})
|
})
|
||||||
|
|
||||||
pair, err := tls.LoadX509KeyPair(tlsDir+"/peer.crt", tlsDir+"/peer.key")
|
pair, err := tls.LoadX509KeyPair(tlsDir+"/peer.crt", tlsDir+"/peer.key")
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ func Run(ctx context.Context, gens []configgen.Generator, only []string) ([]Resu
|
|||||||
whitelist[n] = true
|
whitelist[n] = true
|
||||||
}
|
}
|
||||||
out := make([]Result, 0, len(gens))
|
out := make([]Result, 0, len(gens))
|
||||||
|
var errs []error
|
||||||
for _, g := range gens {
|
for _, g := range gens {
|
||||||
if len(whitelist) > 0 && !whitelist[g.Name()] {
|
if len(whitelist) > 0 && !whitelist[g.Name()] {
|
||||||
out = append(out, Result{Name: g.Name(), Skipped: true})
|
out = append(out, Result{Name: g.Name(), Skipped: true})
|
||||||
@@ -52,11 +53,14 @@ func Run(ctx context.Context, gens []configgen.Generator, only []string) ([]Resu
|
|||||||
err := g.Render(ctx)
|
err := g.Render(ctx)
|
||||||
out = append(out, Result{Name: g.Name(), Err: err})
|
out = append(out, Result{Name: g.Name(), Err: err})
|
||||||
if err != nil && !errors.Is(err, configgen.ErrNotImplemented) {
|
if err != nil && !errors.Is(err, configgen.ErrNotImplemented) {
|
||||||
// hard failure — surface it but return what's done so far
|
// Weitermachen: die Generatoren sind unabhängig und reloaden
|
||||||
return out, fmt.Errorf("%s: %w", g.Name(), err)
|
// inline (nft/Service-Reload sind atomar). Abbrechen würde die
|
||||||
|
// restlichen Dienste auf altem Stand lassen → halb angewandt.
|
||||||
|
// Stattdessen alle versuchen und Fehler gesammelt zurückgeben.
|
||||||
|
errs = append(errs, fmt.Errorf("%s: %w", g.Name(), err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, errors.Join(errs...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Summarise turns the result slice into a human-readable multiline
|
// Summarise turns the result slice into a human-readable multiline
|
||||||
|
|||||||
@@ -96,14 +96,15 @@ func loadOrCreateSecret(path string) ([]byte, error) {
|
|||||||
return secret, nil
|
return secret, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// IssueWithRole returns a signed token for the given actor + role.
|
// issue builds + signs a token with an explicit TTL. No shared-state
|
||||||
func (s *Signer) IssueWithRole(actor, role string) (string, *Token, error) {
|
// mutation — safe for concurrent use of the shared Signer singleton.
|
||||||
|
func (s *Signer) issue(actor, role string, ttl time.Duration) (string, *Token, error) {
|
||||||
now := s.Now()
|
now := s.Now()
|
||||||
t := Token{
|
t := Token{
|
||||||
Actor: actor,
|
Actor: actor,
|
||||||
Role: role,
|
Role: role,
|
||||||
Iat: now.Unix(),
|
Iat: now.Unix(),
|
||||||
Exp: now.Add(s.TTL).Unix(),
|
Exp: now.Add(ttl).Unix(),
|
||||||
}
|
}
|
||||||
data, err := json.Marshal(t)
|
data, err := json.Marshal(t)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -117,18 +118,20 @@ func (s *Signer) IssueWithRole(actor, role string) (string, *Token, error) {
|
|||||||
return encoded, &t, nil
|
return encoded, &t, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Issue is IssueWithRole with empty role.
|
// IssueWithRole returns a signed token for the given actor + role.
|
||||||
func (s *Signer) Issue(actor string) (string, *Token, error) {
|
func (s *Signer) IssueWithRole(actor, role string) (string, *Token, error) {
|
||||||
return s.IssueWithRole(actor, "")
|
return s.issue(actor, role, s.TTL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IssueWithRoleTTL issues a token with a custom TTL (overrides s.TTL for this call).
|
// Issue is IssueWithRole with empty role.
|
||||||
|
func (s *Signer) Issue(actor string) (string, *Token, error) {
|
||||||
|
return s.issue(actor, "", s.TTL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IssueWithRoleTTL issues a token with a custom TTL — no longer mutates
|
||||||
|
// the shared Signer (previously a data race under concurrent logins).
|
||||||
func (s *Signer) IssueWithRoleTTL(actor, role string, ttl time.Duration) (string, *Token, error) {
|
func (s *Signer) IssueWithRoleTTL(actor, role string, ttl time.Duration) (string, *Token, error) {
|
||||||
orig := s.TTL
|
return s.issue(actor, role, 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.
|
// Verify checks a token. Returns ErrInvalidToken or ErrExpiredToken.
|
||||||
|
|||||||
47
internal/services/session/ttl_test.go
Normal file
47
internal/services/session/ttl_test.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package session
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSigner_TTLNotShared beweist Fix #1: IssueWithRoleTTL darf das geteilte
|
||||||
|
// s.TTL nicht mehr mutieren. Unter `go test -race` schlägt die alte Version
|
||||||
|
// als Data-Race an; zusätzlich prüfen wir, dass parallele normale Logins nie
|
||||||
|
// die kurze TOTP-TTL erben.
|
||||||
|
func TestSigner_TTLNotShared(t *testing.T) {
|
||||||
|
s := NewSigner([]byte("0123456789abcdef0123456789abcdef"), nil, time.Hour)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var bad int32
|
||||||
|
for i := 0; i < 200; i++ {
|
||||||
|
wg.Add(2)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
_, _, _ = s.IssueWithRoleTTL("a", "totp_pending", 2*time.Minute)
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
_, tok, err := s.IssueWithRole("b", "admin")
|
||||||
|
if err != nil {
|
||||||
|
atomic.AddInt32(&bad, 1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Normale Session muss ~1h gelten, nie die 2-Min-TOTP-TTL.
|
||||||
|
if tok.Exp-tok.Iat < int64((30 * time.Minute).Seconds()) {
|
||||||
|
atomic.AddInt32(&bad, 1)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
if bad > 0 {
|
||||||
|
t.Fatalf("%d normale Tokens bekamen eine zu kurze TTL → geteilter Zustand", bad)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TTL-Override wirkt weiterhin korrekt für den TOTP-Token.
|
||||||
|
_, ptok, _ := s.IssueWithRoleTTL("x", "totp_pending", 2*time.Minute)
|
||||||
|
if d := ptok.Exp - ptok.Iat; d > int64((3 * time.Minute).Seconds()) {
|
||||||
|
t.Fatalf("totp-pending TTL = %ds, want ~120s", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@ package waf
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
@@ -28,6 +30,10 @@ type Alert struct {
|
|||||||
type AlertWriter struct {
|
type AlertWriter struct {
|
||||||
pool *pgxpool.Pool
|
pool *pgxpool.Pool
|
||||||
ch chan Alert
|
ch chan Alert
|
||||||
|
stop chan struct{}
|
||||||
|
done chan struct{}
|
||||||
|
closeOnce sync.Once
|
||||||
|
closed atomic.Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAlertWriter creates an AlertWriter and starts its background goroutine.
|
// NewAlertWriter creates an AlertWriter and starts its background goroutine.
|
||||||
@@ -36,14 +42,19 @@ func NewAlertWriter(pool *pgxpool.Pool, bufSize int) *AlertWriter {
|
|||||||
aw := &AlertWriter{
|
aw := &AlertWriter{
|
||||||
pool: pool,
|
pool: pool,
|
||||||
ch: make(chan Alert, bufSize),
|
ch: make(chan Alert, bufSize),
|
||||||
|
stop: make(chan struct{}),
|
||||||
|
done: make(chan struct{}),
|
||||||
}
|
}
|
||||||
go aw.run()
|
go aw.run()
|
||||||
return aw
|
return aw
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send enqueues an alert. Drops silently if the channel is full to
|
// Send enqueues an alert. Drops silently if the channel is full (or the
|
||||||
// avoid slowing down SPOE request handling.
|
// writer is closing) to avoid slowing down / panicking SPOE handling.
|
||||||
func (aw *AlertWriter) Send(a Alert) {
|
func (aw *AlertWriter) Send(a Alert) {
|
||||||
|
if aw.closed.Load() {
|
||||||
|
return
|
||||||
|
}
|
||||||
select {
|
select {
|
||||||
case aw.ch <- a:
|
case aw.ch <- a:
|
||||||
default:
|
default:
|
||||||
@@ -51,9 +62,34 @@ func (aw *AlertWriter) Send(a Alert) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Close stops the writer and flushes buffered alerts (best-effort).
|
||||||
|
// Safe to call multiple times. The channel is never closed → Send never
|
||||||
|
// panics even if it races with Close.
|
||||||
|
func (aw *AlertWriter) Close() {
|
||||||
|
aw.closeOnce.Do(func() {
|
||||||
|
aw.closed.Store(true)
|
||||||
|
close(aw.stop)
|
||||||
|
})
|
||||||
|
<-aw.done
|
||||||
|
}
|
||||||
|
|
||||||
func (aw *AlertWriter) run() {
|
func (aw *AlertWriter) run() {
|
||||||
for a := range aw.ch {
|
defer close(aw.done)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case a := <-aw.ch:
|
||||||
aw.write(a)
|
aw.write(a)
|
||||||
|
case <-aw.stop:
|
||||||
|
// Restliche gepufferte Alerts noch wegschreiben, dann Ende.
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case a := <-aw.ch:
|
||||||
|
aw.write(a)
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
61
internal/waf/alerts_test.go
Normal file
61
internal/waf/alerts_test.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package waf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Beweist Fix #15: AlertWriter.Close() flusht, ist idempotent, und Send/Close
|
||||||
|
// racen ohne Panic (Kanal wird nie geschlossen). Guarded per EG_FWTEST_DSN.
|
||||||
|
func TestAlertWriter_CloseFlush(t *testing.T) {
|
||||||
|
dsn := os.Getenv("EG_FWTEST_DSN")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("set EG_FWTEST_DSN to run the alert-writer test")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
var mErr error
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if mErr = database.Migrate(ctx, dsn); mErr == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(700 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if mErr != nil {
|
||||||
|
t.Fatalf("migrate: %v", mErr)
|
||||||
|
}
|
||||||
|
pool, err := database.Open(ctx, dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open: %v", err)
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
aw := NewAlertWriter(pool, 64)
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
aw.Send(Alert{Hostname: "t.local", ClientIP: "203.0.113.1", Method: "GET", URI: "/", Action: "detected"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send parallel zu Close → darf nicht paniken.
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() { defer wg.Done(); aw.Send(Alert{Hostname: "t.local", Action: "detected"}) }()
|
||||||
|
}
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() { aw.Close(); close(done) }()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("Close() did not return (flush hung)")
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// Idempotent + Send nach Close ist No-op (kein Panic).
|
||||||
|
aw.Close()
|
||||||
|
aw.Send(Alert{Hostname: "after.local", Action: "detected"})
|
||||||
|
}
|
||||||
@@ -66,14 +66,10 @@ func buildDirectives(cfg models.WafConfig, crsDir string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trusted proxies: tell Coraza to trust X-Forwarded-For from these IPs.
|
// Trusted proxies are NOT a SecLang directive — they are applied in the
|
||||||
for _, ip := range cfg.TrustedProxies {
|
// SPOE agent (spoe.go): when the connection source is a trusted proxy,
|
||||||
ip = strings.TrimSpace(ip)
|
// the real client IP is taken from X-Forwarded-For before Coraza sees
|
||||||
if ip != "" {
|
// it. (Previously this loop emitted a bogus, unrelated directive.)
|
||||||
sb.WriteString(fmt.Sprintf("SecRemoteRulesFailAction Abort\n"))
|
|
||||||
_ = ip // used in custom rules below if needed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Custom rules (appended last so they can override CRS).
|
// Custom rules (appended last so they can override CRS).
|
||||||
if strings.TrimSpace(cfg.CustomRules) != "" {
|
if strings.TrimSpace(cfg.CustomRules) != "" {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package waf
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/corazawaf/coraza/v3"
|
"github.com/corazawaf/coraza/v3"
|
||||||
@@ -14,6 +15,7 @@ import (
|
|||||||
type DomainEngine struct {
|
type DomainEngine struct {
|
||||||
WAF coraza.WAF
|
WAF coraza.WAF
|
||||||
Mode string // "detection" | "blocking"
|
Mode string // "detection" | "blocking"
|
||||||
|
TrustedProxies []string // wenn src ∈ diese → echte Client-IP aus X-Forwarded-For
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manager holds per-domain Coraza engine instances. Engines are
|
// Manager holds per-domain Coraza engine instances. Engines are
|
||||||
@@ -90,7 +92,7 @@ func (m *Manager) Reload(domains []DomainConfig) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("waf: build engine for %s: %w", dc.Hostname, err)
|
return fmt.Errorf("waf: build engine for %s: %w", dc.Hostname, err)
|
||||||
}
|
}
|
||||||
newEngines[dc.Hostname] = &DomainEngine{WAF: waf, Mode: dc.Config.Mode}
|
newEngines[dc.Hostname] = &DomainEngine{WAF: waf, Mode: dc.Config.Mode, TrustedProxies: dc.Config.TrustedProxies}
|
||||||
slog.Info("waf: engine (re)loaded",
|
slog.Info("waf: engine (re)loaded",
|
||||||
"host", dc.Hostname,
|
"host", dc.Hostname,
|
||||||
"mode", dc.Config.Mode,
|
"mode", dc.Config.Mode,
|
||||||
@@ -110,8 +112,9 @@ func (m *Manager) Reload(domains []DomainConfig) error {
|
|||||||
// (nil, false) when the domain has no WAF or WAF is disabled.
|
// (nil, false) when the domain has no WAF or WAF is disabled.
|
||||||
func (m *Manager) GetForHost(host string) (*DomainEngine, bool) {
|
func (m *Manager) GetForHost(host string) (*DomainEngine, bool) {
|
||||||
// Strip port if present (e.g. "example.com:443" → "example.com").
|
// Strip port if present (e.g. "example.com:443" → "example.com").
|
||||||
if i := lastColon(host); i >= 0 {
|
// SplitHostPort errors for a bare host or bare IPv6 literal → keep as-is.
|
||||||
host = host[:i]
|
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||||
|
host = h
|
||||||
}
|
}
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
de, ok := m.engines[host]
|
de, ok := m.engines[host]
|
||||||
@@ -122,36 +125,3 @@ func (m *Manager) GetForHost(host string) (*DomainEngine, bool) {
|
|||||||
return de, true
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package waf
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -75,6 +76,15 @@ func (a *SPOEAgent) handle(ctx context.Context, w *encoding.ActionWriter, m *enc
|
|||||||
return // WAF not configured or disabled for this domain
|
return // WAF not configured or disabled for this domain
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Trusted-Proxy-Handling: stammt die Verbindung von einem konfigurierten
|
||||||
|
// Trusted-Proxy, ist die echte Client-IP das letzte X-Forwarded-For-Glied
|
||||||
|
// (das der Proxy angehängt hat), nicht die Proxy-IP selbst.
|
||||||
|
if clientIP != "" && len(de.TrustedProxies) > 0 && ipMatchesAny(clientIP, de.TrustedProxies) {
|
||||||
|
if real := rightmostXFF(rawHdrs); real != "" {
|
||||||
|
clientIP = real
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
tx := de.WAF.NewTransaction()
|
tx := de.WAF.NewTransaction()
|
||||||
defer func() {
|
defer func() {
|
||||||
tx.ProcessLogging()
|
tx.ProcessLogging()
|
||||||
@@ -165,6 +175,53 @@ func (a *SPOEAgent) sendAlert(host, clientIP, method, uri string, mr types.Match
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rightmostXFF gibt den letzten (vom nächstgelegenen Proxy angehängten)
|
||||||
|
// X-Forwarded-For-Eintrag zurück, sofern es eine gültige IP ist.
|
||||||
|
func rightmostXFF(rawHdrs string) string {
|
||||||
|
var val string
|
||||||
|
for _, line := range strings.Split(rawHdrs, "\n") {
|
||||||
|
line = strings.TrimRight(line, "\r")
|
||||||
|
idx := strings.IndexByte(line, ':')
|
||||||
|
if idx <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.EqualFold(strings.TrimSpace(line[:idx]), "x-forwarded-for") {
|
||||||
|
val = strings.TrimSpace(line[idx+1:]) // letzter XFF-Header gewinnt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if val == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parts := strings.Split(val, ",")
|
||||||
|
cand := strings.TrimSpace(parts[len(parts)-1])
|
||||||
|
if net.ParseIP(cand) == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return cand
|
||||||
|
}
|
||||||
|
|
||||||
|
// ipMatchesAny prüft, ob ip exakt einer IP oder einem CIDR aus list entspricht.
|
||||||
|
func ipMatchesAny(ip string, list []string) bool {
|
||||||
|
parsed := net.ParseIP(ip)
|
||||||
|
if parsed == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, e := range list {
|
||||||
|
e = strings.TrimSpace(e)
|
||||||
|
if e == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.Contains(e, "/") {
|
||||||
|
if _, n, err := net.ParseCIDR(e); err == nil && n.Contains(parsed) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} else if pe := net.ParseIP(e); pe != nil && pe.Equal(parsed) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// parseHeaders splits HAProxy raw headers ("Name: value\r\n…") and
|
// parseHeaders splits HAProxy raw headers ("Name: value\r\n…") and
|
||||||
// calls fn for each valid header line.
|
// calls fn for each valid header line.
|
||||||
func parseHeaders(raw string, fn func(name, val string)) {
|
func parseHeaders(raw string, fn func(name, val string)) {
|
||||||
|
|||||||
37
internal/waf/spoe_test.go
Normal file
37
internal/waf/spoe_test.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package waf
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// Beweist Fix #2: Trusted-Proxy-XFF-Auflösung.
|
||||||
|
func TestRightmostXFF(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"X-Forwarded-For: 203.0.113.7": "203.0.113.7",
|
||||||
|
"X-Forwarded-For: 203.0.113.7, 10.0.0.1": "10.0.0.1", // rightmost
|
||||||
|
"x-forwarded-for: 1.2.3.4 , 5.6.7.8": "5.6.7.8",
|
||||||
|
"Host: x\r\nX-Forwarded-For: 2001:db8::1": "2001:db8::1",
|
||||||
|
"X-Forwarded-For: not-an-ip": "",
|
||||||
|
"User-Agent: foo": "",
|
||||||
|
"": "",
|
||||||
|
}
|
||||||
|
for raw, want := range cases {
|
||||||
|
if got := rightmostXFF(raw); got != want {
|
||||||
|
t.Errorf("rightmostXFF(%q) = %q, want %q", raw, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIPMatchesAny(t *testing.T) {
|
||||||
|
list := []string{"10.0.0.5", "192.168.0.0/16", "2001:db8::/32"}
|
||||||
|
yes := []string{"10.0.0.5", "192.168.4.7", "2001:db8::abcd"}
|
||||||
|
no := []string{"10.0.0.6", "172.16.0.1", "2002::1", "garbage"}
|
||||||
|
for _, ip := range yes {
|
||||||
|
if !ipMatchesAny(ip, list) {
|
||||||
|
t.Errorf("ipMatchesAny(%q) = false, want true", ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, ip := range no {
|
||||||
|
if ipMatchesAny(ip, list) {
|
||||||
|
t.Errorf("ipMatchesAny(%q) = true, want false", ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
|
||||||
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
|
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
|
||||||
@@ -31,6 +32,7 @@ type Generator struct {
|
|||||||
Box *secrets.Box
|
Box *secrets.Box
|
||||||
Ifaces *wgsvc.InterfacesRepo
|
Ifaces *wgsvc.InterfacesRepo
|
||||||
Peers *wgsvc.PeersRepo
|
Peers *wgsvc.PeersRepo
|
||||||
|
SkipReload bool // nur Configs schreiben, keine wg-quick@-Service-Aktionen
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(pool *pgxpool.Pool, box *secrets.Box) *Generator {
|
func New(pool *pgxpool.Pool, box *secrets.Box) *Generator {
|
||||||
@@ -151,10 +153,12 @@ func (g *Generator) Render(ctx context.Context) error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
_ = os.Remove(filepath.Join(ConfDir, e.Name()))
|
_ = os.Remove(filepath.Join(ConfDir, e.Name()))
|
||||||
|
if !g.SkipReload {
|
||||||
_ = stopWGQuick(ifaceName)
|
_ = stopWGQuick(ifaceName)
|
||||||
_ = disableWGQuick(ifaceName)
|
_ = disableWGQuick(ifaceName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,21 +232,31 @@ func (g *Generator) renderIface(ctx context.Context, ifc models.WireguardInterfa
|
|||||||
}
|
}
|
||||||
|
|
||||||
path := filepath.Join(ConfDir, ifc.Name+".conf")
|
path := filepath.Join(ConfDir, ifc.Name+".conf")
|
||||||
|
// Config (enthält den Private Key) ZUERST atomar schreiben — vorher
|
||||||
|
// keinen Symlink/Service auf eine evtl. fehlende/abgeschnittene Datei
|
||||||
|
// zeigen lassen. AtomicWrite = temp+fsync+rename, 0600.
|
||||||
|
changed := true
|
||||||
|
if existing, err := os.ReadFile(path); err == nil && bytes.Equal(existing, body.Bytes()) {
|
||||||
|
changed = false
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
if err := configgen.AtomicWrite(path, body.Bytes(), 0o600); err != nil {
|
||||||
|
return fmt.Errorf("write %s: %w", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if g.SkipReload {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
// wg-quick@<iface>.service liest /etc/wireguard/<iface>.conf (Distro-
|
// wg-quick@<iface>.service liest /etc/wireguard/<iface>.conf (Distro-
|
||||||
// Default), nicht unseren ConfDir. Wir lassen die Quelle of truth in
|
// Default), nicht unseren ConfDir. Symlink via sudo (/etc/wireguard/
|
||||||
// /etc/edgeguard/wireguard/ und symlinken via sudo — /etc/wireguard/
|
// ist root:root 700). Das sudoers-Entry wird von postinst angelegt.
|
||||||
// ist root:root 700, daher braucht es sudo /bin/ln. Das sudoers-Entry
|
|
||||||
// wird von postinst angelegt.
|
|
||||||
if err := symlinkWGQuickConf(ifc.Name, path); err != nil {
|
if err := symlinkWGQuickConf(ifc.Name, path); err != nil {
|
||||||
return fmt.Errorf("symlink: %w", err)
|
return fmt.Errorf("symlink: %w", err)
|
||||||
}
|
}
|
||||||
_ = enableWGQuick(ifc.Name)
|
_ = enableWGQuick(ifc.Name)
|
||||||
if existing, err := os.ReadFile(path); err == nil && bytes.Equal(existing, body.Bytes()) {
|
if !changed {
|
||||||
return startWGQuick(ifc.Name)
|
return startWGQuick(ifc.Name)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(path, body.Bytes(), 0o600); err != nil {
|
|
||||||
return fmt.Errorf("write %s: %w", path, err)
|
|
||||||
}
|
|
||||||
return restartWGQuick(ifc.Name)
|
return restartWGQuick(ifc.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1372,6 +1372,7 @@
|
|||||||
"yes": "Ja",
|
"yes": "Ja",
|
||||||
"no": "Nein",
|
"no": "Nein",
|
||||||
"or": "oder",
|
"or": "oder",
|
||||||
|
"status": "Status",
|
||||||
"save": "Speichern",
|
"save": "Speichern",
|
||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
"loading": "Lädt …",
|
"loading": "Lädt …",
|
||||||
|
|||||||
@@ -1372,6 +1372,7 @@
|
|||||||
"yes": "Yes",
|
"yes": "Yes",
|
||||||
"no": "No",
|
"no": "No",
|
||||||
"or": "or",
|
"or": "or",
|
||||||
|
"status": "Status",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"loading": "Loading …",
|
"loading": "Loading …",
|
||||||
|
|||||||
Reference in New Issue
Block a user