15 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
85 changed files with 897 additions and 372 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

@@ -131,8 +131,11 @@ HAProxy terminiert TLS auf `:443`, routet per Host-Header an Backends und fällt
```bash
make build # Host-Architektur (amd64)
make test # go test ./...
make lint # golangci-lint
make deb # amd64 + arm64 .deb
make test-race # go test -race ./... (Race-Detector)
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 install-local # direkt auf Dev-Server installieren (kein .deb)
@@ -140,6 +143,15 @@ make install-local # direkt auf Dev-Server installieren (kein .deb)
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

View File

@@ -10,7 +10,7 @@ LDFLAGS := -s -w -X main.version=$(VERSION)
GOFLAGS := -trimpath -mod=readonly
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 \
deb deb-amd64 deb-arm64 \
publish publish-amd64 publish-arm64
@@ -61,9 +61,41 @@ build-linux-arm64:
test:
$(GO) test $(GOFLAGS) ./...
test-race:
CGO_ENABLED=1 $(GO) test $(GOFLAGS) -race ./...
GOBIN := $(shell $(GO) env GOPATH)/bin
GOLANGCI_VERSION := v2.12.2
lint:
$(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:
$(GO) mod tidy
@@ -74,10 +106,10 @@ ui:
if [ -x "$$(command -v bun)" ]; then bun install --silent && bun run build; \
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)
deb-arm64: build-linux-arm64 ui
deb-arm64: release-check build-linux-arm64 ui
@./scripts/apt-repo/build-package.sh arm64 $(VERSION)
deb: deb-amd64 deb-arm64

View File

@@ -1 +1 @@
1.2.100
1.3.3

View File

@@ -17,54 +17,54 @@ import (
"github.com/gin-gonic/gin"
"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/clustertls"
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/jointoken"
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
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"
"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/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/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/backends"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/backendservers"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/backup"
backupremote "git.netcell-it.de/projekte/edgeguard-native/internal/services/backup/remote"
dnssvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/dns"
"git.netcell-it.de/projekte/edgeguard-native/internal/aggregator"
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/clustertls"
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/jointoken"
"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/domains"
"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/syslogs"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/forwardproxy"
"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"
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"
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"
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"
@@ -287,7 +287,7 @@ func main() {
// reload haproxy. Wird in Domains/Backends/RoutingRules-Handler
// injiziert, damit jede Änderung ohne expliziten render-config-
// 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
// nachdem haproxyReloader-closure existiert.
haproxyReloaderForLater := func(ctx context.Context) error {
@@ -296,18 +296,18 @@ func main() {
systemHdl.WithMaintenance(setupStore, haproxyReloaderForLater)
// 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.
systemHdl.WithAudit(auditRepo, nodeID)
systemHdl.WithDB(pool)
systemHdl.WithConfigPreviewers(map[string]func(context.Context) (string, error){
"haproxy": haproxy.New(pool).RenderToString,
"nftables": firewallrender.New(pool).RenderToString,
"squid": squidrender.New(pool).RenderToString,
"unbound": unboundrender.New(pool).RenderToString,
"chrony": chronyrender.New(pool).RenderToString,
"wireguard": wgrender.New(pool, secretsBox).RenderToString,
})
"haproxy": haproxy.New(pool).RenderToString,
"nftables": firewallrender.New(pool).RenderToString,
"squid": squidrender.New(pool).RenderToString,
"unbound": unboundrender.New(pool).RenderToString,
"chrony": chronyrender.New(pool).RenderToString,
"wireguard": wgrender.New(pool, secretsBox).RenderToString,
})
setupHdl.WithAudit(auditRepo, nodeID)
setupHdl.WithClusterSupport(clusterStore, func(ctx context.Context) error {
return firewallrender.New(pool).Render(ctx)
@@ -411,7 +411,7 @@ func main() {
// services whose state feeds the auto-FW-rule generator (DNS
// listen-IPs, Squid ACL count, WG listen-port, NTP serve-clients).
// 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 {
return func(ctx context.Context) error {
if err := svc(ctx); err != nil {
@@ -449,14 +449,14 @@ func main() {
handlers.NewForwardProxyHandler(fwdProxyRepo, auditRepo, nodeID, withFW(squidReloader)).Register(authed)
// 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 {
return unboundrender.New(pool).Render(ctx)
}
handlers.NewDNSHandler(dnsRepo, auditRepo, nodeID, withFW(unboundReloader)).Register(authed)
// 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 {
return chronyrender.New(pool).Render(ctx)
}
@@ -502,7 +502,7 @@ func main() {
// Startup-Render nftables: stellt sicher dass Template-Änderungen
// aus einem Update (z.B. neue WireGuard forward-Chain-Auto-Regel)
// 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.
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
@@ -528,7 +528,17 @@ func main() {
handlers.FinishRollingUpdateIfPending()
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 {
log.Fatalf("edgeguard-api: %v", err)
}
@@ -677,30 +687,6 @@ func openDBBestEffort() (*pgxpoolPool, error) {
// main.go on every platform — keeps the import block lean.
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
// /var/lib/edgeguard isn't writable. Tokens issued with this secret
// die on restart — production reads/writes the persistent file via
@@ -859,7 +845,7 @@ func runPrimaryPush(ctx context.Context, pool *pgxpoolPool, nodeID, fqdn, versio
pCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
hash, _ := cluster.ComputeConfigHash(pCtx, pool)
if err := clusterjoin.PushSelfToPrimary(primaryURL, "", nodeID, fqdn, version, hash); err != nil {
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)
} else {
slog.Debug("cluster: config_hash pushed to primary", "hash", hash)
@@ -905,7 +891,7 @@ func runPeerPush(ctx context.Context, pool *pgxpoolPool, store *cluster.Store, n
if target == "" {
target = "https://" + p.FQDN
}
if err := clusterjoin.PushSelfToPeer(target, "", nodeID, fqdn, version, hash, "primary"); err != nil {
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)
}
}

View File

@@ -1,6 +1,7 @@
package main
import (
"context"
"crypto/rand"
"crypto/tls"
"crypto/x509"
@@ -319,9 +320,11 @@ host replication %s ::/0 scram-sha-256
if err != nil {
return fmt.Errorf("open: %w", err)
}
defer f.Close()
_, err = f.WriteString(entry)
return err
if _, err = f.WriteString(entry); err != nil {
_ = f.Close()
return err
}
return f.Close()
}
// cmdClusterSetupStandby richtet diesen Node als Logical-Replication-
@@ -497,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"
resp, err := client.Get(url)
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
if err != nil {
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 {
return nil, fmt.Errorf("GET %s: HTTP %d", url, resp.StatusCode)
}
@@ -543,11 +550,15 @@ func syncMasterKey(host string, agentPort int, tlsDir string) error {
},
}
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 {
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 {
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 {
return false
}
if !(s[0] == '_' || (s[0] >= 'a' && s[0] <= 'z')) {
if s[0] != '_' && (s[0] < 'a' || s[0] > 'z') {
return false
}
for _, r := range s[1:] {

View File

@@ -8,13 +8,13 @@ import (
"time"
"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/kea"
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster"
"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/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/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/services/configorch"
"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)
fmt.Print(configorch.Summarise(results))
fmt.Print(configorch.Summarize(results))
if runErr != nil {
fmt.Fprintln(os.Stderr, "render-config aborted:", runErr)
return 1

View File

@@ -129,7 +129,7 @@ const (
// ntpSyncCheckInterval — alle 10 Minuten chronyc tracking aufrufen.
// Keine Sync bedeutet: Uhr driftet → TLS-Cert-Prüfung schlägt fehl
// wenn die Abweichung > Toleranz des Gegenstücks (i.d.R. ±1 min),
// JWT-Ablauf inkonsistent, Cluster-Split-Brain möglich. Dedupe 1h
// JWT-Ablauf inconsistent, Cluster-Split-Brain möglich. Dedupe 1h
// damit ein kurzer Upstream-Ausfall (Reboot, DHCP-Pause) keinen
// Alert-Regen produziert.
ntpSyncCheckInterval = 10 * time.Minute
@@ -140,7 +140,7 @@ const (
// ist der Tunnel effektiv tot — Traffic droht lautlos. Dedupe 30min
// pro Tunnel damit schnell wiederhergestellte Tunnels nur einmal feuern.
wgTunnelCheckInterval = 5 * time.Minute
wgStaleSec = int64(5 * 60) // 5 Minuten ohne Handshake = tot
wgStaleSec = int64(5 * 60) // 5 Minuten ohne Handshake = tot
)
func main() {
@@ -241,7 +241,7 @@ func main() {
ntpSyncTick := time.NewTicker(ntpSyncCheckInterval)
defer ntpSyncTick.Stop()
// 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.
wgTunnelTick := time.NewTicker(wgTunnelCheckInterval)
@@ -567,7 +567,7 @@ func parseChronyTrackingForAlert(out string) (synced bool, stratum int, referenc
synced = true
}
case "Stratum":
fmt.Sscanf(val, "%d", &stratum)
_, _ = fmt.Sscanf(val, "%d", &stratum)
if stratum > 0 && stratum < 16 {
synced = true
} 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 {
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 {
// HAProxy läuft nicht oder Socket nicht erreichbar — kein Alert,
// das ist der Dienst selbst nicht der Scheduler.
return
}
defer conn.Close()
defer func() { _ = conn.Close() }()
_ = conn.SetDeadline(time.Now().Add(3 * time.Second))
if _, err := conn.Write([]byte("show stat\n")); err != nil {
return
}
type srvEntry struct{ status string; hasCheck bool }
type srvEntry struct {
status string
hasCheck bool
}
byBackend := map[string][]srvEntry{}
colIdx := map[string]int{}
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")
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 {
_ = repo.MarkError(ctx, key, err.Error())
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,
"Cert-Renewal fehlgeschlagen: "+domain,
"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).")
}
}

View File

@@ -83,7 +83,7 @@ func main() {
slog.Error("waf: SPOE agent stopped", "error", err)
os.Exit(1)
}
// Graceful shutdown (ctx cancelled): gepufferte Alerts flushen.
// Graceful shutdown (ctx canceled): gepufferte Alerts flushen.
alertWriter.Close()
}

8
go.mod
View File

@@ -1,6 +1,6 @@
module git.netcell-it.de/projekte/edgeguard-native
go 1.26.0
go 1.26.4
require (
github.com/corazawaf/coraza/v3 v3.7.0
@@ -16,7 +16,7 @@ require (
github.com/pquerna/otp v1.5.0
github.com/pressly/goose/v3 v3.27.1
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
golang.org/x/crypto v0.51.0
golang.org/x/crypto v0.52.0
golang.org/x/oauth2 v0.36.0
)
@@ -79,9 +79,9 @@ require (
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.8.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/sys v0.44.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/tools v0.44.0 // 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.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
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.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
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/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
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/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
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/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
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()
return res
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1 MiB cap
if resp.StatusCode != http.StatusOK {
res.Err = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
@@ -217,7 +217,7 @@ func (a *Aggregator) PostPeer(ctx context.Context, p models.HANode, path string)
res.Duration = time.Since(start).Milliseconds()
return res
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted {
res.Err = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
@@ -253,7 +253,7 @@ func (a *Aggregator) PostPeerWithBody(ctx context.Context, p models.HANode, path
res.Duration = time.Since(start).Milliseconds()
return res
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted && resp.StatusCode != http.StatusNoContent {
res.Err = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))

View File

@@ -13,14 +13,16 @@
{{end}}
# ── Listen-Bind ────────────────────────────────────────────────
# Wenn nichts ausser localhost gebound ist, lassen wir bindaddress
# weg (chrony default = alle Interfaces). Sonst explizite bindaddress
# pro IP. Mit serve_clients=false wird port 0 → kein Listen-Socket
# (= reiner Client).
# KEIN bindaddress: chrony honoriert nur EINE bindaddress pro Adress-
# familie — bei mehreren Listen-IPs (z. B. mehrere VLAN-/Cluster-VIPs)
# würde nur die letzte gebunden, alle anderen NTP-Clients liefen ins
# 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}}
{{- range .ListenAddresses}}
bindaddress {{.}}
{{- end}}
{{- range .AllowACLs}}
allow {{.}}
{{- end}}

View File

@@ -121,7 +121,7 @@ func splitCSV(s string) []string {
// filterNonLoopback wirft 127.x / ::1 raus — wenn NUR localhost im
// listen_addresses ist, lassen wir den bindaddress-Block weg und
// 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 {
out := []string{}
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.
//
// Layout on disk:
// /var/lib/edgeguard/cluster-tls/ca.crt (0644)
// /var/lib/edgeguard/cluster-tls/ca.key (0600, edgeguard:edgeguard)
// /var/lib/edgeguard/cluster-tls/peer.crt (0644) — diese Node
// /var/lib/edgeguard/cluster-tls/peer.key (0600, edgeguard:edgeguard)
//
// /var/lib/edgeguard/cluster-tls/ca.crt (0644)
// /var/lib/edgeguard/cluster-tls/ca.key (0600, edgeguard:edgeguard)
// /var/lib/edgeguard/cluster-tls/peer.crt (0644) — diese Node
// /var/lib/edgeguard/cluster-tls/peer.key (0600, edgeguard:edgeguard)
//
// Workflow:
// * Erste Node (cluster founder): InitCA generiert CA, dann EnsureSelfSigned
// - Erste Node (cluster founder): InitCA generiert CA, dann EnsureSelfSigned
// erstellt + signiert ihren eigenen peer.crt mit eigener CA.
// * Joining Node: lädt CA-Cert vom Primary, generiert lokal CSR, POSTet
// - Joining Node: lädt CA-Cert vom Primary, generiert lokal CSR, POSTet
// ihn mit cluster-join-token zu /api/v1/cluster/issue-cert; Primary
// signiert via SignCSR und liefert peer.crt zurück. (Phase 3.4.)
// * Single-Node: InitCA + EnsureSelfSigned werden beim API-Boot
// - Single-Node: InitCA + EnsureSelfSigned werden beim API-Boot
// idempotent gerufen; Listener auf :8443 kann sofort hochfahren.
//
// Pattern 1:1 aus mail-gateway/internal/services/clustertls/clustertls.go,
@@ -73,8 +74,8 @@ func (s *Store) HasPeer() bool {
}
// InitCA generiert die Cluster-CA falls noch keine existiert. Idempotent.
// organisation landet im Subject — typischerweise die FQDN-Domain.
func (s *Store) InitCA(organisation string, now func() time.Time) error {
// organization landet im Subject — typischerweise die FQDN-Domain.
func (s *Store) InitCA(organization string, now func() time.Time) error {
if s.HasCA() {
return nil
}
@@ -93,7 +94,7 @@ func (s *Store) InitCA(organisation string, now func() time.Time) error {
SerialNumber: serial,
Subject: pkix.Name{
CommonName: "EdgeGuard Cluster CA",
Organization: []string{organisation},
Organization: []string{organization},
},
NotBefore: now().UTC(),
NotAfter: now().Add(caValidity).UTC(),
@@ -325,12 +326,12 @@ func (s *Store) CACertPEM() (string, error) {
// CertInfo: zusammengefasste Cert-Metadata für UI-Status. days_remaining
// kann negativ sein wenn der Cert schon abgelaufen ist.
type CertInfo struct {
CommonName string `json:"common_name"`
NotBefore time.Time `json:"not_before"`
NotAfter time.Time `json:"not_after"`
DaysRemaining int `json:"days_remaining"`
IsCA bool `json:"is_ca"`
SerialHex string `json:"serial_hex"`
CommonName string `json:"common_name"`
NotBefore time.Time `json:"not_before"`
NotAfter time.Time `json:"not_after"`
DaysRemaining int `json:"days_remaining"`
IsCA bool `json:"is_ca"`
SerialHex string `json:"serial_hex"`
}
// PeerCertInfo liefert die Metadata des eigenen peer.crt. Wenn keiner

View File

@@ -1,7 +1,7 @@
package 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_ID eindeutige UUID (autogeneriert in EnsureNodeID; hier
@@ -54,7 +54,7 @@ func LoadLocalConfig(path string) (*LocalConfig, error) {
}
return nil, err
}
defer f.Close()
defer func() { _ = f.Close() }()
c := &LocalConfig{}
sc := bufio.NewScanner(f)
for sc.Scan() {
@@ -91,7 +91,7 @@ func LoadLocalConfig(path string) (*LocalConfig, error) {
}
// 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 {
if path == "" {
path = DefaultLocalConfigPath

View File

@@ -23,7 +23,7 @@ import (
//
// Name returns a stable identifier ("haproxy", "nftables", …)
// 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 {
Name() string
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)
}
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 {
tmp.Close()
_ = tmp.Close()
return fmt.Errorf("write %s: %w", tmpPath, err)
}
if err := tmp.Sync(); err != nil {
tmp.Close()
_ = tmp.Close()
return fmt.Errorf("fsync %s: %w", tmpPath, err)
}
if err := tmp.Close(); err != nil {

View File

@@ -98,7 +98,7 @@ type HubItem struct {
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 {
Installed bool `json:"installed"`
AgentRunning bool `json:"agent_running"`
@@ -362,15 +362,7 @@ func Machines(ctx context.Context) ([]Machine, error) {
}
result := make([]Machine, len(raw))
for i, r := range raw {
result[i] = Machine{
MachineID: r.MachineID,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
LastPush: r.LastPush,
IsValidated: r.IsValidated,
Version: r.Version,
Status: r.Status,
}
result[i] = Machine(r)
}
return result, nil
}

View File

@@ -97,7 +97,7 @@ func Migrate(ctx context.Context, dsnOverride string) error {
if err != nil {
return fmt.Errorf("open db for migrate: %w", err)
}
defer db.Close()
defer func() { _ = db.Close() }()
goose.SetBaseFS(embeddedMigrations)
if err := goose.SetDialect("postgres"); err != nil {
@@ -117,7 +117,7 @@ func MigrateDown(ctx context.Context, dsnOverride string) error {
if err != nil {
return err
}
defer db.Close()
defer func() { _ = db.Close() }()
goose.SetBaseFS(embeddedMigrations)
if err := goose.SetDialect("postgres"); err != nil {
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.
//
// 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
// service start via Migrate() and via `edgeguard-ctl migrate check`
// in postinst (defense in depth).

View File

@@ -8,7 +8,7 @@
// + groups, policy rules, nat rules, ha_nodes peer IPs.
// 2. Each rule and nat-rule is "resolved" — group references
// 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
// sets, default-deny baseline, forward + input chains carrying
// the resolved rules (priority-sorted), nat prerouting +
@@ -150,12 +150,12 @@ type AutoFWRule struct {
Proto string
Port int
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)
Comment string
}
// RuleLeg is one materialised nft policy line.
// RuleLeg is one materialized nft policy line.
type RuleLeg struct {
RuleID int64
Action string
@@ -166,7 +166,7 @@ type RuleLeg struct {
DstIfaces []string
SrcAddrs []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
// Regeln bleibt es "" (familienagnostisch, kein ip/ip6-Match).
L3 string
@@ -177,14 +177,14 @@ type RuleLeg struct {
// template just emits one nft line per "leg" of the cross-product.
type ResolvedRule struct {
ID int64
Action string // accept | drop | reject
Action string // accept | drop | reject
Log bool
Name string
Priority int
SrcIfaces []string // empty = any
DstIfaces []string // empty = any
SrcAddrs []string // each is an nft expression like "1.2.3.4" or "10.0.0.0/24" or "{ 1.2.3.4, 5.6.7.8 }"
SrcIfaces []string // empty = any
DstIfaces []string // empty = any
SrcAddrs []string // each is an nft expression like "1.2.3.4" or "10.0.0.0/24" or "{ 1.2.3.4, 5.6.7.8 }"
DstAddrs []string
Services []ResolvedService // empty = any
Comment string
@@ -192,16 +192,16 @@ type ResolvedRule struct {
// ResolvedNATRule is one nat-rule joined with iface-sets.
type ResolvedNATRule struct {
ID int64
Kind string // dnat | snat | masquerade
Priority int
InIfaces []string
OutIfaces []string
Proto string // empty = any
SrcCIDR string
DstCIDR string
DPortStart, DPortEnd int
TargetAddr string
ID int64
Kind string // dnat | snat | masquerade
Priority int
InIfaces []string
OutIfaces []string
Proto string // empty = any
SrcCIDR string
DstCIDR string
DPortStart, DPortEnd int
TargetAddr string
TargetPortStart, TargetPortEnd int
// L3 ist "ip" oder "ip6" — Adressfamilie der Regel (aus SrcCIDR/
// DstCIDR/TargetAddr abgeleitet). TargetHost ist TargetAddr, bei
@@ -209,14 +209,14 @@ type ResolvedNATRule struct {
// nft-dnat-Syntax.
L3 string
TargetHost string
Comment string
Comment string
}
// ResolvedService is one nft (proto, dport-spec) tuple.
type ResolvedService struct {
Proto string // tcp|udp|icmp|icmpv6
PortStart int // 0 = no port match
PortEnd int
Proto string // tcp|udp|icmp|icmpv6
PortStart int // 0 = no port match
PortEnd int
}
func (g *Generator) loadView(ctx context.Context) (*View, error) {
@@ -269,6 +269,31 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) {
}
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 ──
addrObjs, err := g.loadAddrObjects(ctx)
if err != nil {
@@ -535,7 +560,7 @@ func splitByFamily(exprs []string) (v4, v6 []string) {
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 {
switch svc.Proto {
case "icmp":
@@ -583,7 +608,7 @@ func expandFamilyLegs(r ResolvedRule, svc ResolvedService, hasSvc bool) []RuleLe
}
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.
return []RuleLeg{base}
}
@@ -777,15 +802,15 @@ ORDER BY priority DESC, id ASC`)
out := []ResolvedRule{}
for rows.Next() {
var (
id int64
name, action, com string
pr int
log bool
srcZone, dstZone string
srcObjID, srcGrpID *int64
dstObjID, dstGrpID *int64
srcCIDR, dstCIDR *string
svcObjID, svcGrpID *int64
id int64
name, action, com string
pr int
log bool
srcZone, dstZone string
srcObjID, srcGrpID *int64
dstObjID, dstGrpID *int64
srcCIDR, dstCIDR *string
svcObjID, svcGrpID *int64
)
if err := rows.Scan(
&id, &name, &pr, &action, &log, &com,
@@ -842,12 +867,12 @@ ORDER BY priority DESC, id ASC`)
out := []ResolvedNATRule{}
for rows.Next() {
var (
id int64
pr int
kind, com string
id int64
pr int
kind, com string
inZone, outZone, proto, srcCIDR, dstCIDR *string
dpStart, dpEnd, tpStart, tpEnd int
targetAddr string
dpStart, dpEnd, tpStart, tpEnd int
targetAddr string
)
if err := rows.Scan(
&id, &pr, &kind, &com,
@@ -861,7 +886,7 @@ ORDER BY priority DESC, id ASC`)
r := ResolvedNATRule{
ID: id, Kind: kind, Priority: pr, Comment: com,
DPortStart: dpStart, DPortEnd: dpEnd,
TargetAddr: targetAddr,
TargetAddr: targetAddr,
TargetPortStart: tpStart, TargetPortEnd: tpEnd,
}
if proto != nil {

View File

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

View File

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

View File

@@ -107,7 +107,7 @@ func renderView(t *testing.T) string {
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: "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{
{ID: 5, Kind: "dnat", L3: "ip6", DstCIDR: "2001:db8::/64", Proto: "tcp", DPortStart: 80, TargetAddr: "fd00::2", TargetHost: "[fd00::2]", TargetPortStart: 8080},
@@ -126,11 +126,11 @@ func renderView(t *testing.T) string {
func TestTemplate_v6AndV4Render(t *testing.T) {
out := renderView(t)
mustContain := []string{
"ip saddr { 10.0.0.0/24 }", // v4-Regel unverändert
"ip6 saddr { fd00::/64 }", // v6-Regel
"ip6 daddr 2001:db8::/64", // v6-DNAT-Match
"dnat to [fd00::2]:8080", // v6-DNAT-Target geklammert
"dnat to 10.0.0.5:80", // v4-DNAT-Target unverändert
"ip saddr { 10.0.0.0/24 }", // v4-Regel unverändert
"ip6 saddr { fd00::/64 }", // v6-Regel
"ip6 daddr 2001:db8::/64", // v6-DNAT-Match
"dnat to [fd00::2]:8080", // v6-DNAT-Target geklammert
"dnat to 10.0.0.5:80", // v4-DNAT-Target unverändert
"ip6 saddr fd00::/64 snat to 2001:db8::99",
`oifname "wg7" ip6 saddr fd00:99::/64 masquerade`,
}
@@ -162,7 +162,7 @@ func TestTemplate_nftSyntax(t *testing.T) {
if _, err := f.WriteString(out); err != nil {
t.Fatal(err)
}
f.Close()
_ = f.Close()
// `nft -c` liest die Kernel-Ruleset-Cache via netlink → braucht root.
// Als nicht-root via sudo -n versuchen; klappt das nicht, skip statt fail
// (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 6379 ip saddr @peer_ipv4 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/...) ──
# Aus dem laufenden Service-State abgeleitet — Operator

View File

@@ -1,6 +1,7 @@
package handlers
import (
"context"
"net/http"
"net/http/httptest"
"os"
@@ -31,7 +32,7 @@ func TestACME_ServesExistingToken(t *testing.T) {
t.Fatal(err)
}
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)
if rec.Code != http.StatusOK {
@@ -45,7 +46,7 @@ func TestACME_ServesExistingToken(t *testing.T) {
func TestACME_MissingToken_Returns404(t *testing.T) {
r, _ := setupACME(t)
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)
if rec.Code != http.StatusNotFound {
t.Errorf("status: %d", rec.Code)
@@ -76,7 +77,7 @@ func TestACME_DirIsNotAFile(t *testing.T) {
t.Fatal(err)
}
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)
if rec.Code != http.StatusNotFound {
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 {
return
}
defer conn.Close()
defer func() { _ = conn.Close() }()
// Snapshot
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 {
return "", "", err
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if resp.StatusCode != http.StatusOK {
return "", "", errors.New("primary: " + strings.TrimSpace(string(raw)))

View File

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

View File

@@ -35,7 +35,7 @@ type ClusterHandler struct {
Store *cluster.Store
LocalID string
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
// 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}
}
// WithAggregator: optionale Aggregator-Konfiguration. Nur wenn vorhanden
// WithAggregator: optionale Aggregator-Configuration. Nur wenn vorhanden
// wird /cluster/system/load die Peers via mTLS abklappern.
func (h *ClusterHandler) WithAggregator(a *aggregator.Aggregator) *ClusterHandler {
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
// lokale Node (LocalID) — die kannst du nicht via UI löschen, sonst
// kommt der nächste Heartbeat-Tick die Row wieder anlegen oder
// die Cluster-Page wird inkonsistent.
// die Cluster-Page wird inconsistent.
//
// Nach erfolgreichem Delete triggert der PeerReloader (falls gesetzt)
// 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)
}
// UpdateVIPSettings speichert die VIP/VRRP-Konfiguration und triggert
// UpdateVIPSettings speichert die VIP/VRRP-Configuration und triggert
// einen Keepalived-Config-Render. Viewer-Schutz via RequireAdminForMutations-
// Middleware auf der authed-Group — kein Extra-Check nötig.
func (h *ClusterHandler) UpdateVIPSettings(c *gin.Context) {
@@ -338,7 +338,7 @@ func (h *ClusterHandler) WithPeerReloader(r PeerReloader) *ClusterHandler {
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 {
h.Version = v
return h
@@ -359,7 +359,7 @@ type ClusterStatus struct {
LocalID string `json:"local_id"`
LocalNode *models.HANode `json:"local_node,omitempty"`
Peers []models.HANode `json:"peers"`
Mode string `json:"mode"` // "single-node" | "cluster"
Mode string `json:"mode"` // "single-node" | "cluster"
Health string `json:"health"` // "ok" | "degraded" | "split-brain"
DriftFound bool `json:"drift_found"`
UpdatedAt time.Time `json:"updated_at"`
@@ -822,7 +822,7 @@ func (h *ClusterHandler) CertStatus(c *gin.Context) {
//
// Nach Renew muss edgeguard-api restartet werden damit der Agent-
// Listener das neue Cert in seinen TLS-Config-Snapshot lädt — wir
// triggern das NICHT automatisch (würde die HTTP-Response abreißen);
// triggering das NICHT automatisch (würde die HTTP-Response abreißen);
// stattdessen liefern wir einen Hinweis im Response.
func (h *ClusterHandler) RenewSelf(c *gin.Context) {
if !h.TLSStore.HasCA() {
@@ -857,16 +857,16 @@ func (h *ClusterHandler) RenewSelf(c *gin.Context) {
// die wir wirklich brauchen — sonst kann ein joining Peer beliebige
// ha_nodes-Felder überschreiben.
type registerPeerRequest struct {
ID string `json:"id"` // Joiner's eigene node-id
Name string `json:"name"` // hostname
FQDN string `json:"fqdn"` // sollte mit Client-Cert-CN matchen
APIURL string `json:"api_url"` // https://<fqdn>
PublicIP string `json:"public_ip"` // optional
InternalIP string `json:"internal_ip"` // mTLS-Listener-IP (für peer_ipv4-Set)
MgmtIP string `json:"mgmt_ip"` // optional
ID string `json:"id"` // Joiner's eigene node-id
Name string `json:"name"` // hostname
FQDN string `json:"fqdn"` // sollte mit Client-Cert-CN matchen
APIURL string `json:"api_url"` // https://<fqdn>
PublicIP string `json:"public_ip"` // optional
InternalIP string `json:"internal_ip"` // mTLS-Listener-IP (für peer_ipv4-Set)
MgmtIP string `json:"mgmt_ip"` // optional
Version string `json:"version"`
ConfigHash *string `json:"config_hash"` // nil=absent (don't change), ""=no user config
Role string `json:"role"` // "" → "peer" (joining peer); "primary" beim Push des Primary
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.
@@ -925,6 +925,14 @@ func (h *ClusterHandler) AgentRegisterPeer(c *gin.Context) {
if req.PublicIP != "" {
v := req.PublicIP
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 != "" {
v := req.InternalIP

View File

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

View File

@@ -177,7 +177,7 @@ func (h *ClusterHandler) runRollingUpdate(secondary *models.HANode) {
slog.Info("rolling-update: secondary already at target — skipping secondary step",
"version", candidate)
} else {
// 1. Secondary triggern
// 1. Secondary triggering
slog.Info("rolling-update: posting trigger-update to secondary", "fqdn", secondary.FQDN)
result := h.Aggregator.PostPeer(ctx, *secondary, "/agent/cluster/trigger-update")
if !result.OK {

View File

@@ -22,7 +22,7 @@ import (
// 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)
// services — proto+port (Builtins lassen sich nicht editieren)
// service-groups — Gruppen von services
@@ -271,7 +271,7 @@ func zoneNamePattern(s string) bool {
if s == "" || len(s) > 32 {
return false
}
if !(s[0] >= 'a' && s[0] <= 'z') {
if s[0] < 'a' || s[0] > 'z' {
return false
}
for i := 1; i < len(s); i++ {
@@ -352,7 +352,8 @@ func (h *FirewallHandler) CreateAddrObj(c *gin.Context) {
return
}
_ = 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) {
@@ -379,7 +380,8 @@ func (h *FirewallHandler) UpdateAddrObj(c *gin.Context) {
return
}
_ = 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) {
@@ -397,7 +399,8 @@ func (h *FirewallHandler) DeleteAddrObj(c *gin.Context) {
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_obj.delete",
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 ─────────────────────────────────────────────────────
@@ -440,7 +443,8 @@ func (h *FirewallHandler) CreateAddrGrp(c *gin.Context) {
return
}
_ = 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) {
@@ -463,7 +467,8 @@ func (h *FirewallHandler) UpdateAddrGrp(c *gin.Context) {
return
}
_ = 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) {
@@ -481,7 +486,8 @@ func (h *FirewallHandler) DeleteAddrGrp(c *gin.Context) {
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_grp.delete",
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 ───────────────────────────────────────────────────────────
@@ -524,7 +530,8 @@ func (h *FirewallHandler) CreateService(c *gin.Context) {
return
}
_ = 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) {
@@ -547,7 +554,8 @@ func (h *FirewallHandler) UpdateService(c *gin.Context) {
return
}
_ = 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) {
@@ -565,7 +573,8 @@ func (h *FirewallHandler) DeleteService(c *gin.Context) {
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.service.delete",
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 ─────────────────────────────────────────────────────
@@ -608,7 +617,8 @@ func (h *FirewallHandler) CreateSvcGrp(c *gin.Context) {
return
}
_ = 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) {
@@ -631,7 +641,8 @@ func (h *FirewallHandler) UpdateSvcGrp(c *gin.Context) {
return
}
_ = 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) {
@@ -649,7 +660,8 @@ func (h *FirewallHandler) DeleteSvcGrp(c *gin.Context) {
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.svc_grp.delete",
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 ──────────────────────────────────────────────────────────────
@@ -704,7 +716,8 @@ func (h *FirewallHandler) CreateRule(c *gin.Context) {
return
}
_ = 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) {
@@ -739,7 +752,8 @@ func (h *FirewallHandler) UpdateRule(c *gin.Context) {
return
}
_ = 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) {
@@ -757,7 +771,8 @@ func (h *FirewallHandler) DeleteRule(c *gin.Context) {
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.rule.delete",
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) {
@@ -850,7 +865,8 @@ func (h *FirewallHandler) CreateNAT(c *gin.Context) {
return
}
_ = 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) {
@@ -881,7 +897,8 @@ func (h *FirewallHandler) UpdateNAT(c *gin.Context) {
return
}
_ = 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) {
@@ -899,7 +916,8 @@ func (h *FirewallHandler) DeleteNAT(c *gin.Context) {
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.nat.delete",
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) {

View File

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

View File

@@ -58,14 +58,15 @@ type frontendStat struct {
}
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 {
// Socket nicht erreichbar (haproxy down oder no perm) →
// leere Liste statt 500 damit das Dashboard nicht rot wird.
response.OK(c, gin.H{"backends": []backendStat{}, "frontends": []frontendStat{}, "error": err.Error()})
return
}
defer conn.Close()
defer func() { _ = conn.Close() }()
_ = conn.SetDeadline(time.Now().Add(3 * time.Second))
if _, err := conn.Write([]byte("show stat\n")); err != nil {
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
// (status stays as before — grace).
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 {
_ = h.Repo.MarkError(ctx, key, err.Error())
slog.Warn("license: verify failed", "error", err)

View File

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

View File

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

View File

@@ -132,7 +132,7 @@ func (h *SystemHandler) Register(rg *gin.RouterGroup) {
// Ergebnis für /cluster/system/load.
//
// 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) {
g := rg.Group("/agent/system")
g.GET("/health", h.Health)
@@ -206,7 +206,7 @@ type serviceStatus struct {
Label string `json:"label"`
Unit string `json:"unit"`
Active bool `json:"active"`
State string `json:"state"` // active|inactive|failed|activating|...
State string `json:"state"` // active|inactive|failed|activating|...
Since string `json:"since,omitempty"` // ActiveEnterTimestamp
}
@@ -378,7 +378,7 @@ func (h *SystemHandler) Maintenance(c *gin.Context) {
func (h *SystemHandler) ToggleMaintenance(c *gin.Context) {
if h.Setup == nil {
response.Err(c, http.StatusServiceUnavailable,
simpleErr("setup not initialised"))
simpleErr("setup not initialized"))
return
}
var req struct {
@@ -430,7 +430,7 @@ func (h *SystemHandler) BackupRetention(c *gin.Context) {
// keep=0 → wieder Default, keep=1..365 → custom.
func (h *SystemHandler) SetBackupRetention(c *gin.Context) {
if h.Setup == nil {
response.Err(c, http.StatusServiceUnavailable, simpleErr("setup not initialised"))
response.Err(c, http.StatusServiceUnavailable, simpleErr("setup not initialized"))
return
}
var req struct {
@@ -466,7 +466,7 @@ func (h *SystemHandler) AuditRetention(c *gin.Context) {
// SetAuditRetention setzt Audit-Retention in Tagen. 0..3650.
func (h *SystemHandler) SetAuditRetention(c *gin.Context) {
if h.Setup == nil {
response.Err(c, http.StatusServiceUnavailable, simpleErr("setup not initialised"))
response.Err(c, http.StatusServiceUnavailable, simpleErr("setup not initialized"))
return
}
var req struct {
@@ -513,7 +513,7 @@ func (h *SystemHandler) IPv6(c *gin.Context) {
func (h *SystemHandler) SetIPv6(c *gin.Context) {
if h.Setup == nil {
response.Err(c, http.StatusServiceUnavailable, simpleErr("setup not initialised"))
response.Err(c, http.StatusServiceUnavailable, simpleErr("setup not initialized"))
return
}
var req struct {
@@ -582,7 +582,7 @@ LIMIT 10`)
// HAProxyReload zwingt ein systemctl reload haproxy.service — nützlich
// wenn der Operator manuell in /etc/edgeguard/tls/ geschrieben hat
// (z. B. eigenes PEM per SSH kopiert) und HAProxy das neue Cert sehen
// soll, ohne eine UI-Mutation zu triggern die das automatisch täte.
// soll, ohne eine UI-Mutation zu triggering die das automatisch täte.
func (h *SystemHandler) HAProxyReload(c *gin.Context) {
out, err := exec.Command("sudo", "-n", "/usr/bin/systemctl", "reload", "haproxy.service").CombinedOutput()
if err != nil {
@@ -600,12 +600,12 @@ func (h *SystemHandler) HAProxyReload(c *gin.Context) {
// starten darf. edgeguard-api selbst ist bewusst ausgeschlossen (würde
// die eigene HTTP-Response killen). postgresql ebenfalls (Datenpfad).
var restartAllowlist = map[string]bool{
"haproxy": true,
"squid": true,
"unbound": true,
"chrony": true,
"nftables": true,
"wireguard": true, // wireguard als Metadienst; einzelne wg-Ifaces über wg-quick@<name>
"haproxy": true,
"squid": true,
"unbound": true,
"chrony": true,
"nftables": true,
"wireguard": true, // wireguard als Metadienst; einzelne wg-Ifaces über wg-quick@<name>
"edgeguard-scheduler": true,
}
@@ -790,7 +790,7 @@ func (h *SystemHandler) UpgradeStatus(c *gin.Context) {
).CombinedOutput(); err == nil {
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
// Leere "no entries"-Antwort als leeres Log zurückgeben.
if !(len(lines) == 1 && (lines[0] == "" || strings.HasPrefix(lines[0], "-- No entries"))) {
if len(lines) != 1 || (lines[0] != "" && !strings.HasPrefix(lines[0], "-- No entries")) {
out.Log = lines
}
}
@@ -920,7 +920,7 @@ func (h *SystemHandler) Upgrade(c *gin.Context) {
// beiden Namespaces aus zugänglich.
const scriptPath = "/var/lib/edgeguard/upgrade.sh"
// Retry-Logik gegen Gitea-Packages.gz-Race: nach einem frischen
// Publish kann der Packages-Index für ein paar Sekunden inkonsistent
// Publish kann der Packages-Index für ein paar Sekunden inconsistent
// sein (z. B. Meta uploaded, api/ui noch nicht in der regenerierten
// Index-Datei) → apt-resolver-fail mit "no choices are installable".
// Drei Versuche mit 15s/30s Backoff geben Gitea Zeit den Index
@@ -1235,4 +1235,3 @@ func flagsToList(f net.Flags) []string {
}
return out
}

View File

@@ -113,6 +113,11 @@ frontend public_https
# www-Redirect: {{$d.RedirectFromHost}}{{$d.Name}}
http-request redirect prefix https://{{$d.Name}} code 301 if { hdr(host) -i {{$d.RedirectFromHost}} }
{{- 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}}
# 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}} }

View File

@@ -212,6 +212,11 @@ type DomainView struct {
// to-www → Name="www.example.com" → "example.com" (strip www.-Prefix)
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
// diese Domain setzt. Werte sind bereits HAProxy-safe escaped
// (Quotes → ', Newlines → Space).
@@ -313,6 +318,7 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) {
HSTSHeader: buildHSTSHeader(d),
MaintMessage: buildMaintMessage(d),
RedirectFromHost: buildRedirectFromHost(d),
RedirectTo: buildRedirectTo(d),
ResponseHeaders: headersByDomain[d.ID],
}
if d.MaxBodyKB > 0 {
@@ -429,3 +435,23 @@ func buildRedirectFromHost(d models.Domain) string {
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)
}
}
if strings.Contains(out, "Strict-Transport-Security \"\" if { hdr(host) -i b.example.com }") ||
strings.Contains(out, "if { hdr(host) -i b.example.com }") && strings.Contains(out, "Strict-Transport-Security") &&
strings.Contains(out, "b.example.com") && strings.Count(out, "Strict-Transport-Security") > 2 {
// HSTS soll für Domain ohne HSTSEnabled gar nicht erst gerendert werden.
// (mgmt_https hat noch eins, plus die eine Zeile von a.example.com → 2 Vorkommen erwartet.)
// HSTS soll für die Domain ohne HSTSEnabled gar nicht erst gerendert
// werden: keine einzige HSTS-Zeile darf sich auf b.example.com beziehen.
for _, line := range strings.Split(out, "\n") {
if strings.Contains(line, "Strict-Transport-Security") &&
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) {
cases := []struct {
name string

View File

@@ -1,7 +1,7 @@
// Package kea renders the Kea DHCPv4 server config from the dhcp_*
// 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
// at /etc/edgeguard/kea/kea-dhcp4.conf (edgeguard-owned); postinst
// 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 {
f, _ := os.CreateTemp(t.TempDir(), "kea-*.conf")
_, _ = f.WriteString(out)
f.Close()
_ = f.Close()
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)
}

View File

@@ -2,21 +2,32 @@ global_defs {
router_id {{ .RouterID }}
script_user root
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 {
script "/usr/lib/edgeguard/keepalived-check.sh"
interval 2
weight -50
fall 3
fall 8
rise 2
}
{{ if .GWCheckIP }}
vrrp_script chk_gateway {
script "/usr/lib/edgeguard/keepalived-gw-check.sh {{ .GWCheckIP }}"
interval 5
weight -110
fall 2
fall 5
rise 2
}
{{ end }}
@@ -33,7 +44,8 @@ vrrp_instance VI_1 {
interface {{ .Interface }}
virtual_router_id {{ .RouterID }}
priority {{ .Priority }}
advert_int 1
advert_int 2
nopreempt
{{ if .SrcIP }} unicast_src_ip {{ .SrcIP }}
unicast_peer {
{{ .PeerIP }}
@@ -59,7 +71,8 @@ vrrp_instance VI_HB {
interface {{ .HBInterface }}
virtual_router_id {{ .HBRouterID }}
priority {{ .Priority }}
advert_int 1
advert_int 2
nopreempt
{{ if .HBSrcIP }} unicast_src_ip {{ .HBSrcIP }}
unicast_peer {
{{ .HBPeerIP }}

View File

@@ -167,17 +167,19 @@ func (g *generator) buildView(cs *models.ClusterSettings, vips []VIPEntry, local
v.HBRouterID = 52
}
// pg_role=standby ist das härtere Signal — ein Standby-Node ist niemals
// MASTER, auch wenn role='primary' noch aus dem Join-Prozess stammt.
// Reihenfolge: standby → BACKUP; sonst primary-Check.
// State IMMER BACKUP: das Template setzt `nopreempt`, und nopreempt wirkt
// in keepalived NUR, wenn die Instanz im BACKUP-Zustand startet (bei state
// MASTER wird nopreempt ignoriert). Die Priorität entscheidet weiterhin die
// 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"
if local.PGRole == "standby" {
v.State = "BACKUP"
v.Priority = 100
} else if local.PGRole == "primary" || local.Role == "primary" {
v.State = "MASTER"
v.Priority = 200
} else {
v.State = "BACKUP"
v.Priority = 100
}

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
import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
@@ -136,11 +137,15 @@ func (c *Client) Verify(key string) (*Result, error) {
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 {
return nil, fmt.Errorf("license server unreachable: %w", err)
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("license server returned status %d", resp.StatusCode)

View File

@@ -3,7 +3,7 @@ package models
import "time"
// 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).
// gw_check_ip = Gateway-IP für vrrp_script chk_gateway (0033).
type ClusterSettings struct {

View File

@@ -2,7 +2,7 @@ package models
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).
type DHCPSettings struct {
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"`
DisableH3 bool `gorm:"column:disable_h3" json:"disable_h3"`
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"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
}

View File

@@ -2,7 +2,7 @@ package models
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)
// und wird NIE serialisiert (json:"-").
type OIDCSettings struct {

View File

@@ -2,7 +2,7 @@ package models
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).
type RADIUSSettings struct {
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"`
// 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
// the model directly.
PrivateKeyEnc []byte `gorm:"column:private_key_enc" json:"-"`

View File

@@ -52,9 +52,6 @@ type Service struct {
AccountDir string
DirURL string
Email string
// loaded lazily on first call
user *acmeUser
}
// 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) {
if c.Settings == nil || len(c.Settings) == 0 {
if len(c.Settings) == 0 {
c.Settings = json.RawMessage(`{}`)
}
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) {
if c.Settings == nil || len(c.Settings) == 0 {
if len(c.Settings) == 0 {
c.Settings = json.RawMessage(`{}`)
}
row := s.Pool.QueryRow(ctx, `
@@ -269,7 +269,7 @@ func (s *Service) sendWebhook(ctx context.Context, c Channel, kind string,
if err != nil {
return err
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
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()
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+"_available"] = candidate
}
@@ -110,7 +110,7 @@ func refreshInternal(ctx context.Context, force bool) {
}
// 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
// `make publish` ein verfügbares Update, statt 5 min auf die nächste
// 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 {
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, `
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 {
return 0, err
}

View File

@@ -30,7 +30,6 @@ import (
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
"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)
return res, err
}
if err := f.Sync(); err != nil {
// Nicht fatal — fsync-failure kann passieren bei tmpfs in
// Dev, aber der File ist da.
}
// fsync best-effort — Nicht fatal: fsync-failure kann bei tmpfs in
// Dev passieren, der File ist trotzdem da.
_ = f.Sync()
if err := f.Close(); err != nil {
s.recordFailure(ctx, res, hostname, kind)
return res, err
@@ -644,10 +642,6 @@ func (b *bytes) Write(p []byte) (int, error) {
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
// (DB-Query macht's), aber praktisch wenn der Caller eine eigene
// Liste hat.

View File

@@ -180,7 +180,7 @@ func uploadS3(ctx context.Context, s S3Settings, localPath string) (int64, error
if err != nil {
return 0, err
}
defer f.Close()
defer func() { _ = f.Close() }()
stat, err := f.Stat()
if err != nil {
return 0, err
@@ -245,12 +245,12 @@ func uploadSFTP(ctx context.Context, s SFTPSettings, localPath string) (int64, e
if err != nil {
return 0, fmt.Errorf("ssh dial %s: %w", addr, err)
}
defer conn.Close()
defer func() { _ = conn.Close() }()
cl, err := sftp.NewClient(conn)
if err != nil {
return 0, fmt.Errorf("sftp client: %w", err)
}
defer cl.Close()
defer func() { _ = cl.Close() }()
// remote-dir anlegen (idempotent)
_ = cl.MkdirAll(s.RemoteDir)
@@ -259,7 +259,7 @@ func uploadSFTP(ctx context.Context, s SFTPSettings, localPath string) (int64, e
if err != nil {
return 0, err
}
defer src.Close()
defer func() { _ = src.Close() }()
stat, err := src.Stat()
if err != nil {
return 0, err
@@ -269,10 +269,16 @@ func uploadSFTP(ctx context.Context, s SFTPSettings, localPath string) (int64, e
if err != nil {
return 0, fmt.Errorf("create remote: %w", err)
}
defer dst.Close()
if _, err := io.Copy(dst, src); err != nil {
_ = dst.Close()
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
}
@@ -284,9 +290,9 @@ func (s *Service) Test(ctx context.Context, t Target) error {
if err != nil {
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.Close()
_ = tmp.Close()
r := s.uploadOne(ctx, t, tmp.Name())
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 {
if cl, err := sftp.NewClient(conn); err == nil {
_ = 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 (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
@@ -177,7 +178,7 @@ func NormalizePrimaryURL(in string) (string, error) {
func issueCert(primary, token, csr string, insecure bool) (caCert, peerCert string, err error) {
body, _ := json.Marshal(map[string]string{"token": token, "csr": csr})
req, err := http.NewRequest(http.MethodPost,
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost,
primary+"/api/v1/cluster/issue-cert", bytes.NewReader(body))
if err != nil {
return "", "", err
@@ -193,7 +194,7 @@ func issueCert(primary, token, csr string, insecure bool) (caCert, peerCert stri
if err != nil {
return "", "", err
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
return "", "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
@@ -287,7 +288,7 @@ func autoRegister(primary, tlsDir, commonName, version, nodeID, configHash, role
}
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 {
return err
}
@@ -296,7 +297,7 @@ func autoRegister(primary, tlsDir, commonName, version, nodeID, configHash, role
if err != nil {
return err
}
defer resp.Body.Close()
defer func() { _ = resp.Body.Close() }()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))

View File

@@ -63,9 +63,9 @@ func Run(ctx context.Context, gens []configgen.Generator, only []string) ([]Resu
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.
func Summarise(results []Result) string {
func Summarize(results []Result) string {
var b strings.Builder
for _, r := range results {
if r.Skipped {

View File

@@ -34,7 +34,7 @@ type Result struct {
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,
// Unterstrich. Whitespace, $, `, `;`, `&`, `|`, `>` etc. werden gesperrt.
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,
maintenance_mode, maintenance_message, www_redirect,
rate_limit_rps, max_body_kb, disable_h3,
notes, created_at, updated_at
notes, redirect_to, created_at, updated_at
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,
hsts_enabled, hsts_max_age, hsts_subdomains, hsts_preload,
maintenance_mode, maintenance_message, www_redirect,
rate_limit_rps, max_body_kb, disable_h3, notes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
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, $16)
RETURNING id, name, active, primary_backend_id, http_to_https,
hsts_enabled, hsts_max_age, hsts_subdomains, hsts_preload,
maintenance_mode, maintenance_message, www_redirect,
rate_limit_rps, max_body_kb, disable_h3,
notes, created_at, updated_at`,
notes, redirect_to, created_at, updated_at`,
d.Name, d.Active, d.PrimaryBackendID, d.HTTPToHTTPS,
d.HSTSEnabled, d.HSTSMaxAge, d.HSTSSubdomains, d.HSTSPreload,
d.MaintenanceMode, d.MaintenanceMessage, d.WWWRedirect,
d.RateLimitRPS, d.MaxBodyKB, d.DisableH3, d.Notes)
d.RateLimitRPS, d.MaxBodyKB, d.DisableH3, d.Notes, d.RedirectTo)
return scanDomain(row)
}
@@ -100,17 +100,18 @@ UPDATE domains SET
max_body_kb = $13,
disable_h3 = $14,
notes = $15,
redirect_to = $16,
updated_at = NOW()
WHERE id = $16
WHERE id = $17
RETURNING id, name, active, primary_backend_id, http_to_https,
hsts_enabled, hsts_max_age, hsts_subdomains, hsts_preload,
maintenance_mode, maintenance_message, www_redirect,
rate_limit_rps, max_body_kb, disable_h3,
notes, created_at, updated_at`,
notes, redirect_to, created_at, updated_at`,
d.Name, d.Active, d.PrimaryBackendID, d.HTTPToHTTPS,
d.HSTSEnabled, d.HSTSMaxAge, d.HSTSSubdomains, d.HSTSPreload,
d.MaintenanceMode, d.MaintenanceMessage, d.WWWRedirect,
d.RateLimitRPS, d.MaxBodyKB, d.DisableH3, d.Notes, id)
d.RateLimitRPS, d.MaxBodyKB, d.DisableH3, d.Notes, d.RedirectTo, id)
out, err := scanDomain(row)
if err != nil {
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.MaintenanceMode, &d.MaintenanceMessage, &d.WWWRedirect,
&d.RateLimitRPS, &d.MaxBodyKB, &d.DisableH3,
&d.Notes, &d.CreatedAt, &d.UpdatedAt,
&d.Notes, &d.RedirectTo, &d.CreatedAt, &d.UpdatedAt,
); err != nil {
return nil, err
}

View File

@@ -97,7 +97,7 @@ func (r *AddressGroupsRepo) Create(ctx context.Context, g models.FirewallAddress
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
defer func() { _ = tx.Rollback(ctx) }()
row := tx.QueryRow(ctx, `
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 {
return nil, err
}
defer tx.Rollback(ctx)
defer func() { _ = tx.Rollback(ctx) }()
row := tx.QueryRow(ctx, `
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 {
return nil, err
}
defer tx.Rollback(ctx)
defer func() { _ = tx.Rollback(ctx) }()
row := tx.QueryRow(ctx, `
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 {
return nil, err
}
defer tx.Rollback(ctx)
defer func() { _ = tx.Rollback(ctx) }()
row := tx.QueryRow(ctx, `
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
}
defer file.Close()
defer func() { _ = file.Close() }()
// Ring-Buffer für die letzten f.Limit matching entries.
buf := make([]Entry, 0, f.Limit)

View File

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

View File

@@ -40,11 +40,17 @@ func (g *Generator) render(ctx context.Context, excludeEthernet bool) error {
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 := `
SELECT ni.name, ia.address, ia.prefix
FROM ip_addresses ia
JOIN network_interfaces ni ON ni.id = ia.interface_id
WHERE ia.active = true`
WHERE ia.active = true
AND ia.is_vip = false`
if excludeEthernet {
q += `
AND ni.type != 'ethernet'`

View File

@@ -23,7 +23,7 @@ type Claims struct {
}
// 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.
type Authenticator interface {
// 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
// OIDC-Provider/Verifier. Login-Flow-State ist stateless (signiertes
// 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"
// 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 {
once sync.Once
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.
//
// 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 {
_, 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,
)
return err

View File

@@ -15,12 +15,12 @@ import (
"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.
type ImportResult struct {
IfacesAdded int `json:"ifaces_added"`
PeersAdded int `json:"peers_added"`
Skipped []string `json:"skipped,omitempty"` // ifaces already present, with reason
IfacesAdded int `json:"ifaces_added"`
PeersAdded int `json:"peers_added"`
Skipped []string `json:"skipped,omitempty"` // ifaces already present, with reason
}
// Importer takes existing /etc/wireguard/*.conf files and translates
@@ -292,7 +292,7 @@ func parseWGConf(path string) (*parsedConf, error) {
if err != nil {
return nil, err
}
defer f.Close()
defer func() { _ = f.Close() }()
var (
out parsedConf

View File

@@ -36,10 +36,10 @@ func buildDirectives(cfg models.WafConfig, crsDir string) string {
sb.WriteString("SecRequestBodyAccess On\n")
sb.WriteString("SecResponseBodyAccess Off\n")
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(fmt.Sprintf("SecRuleEngine %s\n", ruleEngineMode(cfg.Mode)))
fmt.Fprintf(&sb, "SecRuleEngine %s\n", ruleEngineMode(cfg.Mode))
if crsDir != "" && crsAvailable(crsDir) {
// 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 {
pl = 1
}
sb.WriteString(fmt.Sprintf(
"SecAction \"id:900000,phase:1,nolog,pass,t:none,setvar:tx.paranoia_level=%d\"\n", pl,
))
fmt.Fprintf(&sb, "SecAction \"id:900000,phase:1,nolog,pass,t:none,setvar:tx.paranoia_level=%d\"\n", pl)
setupConf := filepath.Join(crsDir, "crs-setup.conf")
if _, err := os.Stat(setupConf); err == nil {
sb.WriteString(fmt.Sprintf("Include %s\n", setupConf))
fmt.Fprintf(&sb, "Include %s\n", setupConf)
}
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).
for _, id := range cfg.RuleExclusions {
id = strings.TrimSpace(id)
if id != "" {
sb.WriteString(fmt.Sprintf("SecRuleRemoveById %s\n", id))
fmt.Fprintf(&sb, "SecRuleRemoveById %s\n", id)
}
}

View File

@@ -15,12 +15,12 @@ import (
// SPOEAgent wraps the haproxy-go SPOE server and dispatches each
// inspected request to the appropriate per-domain Coraza engine.
type SPOEAgent struct {
Manager *Manager
AlertWriter *AlertWriter
Addr string
Manager *Manager
AlertWriter *AlertWriter
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 {
agent := spop.Agent{
Addr: a.Addr,

View File

@@ -533,6 +533,8 @@
"wwwRedirectNone": "Kein Redirect",
"wwwRedirectToNaked": "→ naked (ohne 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)",
"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",

View File

@@ -533,6 +533,8 @@
"wwwRedirectNone": "No redirect",
"wwwRedirectToNaked": "→ naked (no 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)",
"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",

View File

@@ -27,6 +27,7 @@ interface Domain {
hsts_subdomains: boolean; hsts_preload: boolean
maintenance_mode: boolean; maintenance_message?: string | null
www_redirect: '' | 'to-naked' | 'to-www'
redirect_to: string
rate_limit_rps: number; max_body_kb: number
disable_h3: boolean
notes?: string | null
@@ -40,6 +41,7 @@ interface DomainFormValues {
hsts_subdomains: boolean; hsts_preload: boolean
maintenance_mode: boolean; maintenance_message?: string
www_redirect: '' | 'to-naked' | 'to-www'
redirect_to: string
rate_limit_rps: number; max_body_kb: number
disable_h3: boolean
notes?: string
@@ -272,6 +274,7 @@ export default function DomainDetailPage() {
maintenance_mode: domain.maintenance_mode,
maintenance_message: domain.maintenance_message ?? '',
www_redirect: domain.www_redirect ?? '',
redirect_to: domain.redirect_to ?? '',
rate_limit_rps: domain.rate_limit_rps ?? 0,
max_body_kb: domain.max_body_kb ?? 0,
disable_h3: domain.disable_h3 ?? false,
@@ -333,6 +336,10 @@ export default function DomainDetailPage() {
]} />
</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"
extra={t('domains.maintenanceHint')}>
<Switch />

View File

@@ -845,6 +845,25 @@ EOSQL
# atomic-write (tempfile → rename) durchführen kann.
install -d -m 0755 /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
if [ -f /etc/keepalived/keepalived.conf ]; then
systemctl enable keepalived >/dev/null 2>&1 || true

View File

@@ -7,6 +7,21 @@
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"
# ── 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.
# Squid + Unbound + HAProxy binden beim Start an spezifische IPs — war der Dienst
# während des BACKUP-Zustands gecrasht oder gestoppt, muss er gestartet werden.