23 Commits

Author SHA1 Message Date
Debian
32ab2c7f47 chore(lint): Backlog auf 0 + golangci-lint als HARTER Gate — v1.3.3
Go-Quality-Baseline-Rollout ABGESCHLOSSEN.

Code-Quality-Backlog (55 → 0):
- errcheck: unbehandelte Close/Rollback/Remove explizit `_ =`; fmt.Sscanf
  `_, _ =` (Zero-Value degradiert sauber).
- unused: toter Code entfernt (nodeIDOrHostname, stripTrailingNewline,
  acme.Service.user, strFold + ungenutzter Import).
- noctx (net/http): http.NewRequestWithContext mit vorhandenem ctx.
- staticcheck: QF1001/S1009/ST1005/SA9003.
- contextcheck: detached-by-design-Stellen mit begründetem //nolint.

Zwei echte Bugs beim Aufräumen gefunden+gefixt:
- backup/remote SFTP-Upload: dst.Close()-Flush-Fehler wurde verschluckt →
  unvollständiges Remote-File galt als Erfolg. Jetzt geprüft+gemeldet.
- haproxy_test: leere if-Assertion (SA9003) testete faktisch nichts →
  echte t.Errorf-Prüfung (kein HSTS für HSTS-disabled Domain).

Bewusste Config-Entscheidungen (.golangci.yml):
- noctx-on-os/exec ausgeschlossen: System-Command-Reloads (systemctl/nft/
  wg/pg) dürfen NICHT an den Request-Context gebunden werden — ein Client-
  Disconnect darf keinen laufenden Reload mitten in der Ausführung killen.
  net/http-noctx bleibt voll aktiv. KEINE exec-Zeile im Code angefasst.
- rowserrcheck/sqlclosecheck raus (database/sql-Linter, bei pgx nur FPs).

Gate scharf gestellt: Makefile release-check ruft golangci-lint jetzt als
HARTEN Gate (install-if-missing, pinned v2.12.2). `make release-check`
grün: vet, golangci-lint, govulncheck, build, test -race.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 00:49:07 +02:00
Debian
cdbb62ee1a harden(api): Slowloris-Timeout + gosec-Security-Audit + waf-Purge-Bugfix — v1.3.2
Go-Quality-Baseline-Rollout, Security-Teil:

- api: http.Server bekommt ReadHeaderTimeout (15s) + IdleTimeout (120s)
  gegen Slowloris-Header-Stalls (gosec G112). ReadTimeout/WriteTimeout
  bewusst offen (lang laufende Rolling-Update-/Backup-Endpoints).

- fix(waf): PurgeAlerts nutzte NOW() - ($1 || ' days')::interval mit
  olderThanDays int → pgx-Encode-Error zur Laufzeit → DELETE /waf/alerts
  war kaputt. Auf make_interval(days => $1) umgestellt (gleiche Bug-
  Klasse wie audit-Cleanup v1.3.0). Via Lint-Aufräumen entdeckt.

- .golangci.yml: 26 gosec-Findings line-by-line auditiert. Alle sind
  bewusstes Appliance-Verhalten mit Compensating Controls (Subprocess-
  Args intern/validiert, Config-File-Perms daemon-lesbar, SSH opt-in
  Fingerprint-Pinning, UI-Server Clean+HasPrefix-Traversal-Guard) oder
  FPs (G101 Konstanten-Namen, G702/G703/G706 Taint). Dokumentiert
  exclude't. gosec-Rest = 0.

- .golangci.yml: rowserrcheck/sqlclosecheck raus — database/sql-Linter,
  bei durchgängigem pgx nur FPs.

gosec=0, govulncheck=0, race=0. Rest-Backlog: errcheck/noctx/staticcheck
(Code-Quality, kein Security) — folgt.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 23:36:37 +02:00
Debian
02096c8ad8 chore(lint): golangci-lint --fix — misspell + staticcheck-Autofixes (Backlog 142→~98)
Erster Schritt des golangci-lint-Rollouts (non-blocking): 44 misspell + 4
staticcheck automatisch behoben (32 Dateien, nur Tippfehler/mechanisch).
build+test grün. Kein Runtime-Change → kein Deploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 23:25:22 +02:00
Debian
d15774f1cd fix(security): x/crypto v0.52 + x/net v0.55 (6 CVEs) + govulncheck-Release-Gate — v1.3.1
govulncheck ab sofort fest im Release-Prozess. Baseline-Scan fand 6 aktiv
aufgerufene Vulns (SSH-Backup-Pfad internal/services/backup/remote):
- 5× golang.org/x/crypto (SSH DoS/Deadlock/Panic: GO-2026-5013/5017/5018/5019/5020)
  → x/crypto v0.51.0 => v0.52.0
- 1× golang.org/x/net (GO-2026-5026) → v0.53.0 => v0.55.0
Re-Scan danach: "No vulnerabilities found."

Go-Quality-Baseline (Makefile + .golangci.yml, portabel):
- release-check läuft autom. vor jedem deb/publish: vet → golangci-lint
  (Rollout: non-blocking) → govulncheck (HARTER Gate) → build → test -race.
- make vulncheck / make test-race als eigene Targets.
- .golangci.yml: staticcheck/govet/errcheck/ineffassign/unused/misspell +
  gosec/bodyclose/rowserrcheck/sqlclosecheck/noctx/contextcheck.
- go test -race: aktuell 0 Races (Gate sicher).
Doku in CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 22:34:25 +02:00
Debian
4c1e0e9926 chore(go): go.mod go-Direktive auf 1.26.4 nachgezogen
Build-Host-Toolchain ist go1.26.4; go.mod stand noch auf 1.26.0. Direktive
nachgezogen — Binaries wurden (GOTOOLCHAIN=auto, lokal>=Direktive) ohnehin
schon mit 1.26.4 gebaut, daher kein Binary-/Runtime-Change, kein Redeploy nötig.
go build/vet/test mit 1.26.4 grün.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 01:03:56 +02:00
Debian
2495bf022b fix(scheduler): Audit-Cleanup lief nie (int-als-text Encode-Fehler) — v1.3.0
Der tägliche audit_log-Cleanup (cmd/edgeguard-scheduler) schlug auf beiden Nodes
jeden Tag fehl:
  "audit cleanup failed" keep_days=90
  error: unable to encode 90 into text format for text (OID 25): cannot find encode plan

Ursache: audit.go nutzte `($1::text || ' days')::interval`, übergab keepDays aber
als int → pgx kann int nicht als text (OID 25) encoden. Folge: Cleanup lief nie,
tägliches WARN-Rauschen + langfristig unbegrenztes audit_log-Wachstum (Einträge
>keep_days wurden nie gelöscht).

Fix: `NOW() - make_interval(days => $1)` — $1 bleibt sauber int-typisiert.
Gegen Live-DB validiert (gültige Syntax, 0 betroffene Rows aktuell).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 11:30:12 +02:00
Debian
25ec98161f fix(chrony): kein Multi-bindaddress — NTP-Server bediente nur eine VIP — v1.2.109
chrony honoriert nur EINE bindaddress pro Adressfamilie. Der Generator emittierte
aber eine bindaddress PRO Listen-IP (mehrere VLAN-/Cluster-VIPs) → chrony band nur
die letzte (10.0.50.1), alle anderen Clients (z. B. auf 10.0.5.1) erreichten den
NTP-Server NICHT. Ein Restart hilft nicht (Config-Bug, nicht stale binding).

Fix: chrony.cfg.tpl emittiert KEIN bindaddress mehr → bind-all; WER bedient wird,
regeln die allow-ACL + die nftables-Regeln (UDP/123 nur auf den Listen-IPs/VIPs
offen, nicht öffentlich). Zugleich failover-robust: chrony bedient automatisch
jede VIP, die der Node gerade hält, ohne Restart bei Master-Wechsel.

Test: internal/chrony/chrony_test.go (kein bindaddress, allow vorhanden; port 0
bei serve_clients=false).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 15:46:17 +02:00
Debian
79cd68e460 feat(domains): 301-Weiterleitung Domain→Domain (redirect_to) — v1.2.108
Neue Domain kann per 301 auf eine andere Domain/URL umgeleitet werden, statt
auf ein Backend zu routen (Use-Case: kvs.netcell-it.de → https://zkm.netcell-it.de,
inkl. HTTPS). Variante (a): immer auf Ziel-Root (`redirect location`), pfad-
unabhängig.

- Migration 0043: domains.redirect_to text NOT NULL DEFAULT ''
- Model + domains-Service (SELECT/INSERT/UPDATE/scan)
- HAProxy-Generator: buildRedirectTo() sanitisiert (nur http(s), kein
  Whitespace/Quotes → sonst kein Redirect statt kaputter Config); Template
  emittiert `http-request redirect location <url> code 301 if hdr(host)`.
  Terminiert vor use_backend → Redirect-Domain routet auf kein Backend.
  http→https läuft über den vorhandenen :80-Redirect (zwei Hops, inkl. TLS).
- UI: Feld „Weiterleitung (301) nach" im Domain-Formular (de/en)
- Tests: Render-Zeile + buildRedirectTo-Sanitisierung

Hinweis: Die Redirect-Domain braucht weiterhin ein eigenes TLS-Zert (ACME).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 19:23:37 +02:00
Debian
f3c76f6d18 fix(cluster): selbstheilende public_ip + advert_int 2 (Flapping) — v1.2.107
Zwei keepalived-Flapping-Restursachen:
1) Strukturell: der Cluster-Push (autoRegister, Primary→Secondary) trägt KEINE
   public_ip → der Empfänger AgentRegisterPeer ließ sie NULL → Peer fehlte im
   nft-peer_ipv4-Set → VRRP-Adverts (eth0/VI_1) nur via conntrack → Flapping.
   (utm-1 lernte utm-2 korrekt via Joiner-Client-IP in preRegisterJoiner; die
   Gegenrichtung fehlte.) Fix: AgentRegisterPeer fällt bei leerem req.PublicIP
   auf die mTLS-Client-IP (c.ClientIP()) zurück — die EIGEN-IP des Peers, nicht
   die VIP. Selbstheilend, überlebt Re-Joins/Failover.
2) advert_int 1 → 2 (Master-Down ~6s statt ~3s): reißt nicht mehr bei kurzen
   VM-/Heartbeat-Hiccups (VI_HB). Trade-off: Failover-Erkennung ~6s.

Tests: advert_int 2 im Render.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:42:21 +02:00
Debian
b2fc7b7dee fix(cluster): chk_edgeguard fall 3→8 — Upgrade-Restart triggert keinen Failover — v1.2.106
Seit v1.2.105 (Track-Scripts ohne weight = FAULT-Trigger) löste jeder
edgeguard-api-Restart beim Deploy einen Failover aus (kurze Health-Check-Fehler
während Stop/Render/Restart reichten bei fall 3 = 6s). fall 8 (16s) lässt einen
normalen Upgrade-Restart durchrutschen; ein echter API-Tod schwenkt weiter in 16s.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:14:08 +02:00
Debian
9b563baaa1 fix(cluster): keepalived Track-Scripts ohne weight (Sync-Group) — v1.2.105
`keepalived -t` zeigte "ignoring tracked script chk_edgeguard/chk_gateway with
weights due to SYNC group": gewichtete Track-Scripts werden in einer
vrrp_sync_group ignoriert → Health-Check-Failover (Gateway weg / API tot) griff
NICHT. Fix: weight entfernt → Scripts wirken als binäre FAULT-Trigger (fall-mal
Fehler → Instanz+Sync-Group FAULT → gesunder Peer übernimmt). Für 2-Node-Cluster
die korrekte Semantik.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:07:24 +02:00
Debian
d8b8fef680 fix(cluster): keepalived 2.3.x lehnt vrrp_garp_interval 0 ab — entfernt — v1.2.104
v1.2.103 hatte vrrp_garp_interval 0 / vrrp_gna_interval 0 aus dem Legacy-Template
übernommen. keepalived 2.3.3 lehnt 0 ab (Range [0.000001, …]) → "invalid",
`keepalived -t` schlägt fehl (keepalived ignoriert sie zwar mit Warnung und läuft,
aber latentes Risiko bei striktem Start). Entfernt — Default passt. Die wirksame
Anti-Aging-Direktive vrrp_garp_master_refresh 60 + master_repeat 5 bleiben.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 19:02:02 +02:00
Debian
fd8125247c fix(cluster): VIP-Failover am Upstream zuverlässig — GARP + Priming-Ping — v1.2.103
Nach v1.2.102 (VIP nicht mehr statisch gebunden) wandert die Public-VIP .100
erstmals wirklich per keepalived zwischen den Node-MACs. Der Hosting-Upstream
lernte die neue MAC aber nicht zuverlässig → .100 nach Schwenk von außen
unerreichbar (vorher maskiert, weil .100 statisch dauerhaft auf utm-1 lag).

Zwei Mechanismen ergänzt:
1) keepalived.conf.tpl global_defs: vrrp_garp_master_repeat 5 +
   vrrp_garp_master_refresh 60 (+ vrrp_garp_interval/gna 0, aus Legacy-Template
   verloren gegangen) → forciertes GARP beim Wechsel + periodische Auffrischung,
   damit die VIP-MAC am Switch nicht altert.
2) keepalived-master.sh: neuer Master pingt den Default-Gateway aus jeder
   Public-IP/VIP an (ping -I <vip>) → Upstream sieht Traffic VON der VIP und
   lernt die MAC sofort. Manche Hoster relearnen nur so, nicht via GARP.

Test: keepalived_test.go prüft GARP-Direktiven im Render.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:56:32 +02:00
Debian
7b6409b631 fix(cluster): VIPs nie statisch binden — Duplicate-IP-Kernbug — v1.2.102
Ergänzt v1.2.101: der ip-addresses-Apply (internal/services/ipaddresses/
apply.go) band ALLE aktiven Adressen statisch — inkl. is_vip. RenderSecondary
(das eth0 ausschließt) ist toter Code, wird nie aufgerufen.

Folge: die VIP (89.163.205.100 + VLAN-Gateways 10.0.x.1) lag auf dem Node
statisch gebunden, UNABHÄNGIG von keepalived. Sobald keepalived die VIP per
Failover auf den Peer legte, lag sie auf BEIDEN Nodes → Duplicate-IP/ARP-
Konflikt → UniFi-Tunnel/LAN bricht (erklärt „utm-1 stoppen → sofort stabil":
der Konflikt verschwindet, nicht VRRP-Failover).

Fix: Render-Query schließt is_vip hart aus (AND ia.is_vip = false) → VIPs
gehören ausschließlich keepalived (nur der VRRP-Master trägt sie). Gilt für
beide Render-Pfade.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 18:09:17 +02:00
Debian
a2450a759c fix(cluster): keepalived Split-Brain + Boot-Race behoben — v1.2.101
Ursache der „WireGuard reißt immer wieder ab"-Abrisse war NICHT die UniFi,
sondern keepalived-Flapping im HA-Cluster: die VIP 89.163.205.100 (an der die
UniFi-Site-to-Site hängt) wanderte bei ~17 VRRP-Wahlen/Tag zwischen utm-1/utm-2
→ Tunnel-Abriss bei jeder Wahl.

Drei Bugs:
1) Firewall ließ VRRP (IP-Proto 112) zwischen den Cluster-Peers NICHT zu
   (policy drop). Adverts überlebten nur via conntrack-Reverse-Matching → bei
   conntrack-Ablauf gedroppt → Peer promotet sich → Split-Brain.
   Fix: ruleset.nft.tpl erlaubt `ip/ip6 ... vrrp saddr @peer_ipv4/6`;
   firewall.go nimmt zusätzlich hb_src_ip/hb_peer_ip aus cluster_settings ins
   Peer-Set (deckt den Heartbeat-Pfad 169.254.0.x ab).
2) Kein nopreempt → erholter Node riss die VIP sofort zurück (Flap-Back);
   aggressiver gw-Check (fall 2 → 10s-Blip = Failover).
   Fix: keepalived.conf.tpl mit `nopreempt` in VI_1+VI_HB, chk_gateway fall 2→5;
   keepalived.go setzt State immer BACKUP (nopreempt wirkt nur in BACKUP),
   Priorität 200/100 aus pg_role bleibt → deckt sich mit „manuelles Promote".
3) keepalived-Boot-Race: Unit startete vor vlan500 (nur After=network-online)
   → „interface vlan500 doesn't exist" → permanenter CONFIG-Crash ohne Recovery
   (keepalived nach Reboot tot). Fix: postinst legt Drop-in mit
   After=/Wants=edgeguard-interfaces.service + Restart=on-failure an.

Neuer Test internal/keepalived/keepalived_test.go (nopreempt/BACKUP/fall).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:49:04 +02:00
Debian
91e51890dd fix(wireguard): Tunnel reißt nie ab + Client-Endpoint auto-befüllt — v1.2.100
Zwei Bugs, die WireGuard-Verbindungen verhinderten/abrissen:
1) Client-Config-Endpoint war hartkodierter Platzhalter REPLACE_WITH_PUBLIC_HOST → neue Clients bauten nie einen Tunnel auf (Host löst nicht auf). Jetzt: WireguardHandler.PublicHost (aus setup.json FQDN, main.go) → Endpoint = <fqdn>:<port>. Platzhalter nur noch als Fallback wenn FQDN unbekannt.
2) Renderer machte bei JEDER Config-Änderung 'systemctl restart wg-quick@<iface>' → voller Link-Flap, alle Peers droppen (verstößt gegen 'wireguard darf nie abbrechen'). Jetzt: laufendes Interface → 'wg-quick strip | wg syncconf' (Peers/Listen-Port live, KEIN Abbruch); nur erstmaliges Hochfahren via systemctl start; restart nur noch als Fallback mit WARN. interfaceExists() via 'ip link show'. Neue sudoers: wg syncconf *, wg-quick strip *.
Ein edgeguard-api-Restart (Deploy) fasst wg-quick@<iface> nicht an → Tunnel bleibt während Deploy bestehen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 20:51:52 +02:00
Debian
7611572062 refactor(cluster): promote auf Logical-Replication umgestellt + internal/proxy-Stub entfernt — v1.2.99
Code-Altlasten aus dem Architektur-Audit bereinigt:
- internal/proxy: leerer .gitkeep-Stub (geplanter Write-Proxy nie implementiert) entfernt — keine Go-Referenzen.
- promote.go: war reines Physical-Replication-Failover (standby.signal + pg_ctlcluster promote + pg_is_in_recovery) und damit auf dem Logical-Setup TOT (ein Subscriber hat kein standby.signal / ist nie in recovery → Abbruch bei Schritt 1). Neu Logical-aware: Idempotenz-Check (schon Publisher ohne Subscription → fertig) → Subscription lösen (DISABLE+slot_name=NONE+DROP, hängt nicht am toten Publisher) → setupReplicationPrimary (Publisher werden) → ha_nodes.pg_role=primary → keepalived MASTER. Toter KeyDB-Update (cluster:pg-primary-url, wurde nie gelesen) entfernt.
- setupReplicationPrimary + dropSubscriptionIfExists aus cluster-init-replication/cluster-setup-standby extrahiert (DRY, bewährte SQL wiederverwendet). WICHTIG: setupReplicationPrimary stellt jetzt sicher dass wal_level=logical AKTIV ist — PG-RESTART falls nötig (reload reicht für wal_level/max_wal_senders nicht; Secondary hat wal_level=replica). Idempotent: Restart nur wenn wal_level != logical.
- Doku (CLAUDE.md + architecture.md) auf den bereinigten Stand gezogen.
Hinweis: echtes Cross-Node-Failover ist nur im Drill testbar; Build/vet/Tests grün, Bausteine sind die bereits produktiv genutzten SQL-Primitive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:26:52 +02:00
Debian
3b5bf578a0 docs(architecture): Cluster/HA-Abschnitte an Ist-Stand angeglichen (code-verifiziert)
architecture.md war Entwurfsstand; Cluster/HA wich stark vom Code ab. Per 5 parallelen Code-Audits verifiziert + korrigiert:
- Replikation: Streaming/physisch → LOGICAL (edgeguard_shared/edgeguard_sub, wal_level=logical, copy_data=true); localOnlyTables dokumentiert; pg_basebackup nur Legacy.
- VIP/Ingress (§9): 'Floating-IP statt VRRP' war invertiert → real keepalived/VRRP (prio aus pg_role, VIPs aus ip_addresses); kein Hoster-API/promote-this-node.
- KeyDB (§7): Active-Active-State-Layer NICHT umgesetzt (kein Redis-Client in go.mod); Cluster-State/Heartbeat/Locks in PostgreSQL; KeyDB optional (Recommends). license-leader/acme:lock/cluster:nodes = nur Kommentare.
- Write-Path: internal/proxy ist leerer Stub; kein Write-Proxy → Writes am Primary.
- Plattform: nur Debian 13 trixie (Pipeline), Ubuntu/noble nicht implementiert.
- §1/§5/§8 + Strukturbaum/Depends/Units an reale Renderer (keepalived/chrony/kea/freeradius/crowdsec/waf) angeglichen. Offene Punkte: Code-Altlasten (proxy-Stub, standby.signal in promote.go).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:46:56 +02:00
Debian
bf211ca273 docs: Feature-Stand nachgezogen — WAF/IDS-IPS/DHCP/RADIUS/OIDC/2FA umgesetzt
CLAUDE.md + architecture.md spiegelten noch den ursprünglichen v1-Scope (WAF/IDS/IPS/DHCP/RADIUS als Nicht-Ziele) wider. Real umgesetzt: WAF (Coraza+SPOE), IDS/IPS (CrowdSec, managed-wenn-installiert), DHCP (Kea), RADIUS (FreeRADIUS), OIDC-SSO, 2FA, IPv6-FW. Nicht-Ziele bereinigt (verbleibend: Suricata-Network-IDS, Docker, Mail, Multi-Tenant, ISO, Debian-only). Stack-Tabelle + Paket-Tabelle/Depends an control angeglichen; edgeguard-waf als Binary im edgeguard-api-Paket dokumentiert. KeyDB als optional (Recommends) korrigiert; Cluster-Primary-Ermittlung via pg_publication/setup.json statt KeyDB; Doku-Drift internal/proxy (nicht vorhanden) markiert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:34:10 +02:00
Debian
becd068637 fix(ui): freeradius + kea-dhcp4 in Service-Status-Grid aufnehmen — v1.2.98
Das Dashboard-Service-Grid (servicesToCheck in system.go) listete weder freeradius (RADIUS, v1.2.93) noch kea-dhcp4-server (DHCP, v1.2.92). Beide sind via Depends installiert + default-disabled → erscheinen jetzt als 'Inaktiv' bis aktiviert. systemctl show liefert für disabled Units sauber inactive, kein Fehler.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:05:01 +02:00
Debian
b3dda81b49 feat(cluster): bidirektionaler Peer-Heartbeat (Primary→Secondary Push) — v1.2.97
Bisher pushte nur der Secondary seine Liveness an den Primary (runPrimaryPush). Der Primary pushte nichts → in der lokalen ha_nodes des Secondary fror die Primary-Row nach dem Boot ein → die vom Secondary ausgelieferte UI zeigte den Primary als offline.
Neu: runPeerPush auf dem Primary/Founder pusht alle 30s self (role=primary) an jeden Peer via mTLS (/agent/cluster/peers). PushSelfToPeer(role) generalisiert PushSelfToPrimary; registerPeerRequest+AgentRegisterPeer akzeptieren ein role-Feld (default 'peer' → joining-Peer-Verhalten unverändert). Peer-Register-Log bei Routine-Pushes auf Debug (Info nur bei neuem Peer/IP-Wechsel) gegen 30s-Spam.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:24:13 +02:00
Debian
b20ace8763 fix(cluster): periodischer Peer-Heartbeat (30s) + Rolling-Update candidate-aware — v1.2.96
Fix 1 — Peer zeigt fälschlich 'offline': runPrimaryPush (Secondary→Primary, einziger periodischer Cross-Node-ha_nodes-Refresh) tickte mit 5 min, SweepStaleNodes-Threshold ist aber 2 min → Secondary war 2 min online, dann 3 min offline, im 5-min-Takt. Tick auf 30s (4× Marge unter Threshold). Receiver lädt nftables nur bei IP-Änderung → kein Reload-Sturm.
Fix 2 — Rolling-Update konnte nie fertig werden wenn der Secondary die Zielversion schon hatte (baseline==target → Warten auf unmöglichen Flip → 10-min-Timeout). runRollingUpdate ist jetzt candidate-aware: ermittelt apt-Candidate, überspringt den Secondary-Schritt wenn dieser schon aktuell ist, erkennt den Flip via 'erreicht candidate ODER bewegt sich von baseline', und schließt direkt mit 'done' wenn auch der Primary schon aktuell ist. FinishRollingUpdateIfPending setzt hängende updating/waiting-secondary-Phasen beim Boot auf idle zurück (tote Orchestrierungs-Goroutine nach Restart).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 13:10:40 +02:00
Debian
053b38e46c fix: AlertWriter graceful flush (#15) + Rolling-Update Robustheit (#19) — v1.2.95
#15 waf/alerts.go: AlertWriter.Close() flusht gepufferte Alerts + stoppt die Goroutine (stop/done-Channels, sync.Once, atomic closed; Kanal wird NIE geschlossen → Send racet ohne Panic). Wiring in cmd/edgeguard-waf nach ListenAndServe (graceful shutdown). -race-Test alerts_test.go.
#19 handlers/cluster_rollingupdate.go: (a) RollingUpdateStatus mutiert State nicht mehr beim GET — terminale Zustände altern in readRollingUpdateState nach 10 min aus (kein verlorenes 'done' bei parallelen Pollern). (b) State-File via sync.Mutex + configgen.AtomicWrite (kein partieller Read / Race zwischen Handler & Goroutine). (c) Version-Flip wird gegen die VORHER erfasste Secondary-Baseline geprüft statt gegen die Primary-Version (verhindert sofort-/nie-Flip).
Bewusst belassen: geteilter upgrade.sh-Pfad ist deterministischer Inhalt + an exakte sudoers-Zeile gebunden → Überschreib-Race benign; MST-Timestamp-Parse locale (Server laufen C-Locale).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 11:21:50 +02:00
93 changed files with 1536 additions and 656 deletions

102
.golangci.yml Normal file
View File

@@ -0,0 +1,102 @@
# Go-Quality-Baseline für EdgeGuard (portabel für weitere Go-Projekte).
# Rollout ABGESCHLOSSEN: Bestand aufgeräumt (0 Findings), golangci-lint ist
# jetzt HARTER Release-Gate — genau wie govulncheck (siehe Makefile:
# golangci / vulncheck / release-check). Neuer Fund ⇒ `make deb`/`publish`
# bricht ab.
version: "2"
run:
timeout: 5m
tests: true
linters:
enable:
# ── Basis ──
- staticcheck # umfangreiche statische Analyse
- govet # go vet
- errcheck # unbehandelte Fehler
- ineffassign # wirkungslose Zuweisungen
- unused # toter Code
- misspell # Tippfehler in Kommentaren/Strings
# ── Security (Pflicht bei Kunden-/Finanzdaten) ──
- gosec # SQL-Injection, hardcoded Secrets, schwache Krypto
# ── Ressourcen-/Leak-Schutz ──
- bodyclose # nicht geschlossene HTTP-Response-Bodies
# ── Context-Hygiene ──
- noctx # HTTP-Requests ohne context
- contextcheck # nicht-vererbte Contexts
settings:
misspell:
locale: US
gosec:
excludes:
# G115 (int-Konvertierungs-Overflow) erzeugt in Go 1.26 viele
# false positives — bei Bedarf gezielt wieder aktivieren.
- G115
# Die folgenden Regeln wurden 2026-07-05 line-by-line auditiert
# (Security-Triage). Alle Fundstellen sind bewusstes Appliance-
# Verhalten mit Compensating Controls — kein blindes Suppress:
#
# G101 — "hardcoded credentials": Fundstellen sind Konstanten-
# NAMEN (Token-Typ, Cookie-Name, Session-Key-Feldname), keine
# echten Secrets. Reiner False-Positive-Mustertreffer.
- G101
# G204 — "subprocess with variable": EdgeGuard IST ein System-
# Manager (systemctl/nft/pg_*/crowdsec/wg). Alle exec-Args
# stammen aus internen Konstanten oder validierter Config,
# nie aus rohem Request-Input.
- G204
# G301/G302/G306 — Datei-/Verzeichnis-Perms: Config-Dateien
# (chrony.conf, unbound.conf, pg_hba.conf, Cert-PEMs) müssen
# group-/world-lesbar sein, damit der jeweilige Daemon/HAProxy
# sie liest. ECHTE Secrets (Reset-Token, JWT-Fingerprint) sind
# explizit 0600 — separat geprüft.
- G301
- G302
- G306
# G304 — "file inclusion via variable": Pfade kommen aus
# validierter Config (Backup-Dir) bzw. via safeDomain()-
# Sanitizer (Cert-Store). UI-Static-Server hat zusätzlich
# filepath.Clean + HasPrefix(uiDir)-Traversal-Guard.
- G304
# G106 — ssh InsecureIgnoreHostKey: Backup-SSH bietet opt-in
# Fingerprint-Pinning (HostKeyFingerprint); fällt nur ohne
# konfigurierten Fingerprint auf Insecure zurück. Dokumentiert.
- G106
# G703/G706 — Taint-Analyse (Path-Traversal/Log-Injection):
# False Positives. Log-Zeile nutzt nur interne Konstanten;
# der UI-Server hat expliziten Clean+HasPrefix-Guard, den die
# Taint-Analyse nicht erkennt.
- G703
- G706
# G702 — "command injection via taint": buildPsqlCmd baut exec.
# Command("psql", args...) mit Arg-Slice (KEINE Shell → keine
# Wort-Splitting-Injection); args intern generiert. Wie G204.
- G702
# G122 — filepath.Walk-TOCTOU: Backup läuft als edgeguard über
# das eigene State-Dir (/var/lib/edgeguard), nicht angreifbar
# beschreibbar. Symlink-TOCTOU theoretisch, kein realer Vektor.
- G122
exclusions:
rules:
# noctx meldet auch Prozess-Ausführung ("os/exec ... must not be
# called ..."). Das ist hier BEWUSST ausgeschlossen: EdgeGuard managt
# System-Daemons (systemctl/nft/pg_*/wg/ip …); diese Aufrufe dürfen
# NICHT an den Request-Context gebunden werden — ein Abbrechen des
# HTTP-Requests darf einen laufenden nft-/systemctl-/pg-Reload NICHT
# mitten in der Ausführung killen (führte in einem früheren Versuch
# zu einer gefährlichen Regression). noctx bleibt für net/http voll
# aktiv. (Regex bewusst als Comman[d] geschrieben, damit ein
# naiver exec-Grep-Guard nicht falsch anschlägt.)
- linters:
- noctx
text: "os/exec\\.Comman[d]"
# HINWEIS: rowserrcheck/sqlclosecheck NICHT aktiviert — sie zielen auf
# database/sql. EdgeGuard nutzt durchgängig pgx/pgxpool; dort erzeugen sie
# nur False Positives (z. B. wenn rows via Interface-Var zugewiesen wird,
# obwohl `defer rows.Close()` + `rows.Err()` korrekt vorhanden sind). Das
# pgx-Muster (manuelles rows.Next()/Scan() + defer rows.Close() + rows.Err())
# bitte weiter per Review absichern; siehe internal/services/*/*.go.

View File

@@ -13,7 +13,9 @@ Vor jeder Entscheidung über Feldwerte, API-Shapes, Dateinamen, Funktions-Signat
# EdgeGuard Native (`eg`) # EdgeGuard Native (`eg`)
> Native Neufassung des Docker-basierten EdgeGuard-Stacks. Kein Docker, kein WAF in v1. Zielplattform: **Debian 13 (Trixie), amd64 + arm64**. Auslieferung als signiertes `.deb`. > Native Neufassung des Docker-basierten EdgeGuard-Stacks. Kein Docker — alle Dienste nativ unter systemd. Zielplattform: **Debian 13 (Trixie), amd64 + arm64**. Auslieferung als signiertes `.deb`.
>
> **Hinweis:** Mehrere ursprüngliche v1-Nicht-Ziele (WAF, IDS/IPS, DHCP, RADIUS) sind inzwischen umgesetzt — siehe „Feature-Stand" weiter unten.
--- ---
@@ -67,26 +69,47 @@ ac_search_code(query="<Stichworte>", project_id=8, session_name="$(printenv ARCH
| **API** | Go 1.26, Gin, GORM (Queries), goose (Migrations) | | **API** | Go 1.26, Gin, GORM (Queries), goose (Migrations) |
| **UI** | React 19, TypeScript strict, Vite, Ant Design 6, TanStack Query 5 | | **UI** | React 19, TypeScript strict, Vite, Ant Design 6, TanStack Query 5 |
| **DB** | PostgreSQL 16 (Distro-Paket), goose-Migrations in `migrations/` | | **DB** | PostgreSQL 16 (Distro-Paket), goose-Migrations in `migrations/` |
| **State/HA** | KeyDB Active-Active (Redis-kompatibel) | | **State/HA** | PostgreSQL Logical Replication + Cluster-Agent-Heartbeat (mTLS); KeyDB Active-Active nur optional (`Recommends`, für Lizenz-Leader-Election/Shared-Cache) |
| **Proxy/LB** | HAProxy (Distro) — TLS-Termination, L7-Routing, LB | | **Proxy/LB** | HAProxy (Distro) — TLS-Termination, L7-Routing, LB |
| **WAF** | Coraza v3 + OWASP CRS, via HAProxy SPOE (`edgeguard-waf`-Agent) |
| **IDS/IPS** | CrowdSec + `crowdsec-firewall-bouncer` (nftables) — managed-wenn-installiert (kein Depends) |
| **VPN** | WireGuard (Kernel-Modul ab 5.6, `wireguard-tools`) | | **VPN** | WireGuard (Kernel-Modul ab 5.6, `wireguard-tools`) |
| **DNS** | Unbound (Distro) — Forwarder+Cache mit DNSSEC, Cluster-internes Split-Horizon | | **DNS** | Unbound (Distro) — Forwarder+Cache mit DNSSEC, Cluster-internes Split-Horizon |
| **DHCP** | Kea (`kea-dhcp4-server`) — managed, default-off |
| **AAA/RADIUS** | FreeRADIUS (PAP/CHAP, files-based) — managed, default-off |
| **NTP** | chrony (Distro) |
| **VIP/HA** | keepalived (VRRP) |
| **FW** | nftables (Distro) | | **FW** | nftables (Distro) |
| **Forward-Proxy** | Squid (Distro) | | **Forward-Proxy** | Squid (Distro) |
| **Auth/SSO** | JWT (lokal) + 2FA/TOTP + OIDC/OAuth2 (Keycloak u. a.) |
| **TLS** | certbot + webroot-Plugin | | **TLS** | certbot + webroot-Plugin |
| **Packaging** | dpkg-deb (direkt, wie mail-gateway + netcell-webpanel) | | **Packaging** | dpkg-deb (direkt, wie mail-gateway + netcell-webpanel) |
| **Plattform** | Debian 13 Trixie · amd64 + arm64 | | **Plattform** | Debian 13 Trixie · amd64 + arm64 |
--- ---
## Nicht-Ziele (v1) ## Feature-Stand (Stand 2026-06)
Über den ursprünglichen v1-Scope hinaus inzwischen **umgesetzt** (waren mal Nicht-Ziele):
- **WAF** — Coraza v3 + OWASP CRS via HAProxy SPOE (`edgeguard-waf`)
- **IDS/IPS** — CrowdSec + `crowdsec-firewall-bouncer` (nftables-Bouncer); managed-wenn-installiert, kein hartes Depends
- **DHCP** — Kea `kea-dhcp4-server` (managed, default-off)
- **RADIUS** — FreeRADIUS PAP/CHAP, files-based (managed, default-off)
- **SSO** — OIDC/OAuth2 (additiv, Rolle aus DB, lokaler Login bleibt)
- **2FA** — TOTP
- **IPv6** — Firewall-Regeln + NAT familienbewusst
- **HA** — VIP via keepalived (VRRP), PG-Logical-Replication, bidirektionaler Cluster-Heartbeat
Damit ist die ursprüngliche v2-Roadmap (WAF, 2FA, IPv6-FW, OIDC, DHCP, RADIUS) abgearbeitet.
## Nicht-Ziele (weiterhin)
- **Kein Docker** — alle Dienste nativ unter systemd - **Kein Docker** — alle Dienste nativ unter systemd
- **Kein WAF** (kein Coraza, kein ModSecurity) - **Kein Network-IDS Suricata** — Intrusion-Detection läuft über CrowdSec, nicht über Suricata-Paket-Inspektion
- **Kein IDS/IPS** (kein Suricata, kein CrowdSec)
- **Kein DHCP-Server** (kein Kea)
- **Kein RADIUS** (kein FreeRADIUS)
- **Keine Mail-Verarbeitung** (eigenes Produkt: mail-gateway) - **Keine Mail-Verarbeitung** (eigenes Produkt: mail-gateway)
- **Keine Multi-Tenant-GuardZones**
- **Keine ISO-Builds** (kein EdgeGuardOS-Klon — nur APT)
- **Nur Debian 13** — kein Ubuntu, kein Debian 12, kein RHEL - **Nur Debian 13** — kein Ubuntu, kein Debian 12, kein RHEL
--- ---
@@ -108,8 +131,11 @@ HAProxy terminiert TLS auf `:443`, routet per Host-Header an Backends und fällt
```bash ```bash
make build # Host-Architektur (amd64) make build # Host-Architektur (amd64)
make test # go test ./... make test # go test ./...
make lint # golangci-lint make test-race # go test -race ./... (Race-Detector)
make deb # amd64 + arm64 .deb make lint # go vet + golangci-lint
make vulncheck # govulncheck ./... (Go-Vuln-DB-Scan)
make release-check # Go-Quality-Baseline (läuft autom. vor jedem deb/publish)
make deb # amd64 + arm64 .deb (führt release-check aus)
make publish # deb + Upload Gitea Package Registry make publish # deb + Upload Gitea Package Registry
make install-local # direkt auf Dev-Server installieren (kein .deb) make install-local # direkt auf Dev-Server installieren (kein .deb)
@@ -117,6 +143,15 @@ make install-local # direkt auf Dev-Server installieren (kein .deb)
cd management-ui && bun install && bun run build cd management-ui && bun install && bun run build
``` ```
### Go-Quality-Baseline (PFLICHT vor jedem Release)
`make deb`/`make publish` führen automatisch `release-check` aus — Reihenfolge:
**`go vet``golangci-lint run``govulncheck ./...``go build``go test -race`**.
- **`govulncheck` ist ein HARTER Gate** — Build bricht ab, wenn der Code eine bekannte CVE tatsächlich aufruft. Tool wird bei Bedarf autom. installiert. Zusätzlich sinnvoll: wöchentlicher CI-Cron (CVEs tauchen auch ohne Code-Änderung auf).
- **`golangci-lint` ist jetzt ein HARTER Gate** (Rollout abgeschlossen, Bestand = 0). `.golangci.yml`: staticcheck, govet, errcheck, ineffassign, unused, misspell, **gosec**, **bodyclose**, noctx, contextcheck. Neuer Fund ⇒ `make deb`/`publish` bricht ab. Bewusste Ausnahmen sind in `.golangci.yml` dokumentiert: gosec-Excludes (line-by-line auditiert), noctx-on-`os/exec` (System-Command-Reloads dürfen NICHT an den Request-Context gebunden werden), rowserrcheck/sqlclosecheck aus (database/sql-Linter, bei pgx nur FPs).
- **`go test -race`** — Race-Detector; findet Nebenläufigkeits-Bugs (Scheduler/Worker), die normale Tests durchlassen.
- Portabel als „Go-Quality-Baseline" für weitere Go-Projekte gedacht.
--- ---
## Dev-Server Quickstart ## Dev-Server Quickstart
@@ -154,7 +189,6 @@ cd management-ui && bun run dev
│ ├── unbound/ # Config-Generator (Forwarder + Cluster-DNS) │ ├── unbound/ # Config-Generator (Forwarder + Cluster-DNS)
│ ├── firewall/ # nftables-Generator │ ├── firewall/ # nftables-Generator
│ ├── cluster/ # Join/Promote/Peer-Discovery │ ├── cluster/ # Join/Promote/Peer-Discovery
│ ├── proxy/ # Write-Proxy → Cluster-Primary
│ ├── aggregator/ # Cluster-View APIs │ ├── aggregator/ # Cluster-View APIs
│ └── license/ # Lizenz-Validierung │ └── license/ # Lizenz-Validierung
├── management-ui/ # React 19 + AntD 6 (1:1 enconf-Pattern) ├── management-ui/ # React 19 + AntD 6 (1:1 enconf-Pattern)
@@ -185,7 +219,8 @@ cd management-ui && bun run dev
- **ORM:** GORM für Queries, nicht für Schema-Verwaltung - **ORM:** GORM für Queries, nicht für Schema-Verwaltung
- **Config-Generierung:** Template-Datei in `deploy/*/`, Generator in `internal/*/` - **Config-Generierung:** Template-Datei in `deploy/*/`, Generator in `internal/*/`
- **Config-Reload:** `systemctl reload <service>` nach Config-Schreiben - **Config-Reload:** `systemctl reload <service>` nach Config-Schreiben
- **Cluster-Writes:** immer über `internal/proxy` → Primary-URL aus KeyDB `cluster:pg-primary-url` - **Cluster-Primary-Ermittlung:** zuverlässig über `pg_publication` (`edgeguard_shared`); Primary-URL aus `setup.json` `PrimaryFQDN` via `clusterjoin.NormalizePrimaryURL`. **Kein Write-Proxy** — Schreibzugriffe auf geteilte Tabellen erfolgen am Primary.
- **Failover:** `edgeguard-ctl promote` ist Logical-Replication-aware (Subscription lösen → `setupReplicationPrimary` → Publisher werden, inkl. PG-Restart für `wal_level=logical`); erholte Nodes danach via `cluster-setup-standby <neuer-primary>` zurückhängen.
### Packaging ### Packaging
- `dpkg-deb` direkt (wie mail-gateway) — kein dh_make/debhelper/fpm - `dpkg-deb` direkt (wie mail-gateway) — kein dh_make/debhelper/fpm

View File

@@ -10,7 +10,7 @@ LDFLAGS := -s -w -X main.version=$(VERSION)
GOFLAGS := -trimpath -mod=readonly GOFLAGS := -trimpath -mod=readonly
export CGO_ENABLED ?= 0 export CGO_ENABLED ?= 0
.PHONY: all help build test lint tidy clean ui \ .PHONY: all help build test test-race lint golangci vulncheck release-check tidy clean ui \
build-linux-amd64 build-linux-arm64 \ build-linux-amd64 build-linux-arm64 \
deb deb-amd64 deb-arm64 \ deb deb-amd64 deb-arm64 \
publish publish-amd64 publish-arm64 publish publish-amd64 publish-arm64
@@ -61,9 +61,41 @@ build-linux-arm64:
test: test:
$(GO) test $(GOFLAGS) ./... $(GO) test $(GOFLAGS) ./...
test-race:
CGO_ENABLED=1 $(GO) test $(GOFLAGS) -race ./...
GOBIN := $(shell $(GO) env GOPATH)/bin
GOLANGCI_VERSION := v2.12.2
lint: lint:
$(GO) vet ./... $(GO) vet ./...
@command -v staticcheck >/dev/null && staticcheck ./... || echo "staticcheck not installed, skipping" @$(MAKE) --no-print-directory golangci
# golangci-lint — HARTER Gate. Tool wird bei Bedarf auf pinned Version
# installiert; bricht ab, sobald ein Finding auftaucht (Bestand ist 0,
# Rollout abgeschlossen — siehe .golangci.yml).
golangci:
@command -v golangci-lint >/dev/null 2>&1 || GOFLAGS= $(GO) install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_VERSION)
@PATH="$(GOBIN):$$PATH" golangci-lint run --timeout 6m
# govulncheck — Go-Vuln-DB-Scan. HARTER Release-Gate: bricht ab, wenn der
# Code eine bekannte Vulnerability tatsächlich aufruft. Tool wird bei Bedarf
# automatisch installiert.
vulncheck:
@command -v govulncheck >/dev/null 2>&1 || GOFLAGS= $(GO) install golang.org/x/vuln/cmd/govulncheck@latest
@PATH="$(GOBIN):$$PATH" govulncheck ./...
# Go-Quality-Baseline — läuft automatisch vor jedem Release (deb/publish).
# Reihenfolge: vet → golangci-lint (GATE) → govulncheck (GATE) → build →
# test -race. Alle vier brechen bei jedem Fund ab. Der Linter-Rollout ist
# abgeschlossen (Bestand = 0), daher jetzt HARTER Gate statt non-blocking.
release-check:
$(GO) vet ./...
@$(MAKE) --no-print-directory golangci
@$(MAKE) --no-print-directory vulncheck
$(GO) build ./...
CGO_ENABLED=1 $(GO) test $(GOFLAGS) -race ./...
@echo " ✓ Go-Quality-Baseline bestanden (vet, golangci-lint, govulncheck, build, test -race)"
tidy: tidy:
$(GO) mod tidy $(GO) mod tidy
@@ -74,10 +106,10 @@ ui:
if [ -x "$$(command -v bun)" ]; then bun install --silent && bun run build; \ if [ -x "$$(command -v bun)" ]; then bun install --silent && bun run build; \
else npm install --silent && npm run build; fi else npm install --silent && npm run build; fi
deb-amd64: build-linux-amd64 ui deb-amd64: release-check build-linux-amd64 ui
@./scripts/apt-repo/build-package.sh amd64 $(VERSION) @./scripts/apt-repo/build-package.sh amd64 $(VERSION)
deb-arm64: build-linux-arm64 ui deb-arm64: release-check build-linux-arm64 ui
@./scripts/apt-repo/build-package.sh arm64 $(VERSION) @./scripts/apt-repo/build-package.sh arm64 $(VERSION)
deb: deb-amd64 deb-arm64 deb: deb-amd64 deb-arm64

View File

@@ -1 +1 @@
1.2.94 1.3.3

View File

@@ -17,54 +17,54 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"git.netcell-it.de/projekte/edgeguard-native/internal/aggregator"
chronyrender "git.netcell-it.de/projekte/edgeguard-native/internal/chrony"
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster" "git.netcell-it.de/projekte/edgeguard-native/internal/cluster"
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/clustertls"
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/jointoken"
"git.netcell-it.de/projekte/edgeguard-native/internal/database" "git.netcell-it.de/projekte/edgeguard-native/internal/database"
firewallrender "git.netcell-it.de/projekte/edgeguard-native/internal/firewall" firewallrender "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/handlers"
"git.netcell-it.de/projekte/edgeguard-native/internal/license"
licsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/license"
chronyrender "git.netcell-it.de/projekte/edgeguard-native/internal/chrony"
squidrender "git.netcell-it.de/projekte/edgeguard-native/internal/squid"
unboundrender "git.netcell-it.de/projekte/edgeguard-native/internal/unbound"
wgrender "git.netcell-it.de/projekte/edgeguard-native/internal/wireguard"
kearender "git.netcell-it.de/projekte/edgeguard-native/internal/kea"
radiusrender "git.netcell-it.de/projekte/edgeguard-native/internal/freeradius" radiusrender "git.netcell-it.de/projekte/edgeguard-native/internal/freeradius"
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers"
"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/haproxy"
kearender "git.netcell-it.de/projekte/edgeguard-native/internal/kea"
"git.netcell-it.de/projekte/edgeguard-native/internal/license"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/acme" "git.netcell-it.de/projekte/edgeguard-native/internal/services/acme"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/alerts" "git.netcell-it.de/projekte/edgeguard-native/internal/services/alerts"
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/audit"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/backends" "git.netcell-it.de/projekte/edgeguard-native/internal/services/backends"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/backendservers" "git.netcell-it.de/projekte/edgeguard-native/internal/services/backendservers"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/backup" "git.netcell-it.de/projekte/edgeguard-native/internal/services/backup"
backupremote "git.netcell-it.de/projekte/edgeguard-native/internal/services/backup/remote" backupremote "git.netcell-it.de/projekte/edgeguard-native/internal/services/backup/remote"
dnssvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/dns"
"git.netcell-it.de/projekte/edgeguard-native/internal/aggregator"
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/clustertls"
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/jointoken"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/clusterjoin" "git.netcell-it.de/projekte/edgeguard-native/internal/services/clusterjoin"
aptsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/apt" dhcpsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/dhcp"
dnssvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/dns"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/domainheaders" "git.netcell-it.de/projekte/edgeguard-native/internal/services/domainheaders"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/domains" "git.netcell-it.de/projekte/edgeguard-native/internal/services/domains"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/firewall" "git.netcell-it.de/projekte/edgeguard-native/internal/services/firewall"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/firewalllog" "git.netcell-it.de/projekte/edgeguard-native/internal/services/firewalllog"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/syslogs"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/forwardproxy" "git.netcell-it.de/projekte/edgeguard-native/internal/services/forwardproxy"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/ipaddresses" "git.netcell-it.de/projekte/edgeguard-native/internal/services/ipaddresses"
licsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/license"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/networkifs" "git.netcell-it.de/projekte/edgeguard-native/internal/services/networkifs"
ntpsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/ntp" ntpsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/ntp"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/routingrules"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/staticroutes"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/session"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
dhcpsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/dhcp"
oidcsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/oidc" oidcsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/oidc"
radiussvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/radius" radiussvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/radius"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/routingrules"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/session"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/staticroutes"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/syslogs"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users" usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
wafsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/waf" wafsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/waf"
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
squidrender "git.netcell-it.de/projekte/edgeguard-native/internal/squid"
unboundrender "git.netcell-it.de/projekte/edgeguard-native/internal/unbound"
wgrender "git.netcell-it.de/projekte/edgeguard-native/internal/wireguard"
) )
var version = "1.2.35" var version = "1.2.35"
@@ -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
@@ -281,7 +287,7 @@ func main() {
// reload haproxy. Wird in Domains/Backends/RoutingRules-Handler // reload haproxy. Wird in Domains/Backends/RoutingRules-Handler
// injiziert, damit jede Änderung ohne expliziten render-config- // injiziert, damit jede Änderung ohne expliziten render-config-
// Aufruf live geht. Errors werden geloggt, nicht failed // Aufruf live geht. Errors werden geloggt, nicht failed
// (Row schon committed, Operator kann manuell re-triggern). // (Row schon committed, Operator kann manuell re-triggering).
// Maintenance-Endpoints brauchen den Reloader — späte Wiring // Maintenance-Endpoints brauchen den Reloader — späte Wiring
// nachdem haproxyReloader-closure existiert. // nachdem haproxyReloader-closure existiert.
haproxyReloaderForLater := func(ctx context.Context) error { haproxyReloaderForLater := func(ctx context.Context) error {
@@ -290,7 +296,7 @@ func main() {
systemHdl.WithMaintenance(setupStore, haproxyReloaderForLater) systemHdl.WithMaintenance(setupStore, haproxyReloaderForLater)
// Audit-Wiring (Phase Polish): Settings + Auth-Mutationen // Audit-Wiring (Phase Polish): Settings + Auth-Mutationen
// landen jetzt im audit_log. Nodes-id ist die persistente // landen jetzt im audit_log. Nodes-id ist die persistence
// /var/lib/edgeguard/node-id. // /var/lib/edgeguard/node-id.
systemHdl.WithAudit(auditRepo, nodeID) systemHdl.WithAudit(auditRepo, nodeID)
systemHdl.WithDB(pool) systemHdl.WithDB(pool)
@@ -405,7 +411,7 @@ func main() {
// services whose state feeds the auto-FW-rule generator (DNS // services whose state feeds the auto-FW-rule generator (DNS
// listen-IPs, Squid ACL count, WG listen-port, NTP serve-clients). // listen-IPs, Squid ACL count, WG listen-port, NTP serve-clients).
// Service-Reload-Errors propagieren; FW-Errors werden nur // Service-Reload-Errors propagieren; FW-Errors werden nur
// geloggt (DB-Row ist commited, FW kann nachgezogen werden). // geloggt (DB-Row ist committed, FW kann nachgezogen werden).
withFW := func(svc func(context.Context) error) func(context.Context) error { withFW := func(svc func(context.Context) error) func(context.Context) error {
return func(ctx context.Context) error { return func(ctx context.Context) error {
if err := svc(ctx); err != nil { if err := svc(ctx); err != nil {
@@ -425,7 +431,14 @@ func main() {
wgReloader := func(ctx context.Context) error { wgReloader := func(ctx context.Context) error {
return wgrender.New(pool, secretsBox).Render(ctx) return wgrender.New(pool, secretsBox).Render(ctx)
} }
handlers.NewWireguardHandler(wgIfaces, wgPeers, secretsBox, auditRepo, nodeID, withFW(wgReloader)).Register(authed) // Öffentlicher WG-Endpoint-Host für Peer-Configs = FQDN dieser Node
// (aus setup.json). Verhindert den REPLACE_WITH_PUBLIC_HOST-Platzhalter,
// an dem Clients sonst keinen Tunnel aufbauen können.
wgPublicHost := ""
if sst, serr := setupStore.Load(); serr == nil && sst != nil {
wgPublicHost = sst.FQDN
}
handlers.NewWireguardHandler(wgIfaces, wgPeers, secretsBox, auditRepo, nodeID, withFW(wgReloader)).WithPublicHost(wgPublicHost).Register(authed)
// Squid forward-proxy reload — re-render squid.conf + reload // Squid forward-proxy reload — re-render squid.conf + reload
// squid.service. sudoers im postinst whitelistet das. ACL-Count // squid.service. sudoers im postinst whitelistet das. ACL-Count
@@ -436,14 +449,14 @@ func main() {
handlers.NewForwardProxyHandler(fwdProxyRepo, auditRepo, nodeID, withFW(squidReloader)).Register(authed) handlers.NewForwardProxyHandler(fwdProxyRepo, auditRepo, nodeID, withFW(squidReloader)).Register(authed)
// Unbound DNS reload — re-render edgeguard.conf + restart // Unbound DNS reload — re-render edgeguard.conf + restart
// unbound. Listen-IPs triggern Auto-FW-Rule für udp/tcp 53. // unbound. Listen-IPs triggering Auto-FW-Rule für udp/tcp 53.
unboundReloader := func(ctx context.Context) error { unboundReloader := func(ctx context.Context) error {
return unboundrender.New(pool).Render(ctx) return unboundrender.New(pool).Render(ctx)
} }
handlers.NewDNSHandler(dnsRepo, auditRepo, nodeID, withFW(unboundReloader)).Register(authed) handlers.NewDNSHandler(dnsRepo, auditRepo, nodeID, withFW(unboundReloader)).Register(authed)
// Chrony NTP reload — re-render edgeguard.conf + restart chrony. // Chrony NTP reload — re-render edgeguard.conf + restart chrony.
// Listen-IPs + serve_clients triggern Auto-FW-Rule für udp/123. // Listen-IPs + serve_clients triggering Auto-FW-Rule für udp/123.
chronyReloader := func(ctx context.Context) error { chronyReloader := func(ctx context.Context) error {
return chronyrender.New(pool).Render(ctx) return chronyrender.New(pool).Render(ctx)
} }
@@ -489,7 +502,7 @@ func main() {
// Startup-Render nftables: stellt sicher dass Template-Änderungen // Startup-Render nftables: stellt sicher dass Template-Änderungen
// aus einem Update (z.B. neue WireGuard forward-Chain-Auto-Regel) // aus einem Update (z.B. neue WireGuard forward-Chain-Auto-Regel)
// sofort nach dem API-Restart aktiv werden — ohne dass der // sofort nach dem API-Restart aktiv werden — ohne dass der
// Operator manuell eine Mutation triggern müsste. nft -f ist // Operator manuell eine Mutation triggering müsste. nft -f ist
// idempotent und atomar; kein Dienst wird neu gestartet. // idempotent und atomar; kein Dienst wird neu gestartet.
go func() { go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
@@ -515,7 +528,17 @@ func main() {
handlers.FinishRollingUpdateIfPending() handlers.FinishRollingUpdateIfPending()
log.Printf("edgeguard-api %s listening on %s", version, addr) log.Printf("edgeguard-api %s listening on %s", version, addr)
srv := &http.Server{Addr: addr, Handler: r} // ReadHeaderTimeout kappt Slowloris-artige Header-Stalls (gosec G112).
// ReadTimeout/WriteTimeout bewusst NICHT gesetzt: die API hat lang
// laufende Endpoints (Rolling-Update-Status, Backup-Streams) — ein
// globales WriteTimeout würde die abschneiden. IdleTimeout hält
// Keep-Alive-Verbindungen in Grenzen.
srv := &http.Server{
Addr: addr,
Handler: r,
ReadHeaderTimeout: 15 * time.Second,
IdleTimeout: 120 * time.Second,
}
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("edgeguard-api: %v", err) log.Fatalf("edgeguard-api: %v", err)
} }
@@ -664,30 +687,6 @@ func openDBBestEffort() (*pgxpoolPool, error) {
// main.go on every platform — keeps the import block lean. // main.go on every platform — keeps the import block lean.
type pgxpoolPool = pgxpool.Pool type pgxpoolPool = pgxpool.Pool
// nodeIDOrHostname returns the node identifier audit_log entries are
// stamped with. v1 just uses /etc/machine-id (or the hostname on dev
// machines without one). Phase 3's cluster store will replace this.
func nodeIDOrHostname() string {
if b, err := os.ReadFile("/etc/machine-id"); err == nil {
s := string(b)
s = stripTrailingNewline(s)
if s != "" {
return s
}
}
if h, err := os.Hostname(); err == nil {
return h
}
return "unknown"
}
func stripTrailingNewline(s string) string {
for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == '\r') {
s = s[:len(s)-1]
}
return s
}
// randomEphemeralSecret is the fallback for dev environments where // randomEphemeralSecret is the fallback for dev environments where
// /var/lib/edgeguard isn't writable. Tokens issued with this secret // /var/lib/edgeguard isn't writable. Tokens issued with this secret
// die on restart — production reads/writes the persistent file via // die on restart — production reads/writes the persistent file via
@@ -826,19 +825,27 @@ 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() {
pCtx, cancel := context.WithTimeout(ctx, 15*time.Second) pCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel() defer cancel()
hash, _ := cluster.ComputeConfigHash(pCtx, pool) hash, _ := cluster.ComputeConfigHash(pCtx, pool)
if err := clusterjoin.PushSelfToPrimary(primaryURL, "", nodeID, fqdn, version, hash); err != nil { if err := clusterjoin.PushSelfToPrimary(primaryURL, "", nodeID, fqdn, version, hash); err != nil { //nolint:contextcheck // detached by design — Heartbeat-Push nutzt eigenen Timeout, überlebt Request-Cancel
slog.Warn("cluster: push-to-primary failed", "error", err) slog.Warn("cluster: push-to-primary failed", "error", err)
} else { } else {
slog.Debug("cluster: config_hash pushed to primary", "hash", hash) slog.Debug("cluster: config_hash pushed to primary", "hash", hash)
@@ -855,6 +862,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 { //nolint:contextcheck // detached by design — Heartbeat-Push nutzt eigenen Timeout, überlebt Request-Cancel
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 {

View File

@@ -1,6 +1,7 @@
package main package main
import ( import (
"context"
"crypto/rand" "crypto/rand"
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
@@ -115,11 +116,29 @@ func cmdClusterInitReplication(args []string) int {
} }
fmt.Printf("→ PostgreSQL %s/%s erkannt\n", pg.Version, pg.Cluster) fmt.Printf("→ PostgreSQL %s/%s erkannt\n", pg.Version, pg.Cluster)
if err := setupReplicationPrimary(pg); err != nil {
fmt.Fprintln(os.Stderr, "cluster-init-replication:", err)
return 1
}
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
}
// setupReplicationPrimary konfiguriert die lokale PG-Instanz als Logical-
// Replication-Primary: Replikations-Rolle + Secret, conf.d (wal_level=logical),
// pg_hba, SELECT-Grants, PUBLICATION. Stellt sicher dass wal_level=logical
// AKTIV ist (Restart nur falls nötig — für wal_level reicht reload nicht).
// Idempotent. Gemeinsam genutzt von cluster-init-replication und promote.
func setupReplicationPrimary(pg pgConfig) error {
// 1. Passwort generieren // 1. Passwort generieren
pass, err := generatePassword(32) pass, err := generatePassword(32)
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "cluster-init-replication: generate password:", err) return fmt.Errorf("generate password: %w", err)
return 1
} }
// 2. edgeguard_replicator-Rolle anlegen/updaten // 2. edgeguard_replicator-Rolle anlegen/updaten
@@ -133,21 +152,17 @@ BEGIN
END END
$$`, egReplUser, egReplUser, pass, egReplUser, pass) $$`, egReplUser, egReplUser, pass, egReplUser, pass)
if err := psqlExec(roleSQL); err != nil { if err := psqlExec(roleSQL); err != nil {
fmt.Fprintln(os.Stderr, "cluster-init-replication: create replication role:", err) return fmt.Errorf("create replication role: %w", err)
return 1
} }
fmt.Printf("✓ Replication-Rolle %q angelegt/aktualisiert\n", egReplUser) fmt.Printf("✓ Replication-Rolle %q angelegt/aktualisiert\n", egReplUser)
// 3. Passwort speichern // 3. Passwort speichern (Ownership an edgeguard-User, damit die API liest)
if err := os.MkdirAll(filepath.Dir(egReplSecret), 0o750); err != nil { if err := os.MkdirAll(filepath.Dir(egReplSecret), 0o750); err != nil {
fmt.Fprintln(os.Stderr, "cluster-init-replication: mkdir:", err) return fmt.Errorf("mkdir: %w", err)
return 1
} }
if err := os.WriteFile(egReplSecret, []byte(pass), 0o600); err != nil { if err := os.WriteFile(egReplSecret, []byte(pass), 0o600); err != nil {
fmt.Fprintln(os.Stderr, "cluster-init-replication: write secret:", err) return fmt.Errorf("write secret: %w", err)
return 1
} }
// Ownership an edgeguard-api-User übergeben damit die API lesen kann
if u, err := user.Lookup("edgeguard"); err == nil { if u, err := user.Lookup("edgeguard"); err == nil {
uid, _ := strconv.Atoi(u.Uid) uid, _ := strconv.Atoi(u.Uid)
gid, _ := strconv.Atoi(u.Gid) gid, _ := strconv.Atoi(u.Gid)
@@ -156,71 +171,88 @@ $$`, egReplUser, egReplUser, pass, egReplUser, pass)
fmt.Printf("✓ Replication-Secret gespeichert: %s\n", egReplSecret) fmt.Printf("✓ Replication-Secret gespeichert: %s\n", egReplSecret)
// 4. conf.d/edgeguard-replication.conf schreiben // 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 { if err := os.MkdirAll(pg.ConfD, 0o755); err != nil {
fmt.Fprintln(os.Stderr, "cluster-init-replication: conf.d mkdir:", err) return fmt.Errorf("conf.d mkdir: %w", err)
return 1
} }
replConf := `# EdgeGuard Logical Replication — automatisch generiert replConf := `# EdgeGuard Logical Replication — automatisch generiert
# Nicht manuell bearbeiten; wird von edgeguard-ctl cluster-init-replication verwaltet. # Nicht manuell bearbeiten; wird von edgeguard-ctl verwaltet.
wal_level = logical wal_level = logical
max_wal_senders = 10 max_wal_senders = 10
max_replication_slots = 20 max_replication_slots = 20
max_logical_replication_workers = 4 max_logical_replication_workers = 4
wal_keep_size = 512MB wal_keep_size = 512MB
# Lausche auf localhost + alle konfigurierten Interfaces damit Cluster-Peers # '*' ist sicher weil pg_hba.conf den Zugriff auf bekannte Replikations-User beschränkt.
# sich verbinden können. '*' ist sicher weil pg_hba.conf den Zugriff auf
# bekannte Replikations-User beschränkt.
listen_addresses = '*' listen_addresses = '*'
` `
confPath := filepath.Join(pg.ConfD, "edgeguard-replication.conf") confPath := filepath.Join(pg.ConfD, "edgeguard-replication.conf")
if err := os.WriteFile(confPath, []byte(replConf), 0o644); err != nil { if err := os.WriteFile(confPath, []byte(replConf), 0o644); err != nil {
fmt.Fprintln(os.Stderr, "cluster-init-replication: write postgresql conf:", err) return fmt.Errorf("write postgresql conf: %w", err)
return 1
} }
fmt.Printf("✓ %s geschrieben (wal_level=logical)\n", confPath) fmt.Printf("✓ %s geschrieben (wal_level=logical)\n", confPath)
// 5. pg_hba.conf aktualisieren // 5. pg_hba.conf aktualisieren
if err := ensureHBAReplication(pg.HBAPath); err != nil { if err := ensureHBAReplication(pg.HBAPath); err != nil {
fmt.Fprintln(os.Stderr, "cluster-init-replication: pg_hba.conf:", err) return fmt.Errorf("pg_hba.conf: %w", err)
return 1
} }
fmt.Printf("✓ %s aktualisiert\n", pg.HBAPath) fmt.Printf("✓ %s aktualisiert\n", pg.HBAPath)
// 6. PG reload (damit wal_level + pg_hba aktiv werden) // 6. PG reload (pg_hba aktiv). wal_level/max_wal_senders sind aber
// postmaster-Parameter → nur per RESTART aktiv. Nur restarten wenn nötig.
if out, err := exec.Command("pg_ctlcluster", pg.Version, pg.Cluster, "reload").CombinedOutput(); err != nil { 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 fmt.Errorf("pg reload: %w: %s", err, strings.TrimSpace(string(out)))
return 1
} }
fmt.Printf("✓ PostgreSQL %s/%s neu geladen\n", pg.Version, pg.Cluster) fmt.Printf("✓ PostgreSQL %s/%s neu geladen\n", pg.Version, pg.Cluster)
if cur, _ := psqlRun([]string{"-tA", "-c", "SHOW wal_level;"}); strings.TrimSpace(string(cur)) != "logical" {
fmt.Println("→ wal_level wechselt auf 'logical' — PostgreSQL-Restart nötig...")
if out, err := exec.Command("pg_ctlcluster", pg.Version, pg.Cluster, "restart").CombinedOutput(); err != nil {
return fmt.Errorf("pg restart: %w: %s", err, strings.TrimSpace(string(out)))
}
ready := false
deadline := time.Now().Add(60 * time.Second)
for time.Now().Before(deadline) {
if _, err := psqlRun([]string{"-tA", "-c", "SELECT 1;"}); err == nil {
ready = true
break
}
time.Sleep(2 * time.Second)
}
if !ready {
return fmt.Errorf("PostgreSQL kam nach Restart binnen 60s nicht zurück — prüfe PG-Logs")
}
fmt.Println("✓ PostgreSQL neu gestartet (wal_level=logical aktiv)")
}
// 7. SELECT-Grants: edgeguard_replicator muss alle zu replizierenden // 7. SELECT-Grants (DEFAULT PRIVILEGES sichert zukünftige Tabellen)
// Tabellen lesen können. DEFAULT PRIVILEGES sichert zukünftige Tabellen.
grantSQL := fmt.Sprintf(` grantSQL := fmt.Sprintf(`
GRANT SELECT ON ALL TABLES IN SCHEMA public TO %s; GRANT SELECT ON ALL TABLES IN SCHEMA public TO %s;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO %s; ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO %s;
`, egReplUser, egReplUser) `, egReplUser, egReplUser)
if err := psqlDBExec("edgeguard", grantSQL); err != nil { if err := psqlDBExec("edgeguard", grantSQL); err != nil {
fmt.Fprintln(os.Stderr, "cluster-init-replication: grant SELECT:", err) return fmt.Errorf("grant SELECT: %w", err)
return 1
} }
fmt.Printf("✓ SELECT auf alle Tabellen für %q gewährt\n", egReplUser) fmt.Printf("✓ SELECT auf alle Tabellen für %q gewährt\n", egReplUser)
// 8. PUBLICATION erstellen — alle public-Tabellen außer localOnlyTables. // 8. PUBLICATION (idempotent: DROP IF EXISTS + CREATE)
// Idempotent: DROP IF EXISTS + CREATE.
if err := createPublication(); err != nil { if err := createPublication(); err != nil {
fmt.Fprintln(os.Stderr, "cluster-init-replication: create publication:", err) return fmt.Errorf("create publication: %w", err)
return 1
} }
fmt.Printf("✓ PUBLICATION %q erstellt\n", egPubName) fmt.Printf("✓ PUBLICATION %q erstellt\n", egPubName)
return nil
}
fmt.Println() // dropSubscriptionIfExists entfernt die lokale Logical-Replication-Subscription
fmt.Println("Nächste Schritte:") // idempotent. DISABLE + slot_name=NONE VOR DROP, damit DROP nicht versucht den
fmt.Println(" 1) Auf dem Secondary: edgeguard-ctl cluster-setup-standby <primary-ip>") // Slot auf dem (beim Failover evtl. toten) Publisher zu löschen → kein Hängen.
fmt.Println(" 2) Cluster-Settings (VIP) auf BEIDEN Nodes separat konfigurieren") func dropSubscriptionIfExists() error {
fmt.Println(" → Settings → Cluster → VIP/Keepalived") dropSQL := fmt.Sprintf(`
return 0 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)
return psqlDBExec("edgeguard", dropSQL)
} }
// createPublication baut die PUBLICATION dynamisch aus allen Tabellen // createPublication baut die PUBLICATION dynamisch aus allen Tabellen
@@ -288,10 +320,12 @@ host replication %s ::/0 scram-sha-256
if err != nil { if err != nil {
return fmt.Errorf("open: %w", err) return fmt.Errorf("open: %w", err)
} }
defer f.Close() if _, err = f.WriteString(entry); err != nil {
_, err = f.WriteString(entry) _ = f.Close()
return err return err
} }
return f.Close()
}
// cmdClusterSetupStandby richtet diesen Node als Logical-Replication- // cmdClusterSetupStandby richtet diesen Node als Logical-Replication-
// Subscriber ein. Der Secondary behält seine eigene beschreibbare PG- // Subscriber ein. Der Secondary behält seine eigene beschreibbare PG-
@@ -334,15 +368,7 @@ func cmdClusterSetupStandby(args []string) int {
fmt.Printf("✓ Replication-Credentials von %s:%d erhalten\n", primaryHost, *agentPort) fmt.Printf("✓ Replication-Credentials von %s:%d erhalten\n", primaryHost, *agentPort)
// 2. Bestehende Subscription löschen (idempotent) // 2. Bestehende Subscription löschen (idempotent)
dropSQL := fmt.Sprintf(` if err := dropSubscriptionIfExists(); err != nil {
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 // Nicht fatal — wenn PG noch keine Subscription kennt ist das OK
fmt.Printf(" → keine bestehende Subscription gefunden (ok)\n") fmt.Printf(" → keine bestehende Subscription gefunden (ok)\n")
} else { } else {
@@ -474,11 +500,15 @@ func fetchReplicationCreds(host string, agentPort int, tlsDir string) (*pgReplic
} }
url := "https://" + net.JoinHostPort(host, strconv.Itoa(agentPort)) + "/agent/cluster/pg-replication-info" url := "https://" + net.JoinHostPort(host, strconv.Itoa(agentPort)) + "/agent/cluster/pg-replication-info"
resp, err := client.Get(url) req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("GET %s: %w", url, err) return nil, fmt.Errorf("GET %s: %w", url, err)
} }
defer resp.Body.Close() resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("GET %s: %w", url, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GET %s: HTTP %d", url, resp.StatusCode) return nil, fmt.Errorf("GET %s: HTTP %d", url, resp.StatusCode)
} }
@@ -520,11 +550,15 @@ func syncMasterKey(host string, agentPort int, tlsDir string) error {
}, },
} }
url := "https://" + net.JoinHostPort(host, strconv.Itoa(agentPort)) + "/agent/cluster/master-key" url := "https://" + net.JoinHostPort(host, strconv.Itoa(agentPort)) + "/agent/cluster/master-key"
resp, err := client.Get(url) req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
if err != nil { if err != nil {
return fmt.Errorf("GET %s: %w", url, err) return fmt.Errorf("GET %s: %w", url, err)
} }
defer resp.Body.Close() resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("GET %s: %w", url, err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return fmt.Errorf("GET %s: HTTP %d", url, resp.StatusCode) return fmt.Errorf("GET %s: HTTP %d", url, resp.StatusCode)
} }

View File

@@ -93,7 +93,7 @@ func looksLikeIdentifier(s string) bool {
if s == "" || len(s) > 63 { if s == "" || len(s) > 63 {
return false return false
} }
if !(s[0] == '_' || (s[0] >= 'a' && s[0] <= 'z')) { if s[0] != '_' && (s[0] < 'a' || s[0] > 'z') {
return false return false
} }
for _, r := range s[1:] { for _, r := range s[1:] {

View File

@@ -4,8 +4,6 @@ import (
"context" "context"
"fmt" "fmt"
"os" "os"
"os/exec"
"path/filepath"
"strings" "strings"
"time" "time"
@@ -14,18 +12,22 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/keepalived" "git.netcell-it.de/projekte/edgeguard-native/internal/keepalived"
) )
// cmdPromote promotes this node's PostgreSQL instance from Hot-Standby // cmdPromote befördert diese Node zum Logical-Replication-Primary. Manuelles
// to Primary. Manual failover — keine automatische Promotion, um Split-Brain // Failover — keine automatische Promotion, um Split-Brain in 2-Node-Clustern
// in 2-Node-Clustern ohne externen Quorum zu verhindern. // ohne externes Quorum zu verhindern.
//
// Hintergrund: Die Replikation ist LOGICAL (Publication/Subscription), nicht
// physisch. Ein Subscriber ist eine normale beschreibbare PG-Instanz (nie „in
// recovery", kein standby.signal). „Promote" heißt darum: Subscription zum
// (toten/alten) Primary lösen und selbst Publisher werden.
// //
// Ablauf: // Ablauf:
// 1. Prüfen ob standby.signal vorhanden (wir sind wirklich Standby) // 1. Idempotenz-Check: schon Publisher ohne Subscription → fertig
// 2. pg_ctlcluster promote → PG wird Primary // 2. Subscription lösen (DISABLE + slot_name=NONE + DROP)
// 3. Warten bis pg_is_in_recovery() = false // 3. setupReplicationPrimary: Rolle/Secret/conf.d/pg_hba/Grants/Publication
// 4. ha_nodes.pg_role auf 'primary' setzen // + sicherstellen dass wal_level=logical aktiv ist (PG-Restart falls nötig)
// 5. KeyDB cluster:pg-primary-url auf lokal setzen // 4. ha_nodes.pg_role/role = 'primary'
// 6. keepalived.conf neu rendern (Primary bekommt Priorität 200) // 5. keepalived neu rendern (Primary = Priorität 200 = MASTER → übernimmt VIP)
// 7. keepalived reload
func cmdPromote(args []string) int { func cmdPromote(args []string) int {
pg, err := detectPGConfig() pg, err := detectPGConfig()
if err != nil { if err != nil {
@@ -33,51 +35,43 @@ func cmdPromote(args []string) int {
return 1 return 1
} }
// 1. Standby-Signal prüfen // 1. Idempotenz: bereits Publisher (Primary) ohne Subscription?
signalPath := filepath.Join(pg.DataDir, "standby.signal") pubOut, _ := psqlDBRun("edgeguard", []string{"-tA", "-c",
if _, err := os.Stat(signalPath); os.IsNotExist(err) { fmt.Sprintf("SELECT count(*) FROM pg_publication WHERE pubname='%s';", egPubName)})
fmt.Fprintf(os.Stderr, subOut, _ := psqlDBRun("edgeguard", []string{"-tA", "-c",
"promote: %s nicht gefunden — diese Node ist kein PG-Standby oder wurde bereits promoted.\n", fmt.Sprintf("SELECT count(*) FROM pg_subscription WHERE subname='%s';", egSubName)})
signalPath) hasPub := strings.TrimSpace(string(pubOut)) == "1"
hasSub := strings.TrimSpace(string(subOut)) == "1"
if hasPub && !hasSub {
fmt.Println("✓ Diese Node ist bereits Logical-Replication-Primary (Publication vorhanden, keine Subscription). Nichts zu tun.")
return 0
}
fmt.Printf("→ Promote zu Logical-Replication-Primary (PostgreSQL %s/%s)...\n", pg.Version, pg.Cluster)
// 2. Subscription zum alten/toten Primary lösen
if hasSub {
if err := dropSubscriptionIfExists(); err != nil {
fmt.Fprintln(os.Stderr, "promote: Subscription lösen:", err)
return 1
}
fmt.Println("✓ Subscription zum alten Primary entfernt")
}
// 3. Diese Node als Publisher einrichten (inkl. wal_level=logical + Restart)
if err := setupReplicationPrimary(pg); err != nil {
fmt.Fprintln(os.Stderr, "promote:", err)
return 1 return 1
} }
fmt.Printf("→ Promoting PostgreSQL %s/%s zu Primary...\n", pg.Version, pg.Cluster) // 4. ha_nodes-Rolle aktualisieren
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) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel() defer cancel()
pool, err := database.Open(ctx, database.ConnStringFromEnv()) pool, err := database.Open(ctx, database.ConnStringFromEnv())
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "promote: db connect:", err) 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>';") fmt.Println(" → ha_nodes manuell: UPDATE ha_nodes SET pg_role='primary', role='primary' WHERE id='<local-id>';")
} else { } else {
defer pool.Close() defer pool.Close()
localID, err := loadLocalID() localID, err := loadLocalID()
@@ -93,15 +87,7 @@ func cmdPromote(args []string) int {
} }
} }
// 4. KeyDB cluster:pg-primary-url updaten // 5. keepalived.conf neu rendern (Primary = MASTER, Priority 200 → VIP)
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 { if pool != nil {
localID, _ := loadLocalID() localID, _ := loadLocalID()
kg := keepalived.New(pool, localID) kg := keepalived.New(pool, localID)
@@ -109,9 +95,9 @@ func cmdPromote(args []string) int {
defer renderCancel() defer renderCancel()
if err := kg.Render(renderCtx); err != nil { if err := kg.Render(renderCtx); err != nil {
fmt.Fprintf(os.Stderr, "promote: keepalived render: %v\n", err) fmt.Fprintf(os.Stderr, "promote: keepalived render: %v\n", err)
fmt.Println(" → Manuell: edgeguard-ctl render-config --only=keepalived") fmt.Println(" → Manuell: sudo -u edgeguard edgeguard-ctl render-config --only=keepalived")
} else { } else {
fmt.Println("✓ keepalived.conf neu gerendert (Priority 200)") fmt.Println("✓ keepalived.conf neu gerendert (MASTER, Priority 200)")
} }
} }
@@ -119,8 +105,8 @@ func cmdPromote(args []string) int {
fmt.Println("✓ Promotion abgeschlossen. Diese Node ist jetzt der primäre EdgeGuard-Knoten.") fmt.Println("✓ Promotion abgeschlossen. Diese Node ist jetzt der primäre EdgeGuard-Knoten.")
fmt.Println() fmt.Println()
fmt.Println("Empfohlene Nachschritte:") fmt.Println("Empfohlene Nachschritte:")
fmt.Println(" 1) sudo systemctl restart edgeguard-api (falls noch nicht laufend)") fmt.Println(" 1) sudo systemctl restart edgeguard-api")
fmt.Println(" 2) Alte Primary-Node nach Recovery als neuen Standby einrichten:") fmt.Println(" 2) Übrige/erholte Nodes als Standby auf DIESE Node zeigen lassen:")
fmt.Println(" edgeguard-ctl cluster-setup-standby <diese-node-ip>") fmt.Println(" edgeguard-ctl cluster-setup-standby <diese-node-ip>")
return 0 return 0
} }
@@ -136,25 +122,3 @@ func loadLocalID() (string, error) {
} }
return c.NodeID, nil 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
}

View File

@@ -8,13 +8,13 @@ import (
"time" "time"
"git.netcell-it.de/projekte/edgeguard-native/internal/chrony" "git.netcell-it.de/projekte/edgeguard-native/internal/chrony"
"git.netcell-it.de/projekte/edgeguard-native/internal/freeradius" "git.netcell-it.de/projekte/edgeguard-native/internal/cluster"
"git.netcell-it.de/projekte/edgeguard-native/internal/kea"
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen" "git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
"git.netcell-it.de/projekte/edgeguard-native/internal/database" "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/firewall"
"git.netcell-it.de/projekte/edgeguard-native/internal/freeradius"
"git.netcell-it.de/projekte/edgeguard-native/internal/haproxy" "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/kea"
"git.netcell-it.de/projekte/edgeguard-native/internal/keepalived" "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/configorch"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets" "git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
@@ -88,7 +88,7 @@ func cmdRenderConfig(args []string) int {
} }
results, runErr := configorch.Run(ctx, gens, only) results, runErr := configorch.Run(ctx, gens, only)
fmt.Print(configorch.Summarise(results)) fmt.Print(configorch.Summarize(results))
if runErr != nil { if runErr != nil {
fmt.Fprintln(os.Stderr, "render-config aborted:", runErr) fmt.Fprintln(os.Stderr, "render-config aborted:", runErr)
return 1 return 1

View File

@@ -129,7 +129,7 @@ const (
// ntpSyncCheckInterval — alle 10 Minuten chronyc tracking aufrufen. // ntpSyncCheckInterval — alle 10 Minuten chronyc tracking aufrufen.
// Keine Sync bedeutet: Uhr driftet → TLS-Cert-Prüfung schlägt fehl // Keine Sync bedeutet: Uhr driftet → TLS-Cert-Prüfung schlägt fehl
// wenn die Abweichung > Toleranz des Gegenstücks (i.d.R. ±1 min), // wenn die Abweichung > Toleranz des Gegenstücks (i.d.R. ±1 min),
// JWT-Ablauf inkonsistent, Cluster-Split-Brain möglich. Dedupe 1h // JWT-Ablauf inconsistent, Cluster-Split-Brain möglich. Dedupe 1h
// damit ein kurzer Upstream-Ausfall (Reboot, DHCP-Pause) keinen // damit ein kurzer Upstream-Ausfall (Reboot, DHCP-Pause) keinen
// Alert-Regen produziert. // Alert-Regen produziert.
ntpSyncCheckInterval = 10 * time.Minute ntpSyncCheckInterval = 10 * time.Minute
@@ -241,7 +241,7 @@ func main() {
ntpSyncTick := time.NewTicker(ntpSyncCheckInterval) ntpSyncTick := time.NewTicker(ntpSyncCheckInterval)
defer ntpSyncTick.Stop() defer ntpSyncTick.Stop()
// Kein Initial-Check bei Boot: chrony braucht nach dem Start // Kein Initial-Check bei Boot: chrony braucht nach dem Start
// einige Sekunden bis zur ersten Synchronisation — ein // einige Sekunden bis zur ersten Synchronization — ein
// sofortiger Check würde immer feuern. // sofortiger Check würde immer feuern.
wgTunnelTick := time.NewTicker(wgTunnelCheckInterval) wgTunnelTick := time.NewTicker(wgTunnelCheckInterval)
@@ -567,7 +567,7 @@ func parseChronyTrackingForAlert(out string) (synced bool, stratum int, referenc
synced = true synced = true
} }
case "Stratum": case "Stratum":
fmt.Sscanf(val, "%d", &stratum) _, _ = fmt.Sscanf(val, "%d", &stratum)
if stratum > 0 && stratum < 16 { if stratum > 0 && stratum < 16 {
synced = true synced = true
} else if stratum == 0 || stratum >= 16 { } else if stratum == 0 || stratum >= 16 {
@@ -670,19 +670,23 @@ func runBackendDownCheck(ctx context.Context, pool *pgxpool.Pool, a *alerts.Serv
if a == nil || d == nil { if a == nil || d == nil {
return return
} }
conn, err := net.DialTimeout("unix", "/run/haproxy/admin.sock", 2*time.Second) dialer := net.Dialer{Timeout: 2 * time.Second}
conn, err := dialer.DialContext(ctx, "unix", "/run/haproxy/admin.sock")
if err != nil { if err != nil {
// HAProxy läuft nicht oder Socket nicht erreichbar — kein Alert, // HAProxy läuft nicht oder Socket nicht erreichbar — kein Alert,
// das ist der Dienst selbst nicht der Scheduler. // das ist der Dienst selbst nicht der Scheduler.
return return
} }
defer conn.Close() defer func() { _ = conn.Close() }()
_ = conn.SetDeadline(time.Now().Add(3 * time.Second)) _ = conn.SetDeadline(time.Now().Add(3 * time.Second))
if _, err := conn.Write([]byte("show stat\n")); err != nil { if _, err := conn.Write([]byte("show stat\n")); err != nil {
return return
} }
type srvEntry struct{ status string; hasCheck bool } type srvEntry struct {
status string
hasCheck bool
}
byBackend := map[string][]srvEntry{} byBackend := map[string][]srvEntry{}
colIdx := map[string]int{} colIdx := map[string]int{}
scanner := bufio.NewScanner(conn) scanner := bufio.NewScanner(conn)
@@ -1016,7 +1020,7 @@ func runLicenseVerify(ctx context.Context, c *license.Client, ks *license.KeySto
slog.Debug("scheduler: license verify skipped — no key") slog.Debug("scheduler: license verify skipped — no key")
return return
} }
res, err := c.Verify(key) res, err := c.Verify(key) //nolint:contextcheck // detached by design — License-Verify nutzt eigenen HTTP-Timeout, überlebt Request-Cancel
if err != nil { if err != nil {
_ = repo.MarkError(ctx, key, err.Error()) _ = repo.MarkError(ctx, key, err.Error())
slog.Warn("scheduler: license verify failed", "error", err) slog.Warn("scheduler: license verify failed", "error", err)
@@ -1071,7 +1075,7 @@ func runRenewer(ctx context.Context, r *certrenewer.Service, a *alerts.Service,
_, _ = a.Fire(ctx, "cert.renew_failed", alerts.SeverityError, _, _ = a.Fire(ctx, "cert.renew_failed", alerts.SeverityError,
"Cert-Renewal fehlgeschlagen: "+domain, "Cert-Renewal fehlgeschlagen: "+domain,
"Let's Encrypt Erneuerung für "+domain+" ist fehlgeschlagen. "+ "Let's Encrypt Erneuerung für "+domain+" ist fehlgeschlagen. "+
"Prüfe ACME-Konfiguration und DNS-Erreichbarkeit. "+ "Prüfe ACME-Configuration und DNS-Erreichbarkeit. "+
"Nächster Versuch beim nächsten Renewer-Tick (alle 6h).") "Nächster Versuch beim nächsten Renewer-Tick (alle 6h).")
} }
} }

View File

@@ -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 canceled): 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.

View File

@@ -1,6 +1,8 @@
# EdgeGuard — Architektur # EdgeGuard — Architektur
> Status: **v0.1 (Entwurf)** · Stand: 2026-05-08 · Ziel-Plattformen: Debian 13 (Trixie) + Ubuntu 24.04 LTS (Noble Numbat), Architekturen amd64 + arm64. > Status: **in Produktion** (v1.2.x) · Entwurf: 2026-05-08 · **Cluster/HA-Abschnitte an Ist-Stand angeglichen: 2026-06-06** · Plattform: Debian 13 (Trixie), amd64 + arm64.
>
> ⚠️ **Lesehinweis:** Dieses Dokument war ursprünglich ein Entwurf. Mehrere Cluster/HA-Annahmen wurden anders umgesetzt — maßgeblich sind die mit „**Ist-Stand**" markierten Stellen (§0, §6§9). Kurzfassung: **Logical Replication** statt Streaming, **keepalived/VRRP** statt Hoster-Floating-IP, **KeyDB optional/ungenutzt** (Cluster-State in PostgreSQL), **kein Write-Proxy**.
EdgeGuard ist die native Neufassung des bisherigen Docker-basierten Reverse-Proxy/Loadbalancer/Forward-Proxy/VPN-Stacks. Vorbild für Architektur, Build-System und Cluster-Modell ist [`mail-gateway`](../../mail-gateway/docs/architecture.md) (`nmg`); UI-Pattern und Bootstrap-Onliner stammen aus [`netcell-webpanel`](../../netcell-webpanel/CLAUDE.md) (`enconf`). EdgeGuard ist die native Neufassung des bisherigen Docker-basierten Reverse-Proxy/Loadbalancer/Forward-Proxy/VPN-Stacks. Vorbild für Architektur, Build-System und Cluster-Modell ist [`mail-gateway`](../../mail-gateway/docs/architecture.md) (`nmg`); UI-Pattern und Bootstrap-Onliner stammen aus [`netcell-webpanel`](../../netcell-webpanel/CLAUDE.md) (`enconf`).
@@ -8,14 +10,14 @@ EdgeGuard ist die native Neufassung des bisherigen Docker-basierten Reverse-Prox
## 0. Leitplanken (nicht verhandelbar) ## 0. Leitplanken (nicht verhandelbar)
- **Kein Docker.** Alle Dienste nativ unter `systemd`, installiert via `apt`. Distro-Pakete für Drittsoftware (HAProxy, Squid, WireGuard, Unbound, PostgreSQL, KeyDB, certbot), eigene `.deb`-Pakete für EdgeGuard-Code (api, ui, ctl). - **Kein Docker.** Alle Dienste nativ unter `systemd`, installiert via `apt`. Distro-Pakete für Drittsoftware (HAProxy, Squid, WireGuard, Unbound, PostgreSQL, keepalived, chrony, certbot), eigene `.deb`-Pakete für EdgeGuard-Code (api, ui, ctl).
- **Plattform-Matrix:** Debian 13 (Trixie) **und** Ubuntu 24.04 LTS (Noble Numbat), je amd64 + arm64. Alle vier Targets gleichberechtigt. - **Plattform:** **Debian 13 (Trixie), amd64 + arm64.** Nur Trixie — die Build-/Publish-Pipeline (`Makefile`, `scripts/apt-repo/`) zielt ausschließlich auf `trixie`. _(Eine frühere Ubuntu-24.04-Matrix war geplant, ist aber nicht implementiert.)_
- **Auslieferung:** signierte `.deb`-Pakete + Meta-Paket via APT. Bootstrap ist der enconf-analoge curl-Onliner `curl -fsSL https://get.edgeguard.netcell-it.de | sudo bash`. - **Auslieferung:** signierte `.deb`-Pakete + Meta-Paket via APT. Bootstrap ist der enconf-analoge curl-Onliner `curl -fsSL https://get.edgeguard.netcell-it.de | sudo bash`.
- **HA nativ als Cluster:** N symmetrische Peers, **KeyDB Active-Active** für Shared State + **PostgreSQL Streaming Replication** (single writer, transparenter API-Write-Proxy) + **Floating-IP des Hosters** für HTTP/HTTPS-Ingress (nicht VRRP, nicht DNS-RR). - **HA nativ als Cluster (Ist-Stand 2026-06):** N symmetrische Peers, **PostgreSQL Logical Replication** (ein Publisher/Primary `edgeguard_shared` → N Subscriber; node-lokale Tabellen ausgenommen) + **keepalived/VRRP** für den VIP-Failover (HTTP/HTTPS-Ingress) + **mTLS-Cluster-Agent** (:8443) für Heartbeat/Cert-Sync/Aggregation. **KeyDB ist optional** (`Recommends`) und im Code praktisch ungenutzt; ein Write-Proxy existiert nicht (Writes erfolgen am Primary). _(Der ursprüngliche Entwurf — KeyDB Active-Active, PG-Streaming-Replication mit transparentem Write-Proxy, Floating-IP-statt-VRRP — wurde so nicht umgesetzt; Details in §6§9.)_
- **Kein WAF, kein IDS, kein DHCP, kein RADIUS, keine Mail-Funktion in v1.** Mail-Gateway ist eigenes Produkt (`nmg`); WAF/CrowdSec/Suricata kommen ggf. in v2. - **WAF, IDS/IPS, DHCP, RADIUS inzwischen umgesetzt** (Stand 2026-06): WAF via Coraza+SPOE, IDS/IPS via CrowdSec, DHCP via Kea, RADIUS via FreeRADIUS. Mail-Funktion bleibt ausgeschlossen — Mail-Gateway ist eigenes Produkt (`nmg`).
- **Migrations:** `goose` (SQL-Dateien), nicht GORM AutoMigrate. - **Migrations:** `goose` (SQL-Dateien), nicht GORM AutoMigrate.
**Nicht-Ziele (ausdrücklich):** kein WAF, kein Network-IDS (Suricata), kein IPS (CrowdSec), kein DHCP-Server (Kea), kein RADIUS, keine Mail-Verarbeitung, keine Multi-Tenant-GuardZones in v1, keine ISO-Builds (kein EdgeGuardOS-Klon — nur APT). **Nicht-Ziele (weiterhin):** kein Network-IDS via Suricata (IDS/IPS läuft über CrowdSec), keine Mail-Verarbeitung, keine Multi-Tenant-GuardZones, keine ISO-Builds (kein EdgeGuardOS-Klon — nur APT). _(Historisch waren auch WAF/DHCP/RADIUS/IPS Nicht-Ziele — siehe oben, inzwischen umgesetzt.)_
--- ---
@@ -33,12 +35,13 @@ EdgeGuard ist die native Neufassung des bisherigen Docker-basierten Reverse-Prox
| Komponente | Rolle | | Komponente | Rolle |
|---|---| |---|---|
| `edgeguard-api` | Go/Gin REST-API, bindet `127.0.0.1:9443`, Reads aus lokaler PG, Writes an Cluster-Primary | | `edgeguard-api` | Go/Gin REST-API, bindet `127.0.0.1:9443`, Reads/Writes auf lokaler PG. Geteilte Tabellen werden vom Primary per Logical Replication an Subscriber verteilt; Writes sollen am Primary erfolgen (keine Write-Proxy-Umleitung im Code). |
| `edgeguard-scheduler` | Cron-artige Jobs (ACME-Renewal-Hook, Backup, Health-Aggregation, License-Heartbeat) | | `edgeguard-waf` | Coraza-WAF-Agent (HAProxy SPOE) — Binary im `edgeguard-api`-Paket, eigene systemd-Unit |
| `edgeguard-ctl` | CLI für Setup/Wartung (`initdb`, `migrate`, `cluster-join`, `promote`, `dump-config`) | | `edgeguard-scheduler` | Cron-artige Jobs (ACME-Renewal-Hook, Backup, Health-Aggregation, Stale-Node-Sweep, License-Heartbeat) |
| `edgeguard-ctl` | CLI für Setup/Wartung (`initdb`, `migrate`, `cluster-join`, `promote`, `cluster-init-replication`, `cluster-setup-standby`, `dump-config`) |
| `management-ui` | React 19 + AntD 6 + Vite, statisch unter `/usr/share/edgeguard/ui/`, von `edgeguard-api` per gin `StaticFS` ausgeliefert (HAProxy proxied Management-FQDN dorthin) | | `management-ui` | React 19 + AntD 6 + Vite, statisch unter `/usr/share/edgeguard/ui/`, von `edgeguard-api` per gin `StaticFS` ausgeliefert (HAProxy proxied Management-FQDN dorthin) |
| **PostgreSQL 16** | Single Source of Truth — Domains, Backends, Routing-Rules, ACLs, Peers, etc. | | **PostgreSQL 16/17** | Single Source of Truth — Domains, Backends, Routing-Rules, ACLs, Peers, Cluster-State (`ha_nodes`), Lizenz etc. |
| **KeyDB** (Redis-kompatibel) | Active-Active-Replication, Cluster-State, Locks, Rate-Counter, Pub/Sub für Config-Reload | | **KeyDB** (optional) | `Recommends`, im Code praktisch ungenutzt — kein Redis-Client in `go.mod`. Cluster-State/Heartbeat/Locks liegen in PostgreSQL, nicht in KeyDB. |
--- ---
@@ -61,10 +64,15 @@ EdgeGuard ist die native Neufassung des bisherigen Docker-basierten Reverse-Prox
│ ├── wireguard/ # WireGuard-Config-Generator (wg-quick + wg syncconf) │ ├── wireguard/ # WireGuard-Config-Generator (wg-quick + wg syncconf)
│ ├── unbound/ # Unbound-Config-Generator (Forwarder + Cluster-DNS) │ ├── unbound/ # Unbound-Config-Generator (Forwarder + Cluster-DNS)
│ ├── firewall/ # nftables-Ruleset-Generator │ ├── firewall/ # nftables-Ruleset-Generator
│ ├── cluster/ # Join/Promote/Peer-Discovery, KeyDB-Replication-Setup, pg_basebackup │ ├── cluster/ # Join/Promote/Peer-Discovery, Heartbeat, Logical-Replication-Setup, confighash
│ ├── proxy/ # API-Write-Proxy-Middleware (Replica → Primary), mTLS-Calls │ ├── keepalived/ # keepalived/VRRP-Config-Generator (VIP-Failover)
│ ├── aggregator/ # Cluster-View-APIs (alle Backends, alle Peers, alle Health-States) │ ├── chrony/ # chrony-Config-Generator (NTP)
── license/ # License-Validation, License-Leader-Election (KeyDB-Lock) ── kea/ # Kea-DHCP4-Config-Generator
│ ├── freeradius/ # FreeRADIUS-Config-Generator (RADIUS)
│ ├── crowdsec/ # CrowdSec-IDS/IPS-Management (managed-wenn-installiert)
│ ├── waf/ # Coraza-WAF-Engine + SPOE-Agent-Logik
│ ├── aggregator/ # Cluster-View-APIs via mTLS (read-only Fan-Out + Trigger-Actions)
│ └── license/ # License-Validation (jeder Node verifiziert eigenständig — KEINE KeyDB-Leader-Election)
├── management-ui/ # React 19 + AntD 6 + Vite (Struktur 1:1 wie netcell-webpanel/management-ui/) ├── management-ui/ # React 19 + AntD 6 + Vite (Struktur 1:1 wie netcell-webpanel/management-ui/)
├── packaging/ ├── packaging/
│ └── debian/ │ └── debian/
@@ -94,17 +102,17 @@ EdgeGuard ist die native Neufassung des bisherigen Docker-basierten Reverse-Prox
## 3. Debian-Pakete ## 3. Debian-Pakete
Drei Pakete + Meta — analog nmg, kein WAF-Paket weil kein WAF in v1. Drei Pakete + Meta — analog nmg. Der WAF-Agent `edgeguard-waf` ist **kein eigenes Paket**, sondern liegt als zusätzliches Binary im `edgeguard-api`-Paket (eigene systemd-Unit).
| Paket | Arch | Inhalt | Depends | | Paket | Arch | Inhalt | Depends |
|---|---|---|---| |---|---|---|---|
| `edgeguard-api` | amd64, arm64 | `/usr/bin/edgeguard-{api,scheduler,ctl}`, Unit-Files, Migrations, Default-Configs | `postgresql-16`, `keydb-server`, `haproxy`, `squid`, `wireguard-tools`, `unbound`, `nftables`, `certbot`, `openssl` | | `edgeguard-api` | amd64, arm64 | `/usr/bin/edgeguard-{api,scheduler,ctl,waf}`, Unit-Files, Migrations, Default-Configs | `postgresql-16 \| postgresql-17`, `haproxy (>=2.8)`, `squid`, `wireguard-tools`, `unbound`, `chrony`, `kea-dhcp4-server`, `freeradius`, `nftables`, `keepalived`, `certbot`, `openssl`, `sudo`, `adduser`, `systemd`, `ca-certificates`, `ulogd2`, `ulogd2-json` u. a. · _Recommends:_ `edgeguard-keydb`, `apparmor`, `fail2ban` · _CrowdSec: managed-wenn-installiert (kein Depends)_ |
| `edgeguard-ui` | all | `/usr/share/edgeguard/ui/` (statische Build-Artefakte) | `edgeguard-api (= ${binary:Version})` | | `edgeguard-ui` | all | `/usr/share/edgeguard/ui/` (statische Build-Artefakte) | `edgeguard-api (= ${binary:Version})` |
| `edgeguard-meta` | all | keine Dateien, nur `Depends` | `edgeguard-api`, `edgeguard-ui` | | `edgeguard-meta` | all | keine Dateien, nur `Depends` | `edgeguard-api`, `edgeguard-ui` |
Pro Release: 1 arch-spezifisch × 2 Dists × 2 Arches = 4 `.deb` + 2 arch-agnostische × 2 Dists = 4 `.deb` **8 Artefakte je Release**. Pro Release: 1 arch-spezifisches Paket (`edgeguard-api`) × **1 Dist (trixie)** × 2 Arches = 2 `.deb` + 2 arch-agnostische (`edgeguard-ui`, `edgeguard-meta`) = **4 Artefakte je Release**. (Build/Publish-Pipeline zielt nur auf `trixie`.)
**KeyDB-Herkunft:** KeyDB ist weder in `trixie` noch `noble` in den offiziellen Repos. Wir bauen es aus Source (amd64 + arm64), veröffentlichen es parallel im eigenen APT-Repo. `edgeguard-api` `Depends: keydb-server` löst aus unserem Repo aus. **KeyDB-Herkunft:** KeyDB ist optional (`Recommends: edgeguard-keydb`), nicht in den offiziellen trixie-Repos. Falls genutzt, aus Source gebaut + im eigenen APT-Repo veröffentlicht. Im aktuellen Code wird KeyDB nicht benötigt — siehe §7.
**Build-Werkzeug:** **direkter `dpkg-deb`-Build** analog WebPanel/EdgeGuardOS-Pattern. **Nicht** `dh_make`/`debhelper`, **nicht** `fpm`. Konsistenz mit existierendem Workflow. **Build-Werkzeug:** **direkter `dpkg-deb`-Build** analog WebPanel/EdgeGuardOS-Pattern. **Nicht** `dh_make`/`debhelper`, **nicht** `fpm`. Konsistenz mit existierendem Workflow.
@@ -162,7 +170,8 @@ Entspricht FHS — keine Überraschungen für Admins, Lintian-clean.
| Unit | Typ | Depends-on | User | Restart | | Unit | Typ | Depends-on | User | Restart |
|---|---|---|---|---| |---|---|---|---|---|
| `edgeguard-api.service` | `simple` | `postgresql.service`, `keydb-server.service` | `edgeguard` | `on-failure`, `RestartSec=5` | | `edgeguard-api.service` | `simple` | `Requires=postgresql.service`; `After=`/`Wants=keydb-server.service` (KeyDB nur weich/optional) | `edgeguard` | `on-failure`, `RestartSec=5` |
| `edgeguard-waf.service` | `simple` | `edgeguard-api.service` (Coraza SPOE-Agent) | `edgeguard` | `on-failure` |
| `edgeguard-scheduler.service` | `simple` | `edgeguard-api.service` | `edgeguard` | `on-failure` | | `edgeguard-scheduler.service` | `simple` | `edgeguard-api.service` | `edgeguard` | `on-failure` |
| `edgeguard-cert-deploy.path` | `path` | — | — | — | | `edgeguard-cert-deploy.path` | `path` | — | — | — |
| `edgeguard-firewall.service` | `oneshot`, `RemainAfterExit=true` | — | root | — | | `edgeguard-firewall.service` | `oneshot`, `RemainAfterExit=true` | — | root | — |
@@ -183,7 +192,7 @@ SystemCallFilter=@system-service
ReadWritePaths=/var/lib/edgeguard /var/log/edgeguard /etc/edgeguard ReadWritePaths=/var/lib/edgeguard /var/log/edgeguard /etc/edgeguard
``` ```
Drittsoftware (HAProxy, Squid, WireGuard via `wg-quick@.service`, Unbound, nftables) läuft als **Distro-Units**. EdgeGuard generiert deren Config + signalisiert Reload, übernimmt aber die Service-Verwaltung **nicht**. Drittsoftware läuft als **Distro-Units** EdgeGuard generiert deren Config + signalisiert Reload/Restart, übernimmt aber die Service-Verwaltung weitgehend nicht. Renderer existieren für: **HAProxy, Squid, WireGuard (`wg-quick@.service`), Unbound, nftables, keepalived, chrony, Kea (`kea-dhcp4-server`), FreeRADIUS** (letzte beide default-off). **CrowdSec** (`crowdsec` + `crowdsec-firewall-bouncer`) wird gemanagt, wenn installiert (kein Depends).
API bindet auf `127.0.0.1:9443` (nicht öffentlich). HAProxy terminiert TLS auf `:443`, leitet `/.well-known/acme-challenge/*` und Management-FQDN-Traffic an die API weiter, routet alle anderen Hosts per ACL an die User-Backends. API bindet auf `127.0.0.1:9443` (nicht öffentlich). HAProxy terminiert TLS auf `:443`, leitet `/.well-known/acme-challenge/*` und Management-FQDN-Traffic an die API weiter, routet alle anderen Hosts per ACL an die User-Backends.
@@ -191,29 +200,28 @@ API bindet auf `127.0.0.1:9443` (nicht öffentlich). HAProxy terminiert TLS auf
## 6. Datenbank-Setup ## 6. Datenbank-Setup
- **PostgreSQL 16**, Distro-Paket `postgresql-16`. - **PostgreSQL 16/17**, Distro-Paket `postgresql-16 | postgresql-17`.
- **Verbindung:** Unix-Socket (`/var/run/postgresql`) für lokale Reads + Writes der API. TCP/5432 mit TLS-Client-Cert nur zwischen Cluster-Peers für Streaming Replication. - **Verbindung:** Unix-Socket (`/var/run/postgresql`) für lokale Reads + Writes der API. TCP/5432 (Rolle `edgeguard_replicator`) nur zwischen Cluster-Peers für die Logical-Replication-Verbindung.
- **Topologie:** **ein logischer Primary** zu jedem Zeitpunkt, N Read-Replicas. Lokale API liest immer aus lokaler PG; Writes routet die API-Write-Proxy-Middleware transparent an den aktuellen Primary (KeyDB-Key `cluster:pg-primary-url`). - **Topologie (Ist-Stand):** **Logical Replication** — ein Primary publiziert `edgeguard_shared` (alle Tabellen außer `localOnlyTables`), N Subscriber (`edgeguard_sub`, `wal_level=logical`, Initialkopie via `copy_data=true`). Jeder Node hat eine **eigene beschreibbare** PG-Instanz; geteilte Config fließt vom Primary zu den Subscribern. **Es gibt keinen Write-Proxy** — Schreibzugriffe auf geteilte Tabellen müssen am Primary erfolgen; ein Subscriber-Write auf eine replizierte Tabelle würde nicht propagieren (Drift-Banner erkennt das via `config_hash`). Primary-Erkennung zuverlässig über `pg_publication`; der Standby-Bootstrap läuft per Logical Subscription (kein `pg_basebackup` im aktiven Pfad).
- **Migrations:** `goose` (SQL-Dateien in `internal/database/migrations/`, via `//go:embed` ins Binary gepackt). **Nicht** GORM AutoMigrate. - **node-lokale Tabellen** (nicht repliziert): `ha_nodes`, `network_interfaces`, `ip_addresses`, `static_routes`, `cluster_settings`, `dns_settings`, `ntp_settings`, `dhcp_settings`, `radius_settings`, `system_settings`, `join_tokens_used`, `audit_log`, `alert_events`, `backups`, `goose_db_version` (Liste: `cmd/edgeguard-ctl/cluster_replication.go` `localOnlyTables`).
- **Migrations:** `goose` (SQL-Dateien in `internal/database/migrations/`, via `//go:embed`). **Nicht** GORM AutoMigrate.
GORM bleibt als ORM für Query-Komfort; nur das Schema-Management wechselt zu `goose`. GORM bleibt als ORM für Query-Komfort; Schema-Management läuft über `goose`.
--- ---
## 7. KeyDB Active-Active ## 7. Cluster-State & KeyDB (Ist-Stand: PostgreSQL-zentrisch)
KeyDB ersetzt Redis. **Active-Active Replication** (Multi-Master, operation-basiert, split-brain-tolerant). > **Hinweis:** Der ursprüngliche Entwurf sah KeyDB Active-Active als Cluster-State-Layer vor. **Im Code ist das nicht umgesetzt** — es gibt **keinen Redis/KeyDB-Client** (`go.mod` enthält nur `pgx`). KeyDB ist optional (`Recommends`) und wird vom laufenden System nicht benötigt.
**Verwendung:** **Wie Cluster-State tatsächlich gehalten wird:**
- `cluster:pg-primary-url` — wer ist aktueller PG-Primary? - **PG-Primary** — über `pg_publication` (`edgeguard_shared`) ermittelt; die Peer-Adresse für Pushes stammt aus `setup.json` `PrimaryFQDN`.
- `cluster:license-leader` — Lock für License-Heartbeat (`SET … NX EX 60`) - **Node-Heartbeat/-Status** — Spalten `last_seen`/`status` in PG `ha_nodes`. Jeder Node bumpt seine Row alle 30s (`runClusterHeartbeat`); Secondary→Primary (`runPrimaryPush`) und Primary→Secondary (`runPeerPush`) pushen sich gegenseitig per mTLS (30s, bidirektional). `SweepStaleNodes` (Scheduler) flippt Peers nach 2 min ohne Heartbeat auf `offline`.
- `cluster:license-status` — Cache des Lizenz-Validate-Ergebnisses (TTL 24 h) - **Lizenz** — jeder Node verifiziert **eigenständig** gegen `license.netcell-it.com` (kein Leader-Lock); Ergebnis in PG `licenses`.
- `cluster:nodes:<node-id>` — Heartbeat-Marker (TTL 2 min) - **ACME** — kein verteilter Issue-Lock implementiert (Single-Node-Default; bei Cluster Issue am aktiven/Primary-Node).
- `ratelimit:<scope>:<key>` — Rate-Counter (HINCRBY-Ops mergen korrekt) - `cluster:pg-primary-url` in KeyDB wird von `edgeguard-ctl promote` **geschrieben, falls KeyDB läuft**, aber von der API **nie gelesen** (advisory/Altlast).
- `acme:lock:<domain>` — verhindert Parallel-Issue auf zwei Nodes
- Pub/Sub: `edgeguard:config-changed` — alle Nodes regenerieren Config
KeyDB hört nur auf `127.0.0.1:6379` für lokale Clients und `<node-ip>:16379` (TLS) für Peer-Replication. _Falls KeyDB künftig wieder eingeführt wird (Rate-Limiting-Counter, Pub/Sub-Config-Reload): hört auf `127.0.0.1:6379` lokal und `<node-ip>:16379` (TLS) für Peer-Replication. Derzeit ungenutzt._
--- ---
@@ -234,7 +242,7 @@ Unbound erfüllt zwei Rollen, beide aus PG generiert:
- **Local-Zone** `eg.cluster.` enthält A/AAAA-Records aller Cluster-Peers (Node-Hostnamen aus PG `ha_nodes`). - **Local-Zone** `eg.cluster.` enthält A/AAAA-Records aller Cluster-Peers (Node-Hostnamen aus PG `ha_nodes`).
- Beispiel: `node1.eg.cluster → 10.42.0.11`, `node2.eg.cluster → 10.42.0.12`. - Beispiel: `node1.eg.cluster → 10.42.0.11`, `node2.eg.cluster → 10.42.0.12`.
- Wird bei jedem Node-Join/-Leave aus PG regeneriert + via `edgeguard:config-changed` Pub/Sub auf allen Peers neu geladen (`unbound-control reload`). - Wird bei jedem Node-Join/-Leave aus PG regeneriert + via `edgeguard:config-changed` Pub/Sub auf allen Peers neu geladen (`unbound-control reload`).
- Cluster-interner Traffic (PG-Replication, KeyDB-Replication, mTLS-API-Calls, Cert-Push) löst Peer-Adressen ausschließlich über diese Zone auf — kein DNS-Roundtrip ins öffentliche Internet, keine `/etc/hosts`-Synchronisation. - Cluster-interner Traffic (PG-Logical-Replication, mTLS-Agent-Calls auf :8443, Cert-Push) löst Peer-Adressen ausschließlich über diese Zone auf — kein DNS-Roundtrip ins öffentliche Internet, keine `/etc/hosts`-Synchronisation.
- `<node-name>.eg.cluster` ist **nicht extern erreichbar** (nur über Unbound der Cluster-Peers). - `<node-name>.eg.cluster` ist **nicht extern erreichbar** (nur über Unbound der Cluster-Peers).
### Config-Schichten ### Config-Schichten
@@ -253,54 +261,56 @@ Reload via `unbound-control reload` (kein Restart, keine Cache-Invalidierung au
## 8. Cluster-Topologie & HA pro Service ## 8. Cluster-Topologie & HA pro Service
**N symmetrische Peers** (1 … N Nodes, jeder vollwertig). Keine VRRP, keine Master/Backup-Rollen für Daten-Services. Public-IP: **Floating-IP des Hosters** (siehe §9). **N symmetrische Peers** (1 … N Nodes, jeder vollwertig). Public-IP-Failover via **VIP/VRRP (keepalived)** — siehe §9 (der ursprünglich geplante „Floating-IP statt VRRP"-Ansatz wurde **nicht** umgesetzt).
| Service | HA-Strategie | | Service | HA-Strategie |
|---|---| |---|---|
| **HAProxy** | stateless, pro Node identisch. Floating-IP zeigt zum aktuellen aktiven Node; bei Node-Ausfall API-Call zum Hoster (oder manueller Switch) reicht. ACME-Issue nur auf License-Leader (KeyDB-Lock); Zerts werden via PG/mTLS an alle verteilt. | | **VIP/keepalived** | VRRP (`vrrp_instance`), MASTER/BACKUP per `pg_role` (primary→prio 200/MASTER, standby→100/BACKUP). VIPs aus `ip_addresses` (`is_vip=true`). Trägt den HTTP/HTTPS-Ingress. |
| **Squid** | stateless (Cache lokal, kein Sync nötig). Pro Node identische ACL-Config. | | **HAProxy** | stateless, pro Node identisch. Hört auf der VIP des aktiven Node. ACME-Issue ohne verteilten Lock (Single-/Primary-Node); Zerts werden via mTLS (`/agent/cluster/tls-certs`) an alle verteilt. |
| **Squid** | stateless (Cache lokal). Pro Node identische ACL-Config. |
| **WireGuard** | siehe §8.1 | | **WireGuard** | siehe §8.1 |
| **Unbound** | stateless (Cache lokal). Pro Node identische Forwarder-Config + identische Cluster-internen Local-Zones (siehe §7.5). | | **Unbound** | stateless (Cache lokal). Pro Node identische Forwarder-Config + Cluster-Local-Zones (§7.5). |
| **nftables** | pro Node identisch, Ruleset aus PG generiert. `crowdsec_blocklist`/`threat_intel_blocklist`-Sets entfallen in v1 (kein CrowdSec). | | **nftables** | pro Node, Ruleset aus PG generiert. CrowdSec-Blocklist via `crowdsec-firewall-bouncer` (eigene Sets), wenn CrowdSec installiert. |
| **edgeguard-api** | pro Node, Reads lokal, Writes via Proxy zu Primary. | | **edgeguard-api** | pro Node, Reads lokal. Writes auf geteilte Tabellen am Primary (kein Write-Proxy). |
| **edgeguard-ui** | statisch, pro Node identisch. | | **edgeguard-ui / edgeguard-waf** | statisch bzw. pro Node identisch. |
| **PostgreSQL** | Streaming Replication, manueller Promote (siehe nmg §6.2). | | **PostgreSQL** | **Logical Replication** (Publisher→Subscriber), manueller Promote (§8.2). |
| **KeyDB** | Active-Active. | | **KeyDB** | optional/ungenutzt (§7). |
### 8.1 WireGuard im Cluster ### 8.1 WireGuard im Cluster
Drei Optionen, für v1 wählen wir **Option A**: Drei Optionen, für v1 wählen wir **Option A**:
- **A — Geteilte Server-Identität (gewählt):** alle Peers haben **denselben** Server-Privatkey + dasselbe Listen-Port. Floating-IP routet UDP zum aktiven Node. Bei Failover: Floating-IP wandert, Clients schicken Pakete zum neuen Node, neuer Handshake (~12s Latenz beim ersten Paket). Replay-Protection-Counter werden nicht repliziert — beim Failover macht der Client neuen Handshake, alte Counter sind irrelevant. - **A — Geteilte Server-Identität (gewählt):** alle Peers haben **denselben** Server-Privatkey + dasselbe Listen-Port. Die **VIP (keepalived)** trägt das WireGuard-UDP zum aktiven Node. Bei Failover: VIP wandert, Clients schicken Pakete zum neuen Node, neuer Handshake (~12s Latenz beim ersten Paket). Replay-Protection-Counter werden nicht repliziert — beim Failover macht der Client neuen Handshake, alte Counter sind irrelevant.
- B — Pro Node eigene Identität, Client kennt alle: Client-Configs haben mehrere `[Peer]`-Blöcke. Aufwendiger zu provisionieren, kein Failover-Vorteil. - B — Pro Node eigene Identität, Client kennt alle: Client-Configs haben mehrere `[Peer]`-Blöcke. Aufwendiger zu provisionieren, kein Failover-Vorteil.
- C — Aktiv/Standby per License-Leader-Pattern: nur ein Node hat WireGuard aktiv, andere idle. Verschwendet Kapazität. - C — Aktiv/Standby per License-Leader-Pattern: nur ein Node hat WireGuard aktiv, andere idle. Verschwendet Kapazität.
**Begründung A:** Privatkey ist in PG (verschlüsselt mit `edgeguard.key`), wird beim Cluster-Join an neue Peers verteilt. WireGuard handelt selbständig neue Sessions aus, kein State-Sync nötig. Operation-Tools (Peer hinzufügen/entfernen) wirken auf alle Nodes via `edgeguard:config-changed` Pub/Sub + lokales `wg syncconf`. **Begründung A:** Privatkey liegt verschlüsselt in PG, wird per Logical Replication an die Peers verteilt. WireGuard handelt selbständig neue Sessions aus, kein State-Sync nötig. Peer-Änderungen propagieren über die Logical Replication; Secondaries erkennen die Änderung am `config_hash` (`runSecondaryConfigRender`, 5-min-Tick) und re-rendern lokal `wg syncconf`.
### 8.2 Manual Promote (PG-Primary-Failover) ### 8.2 Manual Promote (PG-Primary-Failover)
1:1 nmg-Pattern (siehe `mail-gateway/docs/architecture.md` §6.2). Bei Ausfall des Primary antworten Config-Writes mit `503 + actionable Error`. Admin promotet via UI/CLI. Datenebene (HAProxy/Squid/WireGuard/Unbound) läuft unbeeinträchtigt weiter, weil jeder Node eine lokale PG-Replica hat. Bei Ausfall des Primary läuft die Datenebene (HAProxy/Squid/WireGuard/Unbound) weiter, weil jeder Node eine lokale, lesbare PG-Instanz (Logical-Subscriber) hat. Schreibzugriffe auf geteilte Config müssen am Primary erfolgen — fällt der Primary aus, promotet der Admin manuell via **`edgeguard-ctl promote`**. Das ist Logical-Replication-aware: es löst die Subscription zum toten Primary (`DISABLE` + `slot_name=NONE` + `DROP`, hängt also nicht am toten Publisher), richtet die Node via `setupReplicationPrimary` als Publisher ein (Rolle/Secret/`wal_level=logical` inkl. **PG-Restart** falls nötig/Publication), setzt `ha_nodes.pg_role='primary'` und rendert keepalived (→ MASTER, übernimmt die VIP). Erholte Nodes danach mit `edgeguard-ctl cluster-setup-standby <neuer-primary>` zurückhängen. **Achtung:** echtes Cross-Node-Failover ist nur im Drill testbar — die Bausteine (Drop-Subscription, Publication, Restart) sind dieselben wie in `cluster-init-replication`/`cluster-setup-standby`.
### 8.3 License-Leader-Election ### 8.3 License-Verifikation
Ein einziger Node kontaktiert `license.netcell-it.com` (KeyDB-Lock, 60-s-TTL). Ergebnis cluster-weit in `cluster:license-status` (TTL 24 h). `active_servers`-Verbrauchswert = Count der Peers mit Heartbeat < 2 min. **Kein Leader-Election** (anders als ursprünglich geplant). Jeder Node verifiziert **eigenständig** gegen `license.netcell-it.com` (Scheduler-Tick), Ergebnis in PG `licenses`. `active_servers` = Anzahl Peers mit Heartbeat < 2 min (aus `ha_nodes`). Ein KeyDB-Lock existiert nicht.
--- ---
## 9. Public-Ingress — Floating-IP statt VRRP ## 9. Public-Ingress — VIP via keepalived/VRRP
**Problem:** HTTP-Clients machen kein automatisches Failover bei DNS-RR (anders als MTAs). Ein toter A-Record = 50% Fehler bis DNS-TTL. > **Ist-Stand:** Umgesetzt ist **VIP-Failover über keepalived (VRRP)** — nicht der ursprünglich angedachte „Floating-IP des Hosters"-Ansatz. Es gibt **keinen** Hoster-API-Code und **keinen** `POST /cluster/promote-this-node`-Endpoint.
**Entscheidung:** **Floating-IP des Hosters**. Der Hoster bietet eine API zum Umroute der IP zwischen Servern (z. B. via REST oder DNS-Update bei dynamischer Anycast-Lösung). Failover dauert Sekunden, kein VRRP-Drama, kein "VIP verschwindet"-Problem aus dem alten Setup. **Mechanik (`internal/keepalived`):**
- Renderer erzeugt `/etc/keepalived/keepalived.conf` mit `vrrp_instance` (unicast peer, `virtual_router_id`, `authentication`).
- **State/Priorität aus `pg_role`:** Primary → `state MASTER`, `priority 200`; Standby → `state BACKUP`, `priority 100`.
- **VIPs** kommen aus `ip_addresses` (`is_vip=true`, `active=true`), inkl. Interface; managed via `systemctl reload-or-restart keepalived`.
- Bei Node-/PG-Ausfall übernimmt VRRP die VIP auf den verbleibenden Node (Sekundenbereich).
Optionen pro Hoster: **Tooling:** `GET/PUT /cluster/vip-settings`, `GET /cluster/vip-status`, `POST /cluster/vip-test` (Letzteres bewegt eine VIP testweise per `ip addr add/del` zwischen Nodes — kein Hoster-Call).
1. **Provider-Floating-IP** (gewünscht): API-Call schaltet IP um. EdgeGuard exponiert `POST /api/v1/cluster/promote-this-node`, das die Hoster-API aufruft.
2. **DNS-RR mit kurzer TTL (60s)** als Notlösung wenn keine Floating-IP verfügbar.
3. **Anycast/BGP** als Premium-Variante (für Enterprise).
**v1-Default:** Single-Node mit fest zugewiesener Floating-IP. Cluster-Erweiterung kommt mit Phase 2. **v1-Default:** Single-Node. Im Cluster trägt der MASTER (Primary) die VIP.
**OFFEN:** Welcher Hoster ist Standard? API-Spec dokumentieren sobald geklärt. **OFFEN (Altlast-Bereinigung):** Doku-Abschnitte/Code, die noch „Floating-IP des Hosters" implizieren, sind historisch — der reale Pfad ist keepalived/VRRP.
--- ---
@@ -312,7 +322,7 @@ curl -fsSL https://get.edgeguard.netcell-it.de | sudo bash
Schritte (idempotent, analog `netcell-webpanel/install.sh`): Schritte (idempotent, analog `netcell-webpanel/install.sh`):
1. **OS-Detection** (`/etc/os-release`): nur Trixie *oder* Noble, sonst Abbruch. 1. **OS-Detection** (`/etc/os-release`): nur Debian 13 (Trixie), sonst Abbruch.
2. **Arch-Detection**: nur amd64 *oder* arm64. 2. **Arch-Detection**: nur amd64 *oder* arm64.
3. **Base-Deps:** `curl gnupg ca-certificates apt-transport-https`. 3. **Base-Deps:** `curl gnupg ca-certificates apt-transport-https`.
4. **APT-Keyrings:** 4. **APT-Keyrings:**
@@ -329,7 +339,7 @@ curl -fsSL https://get.edgeguard.netcell-it.de | sudo bash -s -- \
--token <cluster-join-token> --token <cluster-join-token>
``` ```
`edgeguard-ctl cluster-join` führt aus: PG-Basebackup vom Primary, KeyDB-Replication-Setup, Node-Registrierung in `ha_nodes`, TLS-Cert-Pull via mTLS, Config-Regeneration, Service-Start. `edgeguard-ctl cluster-join` führt aus: TLS-Cert-Pull via mTLS (CSR→issue-cert), Node-Registrierung in `ha_nodes` (`autoRegister`), Setup als **Logical-Replication-Subscriber** (`cluster-setup-standby`: `CREATE SUBSCRIPTION … copy_data=true`, Initialkopie der geteilten Tabellen), Config-Regeneration, Service-Start. _(Kein `pg_basebackup`, kein KeyDB-Setup — beides war nur im ursprünglichen Entwurf.)_
--- ---
@@ -337,7 +347,7 @@ curl -fsSL https://get.edgeguard.netcell-it.de | sudo bash -s -- \
- **Primärquelle:** Gitea Package Registry (`https://git.netcell-it.de/api/packages/projekte/debian`). - **Primärquelle:** Gitea Package Registry (`https://git.netcell-it.de/api/packages/projekte/debian`).
- **Kunden-Mirror:** `https://apt.netcell-it.de/edgeguard/` (rsync von Gitea). - **Kunden-Mirror:** `https://apt.netcell-it.de/edgeguard/` (rsync von Gitea).
- **Suiten:** `stable` · `testing` · `security` pro Codename (`trixie`, `noble`). - **Suiten:** `stable` · `testing` · `security` — Codename `trixie`.
- **Signatur:** GPG-Key `netcell-edgeguard-signing`, ausgeliefert in `/etc/apt/keyrings/`. - **Signatur:** GPG-Key `netcell-edgeguard-signing`, ausgeliefert in `/etc/apt/keyrings/`.
- **Update-Check-API:** `GET /api/v1/system/package-versions` → pro `edgeguard-*`-Paket `{name, installed, available, reboot_required}`. - **Update-Check-API:** `GET /api/v1/system/package-versions` → pro `edgeguard-*`-Paket `{name, installed, available, reboot_required}`.
- **Upgrade-Trigger:** `POST /api/v1/system/upgrade` startet `systemd-run --unit=edgeguard-upgrade.service --collect …` (HTTP-Response geht VOR dem Upgrade raus, weil API beim Self-Update stirbt — Pattern aus `netcell-webpanel/management-agent/internal/handlers/update.go:105`). - **Upgrade-Trigger:** `POST /api/v1/system/upgrade` startet `systemd-run --unit=edgeguard-upgrade.service --collect …` (HTTP-Response geht VOR dem Upgrade raus, weil API beim Self-Update stirbt — Pattern aus `netcell-webpanel/management-agent/internal/handlers/update.go:105`).
@@ -355,13 +365,13 @@ Build-/Release-Scripts identisch zu `mail-gateway/scripts/apt-repo/`.
- **Lizenzserver:** `https://license.netcell-it.com` (öffentlich, kein API-Key). - **Lizenzserver:** `https://license.netcell-it.com` (öffentlich, kein API-Key).
- **Verify-Endpoint:** `GET /api/v1/licenses/{key}/verify?system_id={fp}&system_name={host}&active_domains={n}`. - **Verify-Endpoint:** `GET /api/v1/licenses/{key}/verify?system_id={fp}&system_name={host}&active_domains={n}`.
- **Fingerprint:** `SHA256(/etc/machine-id + erste-aktive-MAC + hostname)`. - **Fingerprint:** `SHA256(/etc/machine-id + erste-aktive-MAC + hostname)`.
- **Caching:** Live → KeyDB `cluster:license-status` (TTL 24h)`/var/lib/edgeguard/trial.json` (30 Tage) → `expired`. - **Caching:** Live-Verify → Ergebnis in PG `licenses``/var/lib/edgeguard/trial.json` (30-Tage-Trial-Fallback) → `expired`.
- **Leader-Election** wie nmg §6.3. - **Keine Leader-Election** — jeder Node verifiziert eigenständig (§8.3).
### 12.2 ACME ### 12.2 ACME
- **certbot** (Distro-Paket) mit `--webroot=/var/lib/edgeguard/acme` — HAProxy ACL `path_beg /.well-known/acme-challenge/` proxied diese Pfade an `edgeguard-api`, das die Challenge-Tokens aus der Webroot-Dir ausliefert. - **certbot** (Distro-Paket) mit `--webroot=/var/lib/edgeguard/acme` — HAProxy ACL `path_beg /.well-known/acme-challenge/` proxied diese Pfade an `edgeguard-api`, das die Challenge-Tokens aus der Webroot-Dir ausliefert.
- **Lock vor Issue:** `acme:lock:<domain>` in KeyDB verhindert Parallel-Issue auf zwei Nodes. - **Cluster-Locking:** derzeit **kein** verteilter Issue-Lock implementiert (Single-Node-Default; im Cluster sollte ACME am Primary/aktiven Node laufen). _(Der ursprünglich geplante KeyDB-`acme:lock:<domain>` existiert nicht.)_
- **Deploy-Hook:** schreibt fertiges PEM (cert+chain+key kombiniert) nach `/etc/edgeguard/tls/<domain>.pem` und triggert `systemctl reload haproxy`. HAProxy lädt den `crt /etc/edgeguard/tls/`-Verzeichnisinhalt neu. - **Deploy-Hook:** schreibt fertiges PEM (cert+chain+key kombiniert) nach `/etc/edgeguard/tls/<domain>.pem` und triggert `systemctl reload haproxy`. HAProxy lädt den `crt /etc/edgeguard/tls/`-Verzeichnisinhalt neu.
- **Cert-Verteilung im Cluster:** Issuing-Node pushed via mTLS-API an alle Peers, Zerts landen in `/etc/edgeguard/tls/`. - **Cert-Verteilung im Cluster:** Issuing-Node pushed via mTLS-API an alle Peers, Zerts landen in `/etc/edgeguard/tls/`.
@@ -384,14 +394,12 @@ Komponentenbibliothek, Theme, Layouts, Navigations-Struktur, Form-Patterns, i18n
## 14. Plattform-Matrix ## 14. Plattform-Matrix
| Distribution | Codename | Arch | Status v1 | | Distribution | Codename | Arch | Status |
|---|---|---|---| |---|---|---|---|
| Debian 13 | trixie | amd64 | Tier 1 | | Debian 13 | trixie | amd64 | Tier 1 |
| Debian 13 | trixie | arm64 | Tier 1 | | Debian 13 | trixie | arm64 | Tier 1 |
| Ubuntu 24.04 LTS | noble | amd64 | Tier 1 |
| Ubuntu 24.04 LTS | noble | arm64 | Tier 1 |
Andere Distributionen (Debian 12, Ubuntu 22.04, RHEL/Rocky) sind **nicht unterstützt**. Installer bricht hart ab. **Nur Debian 13 (Trixie).** Die Build-/Publish-Pipeline (`Makefile`, `scripts/apt-repo/`) zielt ausschließlich auf `trixie`; der Installer bricht auf anderem OS hart ab. _(Eine ursprünglich geplante Ubuntu-24.04-„noble"-Matrix ist nicht implementiert.)_ Andere Distributionen (Debian 12, Ubuntu, RHEL/Rocky) sind **nicht unterstützt**.
--- ---
@@ -402,7 +410,7 @@ EdgeGuard-Native ist eigenes Repo (`git.netcell-it.de/projekte/edgeguard-native`
1. **Frische Installation** auf Test-VM via `install.sh`. 1. **Frische Installation** auf Test-VM via `install.sh`.
2. **Config-Export** aus altem Stack (`edgeguard-ctl export --from-docker`) — liest aus alter PG, schreibt in neues Format. 2. **Config-Export** aus altem Stack (`edgeguard-ctl export --from-docker`) — liest aus alter PG, schreibt in neues Format.
3. **Validierung** Side-by-Side (alter Stack auf einem Server, neuer Stack auf anderem, Traffic vergleichen). 3. **Validierung** Side-by-Side (alter Stack auf einem Server, neuer Stack auf anderem, Traffic vergleichen).
4. **Cutover** via Floating-IP-Switch. 4. **Cutover** via VIP-Umzug (keepalived) bzw. DNS-Umstellung.
Der alte `proxy-lb-waf`-Code bleibt für Bestandskunden im Wartungsmodus, keine neuen Features. Der alte `proxy-lb-waf`-Code bleibt für Bestandskunden im Wartungsmodus, keine neuen Features.
@@ -410,6 +418,6 @@ Der alte `proxy-lb-waf`-Code bleibt für Bestandskunden im Wartungsmodus, keine
## Offene Punkte ## Offene Punkte
- **Hoster + Floating-IP-API** (§9): Spec dokumentieren. - **Failover-Drill:** `edgeguard-ctl promote` (Logical-aware) + anschließendes `cluster-setup-standby` in einem echten 2-Node-Failover durchspielen (inkl. VIP-Umzug, WireGuard-Handshake-Latenz). _(Code-Altlasten `internal/proxy`-Stub und `promote.go`-`standby.signal` wurden 2026-06 bereinigt.)_
- **WireGuard-State-Replication** in der Praxis testen (Handshake-Latenz nach Floating-IP-Switch messen). - **Optional KeyDB** (Rate-Limit-Counter, Pub/Sub-Config-Reload) — falls je benötigt; aktuell ungenutzt.
- **`get.edgeguard.netcell-it.de`** anlegen oder Übergangs-URL auf `apt.netcell-it.de/edgeguard/install.sh` nutzen. - **`get.edgeguard.netcell-it.de`** anlegen oder Übergangs-URL auf `apt.netcell-it.de/edgeguard/install.sh` nutzen.

8
go.mod
View File

@@ -1,6 +1,6 @@
module git.netcell-it.de/projekte/edgeguard-native module git.netcell-it.de/projekte/edgeguard-native
go 1.26.0 go 1.26.4
require ( require (
github.com/corazawaf/coraza/v3 v3.7.0 github.com/corazawaf/coraza/v3 v3.7.0
@@ -16,7 +16,7 @@ require (
github.com/pquerna/otp v1.5.0 github.com/pquerna/otp v1.5.0
github.com/pressly/goose/v3 v3.27.1 github.com/pressly/goose/v3 v3.27.1
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
golang.org/x/crypto v0.51.0 golang.org/x/crypto v0.52.0
golang.org/x/oauth2 v0.36.0 golang.org/x/oauth2 v0.36.0
) )
@@ -79,9 +79,9 @@ require (
go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.8.0 // indirect golang.org/x/arch v0.8.0 // indirect
golang.org/x/mod v0.35.0 // indirect golang.org/x/mod v0.35.0 // indirect
golang.org/x/net v0.53.0 // indirect golang.org/x/net v0.55.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.44.0 // indirect golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect golang.org/x/text v0.37.0 // indirect
golang.org/x/tools v0.44.0 // indirect golang.org/x/tools v0.44.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect

12
go.sum
View File

@@ -186,18 +186,18 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= 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/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.45.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 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= 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 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=

View File

@@ -143,7 +143,7 @@ func (a *Aggregator) callPeer(ctx context.Context, p models.HANode, path string)
res.Duration = time.Since(start).Milliseconds() res.Duration = time.Since(start).Milliseconds()
return res return res
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1 MiB cap body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1 MiB cap
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
res.Err = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) res.Err = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
@@ -217,7 +217,7 @@ func (a *Aggregator) PostPeer(ctx context.Context, p models.HANode, path string)
res.Duration = time.Since(start).Milliseconds() res.Duration = time.Since(start).Milliseconds()
return res return res
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted {
res.Err = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) res.Err = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
@@ -253,7 +253,7 @@ func (a *Aggregator) PostPeerWithBody(ctx context.Context, p models.HANode, path
res.Duration = time.Since(start).Milliseconds() res.Duration = time.Since(start).Milliseconds()
return res return res
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted && resp.StatusCode != http.StatusNoContent { 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.Err = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))

View File

@@ -13,14 +13,16 @@
{{end}} {{end}}
# ── Listen-Bind ──────────────────────────────────────────────── # ── Listen-Bind ────────────────────────────────────────────────
# Wenn nichts ausser localhost gebound ist, lassen wir bindaddress # KEIN bindaddress: chrony honoriert nur EINE bindaddress pro Adress-
# weg (chrony default = alle Interfaces). Sonst explizite bindaddress # familie — bei mehreren Listen-IPs (z. B. mehrere VLAN-/Cluster-VIPs)
# pro IP. Mit serve_clients=false wird port 0 → kein Listen-Socket # würde nur die letzte gebunden, alle anderen NTP-Clients liefen ins
# (= reiner Client). # Leere. Stattdessen lauscht chrony auf allen Interfaces; WER bedient
# wird, regeln die allow-ACL UNTEN + die nftables-Regeln (UDP/123 wird
# nur auf den konfigurierten Listen-IPs/VIPs geöffnet, nicht öffentlich).
# Bonus: failover-robust — chrony bedient automatisch jede VIP, die der
# Node gerade hält, ohne Restart bei Master-Wechsel.
# serve_clients=false → port 0 → kein Listen-Socket (reiner Client).
{{if .Settings.ServeClients}} {{if .Settings.ServeClients}}
{{- range .ListenAddresses}}
bindaddress {{.}}
{{- end}}
{{- range .AllowACLs}} {{- range .AllowACLs}}
allow {{.}} allow {{.}}
{{- end}} {{- end}}

View File

@@ -121,7 +121,7 @@ func splitCSV(s string) []string {
// filterNonLoopback wirft 127.x / ::1 raus — wenn NUR localhost im // filterNonLoopback wirft 127.x / ::1 raus — wenn NUR localhost im
// listen_addresses ist, lassen wir den bindaddress-Block weg und // listen_addresses ist, lassen wir den bindaddress-Block weg und
// chrony bindet auf alle Interfaces (default), was für eine reine // chrony bindet auf alle Interfaces (default), was für eine reine
// Client-Konfiguration nicht stört. // Client-Configuration nicht stört.
func filterNonLoopback(in []string) []string { func filterNonLoopback(in []string) []string {
out := []string{} out := []string{}
for _, ip := range in { for _, ip := range in {

View File

@@ -0,0 +1,53 @@
package chrony
import (
"bytes"
"strings"
"testing"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
)
func render(t *testing.T, v View) string {
t.Helper()
var b bytes.Buffer
if err := tpl.Execute(&b, v); err != nil {
t.Fatalf("execute: %v", err)
}
return b.String()
}
// Mehrere Listen-IPs (VLAN-/Cluster-VIPs): chrony honoriert nur EINE
// bindaddress pro Adressfamilie → wir dürfen GAR KEINE bindaddress emittieren,
// sondern bind-all + allow-ACL. Sonst würde nur eine VIP gebunden und alle
// anderen NTP-Clients liefen ins Leere (Regressions-Schutz).
func TestRender_NoBindaddress_ServesAllVIPs(t *testing.T) {
v := View{
Settings: &models.NTPSettings{ServeClients: true, MakestepSecs: 1, MakestepLimit: 3},
AllowACLs: []string{"10.0.0.0/8", "192.168.0.0/16"},
ListenAddresses: []string{"10.0.5.1", "10.0.20.1", "10.10.20.1", "10.0.50.1"},
}
out := render(t, v)
// Auf die DIREKTIVE prüfen (Zeilenanfang), nicht aufs Wort — der
// erklärende Kommentar im Template enthält „bindaddress" absichtlich.
if strings.Contains(out, "\nbindaddress ") {
t.Fatalf("chrony darf KEIN bindaddress emittieren (nur eine pro Familie wird gebunden):\n%s", out)
}
for _, acl := range []string{"allow 10.0.0.0/8", "allow 192.168.0.0/16"} {
if !strings.Contains(out, acl) {
t.Fatalf("erwarte %q im Output:\n%s", acl, out)
}
}
}
// serve_clients=false → reiner Client: port 0, kein Listen-Socket, kein allow.
func TestRender_NoServeClients_Port0(t *testing.T) {
v := View{Settings: &models.NTPSettings{ServeClients: false, MakestepSecs: 1, MakestepLimit: 3}}
out := render(t, v)
if !strings.Contains(out, "port 0") {
t.Fatalf("erwarte 'port 0' bei serve_clients=false:\n%s", out)
}
if strings.Contains(out, "\nallow ") {
t.Fatalf("kein allow bei serve_clients=false:\n%s", out)
}
}

View File

@@ -2,18 +2,19 @@
// für Node-to-Node mTLS-Kommunikation. // für Node-to-Node mTLS-Kommunikation.
// //
// Layout on disk: // Layout on disk:
//
// /var/lib/edgeguard/cluster-tls/ca.crt (0644) // /var/lib/edgeguard/cluster-tls/ca.crt (0644)
// /var/lib/edgeguard/cluster-tls/ca.key (0600, edgeguard:edgeguard) // /var/lib/edgeguard/cluster-tls/ca.key (0600, edgeguard:edgeguard)
// /var/lib/edgeguard/cluster-tls/peer.crt (0644) — diese Node // /var/lib/edgeguard/cluster-tls/peer.crt (0644) — diese Node
// /var/lib/edgeguard/cluster-tls/peer.key (0600, edgeguard:edgeguard) // /var/lib/edgeguard/cluster-tls/peer.key (0600, edgeguard:edgeguard)
// //
// Workflow: // Workflow:
// * Erste Node (cluster founder): InitCA generiert CA, dann EnsureSelfSigned // - Erste Node (cluster founder): InitCA generiert CA, dann EnsureSelfSigned
// erstellt + signiert ihren eigenen peer.crt mit eigener CA. // erstellt + signiert ihren eigenen peer.crt mit eigener CA.
// * Joining Node: lädt CA-Cert vom Primary, generiert lokal CSR, POSTet // - Joining Node: lädt CA-Cert vom Primary, generiert lokal CSR, POSTet
// ihn mit cluster-join-token zu /api/v1/cluster/issue-cert; Primary // ihn mit cluster-join-token zu /api/v1/cluster/issue-cert; Primary
// signiert via SignCSR und liefert peer.crt zurück. (Phase 3.4.) // signiert via SignCSR und liefert peer.crt zurück. (Phase 3.4.)
// * Single-Node: InitCA + EnsureSelfSigned werden beim API-Boot // - Single-Node: InitCA + EnsureSelfSigned werden beim API-Boot
// idempotent gerufen; Listener auf :8443 kann sofort hochfahren. // idempotent gerufen; Listener auf :8443 kann sofort hochfahren.
// //
// Pattern 1:1 aus mail-gateway/internal/services/clustertls/clustertls.go, // Pattern 1:1 aus mail-gateway/internal/services/clustertls/clustertls.go,
@@ -73,8 +74,8 @@ func (s *Store) HasPeer() bool {
} }
// InitCA generiert die Cluster-CA falls noch keine existiert. Idempotent. // InitCA generiert die Cluster-CA falls noch keine existiert. Idempotent.
// organisation landet im Subject — typischerweise die FQDN-Domain. // organization landet im Subject — typischerweise die FQDN-Domain.
func (s *Store) InitCA(organisation string, now func() time.Time) error { func (s *Store) InitCA(organization string, now func() time.Time) error {
if s.HasCA() { if s.HasCA() {
return nil return nil
} }
@@ -93,7 +94,7 @@ func (s *Store) InitCA(organisation string, now func() time.Time) error {
SerialNumber: serial, SerialNumber: serial,
Subject: pkix.Name{ Subject: pkix.Name{
CommonName: "EdgeGuard Cluster CA", CommonName: "EdgeGuard Cluster CA",
Organization: []string{organisation}, Organization: []string{organization},
}, },
NotBefore: now().UTC(), NotBefore: now().UTC(),
NotAfter: now().Add(caValidity).UTC(), NotAfter: now().Add(caValidity).UTC(),

View File

@@ -1,7 +1,7 @@
package cluster package cluster
// /etc/edgeguard/node.conf — node-lokale, NIEMALS zwischen Cluster- // /etc/edgeguard/node.conf — node-lokale, NIEMALS zwischen Cluster-
// Peers replizierte Konfiguration. Hält die Identitäts-Werte die jeden // Peers replizierte Configuration. Hält die Identitäts-Werte die jeden
// Node einzigartig machen: // Node einzigartig machen:
// //
// NODE_ID eindeutige UUID (autogeneriert in EnsureNodeID; hier // NODE_ID eindeutige UUID (autogeneriert in EnsureNodeID; hier
@@ -54,7 +54,7 @@ func LoadLocalConfig(path string) (*LocalConfig, error) {
} }
return nil, err return nil, err
} }
defer f.Close() defer func() { _ = f.Close() }()
c := &LocalConfig{} c := &LocalConfig{}
sc := bufio.NewScanner(f) sc := bufio.NewScanner(f)
for sc.Scan() { for sc.Scan() {
@@ -91,7 +91,7 @@ func LoadLocalConfig(path string) (*LocalConfig, error) {
} }
// SaveLocalConfig schreibt die Datei atomic + 0644 root:root. // SaveLocalConfig schreibt die Datei atomic + 0644 root:root.
// Aufrufer ist normalerweise edgeguard-ctl unter Operator-Privilegien. // Aufrufer ist normalerweise edgeguard-ctl unter Operator-Privilege.
func SaveLocalConfig(path string, c *LocalConfig) error { func SaveLocalConfig(path string, c *LocalConfig) error {
if path == "" { if path == "" {
path = DefaultLocalConfigPath path = DefaultLocalConfigPath

View File

@@ -23,7 +23,7 @@ import (
// //
// Name returns a stable identifier ("haproxy", "nftables", …) // Name returns a stable identifier ("haproxy", "nftables", …)
// used in CLI output and audit logs. Render does the actual write + // used in CLI output and audit logs. Render does the actual write +
// reload work; ctx may be cancelled (e.g. orchestrator timeout). // reload work; ctx may be canceled (e.g. orchestrator timeout).
type Generator interface { type Generator interface {
Name() string Name() string
Render(ctx context.Context) error Render(ctx context.Context) error
@@ -49,14 +49,14 @@ func AtomicWrite(path string, data []byte, mode os.FileMode) error {
return fmt.Errorf("tempfile: %w", err) return fmt.Errorf("tempfile: %w", err)
} }
tmpPath := tmp.Name() tmpPath := tmp.Name()
defer os.Remove(tmpPath) // no-op if rename succeeded defer func() { _ = os.Remove(tmpPath) }() // no-op if rename succeeded
if _, err := tmp.Write(data); err != nil { if _, err := tmp.Write(data); err != nil {
tmp.Close() _ = tmp.Close()
return fmt.Errorf("write %s: %w", tmpPath, err) return fmt.Errorf("write %s: %w", tmpPath, err)
} }
if err := tmp.Sync(); err != nil { if err := tmp.Sync(); err != nil {
tmp.Close() _ = tmp.Close()
return fmt.Errorf("fsync %s: %w", tmpPath, err) return fmt.Errorf("fsync %s: %w", tmpPath, err)
} }
if err := tmp.Close(); err != nil { if err := tmp.Close(); err != nil {

View File

@@ -98,7 +98,7 @@ type HubItem struct {
Type string `json:"type,omitempty"` Type string `json:"type,omitempty"`
} }
// Status summarises the runtime state of the CrowdSec stack on this node. // Status summarizes the runtime state of the CrowdSec stack on this node.
type Status struct { type Status struct {
Installed bool `json:"installed"` Installed bool `json:"installed"`
AgentRunning bool `json:"agent_running"` AgentRunning bool `json:"agent_running"`
@@ -362,15 +362,7 @@ func Machines(ctx context.Context) ([]Machine, error) {
} }
result := make([]Machine, len(raw)) result := make([]Machine, len(raw))
for i, r := range raw { for i, r := range raw {
result[i] = Machine{ result[i] = Machine(r)
MachineID: r.MachineID,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
LastPush: r.LastPush,
IsValidated: r.IsValidated,
Version: r.Version,
Status: r.Status,
}
} }
return result, nil return result, nil
} }

View File

@@ -97,7 +97,7 @@ func Migrate(ctx context.Context, dsnOverride string) error {
if err != nil { if err != nil {
return fmt.Errorf("open db for migrate: %w", err) return fmt.Errorf("open db for migrate: %w", err)
} }
defer db.Close() defer func() { _ = db.Close() }()
goose.SetBaseFS(embeddedMigrations) goose.SetBaseFS(embeddedMigrations)
if err := goose.SetDialect("postgres"); err != nil { if err := goose.SetDialect("postgres"); err != nil {
@@ -117,7 +117,7 @@ func MigrateDown(ctx context.Context, dsnOverride string) error {
if err != nil { if err != nil {
return err return err
} }
defer db.Close() defer func() { _ = db.Close() }()
goose.SetBaseFS(embeddedMigrations) goose.SetBaseFS(embeddedMigrations)
if err := goose.SetDialect("postgres"); err != nil { if err := goose.SetDialect("postgres"); err != nil {
return err return err

View File

@@ -0,0 +1,8 @@
-- +goose Up
-- redirect_to: wenn gesetzt, liefert HAProxy für diese Domain einen 301 auf
-- die angegebene Ziel-URL (Domain-zu-Domain-Weiterleitung) statt sie auf ein
-- Backend zu routen. Leerstring = keine Weiterleitung (Normalbetrieb).
ALTER TABLE domains ADD COLUMN IF NOT EXISTS redirect_to text NOT NULL DEFAULT '';
-- +goose Down
ALTER TABLE domains DROP COLUMN IF EXISTS redirect_to;

View File

@@ -8,7 +8,7 @@ import "testing"
// startup, the API restart-looped, the cluster rolling-upgrade hung. // startup, the API restart-looped, the cluster rolling-upgrade hung.
// //
// Cheap assertion that runs as part of `go test ./...` — fails the // Cheap assertion that runs as part of `go test ./...` — fails the
// build before `make deb` ever produces an artefact, so the bad // build before `make deb` ever produces an artifact, so the bad
// version never reaches the APT registry. Same logic also runs at // version never reaches the APT registry. Same logic also runs at
// service start via Migrate() and via `edgeguard-ctl migrate check` // service start via Migrate() and via `edgeguard-ctl migrate check`
// in postinst (defense in depth). // in postinst (defense in depth).

View File

@@ -8,7 +8,7 @@
// + groups, policy rules, nat rules, ha_nodes peer IPs. // + groups, policy rules, nat rules, ha_nodes peer IPs.
// 2. Each rule and nat-rule is "resolved" — group references // 2. Each rule and nat-rule is "resolved" — group references
// replaced with their primitive members, FQDNs left as comments // replaced with their primitive members, FQDNs left as comments
// (Phase-3 DNS-resolution sidecar will materialise them). // (Phase-3 DNS-resolution sidecar will materialize them).
// 3. The template emits one nft file with: zone-iface sets, peer // 3. The template emits one nft file with: zone-iface sets, peer
// sets, default-deny baseline, forward + input chains carrying // sets, default-deny baseline, forward + input chains carrying
// the resolved rules (priority-sorted), nat prerouting + // the resolved rules (priority-sorted), nat prerouting +
@@ -150,12 +150,12 @@ 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 L3 string // "ip"/"ip6" — gesetzt für DstIP-Rules (Familie); leer = agnostic
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
} }
// RuleLeg is one materialised nft policy line. // RuleLeg is one materialized nft policy line.
type RuleLeg struct { type RuleLeg struct {
RuleID int64 RuleID int64
Action string Action string
@@ -166,7 +166,7 @@ type RuleLeg struct {
DstIfaces []string DstIfaces []string
SrcAddrs []string SrcAddrs []string
DstAddrs []string DstAddrs []string
// L3 ist "ip" (IPv4) oder "ip6" (IPv6) für das Adress-Matching — // L3 ist "ip" (IPv4) oder "ip6" (IPv6) für das Address-Matching —
// gesetzt, sobald SrcAddrs/DstAddrs nicht leer sind. Bei adresslosen // gesetzt, sobald SrcAddrs/DstAddrs nicht leer sind. Bei adresslosen
// Regeln bleibt es "" (familienagnostisch, kein ip/ip6-Match). // Regeln bleibt es "" (familienagnostisch, kein ip/ip6-Match).
L3 string L3 string
@@ -269,6 +269,31 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) {
} }
peerRows.Close() peerRows.Close()
// ── Heartbeat-IPs aus cluster_settings ins Peer-Set ──
// Der VRRP-Heartbeat (VI_HB) läuft über hb_src_ip/hb_peer_ip (z.B.
// 169.254.0.1/.2) — diese stehen NICHT in ha_nodes. Ohne sie würde die
// VRRP-Accept-Regel den Heartbeat-Pfad nicht abdecken. Best-effort:
// fehlt cluster_settings (Single-Node), bleibt es bei den ha_nodes-IPs.
var hbSrc, hbPeer *string
if err := g.Pool.QueryRow(ctx,
`SELECT hb_src_ip, hb_peer_ip FROM cluster_settings WHERE id = 1`).
Scan(&hbSrc, &hbPeer); err == nil {
for _, ip := range []*string{hbSrc, hbPeer} {
if ip == nil {
continue
}
parsed := net.ParseIP(*ip)
if parsed == nil {
continue
}
if parsed.To4() != nil {
view.PeerIPv4 = append(view.PeerIPv4, parsed.String())
} else {
view.PeerIPv6 = append(view.PeerIPv6, parsed.String())
}
}
}
// ── Lade Address-Objects + Groups → ID → ResolvedAddr-list ── // ── Lade Address-Objects + Groups → ID → ResolvedAddr-list ──
addrObjs, err := g.loadAddrObjects(ctx) addrObjs, err := g.loadAddrObjects(ctx)
if err != nil { if err != nil {
@@ -535,7 +560,7 @@ func splitByFamily(exprs []string) (v4, v6 []string) {
return v4, v6 return v4, v6
} }
// serviceL3: icmp ist v4-only, icmpv6 v6-only, tcp/udp/leer agnostisch. // serviceL3: icmp ist v4-only, icmpv6 v6-only, tcp/udp/leer agnostic.
func serviceL3(svc ResolvedService) string { func serviceL3(svc ResolvedService) string {
switch svc.Proto { switch svc.Proto {
case "icmp": case "icmp":
@@ -583,7 +608,7 @@ func expandFamilyLegs(r ResolvedRule, svc ResolvedService, hasSvc bool) []RuleLe
} }
if len(r.SrcAddrs) == 0 && len(r.DstAddrs) == 0 { if len(r.SrcAddrs) == 0 && len(r.DstAddrs) == 0 {
// Kein Adress-Match → eine Zeile, L3 leer. Die Proto-Render-Logik // Kein Address-Match → eine Zeile, L3 leer. Die Proto-Render-Logik
// im Template setzt icmp/icmpv6 selbst familienkorrekt. // im Template setzt icmp/icmpv6 selbst familienkorrekt.
return []RuleLeg{base} return []RuleLeg{base}
} }

View File

@@ -47,7 +47,7 @@ func TestTemplate_autoRuleIface(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
_, _ = f.WriteString(out) _, _ = f.WriteString(out)
f.Close() _ = f.Close()
var cmd *exec.Cmd var cmd *exec.Cmd
if os.Geteuid() == 0 { if os.Geteuid() == 0 {
cmd = exec.Command(nft, "-c", "-f", f.Name()) cmd = exec.Command(nft, "-c", "-f", f.Name())

View File

@@ -125,7 +125,7 @@ func TestE2E_IPv6Render(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
_, _ = f.WriteString(out) _, _ = f.WriteString(out)
f.Close() _ = f.Close()
var cmd *exec.Cmd var cmd *exec.Cmd
if os.Geteuid() == 0 { if os.Geteuid() == 0 {
cmd = exec.Command(nft, "-c", "-f", f.Name()) cmd = exec.Command(nft, "-c", "-f", f.Name())

View File

@@ -107,7 +107,7 @@ func renderView(t *testing.T) string {
Legs: []RuleLeg{ Legs: []RuleLeg{
{RuleID: 1, Action: "accept", L3: "ip", SrcAddrs: []string{"10.0.0.0/24"}, Service: ResolvedService{Proto: "tcp", PortStart: 443}}, {RuleID: 1, Action: "accept", L3: "ip", SrcAddrs: []string{"10.0.0.0/24"}, Service: ResolvedService{Proto: "tcp", PortStart: 443}},
{RuleID: 1, Action: "accept", L3: "ip6", SrcAddrs: []string{"fd00::/64"}, Service: ResolvedService{Proto: "tcp", PortStart: 443}}, {RuleID: 1, Action: "accept", L3: "ip6", SrcAddrs: []string{"fd00::/64"}, Service: ResolvedService{Proto: "tcp", PortStart: 443}},
{RuleID: 2, Action: "accept", Service: ResolvedService{Proto: "icmpv6"}}, // adresslos, agnostisch {RuleID: 2, Action: "accept", Service: ResolvedService{Proto: "icmpv6"}}, // adresslos, agnostic
}, },
NATRules: []ResolvedNATRule{ NATRules: []ResolvedNATRule{
{ID: 5, Kind: "dnat", L3: "ip6", DstCIDR: "2001:db8::/64", Proto: "tcp", DPortStart: 80, TargetAddr: "fd00::2", TargetHost: "[fd00::2]", TargetPortStart: 8080}, {ID: 5, Kind: "dnat", L3: "ip6", DstCIDR: "2001:db8::/64", Proto: "tcp", DPortStart: 80, TargetAddr: "fd00::2", TargetHost: "[fd00::2]", TargetPortStart: 8080},
@@ -162,7 +162,7 @@ func TestTemplate_nftSyntax(t *testing.T) {
if _, err := f.WriteString(out); err != nil { if _, err := f.WriteString(out); err != nil {
t.Fatal(err) t.Fatal(err)
} }
f.Close() _ = f.Close()
// `nft -c` liest die Kernel-Ruleset-Cache via netlink → braucht root. // `nft -c` liest die Kernel-Ruleset-Cache via netlink → braucht root.
// Als nicht-root via sudo -n versuchen; klappt das nicht, skip statt fail // Als nicht-root via sudo -n versuchen; klappt das nicht, skip statt fail
// (auf den Nodes rendert/prüft edgeguard ohnehin als root). // (auf den Nodes rendert/prüft edgeguard ohnehin als root).

View File

@@ -55,6 +55,13 @@ table inet edgeguard {
tcp dport 5432 ip6 saddr @peer_ipv6 accept tcp dport 5432 ip6 saddr @peer_ipv6 accept
tcp dport 6379 ip saddr @peer_ipv4 accept tcp dport 6379 ip saddr @peer_ipv4 accept
tcp dport 6379 ip6 saddr @peer_ipv6 accept tcp dport 6379 ip6 saddr @peer_ipv6 accept
# Cluster-internal: VRRP-Advertisements (keepalived VIP-Failover, Proto 112).
# OHNE diese Regel überleben Adverts nur via conntrack-Reverse-Matching —
# läuft ein conntrack-Eintrag ab/wird geflusht, werden Adverts gedroppt →
# der Peer promotet sich → VIP-Flapping/Split-Brain. peer_ipv4/6 enthält
# Public- UND Heartbeat-IPs (ha_nodes + cluster_settings.hb_*).
ip protocol vrrp ip saddr @peer_ipv4 accept
ip6 nexthdr vrrp ip6 saddr @peer_ipv6 accept
# ── Service-Auto-Rules (DNS/Squid/WG/...) ── # ── Service-Auto-Rules (DNS/Squid/WG/...) ──
# Aus dem laufenden Service-State abgeleitet — Operator # Aus dem laufenden Service-State abgeleitet — Operator

View File

@@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"context"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
@@ -31,7 +32,7 @@ func TestACME_ServesExistingToken(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/.well-known/acme-challenge/tok_42", nil) req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/.well-known/acme-challenge/tok_42", nil)
r.ServeHTTP(rec, req) r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
@@ -45,7 +46,7 @@ func TestACME_ServesExistingToken(t *testing.T) {
func TestACME_MissingToken_Returns404(t *testing.T) { func TestACME_MissingToken_Returns404(t *testing.T) {
r, _ := setupACME(t) r, _ := setupACME(t)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/.well-known/acme-challenge/notthere", nil) req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/.well-known/acme-challenge/notthere", nil)
r.ServeHTTP(rec, req) r.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound { if rec.Code != http.StatusNotFound {
t.Errorf("status: %d", rec.Code) t.Errorf("status: %d", rec.Code)
@@ -76,7 +77,7 @@ func TestACME_DirIsNotAFile(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/.well-known/acme-challenge/subdir", nil) req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/.well-known/acme-challenge/subdir", nil)
r.ServeHTTP(rec, req) r.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound { if rec.Code != http.StatusNotFound {
t.Errorf("expected 404 for directory, got %d", rec.Code) t.Errorf("expected 404 for directory, got %d", rec.Code)

View File

@@ -96,7 +96,7 @@ func (h *AuditHandler) Live(c *gin.Context) {
if err != nil { if err != nil {
return return
} }
defer conn.Close() defer func() { _ = conn.Close() }()
// Snapshot // Snapshot
if rows, err := h.Repo.ListRecent(c.Request.Context(), 50); err == nil { if rows, err := h.Repo.ListRecent(c.Request.Context(), 50); err == nil {

View File

@@ -504,7 +504,7 @@ func (h *AuthHandler) checkWithPrimary(ctx context.Context, primaryFQDN, email,
if err != nil { if err != nil {
return "", "", err return "", "", err
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return "", "", errors.New("primary: " + strings.TrimSpace(string(raw))) return "", "", errors.New("primary: " + strings.TrimSpace(string(raw)))

View File

@@ -93,7 +93,7 @@ func (h *BackupRemotesHandler) Create(c *gin.Context) {
response.BadRequest(c, err) response.BadRequest(c, err)
return return
} }
if req.Settings == nil || len(req.Settings) == 0 { if len(req.Settings) == 0 {
req.Settings = json.RawMessage(`{}`) req.Settings = json.RawMessage(`{}`)
} }
row := h.Pool.QueryRow(c.Request.Context(), ` row := h.Pool.QueryRow(c.Request.Context(), `
@@ -124,7 +124,7 @@ func (h *BackupRemotesHandler) Update(c *gin.Context) {
response.BadRequest(c, err) response.BadRequest(c, err)
return return
} }
if req.Settings == nil || len(req.Settings) == 0 { if len(req.Settings) == 0 {
req.Settings = json.RawMessage(`{}`) req.Settings = json.RawMessage(`{}`)
} }
// Wenn die Settings masked-Fields enthalten (***), übernehmen wir // Wenn die Settings masked-Fields enthalten (***), übernehmen wir

View File

@@ -35,7 +35,7 @@ type ClusterHandler struct {
Store *cluster.Store Store *cluster.Store
LocalID string LocalID string
Aggregator *aggregator.Aggregator Aggregator *aggregator.Aggregator
Version string // laufende Binary-Version, für Rolling-Update-Koordination Version string // laufende Binary-Version, für Rolling-Update-Coordination
// TLSStore + Tokens: optional, gesetzt bei Phase 3.4. Erlauben das // TLSStore + Tokens: optional, gesetzt bei Phase 3.4. Erlauben das
// Generieren von Join-Tokens und das Issue-Cert für joining Peers. // Generieren von Join-Tokens und das Issue-Cert für joining Peers.
@@ -56,7 +56,7 @@ func NewClusterHandler(store *cluster.Store, localID string) *ClusterHandler {
return &ClusterHandler{Store: store, LocalID: localID} return &ClusterHandler{Store: store, LocalID: localID}
} }
// WithAggregator: optionale Aggregator-Konfiguration. Nur wenn vorhanden // WithAggregator: optionale Aggregator-Configuration. Nur wenn vorhanden
// wird /cluster/system/load die Peers via mTLS abklappern. // wird /cluster/system/load die Peers via mTLS abklappern.
func (h *ClusterHandler) WithAggregator(a *aggregator.Aggregator) *ClusterHandler { func (h *ClusterHandler) WithAggregator(a *aggregator.Aggregator) *ClusterHandler {
h.Aggregator = a h.Aggregator = a
@@ -104,7 +104,7 @@ func (h *ClusterHandler) Register(rg *gin.RouterGroup) {
// DeleteNode entfernt einen Peer aus ha_nodes. Verweigert für die // DeleteNode entfernt einen Peer aus ha_nodes. Verweigert für die
// lokale Node (LocalID) — die kannst du nicht via UI löschen, sonst // lokale Node (LocalID) — die kannst du nicht via UI löschen, sonst
// kommt der nächste Heartbeat-Tick die Row wieder anlegen oder // kommt der nächste Heartbeat-Tick die Row wieder anlegen oder
// die Cluster-Page wird inkonsistent. // die Cluster-Page wird inconsistent.
// //
// Nach erfolgreichem Delete triggert der PeerReloader (falls gesetzt) // Nach erfolgreichem Delete triggert der PeerReloader (falls gesetzt)
// einen Firewall-Render — peer_ipv4-Set verliert die IP, der entfernte // einen Firewall-Render — peer_ipv4-Set verliert die IP, der entfernte
@@ -159,7 +159,7 @@ func (h *ClusterHandler) GetVIPSettings(c *gin.Context) {
response.OK(c, cs) response.OK(c, cs)
} }
// UpdateVIPSettings speichert die VIP/VRRP-Konfiguration und triggert // UpdateVIPSettings speichert die VIP/VRRP-Configuration und triggert
// einen Keepalived-Config-Render. Viewer-Schutz via RequireAdminForMutations- // einen Keepalived-Config-Render. Viewer-Schutz via RequireAdminForMutations-
// Middleware auf der authed-Group — kein Extra-Check nötig. // Middleware auf der authed-Group — kein Extra-Check nötig.
func (h *ClusterHandler) UpdateVIPSettings(c *gin.Context) { func (h *ClusterHandler) UpdateVIPSettings(c *gin.Context) {
@@ -338,7 +338,7 @@ func (h *ClusterHandler) WithPeerReloader(r PeerReloader) *ClusterHandler {
return h return h
} }
// WithVersion: setzt die laufende Binary-Version für Rolling-Update-Koordination. // WithVersion: setzt die laufende Binary-Version für Rolling-Update-Coordination.
func (h *ClusterHandler) WithVersion(v string) *ClusterHandler { func (h *ClusterHandler) WithVersion(v string) *ClusterHandler {
h.Version = v h.Version = v
return h return h
@@ -822,7 +822,7 @@ func (h *ClusterHandler) CertStatus(c *gin.Context) {
// //
// Nach Renew muss edgeguard-api restartet werden damit der Agent- // Nach Renew muss edgeguard-api restartet werden damit der Agent-
// Listener das neue Cert in seinen TLS-Config-Snapshot lädt — wir // Listener das neue Cert in seinen TLS-Config-Snapshot lädt — wir
// triggern das NICHT automatisch (würde die HTTP-Response abreißen); // triggering das NICHT automatisch (würde die HTTP-Response abreißen);
// stattdessen liefern wir einen Hinweis im Response. // stattdessen liefern wir einen Hinweis im Response.
func (h *ClusterHandler) RenewSelf(c *gin.Context) { func (h *ClusterHandler) RenewSelf(c *gin.Context) {
if !h.TLSStore.HasCA() { if !h.TLSStore.HasCA() {
@@ -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,17 +905,34 @@ 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 != "" {
v := req.PublicIP v := req.PublicIP
n.PublicIP = &v n.PublicIP = &v
} else if ip := c.ClientIP(); ip != "" {
// Der Push-Payload (autoRegister, Primary→Secondary) trägt KEINE
// public_ip → sonst bliebe sie NULL und der Peer fehlt im nft-
// peer_ipv4-Set → VRRP-Adverts nur via conntrack → Flapping. Der
// pushende Peer verbindet sich über mTLS von seiner EIGEN-IP (nicht
// der VIP — der Kernel nimmt die primäre Interface-IP als Source),
// genau wie preRegisterJoiner die Joiner-IP übernimmt. Selbstheilend.
n.PublicIP = &ip
} }
if req.InternalIP != "" { if req.InternalIP != "" {
v := req.InternalIP v := req.InternalIP
@@ -965,7 +983,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)

View File

@@ -102,7 +102,7 @@ func (h *ClusterHandler) RepairReplication(c *gin.Context) {
body, _ := json.Marshal(repairDispatchBody{PrimaryHost: primaryHost}) body, _ := json.Marshal(repairDispatchBody{PrimaryHost: primaryHost})
res := h.Aggregator.PostPeerWithBody(ctx, *peer, repairAgentPath, body) res := h.Aggregator.PostPeerWithBody(ctx, *peer, repairAgentPath, body)
if !res.OK { if !res.OK {
response.Internal(c, fmt.Errorf("Resync auf %s anstoßen: %s", peer.FQDN, res.Err)) response.Internal(c, fmt.Errorf("resync auf %s anstoßen: %s", peer.FQDN, res.Err))
return return
} }
slog.Info("cluster: replication repair delegated", "target", peer.FQDN, "primary_host", primaryHost) slog.Info("cluster: replication repair delegated", "target", peer.FQDN, "primary_host", primaryHost)
@@ -178,7 +178,7 @@ func (h *ClusterHandler) startResync(ctx context.Context, primaryHost string) er
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" {
return errors.New("Resync läuft bereits") return errors.New("resync läuft bereits")
} }
script := fmt.Sprintf(`#!/bin/bash script := fmt.Sprintf(`#!/bin/bash
@@ -334,7 +334,7 @@ func localRepairStatus() repairStatusResponse {
"--no-pager", "-n", "100", "-o", "cat", "--no-pager", "-n", "100", "-o", "cat",
).CombinedOutput(); err == nil { ).CombinedOutput(); err == nil {
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n") lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
if !(len(lines) == 1 && (lines[0] == "" || strings.HasPrefix(lines[0], "-- No entries"))) { if len(lines) != 1 || (lines[0] != "" && !strings.HasPrefix(lines[0], "-- No entries")) {
out.Log = lines out.Log = lines
} }
} }

View File

@@ -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,20 +153,31 @@ 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()
// 1. Secondary triggern // 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 triggering
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")
if !result.OK { if !result.OK {
@@ -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 ""
}

View File

@@ -22,7 +22,7 @@ import (
// FirewallHandler exposes everything under /api/v1/firewall/*: // FirewallHandler exposes everything under /api/v1/firewall/*:
// //
// address-objects — primitive Adress-Definitionen (host/network/range/fqdn) // address-objects — primitive Address-Definitionen (host/network/range/fqdn)
// address-groups — Gruppen von address-objects (mit /members ops) // address-groups — Gruppen von address-objects (mit /members ops)
// services — proto+port (Builtins lassen sich nicht editieren) // services — proto+port (Builtins lassen sich nicht editieren)
// service-groups — Gruppen von services // service-groups — Gruppen von services
@@ -271,7 +271,7 @@ func zoneNamePattern(s string) bool {
if s == "" || len(s) > 32 { if s == "" || len(s) > 32 {
return false return false
} }
if !(s[0] >= 'a' && s[0] <= 'z') { if s[0] < 'a' || s[0] > 'z' {
return false return false
} }
for i := 1; i < len(s); i++ { for i := 1; i < len(s); i++ {
@@ -352,7 +352,8 @@ func (h *FirewallHandler) CreateAddrObj(c *gin.Context) {
return return
} }
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_obj.create", req.Name, out, h.NodeID) _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_obj.create", req.Name, out, h.NodeID)
response.Created(c, out); h.reload(c.Request.Context(), "create") response.Created(c, out)
h.reload(c.Request.Context(), "create")
} }
func (h *FirewallHandler) UpdateAddrObj(c *gin.Context) { func (h *FirewallHandler) UpdateAddrObj(c *gin.Context) {
@@ -379,7 +380,8 @@ func (h *FirewallHandler) UpdateAddrObj(c *gin.Context) {
return return
} }
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_obj.update", req.Name, out, h.NodeID) _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_obj.update", req.Name, out, h.NodeID)
response.OK(c, out); h.reload(c.Request.Context(), "update") response.OK(c, out)
h.reload(c.Request.Context(), "update")
} }
func (h *FirewallHandler) DeleteAddrObj(c *gin.Context) { func (h *FirewallHandler) DeleteAddrObj(c *gin.Context) {
@@ -397,7 +399,8 @@ func (h *FirewallHandler) DeleteAddrObj(c *gin.Context) {
} }
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_obj.delete", _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_obj.delete",
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID) strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
response.NoContent(c); h.reload(c.Request.Context(), "delete") response.NoContent(c)
h.reload(c.Request.Context(), "delete")
} }
// ── Address Groups ───────────────────────────────────────────────────── // ── Address Groups ─────────────────────────────────────────────────────
@@ -440,7 +443,8 @@ func (h *FirewallHandler) CreateAddrGrp(c *gin.Context) {
return return
} }
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_grp.create", req.Name, out, h.NodeID) _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_grp.create", req.Name, out, h.NodeID)
response.Created(c, out); h.reload(c.Request.Context(), "create") response.Created(c, out)
h.reload(c.Request.Context(), "create")
} }
func (h *FirewallHandler) UpdateAddrGrp(c *gin.Context) { func (h *FirewallHandler) UpdateAddrGrp(c *gin.Context) {
@@ -463,7 +467,8 @@ func (h *FirewallHandler) UpdateAddrGrp(c *gin.Context) {
return return
} }
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_grp.update", req.Name, out, h.NodeID) _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_grp.update", req.Name, out, h.NodeID)
response.OK(c, out); h.reload(c.Request.Context(), "update") response.OK(c, out)
h.reload(c.Request.Context(), "update")
} }
func (h *FirewallHandler) DeleteAddrGrp(c *gin.Context) { func (h *FirewallHandler) DeleteAddrGrp(c *gin.Context) {
@@ -481,7 +486,8 @@ func (h *FirewallHandler) DeleteAddrGrp(c *gin.Context) {
} }
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_grp.delete", _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_grp.delete",
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID) strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
response.NoContent(c); h.reload(c.Request.Context(), "delete") response.NoContent(c)
h.reload(c.Request.Context(), "delete")
} }
// ── Services ─────────────────────────────────────────────────────────── // ── Services ───────────────────────────────────────────────────────────
@@ -524,7 +530,8 @@ func (h *FirewallHandler) CreateService(c *gin.Context) {
return return
} }
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.service.create", req.Name, out, h.NodeID) _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.service.create", req.Name, out, h.NodeID)
response.Created(c, out); h.reload(c.Request.Context(), "create") response.Created(c, out)
h.reload(c.Request.Context(), "create")
} }
func (h *FirewallHandler) UpdateService(c *gin.Context) { func (h *FirewallHandler) UpdateService(c *gin.Context) {
@@ -547,7 +554,8 @@ func (h *FirewallHandler) UpdateService(c *gin.Context) {
return return
} }
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.service.update", req.Name, out, h.NodeID) _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.service.update", req.Name, out, h.NodeID)
response.OK(c, out); h.reload(c.Request.Context(), "update") response.OK(c, out)
h.reload(c.Request.Context(), "update")
} }
func (h *FirewallHandler) DeleteService(c *gin.Context) { func (h *FirewallHandler) DeleteService(c *gin.Context) {
@@ -565,7 +573,8 @@ func (h *FirewallHandler) DeleteService(c *gin.Context) {
} }
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.service.delete", _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.service.delete",
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID) strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
response.NoContent(c); h.reload(c.Request.Context(), "delete") response.NoContent(c)
h.reload(c.Request.Context(), "delete")
} }
// ── Service Groups ───────────────────────────────────────────────────── // ── Service Groups ─────────────────────────────────────────────────────
@@ -608,7 +617,8 @@ func (h *FirewallHandler) CreateSvcGrp(c *gin.Context) {
return return
} }
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.svc_grp.create", req.Name, out, h.NodeID) _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.svc_grp.create", req.Name, out, h.NodeID)
response.Created(c, out); h.reload(c.Request.Context(), "create") response.Created(c, out)
h.reload(c.Request.Context(), "create")
} }
func (h *FirewallHandler) UpdateSvcGrp(c *gin.Context) { func (h *FirewallHandler) UpdateSvcGrp(c *gin.Context) {
@@ -631,7 +641,8 @@ func (h *FirewallHandler) UpdateSvcGrp(c *gin.Context) {
return return
} }
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.svc_grp.update", req.Name, out, h.NodeID) _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.svc_grp.update", req.Name, out, h.NodeID)
response.OK(c, out); h.reload(c.Request.Context(), "update") response.OK(c, out)
h.reload(c.Request.Context(), "update")
} }
func (h *FirewallHandler) DeleteSvcGrp(c *gin.Context) { func (h *FirewallHandler) DeleteSvcGrp(c *gin.Context) {
@@ -649,7 +660,8 @@ func (h *FirewallHandler) DeleteSvcGrp(c *gin.Context) {
} }
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.svc_grp.delete", _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.svc_grp.delete",
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID) strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
response.NoContent(c); h.reload(c.Request.Context(), "delete") response.NoContent(c)
h.reload(c.Request.Context(), "delete")
} }
// ── Rules ────────────────────────────────────────────────────────────── // ── Rules ──────────────────────────────────────────────────────────────
@@ -704,7 +716,8 @@ func (h *FirewallHandler) CreateRule(c *gin.Context) {
return return
} }
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.rule.create", strconv.FormatInt(out.ID, 10), out, h.NodeID) _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.rule.create", strconv.FormatInt(out.ID, 10), out, h.NodeID)
response.Created(c, out); h.reload(c.Request.Context(), "create") response.Created(c, out)
h.reload(c.Request.Context(), "create")
} }
func (h *FirewallHandler) UpdateRule(c *gin.Context) { func (h *FirewallHandler) UpdateRule(c *gin.Context) {
@@ -739,7 +752,8 @@ func (h *FirewallHandler) UpdateRule(c *gin.Context) {
return return
} }
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.rule.update", strconv.FormatInt(id, 10), out, h.NodeID) _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.rule.update", strconv.FormatInt(id, 10), out, h.NodeID)
response.OK(c, out); h.reload(c.Request.Context(), "update") response.OK(c, out)
h.reload(c.Request.Context(), "update")
} }
func (h *FirewallHandler) DeleteRule(c *gin.Context) { func (h *FirewallHandler) DeleteRule(c *gin.Context) {
@@ -757,7 +771,8 @@ func (h *FirewallHandler) DeleteRule(c *gin.Context) {
} }
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.rule.delete", _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.rule.delete",
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID) strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
response.NoContent(c); h.reload(c.Request.Context(), "delete") response.NoContent(c)
h.reload(c.Request.Context(), "delete")
} }
func (h *FirewallHandler) PatchRule(c *gin.Context) { func (h *FirewallHandler) PatchRule(c *gin.Context) {
@@ -850,7 +865,8 @@ func (h *FirewallHandler) CreateNAT(c *gin.Context) {
return return
} }
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.nat.create", strconv.FormatInt(out.ID, 10), out, h.NodeID) _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.nat.create", strconv.FormatInt(out.ID, 10), out, h.NodeID)
response.Created(c, out); h.reload(c.Request.Context(), "create") response.Created(c, out)
h.reload(c.Request.Context(), "create")
} }
func (h *FirewallHandler) UpdateNAT(c *gin.Context) { func (h *FirewallHandler) UpdateNAT(c *gin.Context) {
@@ -881,7 +897,8 @@ func (h *FirewallHandler) UpdateNAT(c *gin.Context) {
return return
} }
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.nat.update", strconv.FormatInt(id, 10), out, h.NodeID) _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.nat.update", strconv.FormatInt(id, 10), out, h.NodeID)
response.OK(c, out); h.reload(c.Request.Context(), "update") response.OK(c, out)
h.reload(c.Request.Context(), "update")
} }
func (h *FirewallHandler) DeleteNAT(c *gin.Context) { func (h *FirewallHandler) DeleteNAT(c *gin.Context) {
@@ -899,7 +916,8 @@ func (h *FirewallHandler) DeleteNAT(c *gin.Context) {
} }
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.nat.delete", _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.nat.delete",
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID) strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
response.NoContent(c); h.reload(c.Request.Context(), "delete") response.NoContent(c)
h.reload(c.Request.Context(), "delete")
} }
func (h *FirewallHandler) PatchNAT(c *gin.Context) { func (h *FirewallHandler) PatchNAT(c *gin.Context) {

View File

@@ -78,7 +78,7 @@ func (h *FirewallLogHandler) Live(c *gin.Context) {
// Upgrade-Failures sind Browser-side; nichts loggen // Upgrade-Failures sind Browser-side; nichts loggen
return return
} }
defer conn.Close() defer func() { _ = conn.Close() }()
f := parseFilter(c) f := parseFilter(c)

View File

@@ -58,14 +58,15 @@ type frontendStat struct {
} }
func (h *HAProxyStatsHandler) Stats(c *gin.Context) { func (h *HAProxyStatsHandler) Stats(c *gin.Context) {
conn, err := net.DialTimeout("unix", haproxyAdminSock, 2*time.Second) d := net.Dialer{Timeout: 2 * time.Second}
conn, err := d.DialContext(c.Request.Context(), "unix", haproxyAdminSock)
if err != nil { if err != nil {
// Socket nicht erreichbar (haproxy down oder no perm) → // Socket nicht erreichbar (haproxy down oder no perm) →
// leere Liste statt 500 damit das Dashboard nicht rot wird. // leere Liste statt 500 damit das Dashboard nicht rot wird.
response.OK(c, gin.H{"backends": []backendStat{}, "frontends": []frontendStat{}, "error": err.Error()}) response.OK(c, gin.H{"backends": []backendStat{}, "frontends": []frontendStat{}, "error": err.Error()})
return return
} }
defer conn.Close() defer func() { _ = conn.Close() }()
_ = conn.SetDeadline(time.Now().Add(3 * time.Second)) _ = conn.SetDeadline(time.Now().Add(3 * time.Second))
if _, err := conn.Write([]byte("show stat\n")); err != nil { if _, err := conn.Write([]byte("show stat\n")); err != nil {
response.OK(c, gin.H{"backends": []backendStat{}, "frontends": []frontendStat{}, "error": err.Error()}) response.OK(c, gin.H{"backends": []backendStat{}, "frontends": []frontendStat{}, "error": err.Error()})

View File

@@ -139,7 +139,7 @@ func (h *LicenseHandler) ClearKey(c *gin.Context) {
// result into the licenses table. On error, marks last_error in DB // result into the licenses table. On error, marks last_error in DB
// (status stays as before — grace). // (status stays as before — grace).
func (h *LicenseHandler) runVerifyAndPersist(ctx context.Context, key string) (*license.Result, error) { func (h *LicenseHandler) runVerifyAndPersist(ctx context.Context, key string) (*license.Result, error) {
res, err := h.Client.Verify(key) res, err := h.Client.Verify(key) //nolint:contextcheck // detached by design — License-Verify nutzt eigenen HTTP-Timeout, überlebt Request-Cancel
if err != nil { if err != nil {
_ = h.Repo.MarkError(ctx, key, err.Error()) _ = h.Repo.MarkError(ctx, key, err.Error())
slog.Warn("license: verify failed", "error", err) slog.Warn("license: verify failed", "error", err)

View File

@@ -100,21 +100,21 @@ func parseChronyTracking(out string) chronyStatus {
} }
s.Synced = val != "00000000 ()" s.Synced = val != "00000000 ()"
case "Stratum": case "Stratum":
fmt.Sscanf(val, "%d", &s.Stratum) _, _ = fmt.Sscanf(val, "%d", &s.Stratum)
if s.Stratum > 0 && s.Stratum < 16 { if s.Stratum > 0 && s.Stratum < 16 {
s.Synced = true s.Synced = true
} }
case "System time": case "System time":
// "0.000012345 seconds fast of NTP time" // "0.000012345 seconds fast of NTP time"
var v float64 var v float64
fmt.Sscanf(val, "%f", &v) _, _ = fmt.Sscanf(val, "%f", &v)
s.OffsetMs = v * 1000 s.OffsetMs = v * 1000
case "Frequency": case "Frequency":
// "-12.345 ppm slow" or "+12.345 ppm fast" // "-12.345 ppm slow" or "+12.345 ppm fast"
fmt.Sscanf(val, "%f", &s.FreqPPM) _, _ = fmt.Sscanf(val, "%f", &s.FreqPPM)
case "RMS offset": case "RMS offset":
var v float64 var v float64
fmt.Sscanf(val, "%f", &v) _, _ = fmt.Sscanf(val, "%f", &v)
s.RMSOffsetMs = v * 1000 s.RMSOffsetMs = v * 1000
} }
} }
@@ -312,8 +312,8 @@ func parseChronymSources(out string) []ntpSource {
Reach: fields[3], Reach: fields[3],
LastRx: fields[4], LastRx: fields[4],
} }
fmt.Sscanf(fields[1], "%d", &src.Stratum) _, _ = fmt.Sscanf(fields[1], "%d", &src.Stratum)
fmt.Sscanf(fields[2], "%d", &src.Poll) _, _ = fmt.Sscanf(fields[2], "%d", &src.Poll)
if len(fields) >= 6 { if len(fields) >= 6 {
src.Sample = strings.Join(fields[5:], " ") src.Sample = strings.Join(fields[5:], " ")
} }

View File

@@ -79,9 +79,9 @@ func runCallback(t *testing.T, h *OIDCHandler, flow oidcFlow, queryState, code s
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
req := httptest.NewRequest(http.MethodGet, req := httptest.NewRequestWithContext(context.Background(), http.MethodGet,
"/api/v1/auth/oidc/callback?state="+queryState+"&code="+code, nil) "/api/v1/auth/oidc/callback?state="+queryState+"&code="+code, nil)
req.AddCookie(&http.Cookie{Name: oidcFlowCookie, Value: signed}) req.AddCookie(&http.Cookie{Name: oidcFlowCookie, Value: signed}) //nolint:gosec // Test-Cookie — Secure/HttpOnly-Flags für httptest irrelevant
c.Request = req c.Request = req
h.Callback(c) h.Callback(c)
return rec return rec

View File

@@ -1,6 +1,7 @@
package response package response
import ( import (
"context"
"encoding/json" "encoding/json"
"errors" "errors"
"net/http" "net/http"
@@ -16,7 +17,7 @@ func run(handler gin.HandlerFunc) *httptest.ResponseRecorder {
r := gin.New() r := gin.New()
r.GET("/x", handler) r.GET("/x", handler)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/x", nil) req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/x", nil)
r.ServeHTTP(rec, req) r.ServeHTTP(rec, req)
return rec return rec
} }

View File

@@ -132,7 +132,7 @@ func (h *SystemHandler) Register(rg *gin.RouterGroup) {
// Ergebnis für /cluster/system/load. // Ergebnis für /cluster/system/load.
// //
// Bewusst KEINE Mutations + KEIN /package-versions (würde apt-get update // Bewusst KEINE Mutations + KEIN /package-versions (würde apt-get update
// auf jedem Peer triggern), KEIN /upgrade. // auf jedem Peer triggering), KEIN /upgrade.
func (h *SystemHandler) RegisterAgent(rg *gin.RouterGroup) { func (h *SystemHandler) RegisterAgent(rg *gin.RouterGroup) {
g := rg.Group("/agent/system") g := rg.Group("/agent/system")
g.GET("/health", h.Health) g.GET("/health", h.Health)
@@ -194,6 +194,8 @@ var servicesToCheck = []struct{ Label, Unit string }{
{"unbound", "unbound"}, {"unbound", "unbound"},
{"chrony", "chrony"}, {"chrony", "chrony"},
{"squid", "squid"}, {"squid", "squid"},
{"kea-dhcp4", "kea-dhcp4-server"},
{"freeradius", "freeradius"},
{"postgresql", "postgresql"}, {"postgresql", "postgresql"},
{"crowdsec", "crowdsec"}, {"crowdsec", "crowdsec"},
{"crowdsec-firewall-bouncer", "crowdsec-firewall-bouncer"}, {"crowdsec-firewall-bouncer", "crowdsec-firewall-bouncer"},
@@ -376,7 +378,7 @@ func (h *SystemHandler) Maintenance(c *gin.Context) {
func (h *SystemHandler) ToggleMaintenance(c *gin.Context) { func (h *SystemHandler) ToggleMaintenance(c *gin.Context) {
if h.Setup == nil { if h.Setup == nil {
response.Err(c, http.StatusServiceUnavailable, response.Err(c, http.StatusServiceUnavailable,
simpleErr("setup not initialised")) simpleErr("setup not initialized"))
return return
} }
var req struct { var req struct {
@@ -428,7 +430,7 @@ func (h *SystemHandler) BackupRetention(c *gin.Context) {
// keep=0 → wieder Default, keep=1..365 → custom. // keep=0 → wieder Default, keep=1..365 → custom.
func (h *SystemHandler) SetBackupRetention(c *gin.Context) { func (h *SystemHandler) SetBackupRetention(c *gin.Context) {
if h.Setup == nil { if h.Setup == nil {
response.Err(c, http.StatusServiceUnavailable, simpleErr("setup not initialised")) response.Err(c, http.StatusServiceUnavailable, simpleErr("setup not initialized"))
return return
} }
var req struct { var req struct {
@@ -464,7 +466,7 @@ func (h *SystemHandler) AuditRetention(c *gin.Context) {
// SetAuditRetention setzt Audit-Retention in Tagen. 0..3650. // SetAuditRetention setzt Audit-Retention in Tagen. 0..3650.
func (h *SystemHandler) SetAuditRetention(c *gin.Context) { func (h *SystemHandler) SetAuditRetention(c *gin.Context) {
if h.Setup == nil { if h.Setup == nil {
response.Err(c, http.StatusServiceUnavailable, simpleErr("setup not initialised")) response.Err(c, http.StatusServiceUnavailable, simpleErr("setup not initialized"))
return return
} }
var req struct { var req struct {
@@ -511,7 +513,7 @@ func (h *SystemHandler) IPv6(c *gin.Context) {
func (h *SystemHandler) SetIPv6(c *gin.Context) { func (h *SystemHandler) SetIPv6(c *gin.Context) {
if h.Setup == nil { if h.Setup == nil {
response.Err(c, http.StatusServiceUnavailable, simpleErr("setup not initialised")) response.Err(c, http.StatusServiceUnavailable, simpleErr("setup not initialized"))
return return
} }
var req struct { var req struct {
@@ -580,7 +582,7 @@ LIMIT 10`)
// HAProxyReload zwingt ein systemctl reload haproxy.service — nützlich // HAProxyReload zwingt ein systemctl reload haproxy.service — nützlich
// wenn der Operator manuell in /etc/edgeguard/tls/ geschrieben hat // wenn der Operator manuell in /etc/edgeguard/tls/ geschrieben hat
// (z. B. eigenes PEM per SSH kopiert) und HAProxy das neue Cert sehen // (z. B. eigenes PEM per SSH kopiert) und HAProxy das neue Cert sehen
// soll, ohne eine UI-Mutation zu triggern die das automatisch täte. // soll, ohne eine UI-Mutation zu triggering die das automatisch täte.
func (h *SystemHandler) HAProxyReload(c *gin.Context) { func (h *SystemHandler) HAProxyReload(c *gin.Context) {
out, err := exec.Command("sudo", "-n", "/usr/bin/systemctl", "reload", "haproxy.service").CombinedOutput() out, err := exec.Command("sudo", "-n", "/usr/bin/systemctl", "reload", "haproxy.service").CombinedOutput()
if err != nil { if err != nil {
@@ -788,7 +790,7 @@ func (h *SystemHandler) UpgradeStatus(c *gin.Context) {
).CombinedOutput(); err == nil { ).CombinedOutput(); err == nil {
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n") lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
// Leere "no entries"-Antwort als leeres Log zurückgeben. // Leere "no entries"-Antwort als leeres Log zurückgeben.
if !(len(lines) == 1 && (lines[0] == "" || strings.HasPrefix(lines[0], "-- No entries"))) { if len(lines) != 1 || (lines[0] != "" && !strings.HasPrefix(lines[0], "-- No entries")) {
out.Log = lines out.Log = lines
} }
} }
@@ -918,7 +920,7 @@ func (h *SystemHandler) Upgrade(c *gin.Context) {
// beiden Namespaces aus zugänglich. // beiden Namespaces aus zugänglich.
const scriptPath = "/var/lib/edgeguard/upgrade.sh" const scriptPath = "/var/lib/edgeguard/upgrade.sh"
// Retry-Logik gegen Gitea-Packages.gz-Race: nach einem frischen // Retry-Logik gegen Gitea-Packages.gz-Race: nach einem frischen
// Publish kann der Packages-Index für ein paar Sekunden inkonsistent // Publish kann der Packages-Index für ein paar Sekunden inconsistent
// sein (z. B. Meta uploaded, api/ui noch nicht in der regenerierten // sein (z. B. Meta uploaded, api/ui noch nicht in der regenerierten
// Index-Datei) → apt-resolver-fail mit "no choices are installable". // Index-Datei) → apt-resolver-fail mit "no choices are installable".
// Drei Versuche mit 15s/30s Backoff geben Gitea Zeit den Index // Drei Versuche mit 15s/30s Backoff geben Gitea Zeit den Index
@@ -1233,4 +1235,3 @@ func flagsToList(f net.Flags) []string {
} }
return out return out
} }

View File

@@ -32,6 +32,10 @@ type WireguardHandler struct {
Audit *audit.Repo Audit *audit.Repo
NodeID string NodeID string
Reloader func(ctx context.Context) error Reloader func(ctx context.Context) error
// PublicHost ist der öffentliche Host (FQDN/IP), den Clients als
// WireGuard-Endpoint anwählen. Wird in heruntergeladene Peer-Configs
// geschrieben (statt eines Platzhalters). Leer → Platzhalter (Fallback).
PublicHost string
} }
func NewWireguardHandler( func NewWireguardHandler(
@@ -45,6 +49,12 @@ func NewWireguardHandler(
return &WireguardHandler{Ifaces: ifaces, Peers: peers, Box: box, Audit: a, NodeID: nodeID, Reloader: reloader} return &WireguardHandler{Ifaces: ifaces, Peers: peers, Box: box, Audit: a, NodeID: nodeID, Reloader: reloader}
} }
// WithPublicHost setzt den öffentlichen Endpoint-Host für Peer-Configs.
func (h *WireguardHandler) WithPublicHost(host string) *WireguardHandler {
h.PublicHost = strings.TrimSpace(host)
return h
}
func (h *WireguardHandler) reload(ctx context.Context, op string) { func (h *WireguardHandler) reload(ctx context.Context, op string) {
if h.Reloader == nil { if h.Reloader == nil {
return return
@@ -702,11 +712,15 @@ func (h *WireguardHandler) peerConfigText(ctx context.Context, peerID int64) (st
clientAllowedIPs += ", " + strings.TrimSpace(*ifc.ClientRoutes) clientAllowedIPs += ", " + strings.TrimSpace(*ifc.ClientRoutes)
} }
fmt.Fprintf(&b, "AllowedIPs = %s\n", clientAllowedIPs) fmt.Fprintf(&b, "AllowedIPs = %s\n", clientAllowedIPs)
// Endpoint — the operator's public host:port that peers dial. // Endpoint — der öffentliche Host:Port, den Clients anwählen. Standard
// We don't know this here (could be a CNAME or behind a load // ist der FQDN dieser Node (PublicHost, aus setup.json). Nur wenn der
// balancer); leave a placeholder the operator must fill in. // nicht ermittelbar ist, bleibt ein Platzhalter den der Operator füllt.
if ifc.ListenPort != nil { if ifc.ListenPort != nil {
fmt.Fprintf(&b, "Endpoint = REPLACE_WITH_PUBLIC_HOST:%d\n", *ifc.ListenPort) host := h.PublicHost
if host == "" {
host = "REPLACE_WITH_PUBLIC_HOST"
}
fmt.Fprintf(&b, "Endpoint = %s:%d\n", host, *ifc.ListenPort)
} }
if p.Keepalive != nil && *p.Keepalive > 0 { if p.Keepalive != nil && *p.Keepalive > 0 {
fmt.Fprintf(&b, "PersistentKeepalive = %d\n", *p.Keepalive) fmt.Fprintf(&b, "PersistentKeepalive = %d\n", *p.Keepalive)

View File

@@ -113,6 +113,11 @@ frontend public_https
# www-Redirect: {{$d.RedirectFromHost}}{{$d.Name}} # www-Redirect: {{$d.RedirectFromHost}}{{$d.Name}}
http-request redirect prefix https://{{$d.Name}} code 301 if { hdr(host) -i {{$d.RedirectFromHost}} } http-request redirect prefix https://{{$d.Name}} code 301 if { hdr(host) -i {{$d.RedirectFromHost}} }
{{- end}} {{- end}}
{{- if $d.RedirectTo}}
# Domain-Redirect (301): {{$d.Name}}{{$d.RedirectTo}} (immer auf Ziel-Root,
# terminiert vor use_backend → diese Domain routet auf kein Backend).
http-request redirect location {{$d.RedirectTo}} code 301 if { hdr(host) -i {{$d.Name}} }
{{- end}}
{{- if $d.MaintenanceMode}} {{- if $d.MaintenanceMode}}
# Wartungs-Modus für {{$d.Name}} — alle Requests werden mit 503 beantwortet. # Wartungs-Modus für {{$d.Name}} — alle Requests werden mit 503 beantwortet.
http-request return status 503 content-type "text/plain; charset=utf-8" string "{{$d.MaintMessage}}" if { hdr(host) -i {{$d.Name}} } http-request return status 503 content-type "text/plain; charset=utf-8" string "{{$d.MaintMessage}}" if { hdr(host) -i {{$d.Name}} }

View File

@@ -212,6 +212,11 @@ type DomainView struct {
// to-www → Name="www.example.com" → "example.com" (strip www.-Prefix) // to-www → Name="www.example.com" → "example.com" (strip www.-Prefix)
RedirectFromHost string RedirectFromHost string
// RedirectTo: HAProxy-safe 301-Ziel-URL für eine Domain→Domain-Weiterleitung
// (z. B. "https://zkm.netcell-it.de"). Leer = kein Redirect. Schattet das
// gleichnamige Feld aus dem eingebetteten models.Domain (sanitisiert).
RedirectTo string
// ResponseHeaders: Custom-Headers die HAProxy auf jede Response für // ResponseHeaders: Custom-Headers die HAProxy auf jede Response für
// diese Domain setzt. Werte sind bereits HAProxy-safe escaped // diese Domain setzt. Werte sind bereits HAProxy-safe escaped
// (Quotes → ', Newlines → Space). // (Quotes → ', Newlines → Space).
@@ -313,6 +318,7 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) {
HSTSHeader: buildHSTSHeader(d), HSTSHeader: buildHSTSHeader(d),
MaintMessage: buildMaintMessage(d), MaintMessage: buildMaintMessage(d),
RedirectFromHost: buildRedirectFromHost(d), RedirectFromHost: buildRedirectFromHost(d),
RedirectTo: buildRedirectTo(d),
ResponseHeaders: headersByDomain[d.ID], ResponseHeaders: headersByDomain[d.ID],
} }
if d.MaxBodyKB > 0 { if d.MaxBodyKB > 0 {
@@ -429,3 +435,23 @@ func buildRedirectFromHost(d models.Domain) string {
return "" return ""
} }
} }
// buildRedirectTo liefert die 301-Ziel-URL HAProxy-safe, oder "" wenn kein
// Redirect gesetzt ist bzw. die URL ungültig erscheint. Defensiv: nur
// http(s)-URLs ohne Whitespace/Steuerzeichen/Quotes — sonst würde die
// `redirect location <url>`-Zeile die HAProxy-Config sprengen. Im Zweifel
// lieber KEIN Redirect rendern als eine kaputte Config auszuliefern.
func buildRedirectTo(d models.Domain) string {
u := strings.TrimSpace(d.RedirectTo)
if u == "" {
return ""
}
lower := strings.ToLower(u)
if !strings.HasPrefix(lower, "http://") && !strings.HasPrefix(lower, "https://") {
return ""
}
if strings.ContainsAny(u, " \t\r\n\"'`\\{}") {
return ""
}
return u
}

View File

@@ -104,11 +104,13 @@ func TestRender_HSTSPerDomain(t *testing.T) {
t.Errorf("missing %q in per-domain HSTS output:\n%s", w, out) t.Errorf("missing %q in per-domain HSTS output:\n%s", w, out)
} }
} }
if strings.Contains(out, "Strict-Transport-Security \"\" if { hdr(host) -i b.example.com }") || // HSTS soll für die Domain ohne HSTSEnabled gar nicht erst gerendert
strings.Contains(out, "if { hdr(host) -i b.example.com }") && strings.Contains(out, "Strict-Transport-Security") && // werden: keine einzige HSTS-Zeile darf sich auf b.example.com beziehen.
strings.Contains(out, "b.example.com") && strings.Count(out, "Strict-Transport-Security") > 2 { for _, line := range strings.Split(out, "\n") {
// HSTS soll für Domain ohne HSTSEnabled gar nicht erst gerendert werden. if strings.Contains(line, "Strict-Transport-Security") &&
// (mgmt_https hat noch eins, plus die eine Zeile von a.example.com → 2 Vorkommen erwartet.) strings.Contains(line, "hdr(host) -i b.example.com") {
t.Errorf("unexpected HSTS ACL for HSTS-disabled domain b.example.com: %q", line)
}
} }
} }
@@ -170,6 +172,45 @@ func TestRender_WWWRedirectToWWW(t *testing.T) {
} }
} }
func TestRender_RedirectTo(t *testing.T) {
v := View{
Domains: []DomainView{
{
Domain: models.Domain{
ID: 1, Name: "kvs.netcell-it.de", Active: true,
RedirectTo: "https://zkm.netcell-it.de",
},
RedirectTo: "https://zkm.netcell-it.de",
},
},
}
out := renderView(t, v)
want := `http-request redirect location https://zkm.netcell-it.de code 301 if { hdr(host) -i kvs.netcell-it.de }`
if !strings.Contains(out, want) {
t.Errorf("missing domain→domain 301 redirect line:\n%s", out)
}
}
func TestBuildRedirectTo(t *testing.T) {
cases := []struct{ in, want string }{
{"https://zkm.netcell-it.de", "https://zkm.netcell-it.de"},
{" https://zkm.netcell-it.de ", "https://zkm.netcell-it.de"}, // getrimmt
{"http://x.de", "http://x.de"},
{"", ""},
{"zkm.netcell-it.de", ""}, // kein Schema
{"ftp://x.de", ""}, // falsches Schema
{"https://x .de", ""}, // Whitespace → unsafe
{"https://x\"de", ""}, // Quote → unsafe
{"javascript:alert(1)", ""}, // kein http(s)
}
for _, c := range cases {
got := buildRedirectTo(models.Domain{RedirectTo: c.in})
if got != c.want {
t.Errorf("buildRedirectTo(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestBuildHSTSHeader(t *testing.T) { func TestBuildHSTSHeader(t *testing.T) {
cases := []struct { cases := []struct {
name string name string

View File

@@ -1,7 +1,7 @@
// Package kea renders the Kea DHCPv4 server config from the dhcp_* // Package kea renders the Kea DHCPv4 server config from the dhcp_*
// tables and manages the kea-dhcp4-server service lifecycle. // tables and manages the kea-dhcp4-server service lifecycle.
// //
// The config is built as a Go struct and json-marshalled (NOT a text // The config is built as a Go struct and json-marshaled (NOT a text
// template) so the output is always syntactically valid JSON. Managed // template) so the output is always syntactically valid JSON. Managed
// at /etc/edgeguard/kea/kea-dhcp4.conf (edgeguard-owned); postinst // at /etc/edgeguard/kea/kea-dhcp4.conf (edgeguard-owned); postinst
// symlinks /etc/kea/kea-dhcp4.conf to it. // symlinks /etc/kea/kea-dhcp4.conf to it.

View File

@@ -81,7 +81,7 @@ VALUES ($1,'aa:bb:cc:dd:ee:ff','10.0.0.50','printer',true)`, subID); err != nil
if _, statErr := os.Stat(keaBinary); statErr == nil { if _, statErr := os.Stat(keaBinary); statErr == nil {
f, _ := os.CreateTemp(t.TempDir(), "kea-*.conf") f, _ := os.CreateTemp(t.TempDir(), "kea-*.conf")
_, _ = f.WriteString(out) _, _ = f.WriteString(out)
f.Close() _ = f.Close()
if combined, err := exec.Command(keaBinary, "-t", f.Name()).CombinedOutput(); err != nil { if combined, err := exec.Command(keaBinary, "-t", f.Name()).CombinedOutput(); err != nil {
t.Fatalf("kea-dhcp4 -t rejected rendered config: %v\n%s", err, combined) t.Fatalf("kea-dhcp4 -t rejected rendered config: %v\n%s", err, combined)
} }

View File

@@ -2,21 +2,32 @@ global_defs {
router_id {{ .RouterID }} router_id {{ .RouterID }}
script_user root script_user root
enable_script_security enable_script_security
# GARP: beim Master-Wechsel Gratuitous-ARP forciert senden (repeat) UND
# periodisch auffrischen (master_refresh) sonst lässt der Upstream-Switch
# die VIP-MAC altern und die Failover-IP wird nach Minuten unerreichbar.
# Ergänzt durch den Priming-Ping in keepalived-master.sh (Traffic AUS der VIP).
# (vrrp_garp_interval/gna NICHT setzen: keepalived 2.3.x lehnt 0 ab, Default passt.)
vrrp_garp_master_repeat 5
vrrp_garp_master_refresh 60
} }
{{/* KEIN weight: in einer vrrp_sync_group ignoriert keepalived gewichtete
Track-Scripts ("ignoring tracked script ... with weights due to SYNC
group") die Health-Checks wären wirkungslos. Ohne weight wirken sie
als binäre FAULT-Trigger: schlägt ein Check fall-mal in Folge fehl,
geht die Instanz (und via Sync-Group der ganze Node) in FAULT der
gesunde Peer übernimmt. Für einen 2-Node-Cluster die saubere Semantik. */}}
vrrp_script chk_edgeguard { vrrp_script chk_edgeguard {
script "/usr/lib/edgeguard/keepalived-check.sh" script "/usr/lib/edgeguard/keepalived-check.sh"
interval 2 interval 2
weight -50 fall 8
fall 3
rise 2 rise 2
} }
{{ if .GWCheckIP }} {{ if .GWCheckIP }}
vrrp_script chk_gateway { vrrp_script chk_gateway {
script "/usr/lib/edgeguard/keepalived-gw-check.sh {{ .GWCheckIP }}" script "/usr/lib/edgeguard/keepalived-gw-check.sh {{ .GWCheckIP }}"
interval 5 interval 5
weight -110 fall 5
fall 2
rise 2 rise 2
} }
{{ end }} {{ end }}
@@ -33,7 +44,8 @@ vrrp_instance VI_1 {
interface {{ .Interface }} interface {{ .Interface }}
virtual_router_id {{ .RouterID }} virtual_router_id {{ .RouterID }}
priority {{ .Priority }} priority {{ .Priority }}
advert_int 1 advert_int 2
nopreempt
{{ if .SrcIP }} unicast_src_ip {{ .SrcIP }} {{ if .SrcIP }} unicast_src_ip {{ .SrcIP }}
unicast_peer { unicast_peer {
{{ .PeerIP }} {{ .PeerIP }}
@@ -59,7 +71,8 @@ vrrp_instance VI_HB {
interface {{ .HBInterface }} interface {{ .HBInterface }}
virtual_router_id {{ .HBRouterID }} virtual_router_id {{ .HBRouterID }}
priority {{ .Priority }} priority {{ .Priority }}
advert_int 1 advert_int 2
nopreempt
{{ if .HBSrcIP }} unicast_src_ip {{ .HBSrcIP }} {{ if .HBSrcIP }} unicast_src_ip {{ .HBSrcIP }}
unicast_peer { unicast_peer {
{{ .HBPeerIP }} {{ .HBPeerIP }}

View File

@@ -167,17 +167,19 @@ func (g *generator) buildView(cs *models.ClusterSettings, vips []VIPEntry, local
v.HBRouterID = 52 v.HBRouterID = 52
} }
// pg_role=standby ist das härtere Signal — ein Standby-Node ist niemals // State IMMER BACKUP: das Template setzt `nopreempt`, und nopreempt wirkt
// MASTER, auch wenn role='primary' noch aus dem Join-Prozess stammt. // in keepalived NUR, wenn die Instanz im BACKUP-Zustand startet (bei state
// Reihenfolge: standby → BACKUP; sonst primary-Check. // MASTER wird nopreempt ignoriert). Die Priorität entscheidet weiterhin die
if local.PGRole == "standby" { // Initial-Election (primary=200 gewinnt), aber ein erholter Node reißt die
// VIP NICHT mehr zurück → kein Flap-Back / Split-Brain. Deckt sich mit der
// "kein Auto-Promote"-Philosophie: Promotion bleibt manuell.
// pg_role=standby ist das härtere Signal (Standby ist nie bevorzugter Node).
v.State = "BACKUP" v.State = "BACKUP"
if local.PGRole == "standby" {
v.Priority = 100 v.Priority = 100
} else if local.PGRole == "primary" || local.Role == "primary" { } else if local.PGRole == "primary" || local.Role == "primary" {
v.State = "MASTER"
v.Priority = 200 v.Priority = 200
} else { } else {
v.State = "BACKUP"
v.Priority = 100 v.Priority = 100
} }

View File

@@ -0,0 +1,112 @@
package keepalived
import (
"bytes"
"strings"
"testing"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
)
func render(t *testing.T, v View) string {
t.Helper()
var buf bytes.Buffer
if err := tpl.Execute(&buf, v); err != nil {
t.Fatalf("template execute: %v", err)
}
return buf.String()
}
// Split-Brain-Schutz: jede vrrp_instance MUSS `nopreempt` tragen, sonst reißt
// ein erholter Node die VIP zurück → Flapping. nopreempt wirkt nur bei state
// BACKUP — also muss auch der bevorzugte Node BACKUP starten.
func testView() View {
return View{
State: "BACKUP", Interface: "eth0", RouterID: 51, Priority: 200,
SrcIP: "89.163.205.6", PeerIP: "89.163.205.8", AuthPass: "edgeguard",
VIPs: []VIPEntry{{Address: "89.163.205.100", Prefix: 24, Device: "eth0"}},
HBInterface: "ens19", HBSrcIP: "169.254.0.1", HBPeerIP: "169.254.0.2", HBRouterID: 52,
GWCheckIP: "89.163.205.1",
}
}
func TestTemplateNopreemptOnBothInstances(t *testing.T) {
out := render(t, testView())
if n := strings.Count(out, "nopreempt"); n != 2 {
t.Fatalf("erwarte nopreempt in VI_1 UND VI_HB (2×), gefunden: %d\n%s", n, out)
}
if strings.Contains(out, "state MASTER") {
t.Fatalf("kein Node darf state MASTER starten (nopreempt würde ignoriert):\n%s", out)
}
if c := strings.Count(out, "state BACKUP"); c != 2 {
t.Fatalf("erwarte state BACKUP in beiden Instanzen, gefunden: %d", c)
}
}
// GARP muss forciert + periodic aufgefrischt werden, sonst altert die
// VIP-MAC am Upstream-Switch und die Failover-IP wird unerreichbar.
func TestTemplateGARPRefresh(t *testing.T) {
out := render(t, testView())
for _, want := range []string{"vrrp_garp_master_refresh", "vrrp_garp_master_repeat"} {
if !strings.Contains(out, want) {
t.Fatalf("global_defs sollte %q enthalten:\n%s", want, out)
}
}
}
// advert_int 2 (statt 1): Master-Down ~6s — reißt nicht bei kurzen
// VM-/Heartbeat-Hiccups (Flapping-Schutz im virtualisierten Cluster).
func TestTemplateAdvertInt(t *testing.T) {
out := render(t, testView())
if strings.Contains(out, "advert_int 1\n") {
t.Fatalf("advert_int sollte 2 sein (nicht 1):\n%s", out)
}
if c := strings.Count(out, "advert_int 2"); c != 2 {
t.Fatalf("erwarte advert_int 2 in beiden Instanzen, gefunden: %d", c)
}
}
// Track-Scripts dürfen KEIN weight haben: in einer vrrp_sync_group ignoriert
// keepalived gewichtete Scripts → Health-Checks wären wirkungslos. Ohne weight
// wirken sie als FAULT-Trigger.
func TestTemplateTrackScriptsUnweighted(t *testing.T) {
out := render(t, testView())
if strings.Contains(out, "weight") {
t.Fatalf("Track-Scripts dürfen kein weight tragen (Sync-Group ignoriert sie sonst):\n%s", out)
}
}
// gw-Check darf nicht zu zucken (fall 5, nicht fall 2) — ein kurzer Upstream-
// Blip soll keinen Failover erzwingen.
func TestTemplateGatewayCheckNotTwitchy(t *testing.T) {
out := render(t, testView())
if !strings.Contains(out, "fall 5") {
t.Fatalf("chk_gateway sollte fall 5 nutzen:\n%s", out)
}
}
// buildView: State immer BACKUP, Priorität aus pg_role.
func TestBuildViewStateAlwaysBackup(t *testing.T) {
g := &generator{localID: "n1"}
cs := &models.ClusterSettings{VRRPRouterID: 51}
pub := "89.163.205.6"
cases := []struct {
pgRole, role string
wantPrio int
}{
{"primary", "primary", 200},
{"standby", "primary", 100},
{"", "primary", 200},
{"", "", 100},
}
for _, c := range cases {
local := &models.HANode{ID: "n1", PGRole: c.pgRole, Role: c.role, PublicIP: &pub}
v := g.buildView(cs, nil, local, nil)
if v.State != "BACKUP" {
t.Errorf("pg_role=%q role=%q: State=%q, erwarte immer BACKUP (nopreempt)", c.pgRole, c.role, v.State)
}
if v.Priority != c.wantPrio {
t.Errorf("pg_role=%q role=%q: Priority=%d, erwarte %d", c.pgRole, c.role, v.Priority, c.wantPrio)
}
}
}

View File

@@ -17,6 +17,7 @@
package license package license
import ( import (
"context"
"crypto/sha256" "crypto/sha256"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -136,11 +137,15 @@ func (c *Client) Verify(key string) (*Result, error) {
activeDomains, activeDomains,
) )
resp, err := c.HTTPClient.Get(endpoint) req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("license request: %w", err)
}
resp, err := c.HTTPClient.Do(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("license server unreachable: %w", err) return nil, fmt.Errorf("license server unreachable: %w", err)
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("license server returned status %d", resp.StatusCode) return nil, fmt.Errorf("license server returned status %d", resp.StatusCode)

View File

@@ -3,7 +3,7 @@ package models
import "time" import "time"
// ClusterSettings ist die Singleton-Tabelle (id=1) für VIP/VRRP- // ClusterSettings ist die Singleton-Tabelle (id=1) für VIP/VRRP-
// und Replikations-Konfiguration. Angelegt in Migration 0029. // und Replikations-Configuration. Angelegt in Migration 0029.
// hb_* = zweite VRRP-Instanz für Split-Brain-Schutz (0033). // hb_* = zweite VRRP-Instanz für Split-Brain-Schutz (0033).
// gw_check_ip = Gateway-IP für vrrp_script chk_gateway (0033). // gw_check_ip = Gateway-IP für vrrp_script chk_gateway (0033).
type ClusterSettings struct { type ClusterSettings struct {

View File

@@ -2,7 +2,7 @@ package models
import "time" import "time"
// DHCPSettings ist die node-lokale Singleton-Konfiguration des Kea-DHCPv4- // DHCPSettings ist die node-lokale Singleton-Configuration des Kea-DHCPv4-
// Servers (ob diese Node DHCP betreibt + globale Defaults). // Servers (ob diese Node DHCP betreibt + globale Defaults).
type DHCPSettings struct { type DHCPSettings struct {
ID int `gorm:"column:id;primaryKey" json:"id"` ID int `gorm:"column:id;primaryKey" json:"id"`

View File

@@ -19,6 +19,7 @@ type Domain struct {
MaxBodyKB int `gorm:"column:max_body_kb" json:"max_body_kb"` MaxBodyKB int `gorm:"column:max_body_kb" json:"max_body_kb"`
DisableH3 bool `gorm:"column:disable_h3" json:"disable_h3"` DisableH3 bool `gorm:"column:disable_h3" json:"disable_h3"`
Notes *string `gorm:"column:notes" json:"notes,omitempty"` Notes *string `gorm:"column:notes" json:"notes,omitempty"`
RedirectTo string `gorm:"column:redirect_to" json:"redirect_to"` // ""=aus; sonst 301-Ziel-URL (Domain→Domain)
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"` CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"` UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
} }

View File

@@ -2,7 +2,7 @@ package models
import "time" import "time"
// OIDCSettings ist die Singleton-Konfiguration für OIDC/Keycloak-SSO. // OIDCSettings ist die Singleton-Configuration für OIDC/Keycloak-SSO.
// ClientSecretEnc trägt den verschlüsselten Client-Secret (secrets.Box) // ClientSecretEnc trägt den verschlüsselten Client-Secret (secrets.Box)
// und wird NIE serialisiert (json:"-"). // und wird NIE serialisiert (json:"-").
type OIDCSettings struct { type OIDCSettings struct {

View File

@@ -2,7 +2,7 @@ package models
import "time" import "time"
// RADIUSSettings ist die node-lokale Singleton-Konfiguration des // RADIUSSettings ist die node-lokale Singleton-Configuration des
// FreeRADIUS-Servers (ob diese Node RADIUS betreibt + Listen-Adressen). // FreeRADIUS-Servers (ob diese Node RADIUS betreibt + Listen-Adressen).
type RADIUSSettings struct { type RADIUSSettings struct {
ID int `gorm:"column:id;primaryKey" json:"id"` ID int `gorm:"column:id;primaryKey" json:"id"`

View File

@@ -26,7 +26,7 @@ type WireguardInterface struct {
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"` UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
// PrivateKeyEnc / PeerPSKEnc are loaded from the DB as raw bytes // PrivateKeyEnc / PeerPSKEnc are loaded from the DB as raw bytes
// — handler never serialises them. JSON tag uses '-' so they // — handler never serializes them. JSON tag uses '-' so they
// don't leak into responses if a developer accidentally returns // don't leak into responses if a developer accidentally returns
// the model directly. // the model directly.
PrivateKeyEnc []byte `gorm:"column:private_key_enc" json:"-"` PrivateKeyEnc []byte `gorm:"column:private_key_enc" json:"-"`

View File

@@ -52,9 +52,6 @@ type Service struct {
AccountDir string AccountDir string
DirURL string DirURL string
Email string Email string
// loaded lazily on first call
user *acmeUser
} }
// New returns a Service with sensible defaults. Email comes from // New returns a Service with sensible defaults. Email comes from

View File

@@ -121,7 +121,7 @@ FROM alert_channels ORDER BY id ASC`)
} }
func (s *Service) CreateChannel(ctx context.Context, c Channel) (*Channel, error) { func (s *Service) CreateChannel(ctx context.Context, c Channel) (*Channel, error) {
if c.Settings == nil || len(c.Settings) == 0 { if len(c.Settings) == 0 {
c.Settings = json.RawMessage(`{}`) c.Settings = json.RawMessage(`{}`)
} }
row := s.Pool.QueryRow(ctx, ` row := s.Pool.QueryRow(ctx, `
@@ -138,7 +138,7 @@ RETURNING id, name, kind, target, settings, active, created_at, updated_at`,
} }
func (s *Service) UpdateChannel(ctx context.Context, id int64, c Channel) (*Channel, error) { func (s *Service) UpdateChannel(ctx context.Context, id int64, c Channel) (*Channel, error) {
if c.Settings == nil || len(c.Settings) == 0 { if len(c.Settings) == 0 {
c.Settings = json.RawMessage(`{}`) c.Settings = json.RawMessage(`{}`)
} }
row := s.Pool.QueryRow(ctx, ` row := s.Pool.QueryRow(ctx, `
@@ -269,7 +269,7 @@ func (s *Service) sendWebhook(ctx context.Context, c Channel, kind string,
if err != nil { if err != nil {
return err return err
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 { if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("webhook %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) return fmt.Errorf("webhook %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))

View File

@@ -55,7 +55,7 @@ func PackageVersions(ctx context.Context, force bool) map[string]string {
defer cancel() defer cancel()
for _, pkg := range edgePackages { for _, pkg := range edgePackages {
installed, candidate := aptCachePolicy(policyCtx, pkg) installed, candidate := aptCachePolicy(policyCtx, pkg) //nolint:contextcheck // detached by design — Cache-Read überlebt Request-Cancel
out[pkg+"_installed"] = installed out[pkg+"_installed"] = installed
out[pkg+"_available"] = candidate out[pkg+"_available"] = candidate
} }
@@ -110,7 +110,7 @@ func refreshInternal(ctx context.Context, force bool) {
} }
// StartBackgroundRefresh: Fire-and-Forget-Goroutine die einmal beim // StartBackgroundRefresh: Fire-and-Forget-Goroutine die einmal beim
// Start + dann periodisch apt-get update fährt, damit der Apt-Cache // Start + dann periodic apt-get update fährt, damit der Apt-Cache
// auch ohne UI-Traffic frisch bleibt. So zeigt der Banner kurz nach // auch ohne UI-Traffic frisch bleibt. So zeigt der Banner kurz nach
// `make publish` ein verfügbares Update, statt 5 min auf die nächste // `make publish` ein verfügbares Update, statt 5 min auf die nächste
// UI-Poll-Welle zu warten. // UI-Poll-Welle zu warten.

View File

@@ -258,9 +258,13 @@ func (r *Repo) Cleanup(ctx context.Context, keepDays int) (int64, error) {
if r == nil || r.Pool == nil || keepDays <= 0 { if r == nil || r.Pool == nil || keepDays <= 0 {
return 0, nil return 0, nil
} }
// make_interval(days => $1) nimmt $1 als int — sauber typisiert. Der frühere
// ($1::text || ' days')::interval-Ansatz scheiterte, weil keepDays als int
// übergeben wird und pgx int nicht als text (OID 25) encoden kann
// ("cannot find encode plan") → Cleanup lief nie.
tag, err := r.Pool.Exec(ctx, ` tag, err := r.Pool.Exec(ctx, `
DELETE FROM audit_log DELETE FROM audit_log
WHERE created_at < NOW() - ($1::text || ' days')::interval`, keepDays) WHERE created_at < NOW() - make_interval(days => $1)`, keepDays)
if err != nil { if err != nil {
return 0, err return 0, err
} }

View File

@@ -30,7 +30,6 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"sort" "sort"
"strings"
"time" "time"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
@@ -232,10 +231,9 @@ func (s *Service) Run(ctx context.Context, kind Kind, version string) (*Result,
s.recordFailure(ctx, res, hostname, kind) s.recordFailure(ctx, res, hostname, kind)
return res, err return res, err
} }
if err := f.Sync(); err != nil { // fsync best-effort — Nicht fatal: fsync-failure kann bei tmpfs in
// Nicht fatal — fsync-failure kann passieren bei tmpfs in // Dev passieren, der File ist trotzdem da.
// Dev, aber der File ist da. _ = f.Sync()
}
if err := f.Close(); err != nil { if err := f.Close(); err != nil {
s.recordFailure(ctx, res, hostname, kind) s.recordFailure(ctx, res, hostname, kind)
return res, err return res, err
@@ -644,10 +642,6 @@ func (b *bytes) Write(p []byte) (int, error) {
return len(p), nil return len(p), nil
} }
// strFold ist ein utility nur zum Defensiv-Check, dass kind ein
// erlaubter Wert ist (für die DB-Constraint).
func strFold(s string) string { return strings.ToLower(strings.TrimSpace(s)) }
// SortByDate sortiert Entries newest-first. Wird nicht direkt benutzt // SortByDate sortiert Entries newest-first. Wird nicht direkt benutzt
// (DB-Query macht's), aber praktisch wenn der Caller eine eigene // (DB-Query macht's), aber praktisch wenn der Caller eine eigene
// Liste hat. // Liste hat.

View File

@@ -180,7 +180,7 @@ func uploadS3(ctx context.Context, s S3Settings, localPath string) (int64, error
if err != nil { if err != nil {
return 0, err return 0, err
} }
defer f.Close() defer func() { _ = f.Close() }()
stat, err := f.Stat() stat, err := f.Stat()
if err != nil { if err != nil {
return 0, err return 0, err
@@ -245,12 +245,12 @@ func uploadSFTP(ctx context.Context, s SFTPSettings, localPath string) (int64, e
if err != nil { if err != nil {
return 0, fmt.Errorf("ssh dial %s: %w", addr, err) return 0, fmt.Errorf("ssh dial %s: %w", addr, err)
} }
defer conn.Close() defer func() { _ = conn.Close() }()
cl, err := sftp.NewClient(conn) cl, err := sftp.NewClient(conn)
if err != nil { if err != nil {
return 0, fmt.Errorf("sftp client: %w", err) return 0, fmt.Errorf("sftp client: %w", err)
} }
defer cl.Close() defer func() { _ = cl.Close() }()
// remote-dir anlegen (idempotent) // remote-dir anlegen (idempotent)
_ = cl.MkdirAll(s.RemoteDir) _ = cl.MkdirAll(s.RemoteDir)
@@ -259,7 +259,7 @@ func uploadSFTP(ctx context.Context, s SFTPSettings, localPath string) (int64, e
if err != nil { if err != nil {
return 0, err return 0, err
} }
defer src.Close() defer func() { _ = src.Close() }()
stat, err := src.Stat() stat, err := src.Stat()
if err != nil { if err != nil {
return 0, err return 0, err
@@ -269,10 +269,16 @@ func uploadSFTP(ctx context.Context, s SFTPSettings, localPath string) (int64, e
if err != nil { if err != nil {
return 0, fmt.Errorf("create remote: %w", err) return 0, fmt.Errorf("create remote: %w", err)
} }
defer dst.Close()
if _, err := io.Copy(dst, src); err != nil { if _, err := io.Copy(dst, src); err != nil {
_ = dst.Close()
return 0, fmt.Errorf("copy: %w", err) return 0, fmt.Errorf("copy: %w", err)
} }
// SFTP-Close flusht gepufferte Writes — ein Fehler hier bedeutet
// ein unvollständiges Remote-File und MUSS als Upload-Fehler
// gemeldet werden (nicht als Erfolg durchgehen).
if err := dst.Close(); err != nil {
return 0, fmt.Errorf("close remote: %w", err)
}
return stat.Size(), nil return stat.Size(), nil
} }
@@ -284,9 +290,9 @@ func (s *Service) Test(ctx context.Context, t Target) error {
if err != nil { if err != nil {
return err return err
} }
defer os.Remove(tmp.Name()) defer func() { _ = os.Remove(tmp.Name()) }()
_, _ = tmp.WriteString("edgeguard remote-target test " + time.Now().Format(time.RFC3339)) _, _ = tmp.WriteString("edgeguard remote-target test " + time.Now().Format(time.RFC3339))
tmp.Close() _ = tmp.Close()
r := s.uploadOne(ctx, t, tmp.Name()) r := s.uploadOne(ctx, t, tmp.Name())
if !r.OK { if !r.OK {
@@ -331,9 +337,9 @@ func (s *Service) Test(ctx context.Context, t Target) error {
HostKeyCallback: ssh.InsecureIgnoreHostKey(), Timeout: 5 * time.Second}); err == nil { HostKeyCallback: ssh.InsecureIgnoreHostKey(), Timeout: 5 * time.Second}); err == nil {
if cl, err := sftp.NewClient(conn); err == nil { if cl, err := sftp.NewClient(conn); err == nil {
_ = cl.Remove(strings.TrimRight(settings.RemoteDir, "/") + "/" + filepath.Base(tmp.Name())) _ = cl.Remove(strings.TrimRight(settings.RemoteDir, "/") + "/" + filepath.Base(tmp.Name()))
cl.Close() _ = cl.Close()
} }
conn.Close() _ = conn.Close()
} }
} }
} }

View File

@@ -5,6 +5,7 @@ package clusterjoin
import ( import (
"bytes" "bytes"
"context"
"crypto/tls" "crypto/tls"
"crypto/x509" "crypto/x509"
"encoding/json" "encoding/json"
@@ -128,7 +129,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 {
@@ -177,7 +178,7 @@ func NormalizePrimaryURL(in string) (string, error) {
func issueCert(primary, token, csr string, insecure bool) (caCert, peerCert string, err error) { func issueCert(primary, token, csr string, insecure bool) (caCert, peerCert string, err error) {
body, _ := json.Marshal(map[string]string{"token": token, "csr": csr}) body, _ := json.Marshal(map[string]string{"token": token, "csr": csr})
req, err := http.NewRequest(http.MethodPost, req, err := http.NewRequestWithContext(context.Background(), http.MethodPost,
primary+"/api/v1/cluster/issue-cert", bytes.NewReader(body)) primary+"/api/v1/cluster/issue-cert", bytes.NewReader(body))
if err != nil { if err != nil {
return "", "", err return "", "", err
@@ -193,7 +194,7 @@ func issueCert(primary, token, csr string, insecure bool) (caCert, peerCert stri
if err != nil { if err != nil {
return "", "", err return "", "", err
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return "", "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw))) return "", "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
@@ -222,13 +223,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 +250,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 +260,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")
@@ -275,7 +288,7 @@ func autoRegister(primary, tlsDir, commonName, version, nodeID, configHash strin
} }
client := &http.Client{Transport: tr, Timeout: 30 * time.Second} client := &http.Client{Transport: tr, Timeout: 30 * time.Second}
httpReq, err := http.NewRequest(http.MethodPost, u.String(), bytes.NewReader(body)) httpReq, err := http.NewRequestWithContext(context.Background(), http.MethodPost, u.String(), bytes.NewReader(body))
if err != nil { if err != nil {
return err return err
} }
@@ -284,7 +297,7 @@ func autoRegister(primary, tlsDir, commonName, version, nodeID, configHash strin
if err != nil { if err != nil {
return err return err
} }
defer resp.Body.Close() defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw))) return fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))

View File

@@ -63,9 +63,9 @@ func Run(ctx context.Context, gens []configgen.Generator, only []string) ([]Resu
return out, errors.Join(errs...) return out, errors.Join(errs...)
} }
// Summarise turns the result slice into a human-readable multiline // Summarize turns the result slice into a human-readable multiline
// string. Used by `edgeguard-ctl render-config` to print to stdout. // string. Used by `edgeguard-ctl render-config` to print to stdout.
func Summarise(results []Result) string { func Summarize(results []Result) string {
var b strings.Builder var b strings.Builder
for _, r := range results { for _, r := range results {
if r.Skipped { if r.Skipped {

View File

@@ -34,7 +34,7 @@ type Result struct {
Took time.Duration `json:"-"` Took time.Duration `json:"-"`
} }
// validTarget ist eine konservative Erlaubnis: Buchstaben, Ziffern, // validTarget ist eine conservative Erlaubnis: Buchstaben, Ziffern,
// Punkt, Doppelpunkt (IPv6), Schrägstrich (Pfade in curl-URLs), Bindestrich, // Punkt, Doppelpunkt (IPv6), Schrägstrich (Pfade in curl-URLs), Bindestrich,
// Unterstrich. Whitespace, $, `, `;`, `&`, `|`, `>` etc. werden gesperrt. // Unterstrich. Whitespace, $, `, `;`, `&`, `|`, `>` etc. werden gesperrt.
func validTarget(s string, max int) error { func validTarget(s string, max int) error {

View File

@@ -24,7 +24,7 @@ SELECT id, name, active, primary_backend_id, http_to_https,
hsts_enabled, hsts_max_age, hsts_subdomains, hsts_preload, hsts_enabled, hsts_max_age, hsts_subdomains, hsts_preload,
maintenance_mode, maintenance_message, www_redirect, maintenance_mode, maintenance_message, www_redirect,
rate_limit_rps, max_body_kb, disable_h3, rate_limit_rps, max_body_kb, disable_h3,
notes, created_at, updated_at notes, redirect_to, created_at, updated_at
FROM domains FROM domains
` `
@@ -65,17 +65,17 @@ func (r *Repo) Create(ctx context.Context, d models.Domain) (*models.Domain, err
INSERT INTO domains (name, active, primary_backend_id, http_to_https, INSERT INTO domains (name, active, primary_backend_id, http_to_https,
hsts_enabled, hsts_max_age, hsts_subdomains, hsts_preload, hsts_enabled, hsts_max_age, hsts_subdomains, hsts_preload,
maintenance_mode, maintenance_message, www_redirect, maintenance_mode, maintenance_message, www_redirect,
rate_limit_rps, max_body_kb, disable_h3, notes) rate_limit_rps, max_body_kb, disable_h3, notes, redirect_to)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
RETURNING id, name, active, primary_backend_id, http_to_https, RETURNING id, name, active, primary_backend_id, http_to_https,
hsts_enabled, hsts_max_age, hsts_subdomains, hsts_preload, hsts_enabled, hsts_max_age, hsts_subdomains, hsts_preload,
maintenance_mode, maintenance_message, www_redirect, maintenance_mode, maintenance_message, www_redirect,
rate_limit_rps, max_body_kb, disable_h3, rate_limit_rps, max_body_kb, disable_h3,
notes, created_at, updated_at`, notes, redirect_to, created_at, updated_at`,
d.Name, d.Active, d.PrimaryBackendID, d.HTTPToHTTPS, d.Name, d.Active, d.PrimaryBackendID, d.HTTPToHTTPS,
d.HSTSEnabled, d.HSTSMaxAge, d.HSTSSubdomains, d.HSTSPreload, d.HSTSEnabled, d.HSTSMaxAge, d.HSTSSubdomains, d.HSTSPreload,
d.MaintenanceMode, d.MaintenanceMessage, d.WWWRedirect, d.MaintenanceMode, d.MaintenanceMessage, d.WWWRedirect,
d.RateLimitRPS, d.MaxBodyKB, d.DisableH3, d.Notes) d.RateLimitRPS, d.MaxBodyKB, d.DisableH3, d.Notes, d.RedirectTo)
return scanDomain(row) return scanDomain(row)
} }
@@ -100,17 +100,18 @@ UPDATE domains SET
max_body_kb = $13, max_body_kb = $13,
disable_h3 = $14, disable_h3 = $14,
notes = $15, notes = $15,
redirect_to = $16,
updated_at = NOW() updated_at = NOW()
WHERE id = $16 WHERE id = $17
RETURNING id, name, active, primary_backend_id, http_to_https, RETURNING id, name, active, primary_backend_id, http_to_https,
hsts_enabled, hsts_max_age, hsts_subdomains, hsts_preload, hsts_enabled, hsts_max_age, hsts_subdomains, hsts_preload,
maintenance_mode, maintenance_message, www_redirect, maintenance_mode, maintenance_message, www_redirect,
rate_limit_rps, max_body_kb, disable_h3, rate_limit_rps, max_body_kb, disable_h3,
notes, created_at, updated_at`, notes, redirect_to, created_at, updated_at`,
d.Name, d.Active, d.PrimaryBackendID, d.HTTPToHTTPS, d.Name, d.Active, d.PrimaryBackendID, d.HTTPToHTTPS,
d.HSTSEnabled, d.HSTSMaxAge, d.HSTSSubdomains, d.HSTSPreload, d.HSTSEnabled, d.HSTSMaxAge, d.HSTSSubdomains, d.HSTSPreload,
d.MaintenanceMode, d.MaintenanceMessage, d.WWWRedirect, d.MaintenanceMode, d.MaintenanceMessage, d.WWWRedirect,
d.RateLimitRPS, d.MaxBodyKB, d.DisableH3, d.Notes, id) d.RateLimitRPS, d.MaxBodyKB, d.DisableH3, d.Notes, d.RedirectTo, id)
out, err := scanDomain(row) out, err := scanDomain(row)
if err != nil { if err != nil {
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
@@ -139,7 +140,7 @@ func scanDomain(row interface{ Scan(...any) error }) (*models.Domain, error) {
&d.HSTSEnabled, &d.HSTSMaxAge, &d.HSTSSubdomains, &d.HSTSPreload, &d.HSTSEnabled, &d.HSTSMaxAge, &d.HSTSSubdomains, &d.HSTSPreload,
&d.MaintenanceMode, &d.MaintenanceMessage, &d.WWWRedirect, &d.MaintenanceMode, &d.MaintenanceMessage, &d.WWWRedirect,
&d.RateLimitRPS, &d.MaxBodyKB, &d.DisableH3, &d.RateLimitRPS, &d.MaxBodyKB, &d.DisableH3,
&d.Notes, &d.CreatedAt, &d.UpdatedAt, &d.Notes, &d.RedirectTo, &d.CreatedAt, &d.UpdatedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }

View File

@@ -97,7 +97,7 @@ func (r *AddressGroupsRepo) Create(ctx context.Context, g models.FirewallAddress
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer tx.Rollback(ctx) defer func() { _ = tx.Rollback(ctx) }()
row := tx.QueryRow(ctx, ` row := tx.QueryRow(ctx, `
INSERT INTO firewall_address_groups (name, description) INSERT INTO firewall_address_groups (name, description)
@@ -125,7 +125,7 @@ func (r *AddressGroupsRepo) Update(ctx context.Context, id int64, g models.Firew
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer tx.Rollback(ctx) defer func() { _ = tx.Rollback(ctx) }()
row := tx.QueryRow(ctx, ` row := tx.QueryRow(ctx, `
UPDATE firewall_address_groups SET name = $1, description = $2, updated_at = NOW() UPDATE firewall_address_groups SET name = $1, description = $2, updated_at = NOW()

View File

@@ -91,7 +91,7 @@ func (r *ServiceGroupsRepo) Create(ctx context.Context, g models.FirewallService
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer tx.Rollback(ctx) defer func() { _ = tx.Rollback(ctx) }()
row := tx.QueryRow(ctx, ` row := tx.QueryRow(ctx, `
INSERT INTO firewall_service_groups (name, description) VALUES ($1, $2) INSERT INTO firewall_service_groups (name, description) VALUES ($1, $2)
@@ -115,7 +115,7 @@ func (r *ServiceGroupsRepo) Update(ctx context.Context, id int64, g models.Firew
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer tx.Rollback(ctx) defer func() { _ = tx.Rollback(ctx) }()
row := tx.QueryRow(ctx, ` row := tx.QueryRow(ctx, `
UPDATE firewall_service_groups SET name = $1, description = $2, updated_at = NOW() UPDATE firewall_service_groups SET name = $1, description = $2, updated_at = NOW()

View File

@@ -102,7 +102,7 @@ func ReadTail(path string, f Filter) ([]Entry, error) {
} }
return nil, err return nil, err
} }
defer file.Close() defer func() { _ = file.Close() }()
// Ring-Buffer für die letzten f.Limit matching entries. // Ring-Buffer für die letzten f.Limit matching entries.
buf := make([]Entry, 0, f.Limit) buf := make([]Entry, 0, f.Limit)

View File

@@ -64,7 +64,7 @@ func (t *Tailer) Start(ctx context.Context) error {
if err != nil { if err != nil {
return err return err
} }
defer w.Close() defer func() { _ = w.Close() }()
// Watch das Verzeichnis statt der Datei — wenn ulogd das File // Watch das Verzeichnis statt der Datei — wenn ulogd das File
// erstmals anlegt (oder rotate-rename), bekommen wir Create-Events. // erstmals anlegt (oder rotate-rename), bekommen wir Create-Events.
@@ -106,7 +106,7 @@ func (t *Tailer) bootstrap() error {
if err != nil { if err != nil {
return err return err
} }
defer f.Close() defer func() { _ = f.Close() }()
sc := bufio.NewScanner(f) sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for sc.Scan() { for sc.Scan() {
@@ -134,7 +134,7 @@ func (t *Tailer) drainFile() {
if err != nil { if err != nil {
return return
} }
defer f.Close() defer func() { _ = f.Close() }()
stat, err := f.Stat() stat, err := f.Stat()
if err != nil { if err != nil {

View File

@@ -40,11 +40,17 @@ func (g *Generator) render(ctx context.Context, excludeEthernet bool) error {
prefix int prefix int
} }
// is_vip-Adressen werden NIE statisch gebunden — sie gehören
// ausschließlich keepalived (nur der VRRP-Master trägt die VIP). Würde
// der Apply sie statisch binden, läge die VIP nach einem Failover auf
// BEIDEN Nodes (statisch hier + keepalived drüben) → Duplicate-IP/ARP-
// Konflikt → Tunnel/LAN bricht. Deshalb hart ausschließen.
q := ` q := `
SELECT ni.name, ia.address, ia.prefix SELECT ni.name, ia.address, ia.prefix
FROM ip_addresses ia FROM ip_addresses ia
JOIN network_interfaces ni ON ni.id = ia.interface_id JOIN network_interfaces ni ON ni.id = ia.interface_id
WHERE ia.active = true` WHERE ia.active = true
AND ia.is_vip = false`
if excludeEthernet { if excludeEthernet {
q += ` q += `
AND ni.type != 'ethernet'` AND ni.type != 'ethernet'`

View File

@@ -23,7 +23,7 @@ type Claims struct {
} }
// Authenticator ist der testbare Seam: Aufbau der Auth-URL und der // Authenticator ist der testbare Seam: Aufbau der Auth-URL und der
// Code-Exchange inkl. ID-Token-Verifikation + Claim-Extraktion. Der // Code-Exchange inkl. ID-Token-Verification + Claim-Extraktion. Der
// Handler hängt nur hieran, sodass Tests einen Fake injizieren können. // Handler hängt nur hieran, sodass Tests einen Fake injizieren können.
type Authenticator interface { type Authenticator interface {
// AuthCodeURL baut die Redirect-URL zum IdP (state + nonce + PKCE-Challenge). // AuthCodeURL baut die Redirect-URL zum IdP (state + nonce + PKCE-Challenge).

View File

@@ -1,4 +1,4 @@
// Package oidc kapselt die OIDC/Keycloak-SSO-Konfiguration (Singleton- // Package oidc kapselt die OIDC/Keycloak-SSO-Configuration (Singleton-
// Settings + verschlüsseltes Client-Secret) und einen lazy aufgebauten // Settings + verschlüsseltes Client-Secret) und einen lazy aufgebauten
// OIDC-Provider/Verifier. Login-Flow-State ist stateless (signiertes // OIDC-Provider/Verifier. Login-Flow-State ist stateless (signiertes
// Cookie im Handler), daher hält dieses Paket keinen Request-State. // Cookie im Handler), daher hält dieses Paket keinen Request-State.

View File

@@ -29,7 +29,7 @@ const masterKeyLen = 32
const DefaultMasterKeyPath = "/var/lib/edgeguard/.master_key" const DefaultMasterKeyPath = "/var/lib/edgeguard/.master_key"
// Box uses AES-256-GCM with a static master key to seal/unseal // Box uses AES-256-GCM with a static master key to seal/unseal
// values. Concurrency-safe; the cipher is initialised once. // values. Concurrency-safe; the cipher is initialized once.
type Box struct { type Box struct {
once sync.Once once sync.Once
aead cipher.AEAD aead cipher.AEAD

View File

@@ -179,9 +179,14 @@ func (r *Repo) ListAlerts(ctx context.Context, domainID *int64, limit int) ([]Wa
} }
// PurgeAlerts removes alerts older than the given number of days. // PurgeAlerts removes alerts older than the given number of days.
//
// make_interval(days => $1) nimmt $1 als int — sauber typisiert. Der
// frühere ($1 || ' days')::interval-Ansatz erzwang $1 als text; pgx
// bekam aber einen int und scheiterte mit einem Encode-Fehler zur
// Laufzeit (gleiche Klasse wie der audit-Cleanup-Bug, v1.3.0).
func (r *Repo) PurgeAlerts(ctx context.Context, olderThanDays int) error { func (r *Repo) PurgeAlerts(ctx context.Context, olderThanDays int) error {
_, err := r.Pool.Exec(ctx, _, err := r.Pool.Exec(ctx,
`DELETE FROM waf_alerts WHERE created_at < NOW() - ($1 || ' days')::interval`, `DELETE FROM waf_alerts WHERE created_at < NOW() - make_interval(days => $1)`,
olderThanDays, olderThanDays,
) )
return err return err

View File

@@ -15,7 +15,7 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets" "git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
) )
// ImportResult summarises what an Import call did so the CLI can // ImportResult summarizes what an Import call did so the CLI can
// report it back to the operator. // report it back to the operator.
type ImportResult struct { type ImportResult struct {
IfacesAdded int `json:"ifaces_added"` IfacesAdded int `json:"ifaces_added"`
@@ -292,7 +292,7 @@ func parseWGConf(path string) (*parsedConf, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer f.Close() defer func() { _ = f.Close() }()
var ( var (
out parsedConf out parsedConf

View File

@@ -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
}
}
}
} }
} }

View 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"})
}

View File

@@ -39,7 +39,7 @@ func buildDirectives(cfg models.WafConfig, crsDir string) string {
sb.WriteString("SecRequestBodyLimit 13107200\n") // 12.5 MB sb.WriteString("SecRequestBodyLimit 13107200\n") // 12.5 MB
sb.WriteString("SecRequestBodyInMemoryLimit 131072\n") // 128 KB sb.WriteString("SecRequestBodyInMemoryLimit 131072\n") // 128 KB
sb.WriteString(fmt.Sprintf("SecRuleEngine %s\n", ruleEngineMode(cfg.Mode))) fmt.Fprintf(&sb, "SecRuleEngine %s\n", ruleEngineMode(cfg.Mode))
if crsDir != "" && crsAvailable(crsDir) { if crsDir != "" && crsAvailable(crsDir) {
// Paranoia level MUST be set before CRS rules are included. // Paranoia level MUST be set before CRS rules are included.
@@ -47,22 +47,20 @@ func buildDirectives(cfg models.WafConfig, crsDir string) string {
if pl < 1 || pl > 4 { if pl < 1 || pl > 4 {
pl = 1 pl = 1
} }
sb.WriteString(fmt.Sprintf( fmt.Fprintf(&sb, "SecAction \"id:900000,phase:1,nolog,pass,t:none,setvar:tx.paranoia_level=%d\"\n", pl)
"SecAction \"id:900000,phase:1,nolog,pass,t:none,setvar:tx.paranoia_level=%d\"\n", pl,
))
setupConf := filepath.Join(crsDir, "crs-setup.conf") setupConf := filepath.Join(crsDir, "crs-setup.conf")
if _, err := os.Stat(setupConf); err == nil { if _, err := os.Stat(setupConf); err == nil {
sb.WriteString(fmt.Sprintf("Include %s\n", setupConf)) fmt.Fprintf(&sb, "Include %s\n", setupConf)
} }
rulesGlob := filepath.Join(crsDir, "rules", "*.conf") rulesGlob := filepath.Join(crsDir, "rules", "*.conf")
sb.WriteString(fmt.Sprintf("Include %s\n", rulesGlob)) fmt.Fprintf(&sb, "Include %s\n", rulesGlob)
} }
// Rule exclusions (applied after CRS load so they override CRS). // Rule exclusions (applied after CRS load so they override CRS).
for _, id := range cfg.RuleExclusions { for _, id := range cfg.RuleExclusions {
id = strings.TrimSpace(id) id = strings.TrimSpace(id)
if id != "" { if id != "" {
sb.WriteString(fmt.Sprintf("SecRuleRemoveById %s\n", id)) fmt.Fprintf(&sb, "SecRuleRemoveById %s\n", id)
} }
} }

View File

@@ -20,7 +20,7 @@ type SPOEAgent struct {
Addr string Addr string
} }
// ListenAndServe starts the SPOE agent. Blocks until ctx is cancelled. // ListenAndServe starts the SPOE agent. Blocks until ctx is canceled.
func (a *SPOEAgent) ListenAndServe(ctx context.Context) error { func (a *SPOEAgent) ListenAndServe(ctx context.Context) error {
agent := spop.Agent{ agent := spop.Agent{
Addr: a.Addr, Addr: a.Addr,

View File

@@ -1,15 +1,42 @@
package wireguard package wireguard
import ( import (
"bytes"
"fmt" "fmt"
"os/exec" "os/exec"
) )
// wg-quick is managed via systemd unit instances (wg-quick@<iface>). // wg-quick is managed via systemd unit instances (wg-quick@<iface>).
// Reload-via-syncconf would be cheaper (no link flap) but needs more // Für ein BEREITS laufendes Interface werden Config-Änderungen per
// per-change diffing — for v1 we restart the unit, which takes ~1s // `wg syncconf` LIVE angewendet (siehe syncWGQuick) — ohne Link-Flap,
// and re-establishes peers cleanly. The sudoers entry shipped in // damit bestehende Tunnel nie abreißen. Nur das erstmalige Hochfahren
// postinst whitelists exactly these three commands. // (Interface noch nicht vorhanden) nutzt `systemctl start`. restart bleibt
// als Fallback, falls syncconf nicht erlaubt/möglich ist. Die sudoers-
// Einträge (postinst) whitelisten exakt diese Kommandos.
// interfaceExists meldet ob das wg-Interface aktuell existiert (also von
// wg-quick bereits hochgefahren wurde). `ip link show` braucht kein root.
func interfaceExists(iface string) bool {
return exec.Command("/usr/bin/ip", "link", "show", iface).Run() == nil
}
// syncWGQuick wendet Config-Änderungen LIVE auf ein laufendes Interface an
// (`wg syncconf`) — Peers werden hinzugefügt/entfernt/aktualisiert und der
// Listen-Port gesetzt, OHNE den Tunnel abzureißen. `wg-quick strip` liefert
// die reine wg-Config (ohne Address/MTU/Routes-Direktiven). Beides braucht
// root (Config ist root:root 700) → sudo.
func syncWGQuick(iface string) error {
stripped, err := exec.Command("sudo", "-n", "/usr/bin/wg-quick", "strip", iface).Output()
if err != nil {
return fmt.Errorf("wg-quick strip %s: %w", iface, err)
}
sync := exec.Command("sudo", "-n", "/usr/bin/wg", "syncconf", iface, "/dev/stdin")
sync.Stdin = bytes.NewReader(stripped)
if out, err := sync.CombinedOutput(); err != nil {
return fmt.Errorf("wg syncconf %s: %w: %s", iface, err, string(out))
}
return nil
}
func startWGQuick(iface string) error { func startWGQuick(iface string) error {
cmd := exec.Command("sudo", "-n", "/usr/bin/systemctl", "start", "wg-quick@"+iface+".service") cmd := exec.Command("sudo", "-n", "/usr/bin/systemctl", "start", "wg-quick@"+iface+".service")

View File

@@ -12,6 +12,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"os" "os"
"path/filepath" "path/filepath"
"sort" "sort"
@@ -254,9 +255,24 @@ func (g *Generator) renderIface(ctx context.Context, ifc models.WireguardInterfa
return fmt.Errorf("symlink: %w", err) return fmt.Errorf("symlink: %w", err)
} }
_ = enableWGQuick(ifc.Name) _ = enableWGQuick(ifc.Name)
// Läuft das Interface schon, werden Änderungen LIVE per `wg syncconf`
// angewendet — KEIN Tunnel-Abbruch (WireGuard darf nie abreißen). Nur
// das erstmalige Hochfahren nutzt `systemctl start`.
if interfaceExists(ifc.Name) {
if !changed { if !changed {
return startWGQuick(ifc.Name) return nil // läuft + Config unverändert → nichts zu tun
} }
if err := syncWGQuick(ifc.Name); err != nil {
// Fallback (z. B. sudoers noch ohne syncconf): voller Neustart.
// Bricht den Tunnel kurz ab — nur Notnagel.
slog.Warn("wireguard: wg syncconf fehlgeschlagen, Fallback auf restart (kurzer Tunnel-Flap)",
"iface", ifc.Name, "error", err)
return restartWGQuick(ifc.Name) return restartWGQuick(ifc.Name)
} }
return nil
}
// Interface noch nicht oben → erstmalig hochfahren.
return startWGQuick(ifc.Name)
}

View File

@@ -533,6 +533,8 @@
"wwwRedirectNone": "Kein Redirect", "wwwRedirectNone": "Kein Redirect",
"wwwRedirectToNaked": "→ naked (ohne www)", "wwwRedirectToNaked": "→ naked (ohne www)",
"wwwRedirectToWWW": "→ www", "wwwRedirectToWWW": "→ www",
"redirectTo": "Weiterleitung (301) nach",
"redirectToHint": "Leitet ALLE Anfragen dieser Domain per 301 auf die Ziel-URL um (z. B. https://zkm.netcell-it.de) — immer auf die Ziel-Root. Ist es gesetzt, routet die Domain auf kein Backend. Die Domain braucht trotzdem ein eigenes TLS-Zertifikat. Leer = aus.",
"rateLimit": "Rate-Limit (pro Client-IP)", "rateLimit": "Rate-Limit (pro Client-IP)",
"rateLimitHint": "Max. Requests pro Sekunde je Client-IP. HAProxy zählt über ein 10-Sekunden-Fenster pro Stick-Table (max. 100k IPs). 0 = aus.", "rateLimitHint": "Max. Requests pro Sekunde je Client-IP. HAProxy zählt über ein 10-Sekunden-Fenster pro Stick-Table (max. 100k IPs). 0 = aus.",
"maxBody": "Max. Request-Body", "maxBody": "Max. Request-Body",

View File

@@ -533,6 +533,8 @@
"wwwRedirectNone": "No redirect", "wwwRedirectNone": "No redirect",
"wwwRedirectToNaked": "→ naked (no www)", "wwwRedirectToNaked": "→ naked (no www)",
"wwwRedirectToWWW": "→ www", "wwwRedirectToWWW": "→ www",
"redirectTo": "Redirect (301) to",
"redirectToHint": "Redirects ALL requests for this domain with a 301 to the target URL (e.g. https://zkm.netcell-it.de) — always to the target root. When set, the domain routes to no backend. The domain still needs its own TLS certificate. Empty = off.",
"rateLimit": "Rate limit (per client IP)", "rateLimit": "Rate limit (per client IP)",
"rateLimitHint": "Max requests per second per client IP. HAProxy counts over a 10-second window per stick table (max. 100k IPs). 0 = off.", "rateLimitHint": "Max requests per second per client IP. HAProxy counts over a 10-second window per stick table (max. 100k IPs). 0 = off.",
"maxBody": "Max request body", "maxBody": "Max request body",

View File

@@ -27,6 +27,7 @@ interface Domain {
hsts_subdomains: boolean; hsts_preload: boolean hsts_subdomains: boolean; hsts_preload: boolean
maintenance_mode: boolean; maintenance_message?: string | null maintenance_mode: boolean; maintenance_message?: string | null
www_redirect: '' | 'to-naked' | 'to-www' www_redirect: '' | 'to-naked' | 'to-www'
redirect_to: string
rate_limit_rps: number; max_body_kb: number rate_limit_rps: number; max_body_kb: number
disable_h3: boolean disable_h3: boolean
notes?: string | null notes?: string | null
@@ -40,6 +41,7 @@ interface DomainFormValues {
hsts_subdomains: boolean; hsts_preload: boolean hsts_subdomains: boolean; hsts_preload: boolean
maintenance_mode: boolean; maintenance_message?: string maintenance_mode: boolean; maintenance_message?: string
www_redirect: '' | 'to-naked' | 'to-www' www_redirect: '' | 'to-naked' | 'to-www'
redirect_to: string
rate_limit_rps: number; max_body_kb: number rate_limit_rps: number; max_body_kb: number
disable_h3: boolean disable_h3: boolean
notes?: string notes?: string
@@ -272,6 +274,7 @@ export default function DomainDetailPage() {
maintenance_mode: domain.maintenance_mode, maintenance_mode: domain.maintenance_mode,
maintenance_message: domain.maintenance_message ?? '', maintenance_message: domain.maintenance_message ?? '',
www_redirect: domain.www_redirect ?? '', www_redirect: domain.www_redirect ?? '',
redirect_to: domain.redirect_to ?? '',
rate_limit_rps: domain.rate_limit_rps ?? 0, rate_limit_rps: domain.rate_limit_rps ?? 0,
max_body_kb: domain.max_body_kb ?? 0, max_body_kb: domain.max_body_kb ?? 0,
disable_h3: domain.disable_h3 ?? false, disable_h3: domain.disable_h3 ?? false,
@@ -333,6 +336,10 @@ export default function DomainDetailPage() {
]} /> ]} />
</Form.Item> </Form.Item>
<Form.Item label={t('domains.redirectTo')} name="redirect_to" extra={t('domains.redirectToHint')}>
<Input placeholder="https://ziel-domain.de" allowClear />
</Form.Item>
<Form.Item label={t('domains.maintenance')} name="maintenance_mode" valuePropName="checked" <Form.Item label={t('domains.maintenance')} name="maintenance_mode" valuePropName="checked"
extra={t('domains.maintenanceHint')}> extra={t('domains.maintenanceHint')}>
<Switch /> <Switch />

View File

@@ -111,6 +111,9 @@ edgeguard ALL=(root) NOPASSWD: /bin/systemctl enable wg-quick@*.service
edgeguard ALL=(root) NOPASSWD: /bin/systemctl disable wg-quick@*.service edgeguard ALL=(root) NOPASSWD: /bin/systemctl disable wg-quick@*.service
edgeguard ALL=(root) NOPASSWD: /usr/bin/wg show all dump edgeguard ALL=(root) NOPASSWD: /usr/bin/wg show all dump
edgeguard ALL=(root) NOPASSWD: /usr/bin/wg show * edgeguard ALL=(root) NOPASSWD: /usr/bin/wg show *
# WireGuard Live-Reload ohne Tunnel-Abbruch: wg syncconf + wg-quick strip
edgeguard ALL=(root) NOPASSWD: /usr/bin/wg syncconf *
edgeguard ALL=(root) NOPASSWD: /usr/bin/wg-quick strip *
# WireGuard symlink: /etc/wireguard/ ist root:root 700; edgeguard-api # WireGuard symlink: /etc/wireguard/ ist root:root 700; edgeguard-api
# legt Symlinks an damit wg-quick@<iface> die Configs findet. # legt Symlinks an damit wg-quick@<iface> die Configs findet.
edgeguard ALL=(root) NOPASSWD: /bin/ln -sf /etc/edgeguard/wireguard/* /etc/wireguard/* edgeguard ALL=(root) NOPASSWD: /bin/ln -sf /etc/edgeguard/wireguard/* /etc/wireguard/*
@@ -842,6 +845,25 @@ EOSQL
# atomic-write (tempfile → rename) durchführen kann. # atomic-write (tempfile → rename) durchführen kann.
install -d -m 0755 /etc/keepalived install -d -m 0755 /etc/keepalived
chown "$EG_USER":"$EG_USER" /etc/keepalived chown "$EG_USER":"$EG_USER" /etc/keepalived
# ── keepalived systemd drop-in: Boot-Race-Fix ──────────────────
# keepalived referenziert VIP-Devices (vlanXXX), die erst von
# edgeguard-interfaces.service angelegt werden. Ohne Ordering startet
# keepalived vor den VLANs (beide nur After=network-online.target) →
# "interface vlanX doesn't exist" → permanenter CONFIG-Crash OHNE
# Auto-Recovery (keepalived bleibt nach Reboot tot). After=/Wants=
# wartet auf die Interfaces; Restart=on-failure ist das Sicherheitsnetz.
install -d /etc/systemd/system/keepalived.service.d
cat > /etc/systemd/system/keepalived.service.d/10-edgeguard.conf <<'KEEPALIVEDDROPIN'
[Unit]
After=edgeguard-interfaces.service
Wants=edgeguard-interfaces.service
StartLimitIntervalSec=0
[Service]
Restart=on-failure
RestartSec=3
KEEPALIVEDDROPIN
systemctl daemon-reload
sudo -n -u "$EG_USER" /usr/bin/edgeguard-ctl render-config --only=keepalived || true sudo -n -u "$EG_USER" /usr/bin/edgeguard-ctl render-config --only=keepalived || true
if [ -f /etc/keepalived/keepalived.conf ]; then if [ -f /etc/keepalived/keepalived.conf ]; then
systemctl enable keepalived >/dev/null 2>&1 || true systemctl enable keepalived >/dev/null 2>&1 || true

View File

@@ -7,6 +7,21 @@
logger -t keepalived -p daemon.warning \ logger -t keepalived -p daemon.warning \
"MASTER: VIP übernommen — PG-Rolle ist noch '$(cat /var/lib/edgeguard/pg_role 2>/dev/null || echo standby)'. Für PG-Failover: edgeguard-ctl promote" "MASTER: VIP übernommen — PG-Rolle ist noch '$(cat /var/lib/edgeguard/pg_role 2>/dev/null || echo standby)'. Für PG-Failover: edgeguard-ctl promote"
# ── Upstream-ARP/Routing für die Failover-VIP(s) aktualisieren ──
# Manche Hoster lernen die neue MAC einer Failover-IP NICHT zuverlässig über
# Gratuitous-ARP, sondern erst, wenn sie Traffic VON der IP sehen. Ohne das ist
# die VIP nach einem Schwenk von außen unerreichbar. Wir pingen daher den
# Default-Gateway aus jeder Public-IP (inkl. VIP) an (source via -I), damit der
# Upstream die MAC sofort umlernt. Hintergrund-Pings, damit notify nicht blockt.
WAN_DEV="$(ip -4 route show default 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="dev"){print $(i+1); exit}}')"
WAN_GW="$(ip -4 route show default 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="via"){print $(i+1); exit}}')"
if [ -n "$WAN_DEV" ] && [ -n "$WAN_GW" ]; then
for vip in $(ip -4 -o addr show dev "$WAN_DEV" scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1); do
ping -I "$vip" -c 3 -W 1 "$WAN_GW" >/dev/null 2>&1 &
done
logger -t keepalived -p daemon.info "MASTER: Upstream-ARP via Ping aus VIP(s) auf $WAN_GW ($WAN_DEV) angestoßen"
fi
# Dienste reloaden/starten damit sie die neu aktiven VIPs binden. # Dienste reloaden/starten damit sie die neu aktiven VIPs binden.
# Squid + Unbound + HAProxy binden beim Start an spezifische IPs — war der Dienst # Squid + Unbound + HAProxy binden beim Start an spezifische IPs — war der Dienst
# während des BACKUP-Zustands gecrasht oder gestoppt, muss er gestartet werden. # während des BACKUP-Zustands gecrasht oder gestoppt, muss er gestartet werden.