Compare commits
195 Commits
2d027b3044
...
v1.3.33
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4be9f7f280 | ||
|
|
a34457f13f | ||
|
|
1b0320a5da | ||
|
|
9137c07c95 | ||
|
|
84112d399b | ||
|
|
0f2fba4a62 | ||
|
|
60358a6d47 | ||
|
|
808f6fc055 | ||
|
|
bb19562bc1 | ||
|
|
a31c94f9b8 | ||
|
|
2c72a82a91 | ||
|
|
ed419c1f5f | ||
|
|
95f2238588 | ||
|
|
de83936fce | ||
|
|
3215da8a84 | ||
|
|
6f69697705 | ||
|
|
33cfc1a90d | ||
|
|
3e05c7fe49 | ||
|
|
924540d7a9 | ||
|
|
561816d79d | ||
|
|
cab78eb3d1 | ||
|
|
f61f82d36f | ||
|
|
7aa2a907d5 | ||
|
|
99df6f731d | ||
|
|
bab82f8d5b | ||
|
|
7ff6575790 | ||
|
|
51e5fe83d9 | ||
|
|
b70db4ccf0 | ||
|
|
f1f7df74f7 | ||
|
|
de153dc13a | ||
|
|
5c268425c1 | ||
|
|
0846eaa05b | ||
|
|
d395e3ea68 | ||
|
|
f0be5be496 | ||
|
|
235b5c3b9a | ||
|
|
37f729381d | ||
|
|
088910ee19 | ||
|
|
09e6e0c4f7 | ||
|
|
18ee243a44 | ||
|
|
4856779db8 | ||
|
|
8e4759ccc6 | ||
|
|
96290253c8 | ||
|
|
dca40761a4 | ||
|
|
0ac91a7c59 | ||
|
|
32ab2c7f47 | ||
|
|
cdbb62ee1a | ||
|
|
02096c8ad8 | ||
|
|
d15774f1cd | ||
|
|
4c1e0e9926 | ||
|
|
2495bf022b | ||
|
|
25ec98161f | ||
|
|
79cd68e460 | ||
|
|
f3c76f6d18 | ||
|
|
b2fc7b7dee | ||
|
|
9b563baaa1 | ||
|
|
d8b8fef680 | ||
|
|
fd8125247c | ||
|
|
7b6409b631 | ||
|
|
a2450a759c | ||
|
|
91e51890dd | ||
|
|
7611572062 | ||
|
|
3b5bf578a0 | ||
|
|
bf211ca273 | ||
|
|
becd068637 | ||
|
|
b3dda81b49 | ||
|
|
b20ace8763 | ||
|
|
053b38e46c | ||
|
|
df31bfa720 | ||
|
|
5f92851a96 | ||
|
|
bac7c7e349 | ||
|
|
3a707e2e3f | ||
|
|
f85552a475 | ||
|
|
92c5e25557 | ||
|
|
9383b870b0 | ||
|
|
4416d361a0 | ||
|
|
58e42eb269 | ||
|
|
90f0df4c45 | ||
|
|
66c71c5fa8 | ||
|
|
f7dd7a3a4b | ||
|
|
025854150d | ||
|
|
134466293d | ||
|
|
13d0b557c1 | ||
|
|
ac3223411a | ||
|
|
19107ee702 | ||
|
|
2ab9da8e36 | ||
|
|
f0120b64f3 | ||
|
|
4f31e18d66 | ||
|
|
8a4fefee73 | ||
|
|
801fa26da7 | ||
|
|
220d9d7050 | ||
|
|
c83bb7b137 | ||
|
|
08119f8ccf | ||
|
|
8041e3924d | ||
|
|
884c52a8f3 | ||
|
|
05ac3344fa | ||
|
|
4d81d31022 | ||
|
|
4f887df658 | ||
|
|
66dec8cf61 | ||
|
|
3a122ffb0f | ||
|
|
bf16ce6666 | ||
|
|
bd32bc343a | ||
|
|
72f793552e | ||
|
|
b7b40ad641 | ||
|
|
d425b696f1 | ||
|
|
9580070a50 | ||
|
|
414dad6b3b | ||
|
|
112a945b5c | ||
|
|
1740cb7ae2 | ||
|
|
e999eb68c2 | ||
|
|
a84b9ae10a | ||
|
|
836016648c | ||
|
|
6a4460dfdc | ||
|
|
98aa7c0bcd | ||
|
|
b48ba65ce3 | ||
|
|
13cb9a8fc4 | ||
|
|
1d06b28064 | ||
|
|
49899e984c | ||
|
|
0ee754e231 | ||
|
|
eaa6a04234 | ||
|
|
884f84d3f1 | ||
|
|
bc6db1fc2b | ||
|
|
25c7cd0cb5 | ||
|
|
c1a4ccff8f | ||
|
|
ffb579879d | ||
|
|
6eb6d6d6a4 | ||
|
|
357113e7be | ||
|
|
8d83de6b0f | ||
|
|
6bb1c5c6d3 | ||
|
|
de28523708 | ||
|
|
8ac066d99a | ||
|
|
5ace250879 | ||
|
|
fd69081336 | ||
|
|
6a5aff69d9 | ||
|
|
2e84268f61 | ||
|
|
60e2aec5d9 | ||
|
|
afe2c951b9 | ||
|
|
7e5a7a83c0 | ||
|
|
a0b3bccbc6 | ||
|
|
33f6fef009 | ||
|
|
31d3485a2f | ||
|
|
0cbc781d4f | ||
|
|
bf09fd2cef | ||
|
|
7dd76d4b58 | ||
|
|
40414d472b | ||
|
|
9dee904169 | ||
|
|
d89569017d | ||
|
|
dc2c1b7471 | ||
|
|
2a9310e947 | ||
|
|
76b5a4586c | ||
|
|
fa86b14635 | ||
|
|
abde59f9b4 | ||
|
|
0a31a253b3 | ||
|
|
e2b8560c65 | ||
|
|
22146c422d | ||
|
|
2a76a6599b | ||
|
|
6a9019058a | ||
|
|
a6ef320c57 | ||
|
|
1c39a0e533 | ||
|
|
d87068535d | ||
|
|
7c50e28f55 | ||
|
|
74d3f25d21 | ||
|
|
44f184d505 | ||
|
|
192384c448 | ||
|
|
22528a54a9 | ||
|
|
36eea71c65 | ||
|
|
f378771dca | ||
|
|
123ee6e34f | ||
|
|
40a1b63c02 | ||
|
|
7f4606ba16 | ||
|
|
1c60affd27 | ||
|
|
7c86656c36 | ||
|
|
67c5180168 | ||
|
|
da554e9e82 | ||
|
|
326f79cf00 | ||
|
|
4406f9ac5b | ||
|
|
feae18772c | ||
|
|
b9dfab6664 | ||
|
|
ac6c580318 | ||
|
|
99b4225e64 | ||
|
|
711c4c7eb1 | ||
|
|
f29d74bad7 | ||
|
|
f6615fd27d | ||
|
|
844f9ddc83 | ||
|
|
4629679ba9 | ||
|
|
57b9cd89b2 | ||
|
|
757b58f607 | ||
|
|
c99b0cef4c | ||
|
|
cfb0e9ed01 | ||
|
|
73619c17f8 | ||
|
|
8d7a43bc8c | ||
|
|
ac068bc9dd | ||
|
|
0856c2fc10 | ||
|
|
d3a3b93c1b | ||
|
|
d4267e2003 | ||
|
|
8b0e8a8ab4 |
102
.golangci.yml
Normal file
102
.golangci.yml
Normal 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.
|
||||
57
CLAUDE.md
57
CLAUDE.md
@@ -13,7 +13,9 @@ Vor jeder Entscheidung über Feldwerte, API-Shapes, Dateinamen, Funktions-Signat
|
||||
|
||||
# EdgeGuard Native (`eg`)
|
||||
|
||||
> Native Neufassung des Docker-basierten EdgeGuard-Stacks. Kein Docker, kein WAF in v1. Zielplattform: **Debian 13 (Trixie), amd64 + arm64**. Auslieferung als signiertes `.deb`.
|
||||
> Native Neufassung des Docker-basierten EdgeGuard-Stacks. Kein Docker — alle Dienste nativ unter systemd. Zielplattform: **Debian 13 (Trixie), amd64 + arm64**. Auslieferung als signiertes `.deb`.
|
||||
>
|
||||
> **Hinweis:** Mehrere ursprüngliche v1-Nicht-Ziele (WAF, IDS/IPS, DHCP, RADIUS) sind inzwischen umgesetzt — siehe „Feature-Stand" weiter unten.
|
||||
|
||||
---
|
||||
|
||||
@@ -67,26 +69,47 @@ ac_search_code(query="<Stichworte>", project_id=8, session_name="$(printenv ARCH
|
||||
| **API** | Go 1.26, Gin, GORM (Queries), goose (Migrations) |
|
||||
| **UI** | React 19, TypeScript strict, Vite, Ant Design 6, TanStack Query 5 |
|
||||
| **DB** | PostgreSQL 16 (Distro-Paket), goose-Migrations in `migrations/` |
|
||||
| **State/HA** | KeyDB Active-Active (Redis-kompatibel) |
|
||||
| **State/HA** | PostgreSQL Logical Replication + Cluster-Agent-Heartbeat (mTLS); KeyDB Active-Active nur optional (`Recommends`, für Lizenz-Leader-Election/Shared-Cache) |
|
||||
| **Proxy/LB** | HAProxy (Distro) — TLS-Termination, L7-Routing, LB |
|
||||
| **WAF** | Coraza v3 + OWASP CRS, via HAProxy SPOE (`edgeguard-waf`-Agent) |
|
||||
| **IDS/IPS** | CrowdSec + `crowdsec-firewall-bouncer` (nftables) — managed-wenn-installiert (kein Depends) |
|
||||
| **VPN** | WireGuard (Kernel-Modul ab 5.6, `wireguard-tools`) |
|
||||
| **DNS** | Unbound (Distro) — Forwarder+Cache mit DNSSEC, Cluster-internes Split-Horizon |
|
||||
| **DHCP** | Kea (`kea-dhcp4-server`) — managed, default-off |
|
||||
| **AAA/RADIUS** | FreeRADIUS (PAP/CHAP, files-based) — managed, default-off |
|
||||
| **NTP** | chrony (Distro) |
|
||||
| **VIP/HA** | keepalived (VRRP) |
|
||||
| **FW** | nftables (Distro) |
|
||||
| **Forward-Proxy** | Squid (Distro) |
|
||||
| **Auth/SSO** | JWT (lokal) + 2FA/TOTP + OIDC/OAuth2 (Keycloak u. a.) |
|
||||
| **TLS** | certbot + webroot-Plugin |
|
||||
| **Packaging** | dpkg-deb (direkt, wie mail-gateway + netcell-webpanel) |
|
||||
| **Plattform** | Debian 13 Trixie · amd64 + arm64 |
|
||||
|
||||
---
|
||||
|
||||
## Nicht-Ziele (v1)
|
||||
## Feature-Stand (Stand 2026-06)
|
||||
|
||||
Über den ursprünglichen v1-Scope hinaus inzwischen **umgesetzt** (waren mal Nicht-Ziele):
|
||||
|
||||
- **WAF** — Coraza v3 + OWASP CRS via HAProxy SPOE (`edgeguard-waf`)
|
||||
- **IDS/IPS** — CrowdSec + `crowdsec-firewall-bouncer` (nftables-Bouncer); managed-wenn-installiert, kein hartes Depends
|
||||
- **DHCP** — Kea `kea-dhcp4-server` (managed, default-off)
|
||||
- **RADIUS** — FreeRADIUS PAP/CHAP, files-based (managed, default-off)
|
||||
- **SSO** — OIDC/OAuth2 (additiv, Rolle aus DB, lokaler Login bleibt)
|
||||
- **2FA** — TOTP
|
||||
- **IPv6** — Firewall-Regeln + NAT familienbewusst
|
||||
- **HA** — VIP via keepalived (VRRP), PG-Logical-Replication, bidirektionaler Cluster-Heartbeat
|
||||
|
||||
Damit ist die ursprüngliche v2-Roadmap (WAF, 2FA, IPv6-FW, OIDC, DHCP, RADIUS) abgearbeitet.
|
||||
|
||||
## Nicht-Ziele (weiterhin)
|
||||
|
||||
- **Kein Docker** — alle Dienste nativ unter systemd
|
||||
- **Kein WAF** (kein Coraza, kein ModSecurity)
|
||||
- **Kein IDS/IPS** (kein Suricata, kein CrowdSec)
|
||||
- **Kein DHCP-Server** (kein Kea)
|
||||
- **Kein RADIUS** (kein FreeRADIUS)
|
||||
- **Kein Network-IDS Suricata** — Intrusion-Detection läuft über CrowdSec, nicht über Suricata-Paket-Inspektion
|
||||
- **Keine Mail-Verarbeitung** (eigenes Produkt: mail-gateway)
|
||||
- **Keine Multi-Tenant-GuardZones**
|
||||
- **Keine ISO-Builds** (kein EdgeGuardOS-Klon — nur APT)
|
||||
- **Nur Debian 13** — kein Ubuntu, kein Debian 12, kein RHEL
|
||||
|
||||
---
|
||||
@@ -108,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)
|
||||
|
||||
@@ -117,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
|
||||
@@ -154,7 +189,6 @@ cd management-ui && bun run dev
|
||||
│ ├── unbound/ # Config-Generator (Forwarder + Cluster-DNS)
|
||||
│ ├── firewall/ # nftables-Generator
|
||||
│ ├── cluster/ # Join/Promote/Peer-Discovery
|
||||
│ ├── proxy/ # Write-Proxy → Cluster-Primary
|
||||
│ ├── aggregator/ # Cluster-View APIs
|
||||
│ └── license/ # Lizenz-Validierung
|
||||
├── management-ui/ # React 19 + AntD 6 (1:1 enconf-Pattern)
|
||||
@@ -185,7 +219,8 @@ cd management-ui && bun run dev
|
||||
- **ORM:** GORM für Queries, nicht für Schema-Verwaltung
|
||||
- **Config-Generierung:** Template-Datei in `deploy/*/`, Generator in `internal/*/`
|
||||
- **Config-Reload:** `systemctl reload <service>` nach Config-Schreiben
|
||||
- **Cluster-Writes:** immer über `internal/proxy` → Primary-URL aus KeyDB `cluster:pg-primary-url`
|
||||
- **Cluster-Primary-Ermittlung:** zuverlässig über `pg_publication` (`edgeguard_shared`); Primary-URL aus `setup.json` `PrimaryFQDN` via `clusterjoin.NormalizePrimaryURL`. **Kein Write-Proxy** — Schreibzugriffe auf geteilte Tabellen erfolgen am Primary.
|
||||
- **Failover:** `edgeguard-ctl promote` ist Logical-Replication-aware (Subscription lösen → `setupReplicationPrimary` → Publisher werden, inkl. PG-Restart für `wal_level=logical`); erholte Nodes danach via `cluster-setup-standby <neuer-primary>` zurückhängen.
|
||||
|
||||
### Packaging
|
||||
- `dpkg-deb` direkt (wie mail-gateway) — kein dh_make/debhelper/fpm
|
||||
|
||||
78
Makefile
78
Makefile
@@ -4,13 +4,13 @@
|
||||
|
||||
GO ?= $(shell which go || echo /usr/local/go/bin/go)
|
||||
MODULE := git.netcell-it.de/projekte/edgeguard-native
|
||||
BINARIES := edgeguard-api edgeguard-scheduler edgeguard-ctl
|
||||
BINARIES := edgeguard-api edgeguard-scheduler edgeguard-ctl edgeguard-waf
|
||||
VERSION := $(shell cat VERSION 2>/dev/null || echo 0.0.1-dev)
|
||||
LDFLAGS := -s -w -X main.version=$(VERSION)
|
||||
GOFLAGS := -trimpath -mod=readonly
|
||||
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,62 @@ 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.13.2
|
||||
# Ziel-Go-Version aus go.mod — golangci-lint MUSS mit genau dieser Toolchain
|
||||
# gebaut sein (siehe golangci-Target).
|
||||
GO_VERSION := $(shell awk '/^go /{print $$2; exit}' go.mod)
|
||||
|
||||
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).
|
||||
# Befund 2026-09-11: golangci-lint verweigert den Dienst ("the Go language
|
||||
# version used to build golangci-lint is lower than the targeted Go version"),
|
||||
# sobald go.mod eine neuere Go-Version zielt als die, mit der der Linter
|
||||
# gebaut wurde — und ein `go install` ohne GOTOOLCHAIN baut ihn mit der in
|
||||
# SEINER go.mod geforderten (älteren) Version. Deshalb die Projekt-Toolchain
|
||||
# pinnen. Der frühere `command -v`-Check hat ausserdem eine bereits
|
||||
# installierte, veraltete Binary nie erneuert; jetzt entscheidet die
|
||||
# tatsächliche Version + Build-Go-Version der Binary über die Neuinstallation.
|
||||
golangci:
|
||||
@if ! "$(GOBIN)/golangci-lint" version 2>/dev/null | grep -q "has version $(patsubst v%,%,$(GOLANGCI_VERSION)) built with go$(GO_VERSION) "; then \
|
||||
echo " -> installing golangci-lint $(GOLANGCI_VERSION) (built with go$(GO_VERSION))"; \
|
||||
GOFLAGS= GOTOOLCHAIN=go$(GO_VERSION) $(GO) install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_VERSION); \
|
||||
fi
|
||||
@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.
|
||||
# Gleiche Toolchain-Falle wie bei golangci-lint (Befund 2026-09-11): ein
|
||||
# `go install` ohne GOTOOLCHAIN baut govulncheck mit einer aelteren Go-Version,
|
||||
# die dann "package requires newer Go version" fuer jedes Paket meldet statt zu
|
||||
# scannen. Reinstall, sobald die Build-Go-Version der Binary abweicht.
|
||||
vulncheck:
|
||||
@if ! "$(GOBIN)/govulncheck" -version 2>/dev/null | grep -q "^Go: go$(GO_VERSION)$$"; then \
|
||||
echo " -> installing govulncheck (built with go$(GO_VERSION))"; \
|
||||
GOFLAGS= GOTOOLCHAIN=go$(GO_VERSION) $(GO) install golang.org/x/vuln/cmd/govulncheck@latest; \
|
||||
fi
|
||||
@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
|
||||
@@ -72,27 +125,28 @@ ui:
|
||||
@echo " -> management-ui (vite build, version $(VERSION))"
|
||||
@cd management-ui && \
|
||||
if [ -x "$$(command -v bun)" ]; then bun install --silent && bun run build; \
|
||||
else npm install --silent && npm run build; fi
|
||||
else npm install --include=dev --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
|
||||
|
||||
GITEA_DEB_URL := https://git.netcell-it.de/api/packages/projekte/debian/pool/trixie/main/upload
|
||||
|
||||
# Direktes `make publish` bleibt als Handnotbremse erhalten, veröffentlicht
|
||||
# aber immer nach stable — für Testing-Releases + das Stable-Promotion-
|
||||
# Gate (verify_channel_debs, Version-Bump, Git-Tag) scripts/release.sh nutzen.
|
||||
publish-amd64: deb-amd64
|
||||
@./scripts/apt-repo/publish.sh $(VERSION) amd64
|
||||
@./scripts/apt-repo/publish.sh $(VERSION) amd64 stable
|
||||
@echo " -> cleanup-old (keep last $${KEEP:-10})"
|
||||
@./scripts/apt-repo/cleanup-old.sh
|
||||
@./scripts/apt-repo/cleanup-old.sh stable
|
||||
|
||||
publish-arm64: deb-arm64
|
||||
@./scripts/apt-repo/publish.sh $(VERSION) arm64
|
||||
@./scripts/apt-repo/publish.sh $(VERSION) arm64 stable
|
||||
@echo " -> cleanup-old (keep last $${KEEP:-10})"
|
||||
@./scripts/apt-repo/cleanup-old.sh
|
||||
@./scripts/apt-repo/cleanup-old.sh stable
|
||||
|
||||
publish: publish-amd64 publish-arm64
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -17,50 +18,58 @@ 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/crowdsec"
|
||||
"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"
|
||||
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/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"
|
||||
"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"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/clusterjoin"
|
||||
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/aggregator"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/clustertls"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/jointoken"
|
||||
aptsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/apt"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/domainheaders"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/domains"
|
||||
"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"
|
||||
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/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/staticroutes"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/syslogs"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
|
||||
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
|
||||
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
|
||||
wafsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/waf"
|
||||
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.1.89"
|
||||
var version = "1.2.35"
|
||||
|
||||
func main() {
|
||||
addr := os.Getenv("EDGEGUARD_API_ADDR")
|
||||
@@ -111,7 +120,7 @@ func main() {
|
||||
|
||||
requireAuth := handlers.RequireAuth(signer)
|
||||
|
||||
setupHdl := handlers.NewSetupHandler(setupStore)
|
||||
setupHdl := handlers.NewSetupHandler(setupStore).WithVersion(version)
|
||||
setupHdl.Register(v1)
|
||||
|
||||
// systemHdl exists früh damit sowohl der frühe (DB-pool nicht
|
||||
@@ -175,6 +184,26 @@ func main() {
|
||||
go runClusterHeartbeat(context.Background(), pool, nodeID, version)
|
||||
}
|
||||
|
||||
// Secondary: push config_hash to primary every 5 min so the primary's
|
||||
// ha_nodes reflects actual state. Without this, the primary retains the
|
||||
// stale hash written at join-time and the drift banner never clears.
|
||||
// st.IsClusterNode + PrimaryFQDN are only set on joined secondary nodes.
|
||||
if nodeID != "" && st != nil && st.IsClusterNode && st.PrimaryFQDN != "" {
|
||||
if primaryURL, normErr := clusterjoin.NormalizePrimaryURL(st.PrimaryFQDN); normErr == nil {
|
||||
go runPrimaryPush(context.Background(), pool, nodeID, st.FQDN, version, primaryURL)
|
||||
} else {
|
||||
slog.Warn("cluster: cannot normalize primary URL for push", "primary", st.PrimaryFQDN, "error", normErr)
|
||||
}
|
||||
// runSecondaryConfigRender wird weiter unten gestartet sobald
|
||||
// clusterAggregator verfügbar ist (braucht mTLS-Client für Cert-Sync).
|
||||
} else if nodeID != "" && st != nil && st.Completed && st.FQDN != "" {
|
||||
// Primary/Founder (kein joined Secondary): self (role=primary) an
|
||||
// alle Peers pushen, damit deren lokale ha_nodes den Primary frisch
|
||||
// hält — sonst zeigt die vom Secondary ausgelieferte UI den Primary
|
||||
// als offline. No-op solange keine Peers existieren (Single-Node).
|
||||
go runPeerPush(context.Background(), pool, clusterStore, nodeID, st.FQDN, version)
|
||||
}
|
||||
|
||||
// Phase 3.3: Cluster-CA + Peer-Cert. Founder-Pfad — auf einem
|
||||
// frisch installierten Single-Node generieren wir die CA und
|
||||
// signieren uns selbst, damit der Agent-Listener auf :8443
|
||||
@@ -219,6 +248,12 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Secondary-Config-Render: jetzt wo der Aggregator bereit ist starten.
|
||||
// Aggregator wird für Cert-Sync (mTLS GET /agent/cluster/tls-certs) benötigt.
|
||||
if nodeID != "" && st != nil && st.IsClusterNode && st.PrimaryFQDN != "" {
|
||||
go runSecondaryConfigRender(context.Background(), pool, secrets.New(""), clusterAggregator, nodeID)
|
||||
}
|
||||
|
||||
auditRepo := audit.New(pool)
|
||||
domainsRepo := domains.New(pool)
|
||||
domainHeadersRepo := domainheaders.New(pool)
|
||||
@@ -254,7 +289,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 {
|
||||
@@ -263,29 +298,55 @@ 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,
|
||||
})
|
||||
"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,
|
||||
"crowdsec-whitelist": crowdsec.NewWhitelistGenerator(pool).RenderToString,
|
||||
})
|
||||
setupHdl.WithAudit(auditRepo, nodeID)
|
||||
setupHdl.WithClusterSupport(clusterStore, func(ctx context.Context) error {
|
||||
return firewallrender.New(pool).Render(ctx)
|
||||
})
|
||||
// Cluster-Node-Startup: Primary in lokalen ha_nodes eintragen damit
|
||||
// nftables @peer_ipv4 korrekt ist — auch ohne erneuten Join.
|
||||
go setupHdl.StartupPeerSync()
|
||||
usersRepo := usersvc.New(pool)
|
||||
authHdl.WithAudit(auditRepo, nodeID).WithUsers(usersRepo)
|
||||
authHdl.WithAudit(auditRepo, nodeID).WithUsers(usersRepo).WithClusterTLS(clusterTLSStore)
|
||||
systemHdl.WithUsers(usersRepo)
|
||||
|
||||
haproxyReloader := func(ctx context.Context) error {
|
||||
return haproxy.New(pool).Render(ctx)
|
||||
}
|
||||
|
||||
// Domain-Mutationen rendern zusätzlich die CrowdSec-Admin-Whitelist neu
|
||||
// (Flag crowdsec_trusted → host-genaue Ausnahme). No-op ohne CrowdSec.
|
||||
// Beide laufen unabhängig; Fehler werden zusammengefasst (nur geloggt).
|
||||
crowdsecWL := crowdsec.NewWhitelistGenerator(pool)
|
||||
domainsReloader := func(ctx context.Context) error {
|
||||
return errors.Join(haproxy.New(pool).Render(ctx), crowdsecWL.Render(ctx))
|
||||
}
|
||||
|
||||
authed := v1.Group("")
|
||||
authed.Use(requireAuth, handlers.RequireAdminForMutations())
|
||||
setupHdl.RegisterAuthed(authed)
|
||||
handlers.NewUsersHandler(usersRepo, auditRepo, nodeID).Register(authed)
|
||||
handlers.NewDomainsHandler(domainsRepo, routingRepo, domainHeadersRepo, auditRepo, nodeID, haproxyReloader).Register(authed)
|
||||
|
||||
// OIDC/Keycloak SSO — public Flow-Endpoints auf v1 (hinter SetupGate),
|
||||
// Admin-Settings auf authed (PUT nur admin via RequireAdminForMutations).
|
||||
oidcRepo := oidcsvc.New(pool, secretsBox)
|
||||
oidcHdl := handlers.NewOIDCHandler(oidcRepo, oidcsvc.NewClient(oidcRepo), usersRepo, signer, setupStore).
|
||||
WithAudit(auditRepo, nodeID)
|
||||
oidcHdl.RegisterPublic(v1)
|
||||
oidcHdl.RegisterAdmin(authed)
|
||||
handlers.NewDomainsHandler(domainsRepo, routingRepo, domainHeadersRepo, auditRepo, nodeID, domainsReloader).Register(authed)
|
||||
handlers.NewBackendsHandler(backendsRepo, auditRepo, nodeID, haproxyReloader).Register(authed)
|
||||
handlers.NewBackendServersHandler(backendServersRepo, auditRepo, nodeID, haproxyReloader).Register(authed)
|
||||
handlers.NewRoutingRulesHandler(routingRepo, auditRepo, nodeID, haproxyReloader).Register(authed)
|
||||
@@ -315,7 +376,9 @@ func main() {
|
||||
clusterHdl := handlers.NewClusterHandler(clusterStore, nodeID).
|
||||
WithAggregator(clusterAggregator).
|
||||
WithJoinFlow(clusterTLSStore, joinTokens).
|
||||
WithPeerReloader(peerReloader)
|
||||
WithPeerReloader(peerReloader).
|
||||
WithAudit(auditRepo, nodeID).
|
||||
WithVersion(version)
|
||||
clusterHdl.Register(authed)
|
||||
// /cluster/issue-cert läuft PUBLIC — joining Peer hat noch
|
||||
// keine Session/Cert. Token + Nonce-Tracking ist die einzige
|
||||
@@ -351,13 +414,15 @@ func main() {
|
||||
return firewallrender.New(pool).Render(ctx)
|
||||
}
|
||||
handlers.NewFirewallHandler(fwZones, fwAddrObj, fwAddrGrp, fwSvc, fwSvcGrp, fwRules, fwNAT, auditRepo, nodeID, fwReloader, pool).Register(authed)
|
||||
handlers.NewCrowdSecHandler(auditRepo, nodeID).Register(authed)
|
||||
handlers.NewWafHandler(wafsvc.New(pool), auditRepo, nodeID, haproxyReloader).Register(authed)
|
||||
|
||||
// withFW wraps a service-reloader so that AFTER the service is
|
||||
// reloaded, the firewall is also re-rendered. Necessary for
|
||||
// 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 {
|
||||
@@ -377,7 +442,14 @@ func main() {
|
||||
wgReloader := func(ctx context.Context) error {
|
||||
return wgrender.New(pool, secretsBox).Render(ctx)
|
||||
}
|
||||
handlers.NewWireguardHandler(wgIfaces, wgPeers, secretsBox, auditRepo, nodeID, withFW(wgReloader)).Register(authed)
|
||||
// Öffentlicher WG-Endpoint-Host für Peer-Configs = FQDN dieser Node
|
||||
// (aus setup.json). Verhindert den REPLACE_WITH_PUBLIC_HOST-Platzhalter,
|
||||
// an dem Clients sonst keinen Tunnel aufbauen können.
|
||||
wgPublicHost := ""
|
||||
if sst, serr := setupStore.Load(); serr == nil && sst != nil {
|
||||
wgPublicHost = sst.FQDN
|
||||
}
|
||||
handlers.NewWireguardHandler(wgIfaces, wgPeers, secretsBox, auditRepo, nodeID, withFW(wgReloader)).WithPublicHost(wgPublicHost).Register(authed)
|
||||
|
||||
// Squid forward-proxy reload — re-render squid.conf + reload
|
||||
// squid.service. sudoers im postinst whitelistet das. ACL-Count
|
||||
@@ -388,19 +460,43 @@ 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)
|
||||
}
|
||||
handlers.NewNTPHandler(ntpRepo, auditRepo, nodeID, withFW(chronyReloader)).Register(authed)
|
||||
|
||||
// DHCP (Kea) — re-render kea-dhcp4.conf + manage service lifecycle.
|
||||
keaReloader := func(ctx context.Context) error {
|
||||
return kearender.New(pool).Render(ctx)
|
||||
}
|
||||
handlers.NewDHCPHandler(dhcpsvc.New(pool), auditRepo, nodeID, withFW(keaReloader)).Register(authed)
|
||||
|
||||
// RADIUS (FreeRADIUS) — re-render clients.conf + authorize + service lifecycle.
|
||||
radiusReloader := func(ctx context.Context) error {
|
||||
return radiusrender.New(pool, secretsBox).Render(ctx)
|
||||
}
|
||||
handlers.NewRADIUSHandler(radiussvc.New(pool, secretsBox), auditRepo, nodeID, withFW(radiusReloader)).Register(authed)
|
||||
|
||||
// Wire all service reloaders into systemHdl so RenderConfigs
|
||||
// re-renders every service from DB state in one shot.
|
||||
systemHdl.WithAllReloaders(map[string]func(context.Context) error{
|
||||
"nftables": fwReloader,
|
||||
"wireguard": wgReloader,
|
||||
"squid": squidReloader,
|
||||
"unbound": unboundReloader,
|
||||
"chrony": chronyReloader,
|
||||
"kea": keaReloader,
|
||||
"freeradius": radiusReloader,
|
||||
})
|
||||
|
||||
// License — node-local key store + DB-mirror of last verify
|
||||
// result. Real verify runs against license.netcell-it.com via
|
||||
// internal/license; the scheduler triggers daily re-verify.
|
||||
@@ -417,7 +513,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)
|
||||
@@ -436,10 +532,24 @@ func main() {
|
||||
// Listener wird nur gestartet wenn Cert-Material vorhanden ist;
|
||||
// auf einer frisch installierten Box hat die Init-Phase oben das
|
||||
// schon erledigt.
|
||||
startAgentListener(version, agentHdl)
|
||||
startAgentListener(version, agentHdl, systemHdl)
|
||||
|
||||
// Nach einem Upgrade-Neustart: wenn die State-Datei "updating-primary"
|
||||
// enthält, sind wir gerade neu gestartet → Update abgeschlossen → "done".
|
||||
handlers.FinishRollingUpdateIfPending()
|
||||
|
||||
log.Printf("edgeguard-api %s listening on %s", version, addr)
|
||||
srv := &http.Server{Addr: addr, Handler: r}
|
||||
// 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)
|
||||
}
|
||||
@@ -450,7 +560,7 @@ func main() {
|
||||
// RegisterAgent — health + resources). Fehler im Cert-Load = no-op
|
||||
// + log; Fehler beim Listen.Serve loggen wir aber lassen die API
|
||||
// weiterlaufen.
|
||||
func startAgentListener(version string, clusterHdl *handlers.ClusterHandler) {
|
||||
func startAgentListener(version string, clusterHdl *handlers.ClusterHandler, sysHdl *handlers.SystemHandler) {
|
||||
store := clustertls.New("")
|
||||
serverTLS, err := store.ServerTLSConfig()
|
||||
if err != nil {
|
||||
@@ -470,7 +580,13 @@ func startAgentListener(version string, clusterHdl *handlers.ClusterHandler) {
|
||||
// hier implizit aus dem Binary (Peer-Roundtrip ist immer same-major).
|
||||
// Aggregator-Aufrufer sehen /agent/... direkt.
|
||||
root := r.Group("")
|
||||
handlers.NewSystemHandler(version).RegisterAgent(root)
|
||||
// Nutze den gewiredeten systemHdl (mit Users + Setup) damit
|
||||
// AgentAuthCheck Credentials gegen die echte DB prüfen kann.
|
||||
if sysHdl != nil {
|
||||
sysHdl.RegisterAgent(root)
|
||||
} else {
|
||||
handlers.NewSystemHandler(version).RegisterAgent(root)
|
||||
}
|
||||
if clusterHdl != nil {
|
||||
// Phase 3.5: /agent/cluster/peers (Auto-Register).
|
||||
clusterHdl.RegisterAgent(root)
|
||||
@@ -535,10 +651,18 @@ func mountUI(r *gin.Engine) {
|
||||
return
|
||||
}
|
||||
if info, err := os.Stat(full); err == nil && !info.IsDir() {
|
||||
// Vite hashed assets are immutable — cache them forever.
|
||||
// index.html must never be cached so updates take effect.
|
||||
if strings.HasPrefix(clean, "/assets/") {
|
||||
c.Header("Cache-Control", "public, max-age=31536000, immutable")
|
||||
} else {
|
||||
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
}
|
||||
c.File(full)
|
||||
return
|
||||
}
|
||||
// SPA fallback — React Router renders the right page.
|
||||
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
c.File(indexPath)
|
||||
})
|
||||
}
|
||||
@@ -574,30 +698,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
|
||||
@@ -655,6 +755,169 @@ func runClusterHeartbeat(ctx context.Context, pool *pgxpoolPool, localID, versio
|
||||
}
|
||||
}
|
||||
|
||||
// runSecondaryConfigRender läuft auf Secondary-Nodes und re-rendert alle
|
||||
// Service-Configs wenn die Logical Replication Änderungen vom Primary
|
||||
// geliefert hat. Erkennt das an einem geänderten config_hash.
|
||||
// Tick: 5 min — balanciert Reaktionszeit gegen Reload-Overhead.
|
||||
//
|
||||
// Cert-Sync läuft auf jedem Tick unabhängig vom config_hash, da certbot-
|
||||
// Renewals auf dem Primary den Hash nicht ändern.
|
||||
func runSecondaryConfigRender(ctx context.Context, pool *pgxpoolPool, box *secrets.Box, agg *aggregator.Aggregator, localID string) {
|
||||
const tick = 5 * time.Minute
|
||||
t := time.NewTicker(tick)
|
||||
defer t.Stop()
|
||||
var lastHash string
|
||||
render := func() {
|
||||
rCtx, cancel := context.WithTimeout(ctx, 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// TLS-Zertifikate bei jedem Tick synchronisieren — unabhängig vom
|
||||
// config_hash, da certbot-Renewals den Hash nicht berühren.
|
||||
if err := handlers.SyncTLSCertsFromPrimary(rCtx, pool, agg, localID); err != nil {
|
||||
slog.Warn("cluster: cert sync failed", "error", err)
|
||||
}
|
||||
|
||||
hash, err := cluster.ComputeConfigHash(rCtx, pool)
|
||||
if err != nil || hash == lastHash {
|
||||
return
|
||||
}
|
||||
lastHash = hash
|
||||
slog.Info("cluster: secondary config changed via replication, re-rendering", "hash", hash)
|
||||
// HAProxy
|
||||
if err := haproxy.New(pool).Render(rCtx); err != nil {
|
||||
slog.Warn("cluster: secondary haproxy render failed", "error", err)
|
||||
}
|
||||
// nftables
|
||||
if err := firewallrender.New(pool).Render(rCtx); err != nil {
|
||||
slog.Warn("cluster: secondary nftables render failed", "error", err)
|
||||
}
|
||||
// WireGuard — Interface-Configs + wg-quick@<iface> reload
|
||||
if err := wgrender.New(pool, box).Render(rCtx); err != nil {
|
||||
slog.Warn("cluster: secondary wireguard render failed", "error", err)
|
||||
}
|
||||
// Squid forward proxy
|
||||
if err := squidrender.New(pool).Render(rCtx); err != nil {
|
||||
slog.Warn("cluster: secondary squid render failed", "error", err)
|
||||
}
|
||||
// Unbound DNS
|
||||
if err := unboundrender.New(pool).Render(rCtx); err != nil {
|
||||
slog.Warn("cluster: secondary unbound render failed", "error", err)
|
||||
}
|
||||
// Chrony NTP
|
||||
if err := chronyrender.New(pool).Render(rCtx); err != nil {
|
||||
slog.Warn("cluster: secondary chrony render failed", "error", err)
|
||||
}
|
||||
// Netzwerk-Interfaces (VLAN/Bridge/Bond) — erstellt Interface-Objekte,
|
||||
// weist aber KEINE IPs zu (das ist node-spezifisch und darf nicht aus
|
||||
// der Replikation kommen — sonst IP-Konflikt mit dem Primary).
|
||||
if err := networkifs.NewGenerator(networkifs.New(pool)).Render(rCtx); err != nil {
|
||||
slog.Warn("cluster: secondary interfaces render failed", "error", err)
|
||||
}
|
||||
// IP-Adressen werden auf dem Secondary NICHT aus der Replikation
|
||||
// angewendet. Jeder Node konfiguriert seine eigenen IPs statisch
|
||||
// (z.B. /etc/network/interfaces). Floating-Service-IPs werden von
|
||||
// Keepalived verwaltet — nicht vom Renderer.
|
||||
}
|
||||
// Initialer Check nach kurzem Delay (Replication braucht einen Moment)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(30 * time.Second):
|
||||
render()
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
render()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runPrimaryPush periodically pushes this secondary node's config_hash to the
|
||||
// primary via mTLS. The primary's ha_nodes view only gets config_hash + last_seen
|
||||
// written during join-time autoRegister — after that the primary never hears about
|
||||
// the secondary unless we push. Without this, the drift banner shows stale hashes
|
||||
// from join-time forever AND the secondary's last_seen freezes → SweepStaleNodes
|
||||
// marks it offline.
|
||||
//
|
||||
// WICHTIG: tick MUSS deutlich unter dem Stale-Threshold (4× 30s = 2 min, siehe
|
||||
// scheduler.staleThreshold / cluster.SweepStaleNodes) liegen. Sonst flippt der
|
||||
// Secondary zwischen den Pushes zwangsläufig auf "offline" (bei 5-min-Tick:
|
||||
// 2 min online, 3 min offline). 30s = 4 Pushes pro Stale-Fenster → ein
|
||||
// verpasster Push (Netz-Glitch) ist unkritisch. Der Receiver (AgentRegisterPeer)
|
||||
// lädt nftables nur bei IP-Änderung neu → kein Reload-Sturm durch häufige Pushes.
|
||||
func runPrimaryPush(ctx context.Context, pool *pgxpoolPool, nodeID, fqdn, version, primaryURL string) {
|
||||
const tick = 30 * time.Second
|
||||
t := time.NewTicker(tick)
|
||||
defer t.Stop()
|
||||
push := func() {
|
||||
pCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
hash, _ := cluster.ComputeConfigHash(pCtx, pool)
|
||||
if err := clusterjoin.PushSelfToPrimary(primaryURL, "", nodeID, fqdn, version, hash); err != nil { //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)
|
||||
}
|
||||
}
|
||||
push() // immediate push on API startup
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
push()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runPeerPush läuft auf dem Primary/Founder und pusht alle 30s die eigene
|
||||
// Identität (role=primary) an jeden Peer via mTLS — das Gegenstück zu
|
||||
// runPrimaryPush (Secondary→Primary). Zusammen ergibt das einen
|
||||
// bidirektionalen Cross-Node-Heartbeat: beide Nodes sehen sich gegenseitig
|
||||
// als online, egal von welchem Node die UI ausgeliefert wird. Tick wie
|
||||
// runPrimaryPush deutlich unter dem 2-min-Stale-Threshold. No-op solange
|
||||
// keine Peers existieren (Single-Node) bzw. wenn ein Peer down ist (Debug-Log).
|
||||
func runPeerPush(ctx context.Context, pool *pgxpoolPool, store *cluster.Store, nodeID, fqdn, version string) {
|
||||
const tick = 30 * time.Second
|
||||
t := time.NewTicker(tick)
|
||||
defer t.Stop()
|
||||
push := func() {
|
||||
pCtx, cancel := context.WithTimeout(ctx, 25*time.Second)
|
||||
defer cancel()
|
||||
peers, err := store.List(pCtx)
|
||||
if err != nil {
|
||||
slog.Warn("cluster: peer-push list failed", "error", err)
|
||||
return
|
||||
}
|
||||
hash, _ := cluster.ComputeConfigHash(pCtx, pool)
|
||||
for i := range peers {
|
||||
p := peers[i]
|
||||
if p.ID == nodeID {
|
||||
continue // nicht an sich selbst pushen
|
||||
}
|
||||
target := p.APIURL
|
||||
if target == "" {
|
||||
target = "https://" + p.FQDN
|
||||
}
|
||||
if err := clusterjoin.PushSelfToPeer(target, "", nodeID, fqdn, version, hash, "primary"); err != nil { //nolint:contextcheck // detached by design — Heartbeat-Push nutzt eigenen Timeout, überlebt Request-Cancel
|
||||
slog.Debug("cluster: push-to-peer failed", "peer", p.FQDN, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
push() // immediate push on API startup
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
push()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func randomEphemeralSecret() []byte {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
|
||||
@@ -1,43 +1,14 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/clustertls"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/clusterjoin"
|
||||
)
|
||||
|
||||
// cmdClusterJoin: provisioniert auf diesem Node das Cluster-Cert-
|
||||
// Material durch einen Aufruf an /api/v1/cluster/issue-cert beim
|
||||
// Primary.
|
||||
//
|
||||
// Usage:
|
||||
// edgeguard-ctl cluster-join <primary-fqdn-or-url> --token <token>
|
||||
// [--insecure]
|
||||
// [--cn <fqdn>]
|
||||
//
|
||||
// --insecure: TLS-Verify überspringen (für Bootstrap wenn der
|
||||
// Primary mit self-signed Cert läuft und die CA noch
|
||||
// nicht woanders verteilt ist — der Cert-Issue-Flow
|
||||
// selbst läuft über HMAC-Token, nicht über TLS-Trust).
|
||||
// --cn: Subject-CN für unseren CSR. Default: os.Hostname().
|
||||
//
|
||||
// Output: schreibt ca.crt + peer.{crt,key} nach /var/lib/edgeguard/
|
||||
// cluster-tls/. Falls Cert-Material schon vorhanden, abort mit
|
||||
// hint auf manuellen rm — wir wollen nicht aus Versehen einen
|
||||
// laufenden Cluster-Node von seiner identity bringen.
|
||||
func cmdClusterJoin(args []string) int {
|
||||
fs := flag.NewFlagSet("cluster-join", flag.ContinueOnError)
|
||||
tokenFlag := fs.String("token", "", "cluster join token (eg-join-v1.…)")
|
||||
@@ -52,245 +23,41 @@ func cmdClusterJoin(args []string) int {
|
||||
fmt.Fprintln(os.Stderr, "usage: edgeguard-ctl cluster-join <primary-fqdn-or-url> --token <…>")
|
||||
return 2
|
||||
}
|
||||
primary := fs.Arg(0)
|
||||
if *tokenFlag == "" {
|
||||
fmt.Fprintln(os.Stderr, "edgeguard-ctl cluster-join: --token required")
|
||||
return 2
|
||||
}
|
||||
store := clustertls.New(*clusterTLSDir)
|
||||
if store.HasPeer() {
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"edgeguard-ctl cluster-join: peer cert already present under %s — "+
|
||||
"refuse to overwrite. Run 'rm -rf %s' first if this is intentional.\n",
|
||||
*clusterTLSDir, *clusterTLSDir)
|
||||
return 1
|
||||
}
|
||||
|
||||
commonName := *cn
|
||||
if commonName == "" {
|
||||
h, _ := os.Hostname()
|
||||
commonName = h
|
||||
}
|
||||
if commonName == "" {
|
||||
commonName = "edgeguard-node"
|
||||
}
|
||||
|
||||
endpoint, err := normalizePrimaryURL(primary)
|
||||
if err != nil {
|
||||
if err := clusterjoin.Join(clusterjoin.Request{
|
||||
PrimaryFQDN: fs.Arg(0),
|
||||
Token: *tokenFlag,
|
||||
CommonName: commonName,
|
||||
Insecure: *insecure,
|
||||
TLSDir: *clusterTLSDir,
|
||||
Version: version,
|
||||
}); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "edgeguard-ctl cluster-join: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
// SAN: gleicher CN + Hostname. IPs hängen wir an wenn das Host-
|
||||
// Argument eine IP war, damit der lokale Agent-Listener auch
|
||||
// gegen IP gechecked werden kann.
|
||||
dnsNames := []string{commonName}
|
||||
var ips []net.IP
|
||||
if ip := net.ParseIP(commonName); ip != nil {
|
||||
ips = append(ips, ip)
|
||||
// Wenn CN eine IP ist, lassen wir DNSNames leer — RFC 6125
|
||||
// erlaubt nicht beides als-ob-DNS.
|
||||
dnsNames = nil
|
||||
}
|
||||
|
||||
keyPEM, csrPEM, err := clustertls.NewPeerKeyAndCSR(commonName, dnsNames, ips)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "edgeguard-ctl cluster-join: gen CSR: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
caCertPEM, peerCertPEM, err := postIssueCert(endpoint, *tokenFlag, csrPEM, *insecure)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "edgeguard-ctl cluster-join: issue-cert: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(*clusterTLSDir, 0o700); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "edgeguard-ctl cluster-join: mkdir %s: %v\n", *clusterTLSDir, err)
|
||||
return 1
|
||||
}
|
||||
// Schreiben in stabiler Reihenfolge: erst CA (wird vom Peer-Cert-
|
||||
// Verify gebraucht), dann peer.{crt,key}.
|
||||
for _, w := range []struct {
|
||||
name string
|
||||
mode os.FileMode
|
||||
data string
|
||||
}{
|
||||
{"ca.crt", 0o644, caCertPEM},
|
||||
{"peer.crt", 0o644, peerCertPEM},
|
||||
{"peer.key", 0o600, keyPEM},
|
||||
} {
|
||||
path := *clusterTLSDir + "/" + w.name
|
||||
if err := os.WriteFile(path, []byte(w.data), w.mode); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "edgeguard-ctl cluster-join: write %s: %v\n", path, err)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3.5: Auto-Register beim Primary. Nutzt das frisch erhaltene
|
||||
// Peer-Cert via mTLS, damit der Primary uns in ha_nodes mit
|
||||
// status='joining' anlegt + sein peer_ipv4-Set updated.
|
||||
if err := autoRegister(endpoint, *clusterTLSDir, commonName); err != nil {
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"edgeguard-ctl cluster-join: auto-register failed (Cert-Material liegt aber schon — kannst manuell nachholen): %v\n", err)
|
||||
// Wir geben hier NICHT-NULL zurück — der Cert-Issue war ja
|
||||
// erfolgreich. Der Operator kann manuell registrieren oder
|
||||
// es funktioniert beim Service-Start (Phase 3.2 Heartbeat).
|
||||
}
|
||||
|
||||
primary, _ := clusterjoin.NormalizePrimaryURL(fs.Arg(0))
|
||||
fmt.Printf("Cluster-Join erfolgreich.\n")
|
||||
fmt.Printf(" Primary: %s\n", endpoint)
|
||||
fmt.Printf(" Primary: %s\n", primary)
|
||||
fmt.Printf(" CN: %s\n", commonName)
|
||||
fmt.Printf(" Files: %s/{ca.crt,peer.crt,peer.key}\n", *clusterTLSDir)
|
||||
fmt.Printf("\nNächste Schritte:\n")
|
||||
fmt.Printf(" 1) sudo systemctl restart edgeguard-api # lädt das neue Cert ins mTLS-Agent-Listener\n")
|
||||
fmt.Printf(" 2) Auf dem Primary in der Cluster-UI prüfen ob der neue Peer in /cluster/nodes auftaucht\n")
|
||||
fmt.Printf(" 3) PG-Basebackup + KeyDB-Replica-Setup folgt mit Phase 3.5 (manuell bis dahin)\n")
|
||||
fmt.Printf(" 1) sudo edgeguard-ctl cluster-setup-standby %s\n", primary)
|
||||
fmt.Printf(" → richtet die Logical Replication ein. OHNE diesen Schritt ist der\n")
|
||||
fmt.Printf(" Node zwar im Cluster, bekommt aber KEINE geteilte Config.\n")
|
||||
fmt.Printf(" 2) sudo systemctl restart edgeguard-api # lädt das neue Cert in den mTLS-Agent-Listener\n")
|
||||
fmt.Printf(" 3) Auf dem Primary in der Cluster-UI prüfen ob der neue Peer auftaucht\n")
|
||||
fmt.Printf("\nHinweis: Beim Join über den Setup-Wizard passiert Schritt 1 automatisch;\n")
|
||||
fmt.Printf("dieser CLI-Pfad ist der manuelle Weg und braucht ihn explizit.\n")
|
||||
return 0
|
||||
}
|
||||
|
||||
// autoRegister: POST mTLS an <primary-host>:8443/agent/cluster/peers.
|
||||
// Note: der mTLS-Agent-Port :8443 ist anders als der Public-Port
|
||||
// (3443). Wir leiten den Host aus der primary-URL ab und ersetzen
|
||||
// den Port.
|
||||
func autoRegister(primary, tlsDir, commonName string) error {
|
||||
// Primary-URL parse + Port-Override
|
||||
u, err := url.Parse(primary)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.Host = u.Hostname() + ":8443"
|
||||
u.Path = "/agent/cluster/peers"
|
||||
|
||||
// Local node-id + body bauen. node-id liegt in /var/lib/edgeguard/
|
||||
// node-id (vom Heartbeat-Subsystem persistiert); wir lesen direkt
|
||||
// statt cluster.EnsureNodeID() um den DB-Abhängigkeit-Pfad nicht
|
||||
// zu öffnen.
|
||||
nodeID, _ := os.ReadFile("/var/lib/edgeguard/node-id")
|
||||
hostname, _ := os.Hostname()
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"id": strings.TrimSpace(string(nodeID)),
|
||||
"name": hostname,
|
||||
"fqdn": commonName,
|
||||
"api_url": "https://" + commonName + ":3443",
|
||||
"version": version,
|
||||
})
|
||||
|
||||
// mTLS-Client mit gerade frisch geschriebenem Material.
|
||||
pair, err := tls.LoadX509KeyPair(tlsDir+"/peer.crt", tlsDir+"/peer.key")
|
||||
if err != nil {
|
||||
return fmt.Errorf("load peer cert: %w", err)
|
||||
}
|
||||
caPEM, err := os.ReadFile(tlsDir + "/ca.crt")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read ca: %w", err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(caPEM) {
|
||||
return errors.New("invalid ca.crt")
|
||||
}
|
||||
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
Certificates: []tls.Certificate{pair},
|
||||
RootCAs: pool,
|
||||
MinVersion: tls.VersionTLS13,
|
||||
// Hostname-Verify: wir checken gegen den CN/SAN des
|
||||
// Primary-Cert. Wenn der Primary-Cert das nicht hat
|
||||
// (Self-Signed for IP only), kann der join trotzdem
|
||||
// erfolgreich sein wenn das CA-Cert validiert.
|
||||
ServerName: u.Hostname(),
|
||||
},
|
||||
TLSHandshakeTimeout: 5 * time.Second,
|
||||
ResponseHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
client := &http.Client{Transport: tr, Timeout: 30 * time.Second}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, u.String(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizePrimaryURL: nimmt "fqdn", "host:port" oder "https://host:port"
|
||||
// und liefert immer "https://host:port" zurück. Default-Port 3443 (das
|
||||
// ist der Mgmt-UI-Listener; /cluster/issue-cert läuft dort).
|
||||
func normalizePrimaryURL(in string) (string, error) {
|
||||
in = strings.TrimSpace(in)
|
||||
if in == "" {
|
||||
return "", errors.New("empty primary fqdn/url")
|
||||
}
|
||||
if !strings.HasPrefix(in, "http://") && !strings.HasPrefix(in, "https://") {
|
||||
in = "https://" + in
|
||||
}
|
||||
u, err := url.Parse(in)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if u.Hostname() == "" {
|
||||
return "", errors.New("primary URL has no host")
|
||||
}
|
||||
if u.Port() == "" {
|
||||
u.Host = u.Hostname() + ":3443"
|
||||
}
|
||||
u.Path = ""
|
||||
u.RawQuery = ""
|
||||
u.Fragment = ""
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
// postIssueCert: POSTet {token, csr} an <primary>/api/v1/cluster/issue-cert.
|
||||
// `insecure` skippt TLS-Verify damit der Bootstrap auch wenn der Primary
|
||||
// mit self-signed Cert hört durchgeht — die Sicherheit hängt am HMAC-
|
||||
// gesigneten Token, nicht am TLS-Layer.
|
||||
func postIssueCert(primary, token, csr string, insecure bool) (caCert, peerCert string, err error) {
|
||||
body, _ := json.Marshal(map[string]string{"token": token, "csr": csr})
|
||||
req, err := http.NewRequest(http.MethodPost,
|
||||
primary+"/api/v1/cluster/issue-cert", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure, MinVersion: tls.VersionTLS12},
|
||||
TLSHandshakeTimeout: 5 * time.Second,
|
||||
ResponseHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
client := &http.Client{Transport: tr, Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
|
||||
}
|
||||
var env struct {
|
||||
Data struct {
|
||||
CACert string `json:"ca_cert"`
|
||||
PeerCert string `json:"peer_cert"`
|
||||
} `json:"data"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
return "", "", fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
if env.Error != "" {
|
||||
return "", "", fmt.Errorf("server: %s", env.Error)
|
||||
}
|
||||
if env.Data.CACert == "" || env.Data.PeerCert == "" {
|
||||
return "", "", errors.New("response missing ca_cert or peer_cert")
|
||||
}
|
||||
return env.Data.CACert, env.Data.PeerCert, nil
|
||||
}
|
||||
|
||||
230
cmd/edgeguard-ctl/cluster_reconcile.go
Normal file
230
cmd/edgeguard-ctl/cluster_reconcile.go
Normal file
@@ -0,0 +1,230 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// cmdClusterReconcileReplication bringt Publication, Grants und Subscription
|
||||
// idempotent in den Soll-Zustand. Verhindert die zwei Fehlermodi, die sich
|
||||
// beim Nachrüsten von Features zeigen:
|
||||
// - Eine `FOR TABLE`-Publication nimmt später per Migration hinzugekommene
|
||||
// Shared-Tables NICHT automatisch auf → sie replizieren nie (Standby
|
||||
// läuft nach Failover ohne WAF/OIDC/DHCP/RADIUS-Config).
|
||||
// - Der GRANT SELECT für den Replikations-User ist ein Snapshot bei Setup;
|
||||
// neue Tabellen fehlen → tablesync hängt in `d` (Permission).
|
||||
//
|
||||
// Rollen-Selbsterkennung (idempotent, läuft im postinst nach migrate):
|
||||
//
|
||||
// Publisher (hat Publication):
|
||||
// - GRANT SELECT auf ALLE Tabellen (+ DEFAULT PRIVILEGES) für den
|
||||
// Replikations-User.
|
||||
// - Publication-Mitgliedschaft angleichen: fehlende Shared-Tables ADD,
|
||||
// fälschlich enthaltene node-lokale (localOnlyTables) DROP.
|
||||
// Subscriber (hat Subscription):
|
||||
// - Frisch zu synchronisierende Shared-Tables lokal TRUNCATE (Primary =
|
||||
// Source of Truth; verhindert Duplicate-Key beim Initial-COPY einer
|
||||
// per Migration seed-befüllten Singleton-Tabelle), dann REFRESH.
|
||||
// Single-Node (weder noch): nichts zu tun.
|
||||
//
|
||||
// Best-effort: Fehler werden geloggt, brechen aber ein Paket-Upgrade nie ab.
|
||||
func cmdClusterReconcileReplication(_ []string) int {
|
||||
hasPub := psqlDBBool("edgeguard",
|
||||
fmt.Sprintf("SELECT EXISTS(SELECT 1 FROM pg_publication WHERE pubname='%s')", egPubName))
|
||||
hasSub := psqlDBBool("edgeguard",
|
||||
fmt.Sprintf("SELECT EXISTS(SELECT 1 FROM pg_subscription WHERE subname='%s')", egSubName))
|
||||
|
||||
switch {
|
||||
case hasPub:
|
||||
reconcilePublisher()
|
||||
case hasSub:
|
||||
reconcileSubscriber()
|
||||
default:
|
||||
// Standalone-Node — keine Replikation eingerichtet.
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// reconcilePublisher gleicht Grants + Publication-Mitgliedschaft an.
|
||||
func reconcilePublisher() {
|
||||
// 1. Grants IMMER neu setzen (idempotent, deckt neue Tabellen ab).
|
||||
grantSQL := fmt.Sprintf(
|
||||
"GRANT SELECT ON ALL TABLES IN SCHEMA public TO %s;\n"+
|
||||
"ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO %s;",
|
||||
egReplUser, egReplUser)
|
||||
if err := psqlDBExec("edgeguard", grantSQL); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "reconcile: GRANT SELECT fehlgeschlagen:", err)
|
||||
} else {
|
||||
fmt.Printf("✓ reconcile: SELECT-Grants für %q aktualisiert\n", egReplUser)
|
||||
}
|
||||
|
||||
// 2. Publication-Mitgliedschaft angleichen.
|
||||
desired, err := desiredSharedTables()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "reconcile: Tabellen-Liste:", err)
|
||||
return
|
||||
}
|
||||
current, err := publicationTables()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "reconcile: Publication-Liste:", err)
|
||||
return
|
||||
}
|
||||
desiredSet := toSet(desired)
|
||||
currentSet := toSet(current)
|
||||
|
||||
var toAdd, toDrop []string
|
||||
for _, t := range desired {
|
||||
if !currentSet[t] {
|
||||
toAdd = append(toAdd, t)
|
||||
}
|
||||
}
|
||||
for _, t := range current {
|
||||
if !desiredSet[t] {
|
||||
toDrop = append(toDrop, t) // node-lokale, die fälschlich drin sind
|
||||
}
|
||||
}
|
||||
sort.Strings(toAdd)
|
||||
sort.Strings(toDrop)
|
||||
|
||||
if len(toAdd) > 0 {
|
||||
if err := psqlDBExec("edgeguard", fmt.Sprintf(
|
||||
"ALTER PUBLICATION %s ADD TABLE %s;", egPubName, strings.Join(toAdd, ", "))); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "reconcile: ADD TABLE fehlgeschlagen:", err)
|
||||
} else {
|
||||
fmt.Printf("✓ reconcile: %d Tabelle(n) zur Publication hinzugefügt: %s\n",
|
||||
len(toAdd), strings.Join(toAdd, ", "))
|
||||
}
|
||||
}
|
||||
if len(toDrop) > 0 {
|
||||
if err := psqlDBExec("edgeguard", fmt.Sprintf(
|
||||
"ALTER PUBLICATION %s DROP TABLE %s;", egPubName, strings.Join(toDrop, ", "))); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "reconcile: DROP TABLE fehlgeschlagen:", err)
|
||||
} else {
|
||||
fmt.Printf("✓ reconcile: %d node-lokale Tabelle(n) aus Publication entfernt: %s\n",
|
||||
len(toDrop), strings.Join(toDrop, ", "))
|
||||
}
|
||||
}
|
||||
if len(toAdd) == 0 && len(toDrop) == 0 {
|
||||
fmt.Println("✓ reconcile: Publication bereits im Soll-Zustand")
|
||||
}
|
||||
}
|
||||
|
||||
// reconcileSubscriber zieht neu publizierte Tabellen nach: erst lokal leeren
|
||||
// (Primary = Source of Truth, verhindert Duplicate-Key beim Initial-COPY),
|
||||
// dann REFRESH PUBLICATION. Bereits synchronisierte Tabellen bleiben unberührt.
|
||||
func reconcileSubscriber() {
|
||||
desired, err := desiredSharedTables()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "reconcile: Tabellen-Liste:", err)
|
||||
return
|
||||
}
|
||||
synced, err := subscriptionRelTables()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "reconcile: subscription_rel-Liste:", err)
|
||||
return
|
||||
}
|
||||
syncedSet := toSet(synced)
|
||||
|
||||
var fresh []string
|
||||
for _, t := range desired {
|
||||
if !syncedSet[t] {
|
||||
fresh = append(fresh, t)
|
||||
}
|
||||
}
|
||||
sort.Strings(fresh)
|
||||
|
||||
if len(fresh) > 0 {
|
||||
// Nur frisch zu synchronisierende Shared-Tables leeren — nie eine
|
||||
// bereits replizierte oder node-lokale Tabelle.
|
||||
if err := psqlDBExec("edgeguard",
|
||||
fmt.Sprintf("TRUNCATE %s;", strings.Join(fresh, ", "))); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "reconcile: TRUNCATE (neue Tabellen) fehlgeschlagen:", err)
|
||||
} else {
|
||||
fmt.Printf("✓ reconcile: %d neue Tabelle(n) für Initial-Sync geleert: %s\n",
|
||||
len(fresh), strings.Join(fresh, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
// REFRESH ist NICHT transaktionssicher → einzelnes Statement, autocommit.
|
||||
if err := psqlDBExec("edgeguard",
|
||||
fmt.Sprintf("ALTER SUBSCRIPTION %s REFRESH PUBLICATION;", egSubName)); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "reconcile: REFRESH PUBLICATION fehlgeschlagen:", err)
|
||||
} else {
|
||||
fmt.Printf("✓ reconcile: Subscription %q refresht\n", egSubName)
|
||||
}
|
||||
}
|
||||
|
||||
// desiredSharedTables = alle public-Tabellen minus localOnlyTables.
|
||||
func desiredSharedTables() ([]string, error) {
|
||||
out, err := psqlDBRun("edgeguard", []string{"-tA", "-c",
|
||||
`SELECT tablename FROM pg_tables WHERE schemaname='public'`})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list tables: %w", err)
|
||||
}
|
||||
return filterSharedTables(splitLines(string(out))), nil
|
||||
}
|
||||
|
||||
// filterSharedTables entfernt localOnlyTables aus der Tabellenliste. Pure
|
||||
// Funktion — unit-testbar.
|
||||
func filterSharedTables(all []string) []string {
|
||||
excluded := toSet(localOnlyTables)
|
||||
var out []string
|
||||
for _, t := range all {
|
||||
if t != "" && !excluded[t] {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// publicationTables listet die aktuell in edgeguard_shared publizierten Tabellen.
|
||||
func publicationTables() ([]string, error) {
|
||||
out, err := psqlDBRun("edgeguard", []string{"-tA", "-c",
|
||||
fmt.Sprintf("SELECT tablename FROM pg_publication_tables WHERE pubname='%s'", egPubName)})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return splitLines(string(out)), nil
|
||||
}
|
||||
|
||||
// subscriptionRelTables listet die Tabellen, die die Subscription bereits kennt.
|
||||
func subscriptionRelTables() ([]string, error) {
|
||||
out, err := psqlDBRun("edgeguard", []string{"-tA", "-c",
|
||||
fmt.Sprintf(`SELECT c.relname FROM pg_subscription_rel r
|
||||
JOIN pg_class c ON c.oid = r.srrelid
|
||||
JOIN pg_subscription s ON s.oid = r.srsubid
|
||||
WHERE s.subname = '%s'`, egSubName)})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return splitLines(string(out)), nil
|
||||
}
|
||||
|
||||
func psqlDBBool(db, sql string) bool {
|
||||
out, err := psqlDBRun(db, []string{"-tA", "-c", sql})
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(string(out)) == "t"
|
||||
}
|
||||
|
||||
func splitLines(s string) []string {
|
||||
var out []string
|
||||
for _, l := range strings.Split(strings.TrimSpace(s), "\n") {
|
||||
if l = strings.TrimSpace(l); l != "" {
|
||||
out = append(out, l)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func toSet(items []string) map[string]bool {
|
||||
m := make(map[string]bool, len(items))
|
||||
for _, it := range items {
|
||||
m[it] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
27
cmd/edgeguard-ctl/cluster_reconcile_test.go
Normal file
27
cmd/edgeguard-ctl/cluster_reconcile_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFilterSharedTables(t *testing.T) {
|
||||
all := []string{
|
||||
"backends", "domains", "waf_configs", "oidc_settings",
|
||||
"ip_addresses", "network_interfaces", "alert_events", "waf_alerts",
|
||||
"ha_nodes", "goose_db_version", "radius_users", "",
|
||||
}
|
||||
got := filterSharedTables(all)
|
||||
want := []string{"backends", "domains", "oidc_settings", "radius_users", "waf_configs"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("filterSharedTables()\n got=%v\nwant=%v", got, want)
|
||||
}
|
||||
// node-lokale müssen raus sein (inkl. der frisch node-lokal gemachten).
|
||||
for _, local := range []string{"ip_addresses", "network_interfaces", "alert_events", "waf_alerts", "ha_nodes", "goose_db_version"} {
|
||||
for _, g := range got {
|
||||
if g == local {
|
||||
t.Errorf("localOnly-Tabelle %q darf NICHT in shared-Liste sein", local)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
624
cmd/edgeguard-ctl/cluster_replication.go
Normal file
624
cmd/edgeguard-ctl/cluster_replication.go
Normal file
@@ -0,0 +1,624 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/clustertls"
|
||||
)
|
||||
|
||||
const (
|
||||
egReplSecret = "/var/lib/edgeguard/pg-replication-secret"
|
||||
egReplUser = "edgeguard_replicator"
|
||||
egPubName = "edgeguard_shared"
|
||||
egSubName = "edgeguard_sub"
|
||||
)
|
||||
|
||||
// pgConfig hält die zur Laufzeit erkannten PG-Pfade.
|
||||
type pgConfig struct {
|
||||
Version string // z.B. "17"
|
||||
Cluster string // z.B. "main"
|
||||
DataDir string // /var/lib/postgresql/17/main
|
||||
HBAPath string // /etc/postgresql/17/main/pg_hba.conf
|
||||
ConfD string // /etc/postgresql/17/main/conf.d
|
||||
}
|
||||
|
||||
// detectPGConfig ermittelt Version, Cluster und Pfade aus der laufenden
|
||||
// PG-Instanz via SHOW hba_file / SHOW data_directory. Damit ist der Code
|
||||
// unabhängig von der PG-Hauptversion (16, 17, …).
|
||||
func detectPGConfig() (pgConfig, error) {
|
||||
hbaRaw, err := psqlRun([]string{"-tA", "-c", "SHOW hba_file;"})
|
||||
if err != nil {
|
||||
return pgConfig{}, fmt.Errorf("cannot detect pg hba_file: %w", err)
|
||||
}
|
||||
hbaPath := strings.TrimSpace(string(hbaRaw))
|
||||
|
||||
dataRaw, err := psqlRun([]string{"-tA", "-c", "SHOW data_directory;"})
|
||||
if err != nil {
|
||||
return pgConfig{}, fmt.Errorf("cannot detect pg data_directory: %w", err)
|
||||
}
|
||||
dataDir := strings.TrimSpace(string(dataRaw))
|
||||
|
||||
// hbaPath: /etc/postgresql/<version>/<cluster>/pg_hba.conf
|
||||
parts := strings.Split(filepath.ToSlash(hbaPath), "/")
|
||||
if len(parts) < 6 {
|
||||
return pgConfig{}, fmt.Errorf("unexpected hba_file path: %s", hbaPath)
|
||||
}
|
||||
version := parts[3]
|
||||
cluster := parts[4]
|
||||
confD := filepath.Join("/etc/postgresql", version, cluster, "conf.d")
|
||||
|
||||
return pgConfig{
|
||||
Version: version,
|
||||
Cluster: cluster,
|
||||
DataDir: dataDir,
|
||||
HBAPath: hbaPath,
|
||||
ConfD: confD,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// localOnlyTables listet alle Tabellen die nicht in die Replikations-
|
||||
// Publication aufgenommen werden. Alles andere wird automatisch repliziert.
|
||||
var localOnlyTables = []string{
|
||||
"ha_nodes", // Node-Identität, Status
|
||||
"network_interfaces", // Eigene Interfaces (eth0, eth1 …)
|
||||
"ip_addresses", // Eigene IP-Adressen (unterschiedlich pro Node!)
|
||||
"static_routes", // Node-spezifisches Routing
|
||||
"cluster_settings", // VIP-Interface kann pro Node unterschiedlich sein
|
||||
"dns_settings", // listen_addresses ist node-spezifisch
|
||||
"ntp_settings", // listen_addresses ist node-spezifisch
|
||||
"dhcp_settings", // ob DIESE Node DHCP betreibt (Dual-DHCP vermeiden)
|
||||
"radius_settings", // ob DIESE Node RADIUS betreibt + Listen-Adressen
|
||||
"system_settings", // Hostname, Maintenance-Mode etc.
|
||||
"join_tokens_used", // Token-Tracking nur auf Primary relevant
|
||||
"audit_log", // Lokales Audit-Protokoll
|
||||
"alert_events", // Lokale Laufzeit-Events
|
||||
"waf_alerts", // Lokale WAF-Detection-Events (wie alert_events)
|
||||
"backups", // Backup-Historie ist per-Node
|
||||
"goose_db_version", // Migration-Tracking, internes Tool-State
|
||||
}
|
||||
|
||||
// cmdClusterInitReplication richtet PG auf dieser Node als Logical-Replication-
|
||||
// Primary ein. Idempotent — kann gefahrlos mehrfach laufen.
|
||||
//
|
||||
// Ablauf:
|
||||
// 1. edgeguard_replicator-Rolle anlegen/aktualisieren
|
||||
// 2. Passwort → /var/lib/edgeguard/pg-replication-secret
|
||||
// 3. conf.d/edgeguard-replication.conf mit wal_level=logical schreiben
|
||||
// 4. pg_hba.conf für Replikations-Verbindungen aktualisieren
|
||||
// 5. SELECT-Grants auf alle geteilten Tabellen
|
||||
// 6. PUBLICATION erstellen (alle Tabellen außer localOnlyTables)
|
||||
// 7. PG reload
|
||||
func cmdClusterInitReplication(args []string) int {
|
||||
fs := flag.NewFlagSet("cluster-init-replication", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
|
||||
pg, err := detectPGConfig()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "cluster-init-replication: PG-Erkennung:", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Printf("→ PostgreSQL %s/%s erkannt\n", pg.Version, pg.Cluster)
|
||||
|
||||
if err := setupReplicationPrimary(pg); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "cluster-init-replication:", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("Nächste Schritte:")
|
||||
fmt.Println(" 1) Auf dem Secondary: edgeguard-ctl cluster-setup-standby <primary-ip>")
|
||||
fmt.Println(" 2) Cluster-Settings (VIP) auf BEIDEN Nodes separat konfigurieren")
|
||||
fmt.Println(" → Settings → Cluster → VIP/Keepalived")
|
||||
return 0
|
||||
}
|
||||
|
||||
// setupReplicationPrimary konfiguriert die lokale PG-Instanz als Logical-
|
||||
// Replication-Primary: Replikations-Rolle + Secret, conf.d (wal_level=logical),
|
||||
// pg_hba, SELECT-Grants, PUBLICATION. Stellt sicher dass wal_level=logical
|
||||
// AKTIV ist (Restart nur falls nötig — für wal_level reicht reload nicht).
|
||||
// Idempotent. Gemeinsam genutzt von cluster-init-replication und promote.
|
||||
func setupReplicationPrimary(pg pgConfig) error {
|
||||
// 1. Passwort generieren
|
||||
pass, err := generatePassword(32)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate password: %w", err)
|
||||
}
|
||||
|
||||
// 2. edgeguard_replicator-Rolle anlegen/updaten
|
||||
roleSQL := fmt.Sprintf(`DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '%s') THEN
|
||||
CREATE ROLE %s REPLICATION LOGIN PASSWORD '%s';
|
||||
ELSE
|
||||
ALTER ROLE %s PASSWORD '%s';
|
||||
END IF;
|
||||
END
|
||||
$$`, egReplUser, egReplUser, pass, egReplUser, pass)
|
||||
if err := psqlExec(roleSQL); err != nil {
|
||||
return fmt.Errorf("create replication role: %w", err)
|
||||
}
|
||||
fmt.Printf("✓ Replication-Rolle %q angelegt/aktualisiert\n", egReplUser)
|
||||
|
||||
// 3. Passwort speichern (Ownership an edgeguard-User, damit die API liest)
|
||||
if err := os.MkdirAll(filepath.Dir(egReplSecret), 0o750); err != nil {
|
||||
return fmt.Errorf("mkdir: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(egReplSecret, []byte(pass), 0o600); err != nil {
|
||||
return fmt.Errorf("write secret: %w", err)
|
||||
}
|
||||
if u, err := user.Lookup("edgeguard"); err == nil {
|
||||
uid, _ := strconv.Atoi(u.Uid)
|
||||
gid, _ := strconv.Atoi(u.Gid)
|
||||
_ = os.Chown(egReplSecret, uid, gid)
|
||||
}
|
||||
fmt.Printf("✓ Replication-Secret gespeichert: %s\n", egReplSecret)
|
||||
|
||||
// 4. conf.d/edgeguard-replication.conf schreiben
|
||||
if err := os.MkdirAll(pg.ConfD, 0o755); err != nil {
|
||||
return fmt.Errorf("conf.d mkdir: %w", err)
|
||||
}
|
||||
replConf := `# EdgeGuard Logical Replication — automatisch generiert
|
||||
# Nicht manuell bearbeiten; wird von edgeguard-ctl verwaltet.
|
||||
wal_level = logical
|
||||
max_wal_senders = 10
|
||||
max_replication_slots = 20
|
||||
max_logical_replication_workers = 4
|
||||
wal_keep_size = 512MB
|
||||
# '*' ist sicher weil pg_hba.conf den Zugriff auf bekannte Replikations-User beschränkt.
|
||||
listen_addresses = '*'
|
||||
`
|
||||
confPath := filepath.Join(pg.ConfD, "edgeguard-replication.conf")
|
||||
if err := os.WriteFile(confPath, []byte(replConf), 0o644); err != nil {
|
||||
return fmt.Errorf("write postgresql conf: %w", err)
|
||||
}
|
||||
fmt.Printf("✓ %s geschrieben (wal_level=logical)\n", confPath)
|
||||
|
||||
// 5. pg_hba.conf aktualisieren
|
||||
if err := ensureHBAReplication(pg.HBAPath); err != nil {
|
||||
return fmt.Errorf("pg_hba.conf: %w", err)
|
||||
}
|
||||
fmt.Printf("✓ %s aktualisiert\n", pg.HBAPath)
|
||||
|
||||
// 6. PG reload (pg_hba aktiv). wal_level/max_wal_senders sind aber
|
||||
// postmaster-Parameter → nur per RESTART aktiv. Nur restarten wenn nötig.
|
||||
if out, err := exec.Command("pg_ctlcluster", pg.Version, pg.Cluster, "reload").CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("pg reload: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
fmt.Printf("✓ PostgreSQL %s/%s neu geladen\n", pg.Version, pg.Cluster)
|
||||
if cur, _ := psqlRun([]string{"-tA", "-c", "SHOW wal_level;"}); strings.TrimSpace(string(cur)) != "logical" {
|
||||
fmt.Println("→ wal_level wechselt auf 'logical' — PostgreSQL-Restart nötig...")
|
||||
if out, err := exec.Command("pg_ctlcluster", pg.Version, pg.Cluster, "restart").CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("pg restart: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
ready := false
|
||||
deadline := time.Now().Add(60 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, err := psqlRun([]string{"-tA", "-c", "SELECT 1;"}); err == nil {
|
||||
ready = true
|
||||
break
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
if !ready {
|
||||
return fmt.Errorf("PostgreSQL kam nach Restart binnen 60s nicht zurück — prüfe PG-Logs")
|
||||
}
|
||||
fmt.Println("✓ PostgreSQL neu gestartet (wal_level=logical aktiv)")
|
||||
}
|
||||
|
||||
// 7. SELECT-Grants (DEFAULT PRIVILEGES sichert zukünftige Tabellen)
|
||||
grantSQL := fmt.Sprintf(`
|
||||
GRANT SELECT ON ALL TABLES IN SCHEMA public TO %s;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO %s;
|
||||
`, egReplUser, egReplUser)
|
||||
if err := psqlDBExec("edgeguard", grantSQL); err != nil {
|
||||
return fmt.Errorf("grant SELECT: %w", err)
|
||||
}
|
||||
fmt.Printf("✓ SELECT auf alle Tabellen für %q gewährt\n", egReplUser)
|
||||
|
||||
// 8. PUBLICATION (idempotent: DROP IF EXISTS + CREATE)
|
||||
if err := createPublication(); err != nil {
|
||||
return fmt.Errorf("create publication: %w", err)
|
||||
}
|
||||
fmt.Printf("✓ PUBLICATION %q erstellt\n", egPubName)
|
||||
return nil
|
||||
}
|
||||
|
||||
// dropSubscriptionIfExists entfernt die lokale Logical-Replication-Subscription
|
||||
// idempotent. DISABLE + slot_name=NONE VOR DROP, damit DROP nicht versucht den
|
||||
// Slot auf dem (beim Failover evtl. toten) Publisher zu löschen → kein Hängen.
|
||||
func dropSubscriptionIfExists() error {
|
||||
dropSQL := fmt.Sprintf(`
|
||||
DO $$ BEGIN
|
||||
IF EXISTS (SELECT FROM pg_subscription WHERE subname = '%s') THEN
|
||||
ALTER SUBSCRIPTION %s DISABLE;
|
||||
ALTER SUBSCRIPTION %s SET (slot_name = NONE);
|
||||
DROP SUBSCRIPTION %s;
|
||||
END IF;
|
||||
END $$;`, egSubName, egSubName, egSubName, egSubName)
|
||||
return psqlDBExec("edgeguard", dropSQL)
|
||||
}
|
||||
|
||||
// createPublication baut die PUBLICATION dynamisch aus allen Tabellen
|
||||
// im public-Schema minus localOnlyTables. Idempotent: löscht eine
|
||||
// bestehende Publication gleichen Namens zuerst.
|
||||
func createPublication() error {
|
||||
// Alle Tabellen im public-Schema ermitteln
|
||||
listSQL := `SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename`
|
||||
out, err := psqlDBRun("edgeguard", []string{"-tA", "-c", listSQL})
|
||||
if err != nil {
|
||||
return fmt.Errorf("list tables: %w", err)
|
||||
}
|
||||
|
||||
excluded := make(map[string]bool)
|
||||
for _, t := range localOnlyTables {
|
||||
excluded[t] = true
|
||||
}
|
||||
|
||||
var tables []string
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
t := strings.TrimSpace(line)
|
||||
if t == "" || excluded[t] {
|
||||
continue
|
||||
}
|
||||
tables = append(tables, t)
|
||||
}
|
||||
if len(tables) == 0 {
|
||||
return fmt.Errorf("keine Tabellen für Publication gefunden")
|
||||
}
|
||||
|
||||
dropSQL := fmt.Sprintf("DROP PUBLICATION IF EXISTS %s;", egPubName)
|
||||
if err := psqlDBExec("edgeguard", dropSQL); err != nil {
|
||||
return fmt.Errorf("drop old publication: %w", err)
|
||||
}
|
||||
|
||||
createSQL := fmt.Sprintf("CREATE PUBLICATION %s FOR TABLE %s;",
|
||||
egPubName, strings.Join(tables, ", "))
|
||||
if err := psqlDBExec("edgeguard", createSQL); err != nil {
|
||||
return fmt.Errorf("create publication: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureHBAReplication fügt Einträge für die Replikations-Verbindung
|
||||
// in pg_hba.conf ein. Für Logical Replication brauchen wir einen
|
||||
// normalen "host edgeguard"-Eintrag (nicht "host replication").
|
||||
// Idempotent via Marker-Kommentar.
|
||||
func ensureHBAReplication(hbaPath string) error {
|
||||
data, err := os.ReadFile(hbaPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read: %w", err)
|
||||
}
|
||||
const marker = "# EdgeGuard replication"
|
||||
if strings.Contains(string(data), marker) {
|
||||
return nil
|
||||
}
|
||||
entry := fmt.Sprintf(`
|
||||
%s
|
||||
host edgeguard %s 0.0.0.0/0 scram-sha-256
|
||||
host edgeguard %s ::/0 scram-sha-256
|
||||
host replication %s 0.0.0.0/0 scram-sha-256
|
||||
host replication %s ::/0 scram-sha-256
|
||||
`, marker, egReplUser, egReplUser, egReplUser, egReplUser)
|
||||
f, err := os.OpenFile(hbaPath, os.O_APPEND|os.O_WRONLY, 0o640)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open: %w", err)
|
||||
}
|
||||
if _, err = f.WriteString(entry); err != nil {
|
||||
_ = f.Close()
|
||||
return err
|
||||
}
|
||||
return f.Close()
|
||||
}
|
||||
|
||||
// cmdClusterSetupStandby richtet diesen Node als Logical-Replication-
|
||||
// Subscriber ein. Der Secondary behält seine eigene beschreibbare PG-
|
||||
// Instanz — nur die geteilten Tabellen werden vom Primary repliziert.
|
||||
// Node-spezifische Tabellen (Interfaces, IPs, Routen, VIP-Settings …)
|
||||
// bleiben lokal und werden NICHT überschrieben. Analog zu OPNsense's
|
||||
// HA-Sync: Interface-IPs und Hostname bleiben immer per-Node konfiguriert.
|
||||
//
|
||||
// Voraussetzungen:
|
||||
// - cluster-join erfolgreich (TLS-Certs in /var/lib/edgeguard/cluster-tls/)
|
||||
// - Primary hat cluster-init-replication ausgeführt
|
||||
// - Dieser Node hat edgeguard-api schon gelaufen (Migrations ausgeführt)
|
||||
//
|
||||
// Ablauf:
|
||||
// 1. Replication-Credentials via mTLS vom Primary holen
|
||||
// 2. Bestehende Subscription löschen (idempotent)
|
||||
// 3. SUBSCRIPTION auf Primary erstellen (copy_data=true → Initialkopiierung)
|
||||
// 4. Warten bis Initialkopiierung abgeschlossen
|
||||
// 5. render-config ausführen damit Service-Configs den neuen Stand reflektieren
|
||||
func cmdClusterSetupStandby(args []string) int {
|
||||
fs := flag.NewFlagSet("cluster-setup-standby", flag.ContinueOnError)
|
||||
agentPort := fs.Int("agent-port", 8443, "mTLS agent port on primary")
|
||||
tlsDir := fs.String("tls-dir", clustertls.DefaultDir, "Verzeichnis mit ca.crt + peer.{crt,key}")
|
||||
fs.SetOutput(os.Stderr)
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
if fs.NArg() < 1 {
|
||||
fmt.Fprintln(os.Stderr, "usage: edgeguard-ctl cluster-setup-standby <primary-ip-or-host>")
|
||||
return 2
|
||||
}
|
||||
primaryHost := fs.Arg(0)
|
||||
|
||||
// 1. Replication-Credentials vom Primary holen
|
||||
creds, err := fetchReplicationCreds(primaryHost, *agentPort, *tlsDir)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "cluster-setup-standby: replication-creds: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Printf("✓ Replication-Credentials von %s:%d erhalten\n", primaryHost, *agentPort)
|
||||
|
||||
// 2. Bestehende Subscription löschen (idempotent)
|
||||
if err := dropSubscriptionIfExists(); err != nil {
|
||||
// Nicht fatal — wenn PG noch keine Subscription kennt ist das OK
|
||||
fmt.Printf(" → keine bestehende Subscription gefunden (ok)\n")
|
||||
} else {
|
||||
fmt.Println("✓ Bestehende Subscription entfernt")
|
||||
}
|
||||
|
||||
// 3. SUBSCRIPTION erstellen
|
||||
// sslmode=require: Verbindung zwischen Cluster-Nodes soll immer verschlüsselt sein.
|
||||
// copy_data=true: Initialkopiierung aller geteilten Tabellen vom Primary.
|
||||
connStr := fmt.Sprintf(
|
||||
"host=%s port=%d user=%s password=%s dbname=edgeguard sslmode=require",
|
||||
creds.Host, creds.Port, creds.User, creds.Password,
|
||||
)
|
||||
createSQL := fmt.Sprintf(
|
||||
"CREATE SUBSCRIPTION %s CONNECTION '%s' PUBLICATION %s WITH (copy_data = true, enabled = true);",
|
||||
egSubName, connStr, egPubName,
|
||||
)
|
||||
// Via stdin (nicht -c), damit das Replikations-Passwort nicht in der
|
||||
// Prozess-Argv (ps/proc) oder in PG-log_statement landet.
|
||||
if err := psqlDBExecStdin("edgeguard", createSQL); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "cluster-setup-standby: create subscription: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Printf("✓ SUBSCRIPTION %q erstellt — Initialkopiierung läuft\n", egSubName)
|
||||
|
||||
// 4. Warten bis Initialkopiierung abgeschlossen
|
||||
fmt.Print("→ Warte auf Initialkopiierung")
|
||||
deadline := time.Now().Add(5 * time.Minute)
|
||||
for time.Now().Before(deadline) {
|
||||
pendingSQL := fmt.Sprintf(`
|
||||
SELECT COUNT(*) FROM pg_subscription_rel
|
||||
WHERE srsubid = (SELECT oid FROM pg_subscription WHERE subname = '%s')
|
||||
AND srsubstate != 'r';`, egSubName)
|
||||
out, err := psqlDBRun("edgeguard", []string{"-tA", "-c", pendingSQL})
|
||||
if err == nil && strings.TrimSpace(string(out)) == "0" {
|
||||
break
|
||||
}
|
||||
fmt.Print(".")
|
||||
time.Sleep(3 * time.Second)
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// Finale Prüfung
|
||||
checkSQL := fmt.Sprintf(`
|
||||
SELECT COUNT(*) FROM pg_subscription_rel
|
||||
WHERE srsubid = (SELECT oid FROM pg_subscription WHERE subname = '%s')
|
||||
AND srsubstate != 'r';`, egSubName)
|
||||
if out, err := psqlDBRun("edgeguard", []string{"-tA", "-c", checkSQL}); err == nil {
|
||||
if n := strings.TrimSpace(string(out)); n != "0" {
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"cluster-setup-standby: %s Tabellen noch nicht synchronisiert — prüfe PG-Logs\n", n)
|
||||
fmt.Println(" → Subscription läuft trotzdem weiter im Hintergrund")
|
||||
} else {
|
||||
fmt.Println("✓ Alle geteilten Tabellen synchronisiert")
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Master-Key vom Primary holen — für WireGuard-Key-Entschlüsselung
|
||||
fmt.Println("→ Secrets Master-Key vom Primary synchronisieren...")
|
||||
if err := syncMasterKey(primaryHost, *agentPort, *tlsDir); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "cluster-setup-standby: master-key: %v (WireGuard-Keys können nicht entschlüsselt werden)\n", err)
|
||||
} else {
|
||||
fmt.Println("✓ Master-Key synchronisiert")
|
||||
}
|
||||
|
||||
// 6. render-config ausführen — muss als edgeguard-User laufen (DB-Zugriff)
|
||||
fmt.Println("→ Service-Configs neu rendern...")
|
||||
if out, err := exec.Command("sudo", "-u", "edgeguard", "edgeguard-ctl", "render-config").CombinedOutput(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "cluster-setup-standby: render-config: %v\n%s\n", err, out)
|
||||
fmt.Println(" → Manuell nachholen: sudo -u edgeguard edgeguard-ctl render-config")
|
||||
} else {
|
||||
fmt.Print(string(out))
|
||||
fmt.Println("✓ Service-Configs aktualisiert")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("✓ Logical Replication eingerichtet.")
|
||||
fmt.Println()
|
||||
fmt.Println("Was repliziert wird (automatisch, in Echtzeit):")
|
||||
fmt.Println(" Domains, Backends, Firewall-Rules, WireGuard, DNS-Zones,")
|
||||
fmt.Println(" TLS-Certs, Users, Forward-Proxy, NTP-Pools, ...")
|
||||
fmt.Println()
|
||||
fmt.Println("Was NICHT repliziert wird (bleibt pro Node konfiguriert):")
|
||||
fmt.Println(" Netzwerk-Interfaces, IP-Adressen, Routen,")
|
||||
fmt.Println(" Cluster-Settings (VIP-Interface!), DNS/NTP-Listen-Adressen")
|
||||
fmt.Println()
|
||||
fmt.Println("Nächste Schritte:")
|
||||
fmt.Println(" 1) sudo systemctl restart edgeguard-api")
|
||||
fmt.Println(" 2) VIP/Keepalived auf BEIDEN Nodes separat konfigurieren:")
|
||||
fmt.Println(" Settings → Cluster → VIP/Keepalived")
|
||||
fmt.Println(" 3) Bei Failover: edgeguard-ctl promote (auf dem Secondary)")
|
||||
return 0
|
||||
}
|
||||
|
||||
// pgReplicationCreds sind die Credentials die der Primary via mTLS zurückgibt.
|
||||
type pgReplicationCreds struct {
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// fetchReplicationCreds ruft GET /agent/cluster/pg-replication-info via mTLS ab.
|
||||
func fetchReplicationCreds(host string, agentPort int, tlsDir string) (*pgReplicationCreds, error) {
|
||||
caPath := filepath.Join(tlsDir, "ca.crt")
|
||||
certPath := filepath.Join(tlsDir, "peer.crt")
|
||||
keyPath := filepath.Join(tlsDir, "peer.key")
|
||||
|
||||
caCert, err := os.ReadFile(caPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read ca.crt: %w", err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
pool.AppendCertsFromPEM(caCert)
|
||||
|
||||
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load peer cert: %w", err)
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
RootCAs: pool,
|
||||
Certificates: []tls.Certificate{cert},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
url := "https://" + net.JoinHostPort(host, strconv.Itoa(agentPort)) + "/agent/cluster/pg-replication-info"
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GET %s: %w", url, err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Data pgReplicationCreds `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
return &result.Data, nil
|
||||
}
|
||||
|
||||
// syncMasterKey holt den Secrets-Master-Key vom Primary via mTLS und schreibt
|
||||
// ihn nach /var/lib/edgeguard/.master_key. Dadurch können replizierte
|
||||
// verschlüsselte WireGuard-Keys und PSKs auf dem Secondary entschlüsselt werden.
|
||||
func syncMasterKey(host string, agentPort int, tlsDir string) error {
|
||||
caPath := filepath.Join(tlsDir, "ca.crt")
|
||||
certPath := filepath.Join(tlsDir, "peer.crt")
|
||||
keyPath := filepath.Join(tlsDir, "peer.key")
|
||||
|
||||
caCert, err := os.ReadFile(caPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read ca.crt: %w", err)
|
||||
}
|
||||
rootPool := x509.NewCertPool()
|
||||
rootPool.AppendCertsFromPEM(caCert)
|
||||
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load peer cert: %w", err)
|
||||
}
|
||||
client := &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
RootCAs: rootPool,
|
||||
Certificates: []tls.Certificate{cert},
|
||||
},
|
||||
},
|
||||
}
|
||||
url := "https://" + net.JoinHostPort(host, strconv.Itoa(agentPort)) + "/agent/cluster/master-key"
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("GET %s: %w", url, err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
var result struct {
|
||||
Data struct {
|
||||
KeyHex string `json:"key_hex"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
key := make([]byte, 32)
|
||||
if _, err := fmt.Sscanf(result.Data.KeyHex, "%x", &key); err != nil {
|
||||
return fmt.Errorf("decode key_hex: %w", err)
|
||||
}
|
||||
const masterKeyPath = "/var/lib/edgeguard/.master_key"
|
||||
if err := os.WriteFile(masterKeyPath, key, 0o600); err != nil {
|
||||
return fmt.Errorf("write master key: %w", err)
|
||||
}
|
||||
if u, err := user.Lookup("edgeguard"); err == nil {
|
||||
uid, _ := strconv.Atoi(u.Uid)
|
||||
gid, _ := strconv.Atoi(u.Gid)
|
||||
_ = os.Chown(masterKeyPath, uid, gid)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// generatePassword erzeugt ein kryptographisch sicheres Passwort.
|
||||
func generatePassword(n int) (string, error) {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
buf := make([]byte, n)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for i, b := range buf {
|
||||
buf[i] = charset[int(b)%len(charset)]
|
||||
}
|
||||
return string(buf), nil
|
||||
}
|
||||
|
||||
// psqlDBExec führt SQL in der angegebenen Datenbank als postgres-Superuser aus.
|
||||
func psqlDBExec(db, sql string) error {
|
||||
_, err := psqlDBRun(db, []string{"-v", "ON_ERROR_STOP=1", "-c", sql})
|
||||
return err
|
||||
}
|
||||
|
||||
// psqlDBExecStdin führt SQL über stdin (`-f -`) aus statt `-c`, damit
|
||||
// Secrets im SQL nicht in der Prozess-Argv / PG-Statement-Logs erscheinen.
|
||||
func psqlDBExecStdin(db, sql string) error {
|
||||
cmd := buildPsqlCmd([]string{"-d", db, "-v", "ON_ERROR_STOP=1", "-f", "-"})
|
||||
cmd.Stdin = strings.NewReader(sql)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// psqlDBRun führt psql-Kommandos gegen eine bestimmte Datenbank aus.
|
||||
func psqlDBRun(db string, args []string) ([]byte, error) {
|
||||
baseArgs := []string{"-d", db}
|
||||
return psqlRun(append(baseArgs, args...))
|
||||
}
|
||||
@@ -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:] {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Command edgeguard-ctl is the admin CLI for setup, migrations and
|
||||
// (later) cluster ops. v1 wires migrate + initdb so postinst can
|
||||
// initialise a fresh node; cluster-* and promote remain stubs until
|
||||
// Phase 3.
|
||||
// cluster ops. v1.2 implements PG streaming replication setup,
|
||||
// VIP/Keepalived config and manual failover (promote).
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -11,7 +10,7 @@ import (
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
||||
)
|
||||
|
||||
var version = "1.1.89"
|
||||
var version = "1.2.15"
|
||||
|
||||
const usage = `edgeguard-ctl — EdgeGuard CLI
|
||||
|
||||
@@ -25,20 +24,25 @@ Commands:
|
||||
migrate check Validate embedded migrations (no DB connect)
|
||||
migrate dump [dir] Write embedded SQL files to dir (default: ./migrations)
|
||||
initdb Create PostgreSQL role + database (idempotent)
|
||||
render-config Regenerate haproxy / nftables configs from PG (--no-reload, --only=)
|
||||
wg-import [--path <dir>] Import existing /etc/wireguard/*.conf files into the DB
|
||||
render-config Regenerate all configs from PG (--no-reload, --only=svc)
|
||||
Services: haproxy nftables squid wireguard unbound chrony keepalived
|
||||
wg-import [--path <dir>] [iface…]
|
||||
Import /etc/wireguard/*.conf files into the DB.
|
||||
reset-password Generate a one-time token for the /reset-password UI flow
|
||||
cluster-join <primary> --token <…>
|
||||
Provision Cluster-TLS material on this node by
|
||||
exchanging the join-token at the primary's
|
||||
/api/v1/cluster/issue-cert endpoint. Writes
|
||||
ca.crt + peer.{crt,key} into /var/lib/edgeguard/
|
||||
cluster-tls/. PG-Basebackup + KeyDB replica
|
||||
setup remain manual until Phase 3.5.
|
||||
cluster-renew-self Re-issue this node's peer.{crt,key} using the
|
||||
local cluster CA (founder/single-node only).
|
||||
1-year validity. Restart edgeguard-api after.
|
||||
promote Promote this node's PG to primary (Phase 3, not yet implemented)
|
||||
Provision Cluster-TLS material; writes ca.crt + peer.{crt,key}
|
||||
cluster-init-replication Richtet PG Logical Replication auf dem Primary ein.
|
||||
Erstellt edgeguard_replicator-Rolle, setzt wal_level=logical,
|
||||
erstellt PUBLICATION edgeguard_shared (alle geteilten Tabellen).
|
||||
Auf dem Primary ausführen bevor der Secondary joined.
|
||||
cluster-setup-standby <ip> Richtet diesen Node als Logical-Replication-Subscriber ein.
|
||||
Erstellt SUBSCRIPTION gegen den Primary (Initialkopiierung
|
||||
aller geteilten Tabellen). Node-eigene Daten (Interfaces,
|
||||
IPs, Routen, VIP-Settings) bleiben unangetastet.
|
||||
Voraussetzung: cluster-join + cluster-init-replication.
|
||||
cluster-renew-self Re-issue this node's peer.{crt,key} using the local cluster CA.
|
||||
promote Promote diesen PG-Standby zum Primary (manueller Failover).
|
||||
Kein Auto-Promote — Split-Brain-Schutz durch manuelle Entscheidung.
|
||||
dump-config Print effective config (Phase 3, not yet implemented)
|
||||
`
|
||||
|
||||
@@ -66,7 +70,15 @@ func main() {
|
||||
os.Exit(cmdClusterJoin(os.Args[2:]))
|
||||
case "cluster-renew-self":
|
||||
os.Exit(cmdClusterRenewSelf(os.Args[2:]))
|
||||
case "cluster-leave", "promote", "dump-config":
|
||||
case "cluster-init-replication":
|
||||
os.Exit(cmdClusterInitReplication(os.Args[2:]))
|
||||
case "cluster-setup-standby":
|
||||
os.Exit(cmdClusterSetupStandby(os.Args[2:]))
|
||||
case "cluster-reconcile-replication":
|
||||
os.Exit(cmdClusterReconcileReplication(os.Args[2:]))
|
||||
case "promote":
|
||||
os.Exit(cmdPromote(os.Args[2:]))
|
||||
case "cluster-leave", "dump-config":
|
||||
fmt.Fprintf(os.Stderr, "edgeguard-ctl: %q is a Phase-3 stub — not yet implemented\n", os.Args[1])
|
||||
os.Exit(1)
|
||||
default:
|
||||
|
||||
124
cmd/edgeguard-ctl/promote.go
Normal file
124
cmd/edgeguard-ctl/promote.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/keepalived"
|
||||
)
|
||||
|
||||
// cmdPromote befördert diese Node zum Logical-Replication-Primary. Manuelles
|
||||
// Failover — keine automatische Promotion, um Split-Brain in 2-Node-Clustern
|
||||
// ohne externes Quorum zu verhindern.
|
||||
//
|
||||
// Hintergrund: Die Replikation ist LOGICAL (Publication/Subscription), nicht
|
||||
// physisch. Ein Subscriber ist eine normale beschreibbare PG-Instanz (nie „in
|
||||
// recovery", kein standby.signal). „Promote" heißt darum: Subscription zum
|
||||
// (toten/alten) Primary lösen und selbst Publisher werden.
|
||||
//
|
||||
// Ablauf:
|
||||
// 1. Idempotenz-Check: schon Publisher ohne Subscription → fertig
|
||||
// 2. Subscription lösen (DISABLE + slot_name=NONE + DROP)
|
||||
// 3. setupReplicationPrimary: Rolle/Secret/conf.d/pg_hba/Grants/Publication
|
||||
// + sicherstellen dass wal_level=logical aktiv ist (PG-Restart falls nötig)
|
||||
// 4. ha_nodes.pg_role/role = 'primary'
|
||||
// 5. keepalived neu rendern (Primary = Priorität 200 = MASTER → übernimmt VIP)
|
||||
func cmdPromote(args []string) int {
|
||||
pg, err := detectPGConfig()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "promote: PG-Erkennung:", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
// 1. Idempotenz: bereits Publisher (Primary) ohne Subscription?
|
||||
pubOut, _ := psqlDBRun("edgeguard", []string{"-tA", "-c",
|
||||
fmt.Sprintf("SELECT count(*) FROM pg_publication WHERE pubname='%s';", egPubName)})
|
||||
subOut, _ := psqlDBRun("edgeguard", []string{"-tA", "-c",
|
||||
fmt.Sprintf("SELECT count(*) FROM pg_subscription WHERE subname='%s';", egSubName)})
|
||||
hasPub := strings.TrimSpace(string(pubOut)) == "1"
|
||||
hasSub := strings.TrimSpace(string(subOut)) == "1"
|
||||
if hasPub && !hasSub {
|
||||
fmt.Println("✓ Diese Node ist bereits Logical-Replication-Primary (Publication vorhanden, keine Subscription). Nichts zu tun.")
|
||||
return 0
|
||||
}
|
||||
|
||||
fmt.Printf("→ Promote zu Logical-Replication-Primary (PostgreSQL %s/%s)...\n", pg.Version, pg.Cluster)
|
||||
|
||||
// 2. Subscription zum alten/toten Primary lösen
|
||||
if hasSub {
|
||||
if err := dropSubscriptionIfExists(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "promote: Subscription lösen:", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Println("✓ Subscription zum alten Primary entfernt")
|
||||
}
|
||||
|
||||
// 3. Diese Node als Publisher einrichten (inkl. wal_level=logical + Restart)
|
||||
if err := setupReplicationPrimary(pg); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "promote:", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
// 4. ha_nodes-Rolle aktualisieren
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
pool, err := database.Open(ctx, database.ConnStringFromEnv())
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "promote: db connect:", err)
|
||||
fmt.Println(" → ha_nodes manuell: UPDATE ha_nodes SET pg_role='primary', role='primary' WHERE id='<local-id>';")
|
||||
} else {
|
||||
defer pool.Close()
|
||||
localID, err := loadLocalID()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "promote: local node ID:", err)
|
||||
} else {
|
||||
_, err = pool.Exec(ctx, `UPDATE ha_nodes SET pg_role='primary', role='primary', status='online', updated_at=NOW() WHERE id=$1`, localID)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "promote: update ha_nodes:", err)
|
||||
} else {
|
||||
fmt.Println("✓ ha_nodes.pg_role = 'primary' gesetzt")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. keepalived.conf neu rendern (Primary = MASTER, Priority 200 → VIP)
|
||||
if pool != nil {
|
||||
localID, _ := loadLocalID()
|
||||
kg := keepalived.New(pool, localID)
|
||||
renderCtx, renderCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer renderCancel()
|
||||
if err := kg.Render(renderCtx); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "promote: keepalived render: %v\n", err)
|
||||
fmt.Println(" → Manuell: sudo -u edgeguard edgeguard-ctl render-config --only=keepalived")
|
||||
} else {
|
||||
fmt.Println("✓ keepalived.conf neu gerendert (MASTER, Priority 200)")
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("✓ Promotion abgeschlossen. Diese Node ist jetzt der primäre EdgeGuard-Knoten.")
|
||||
fmt.Println()
|
||||
fmt.Println("Empfohlene Nachschritte:")
|
||||
fmt.Println(" 1) sudo systemctl restart edgeguard-api")
|
||||
fmt.Println(" 2) Übrige/erholte Nodes als Standby auf DIESE Node zeigen lassen:")
|
||||
fmt.Println(" edgeguard-ctl cluster-setup-standby <diese-node-ip>")
|
||||
return 0
|
||||
}
|
||||
|
||||
// loadLocalID liest die Node-ID aus /var/lib/edgeguard/node.conf.
|
||||
func loadLocalID() (string, error) {
|
||||
c, err := cluster.LoadLocalConfig("")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if c.NodeID == "" {
|
||||
return "", fmt.Errorf("NODE_ID in node.conf ist leer")
|
||||
}
|
||||
return c.NodeID, nil
|
||||
}
|
||||
@@ -8,10 +8,15 @@ import (
|
||||
"time"
|
||||
|
||||
"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/configgen"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/crowdsec"
|
||||
"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/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"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/squid"
|
||||
@@ -59,15 +64,34 @@ func cmdRenderConfig(args []string) int {
|
||||
wg := wireguard.New(pool, secrets.New(""))
|
||||
ub := unbound.New(pool)
|
||||
cn := chrony.New(pool)
|
||||
ke := kea.New(pool)
|
||||
fr := freeradius.New(pool, secrets.New(""))
|
||||
cw := crowdsec.NewWhitelistGenerator(pool)
|
||||
if skipReload {
|
||||
hap.SkipReload = true
|
||||
fw.SkipReload = true
|
||||
sq.SkipReload = true
|
||||
wg.SkipReload = true
|
||||
ub.SkipReload = true
|
||||
cn.SkipReload = true
|
||||
ke.SkipReload = true
|
||||
fr.SkipReload = true
|
||||
cw.SkipReload = true
|
||||
}
|
||||
|
||||
gens := []configgen.Generator{hap, fw, sq, wg, ub, cn}
|
||||
// keepalived: Node-ID aus node.conf für Prioritäts-Berechnung
|
||||
var ka configgen.Generator
|
||||
if lc, err := cluster.LoadLocalConfig(""); err == nil && lc.NodeID != "" {
|
||||
ka = keepalived.New(pool, lc.NodeID)
|
||||
}
|
||||
|
||||
gens := []configgen.Generator{hap, fw, sq, wg, ub, cn, ke, fr, cw}
|
||||
if ka != nil {
|
||||
gens = append(gens, ka)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -46,7 +46,9 @@ func cmdWGImport(args []string) int {
|
||||
wireguard.NewPeersRepo(pool),
|
||||
box,
|
||||
)
|
||||
res, err := im.ImportDir(ctx, *path)
|
||||
// Positional args after flags = specific interface names to import.
|
||||
names := fs.Args()
|
||||
res, err := im.ImportSelected(ctx, *path, names)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "wg-import:", err)
|
||||
return 1
|
||||
|
||||
@@ -9,12 +9,17 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -27,6 +32,7 @@ import (
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/acme"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/alerts"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/backends"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/backup"
|
||||
backupremote "git.netcell-it.de/projekte/edgeguard-native/internal/services/backup/remote"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/certrenewer"
|
||||
@@ -35,7 +41,7 @@ import (
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
|
||||
)
|
||||
|
||||
var version = "1.1.89"
|
||||
var version = "1.2.35"
|
||||
|
||||
const (
|
||||
// renewTickInterval — how often we re-evaluate expiring certs.
|
||||
@@ -97,6 +103,50 @@ const (
|
||||
// ist passiert nichts.
|
||||
auditCleanupInterval = 24 * time.Hour
|
||||
auditRetentionDays = 90
|
||||
|
||||
// alertRetentionDays — alert_events wächst sonst unbegrenzt (node-lokale
|
||||
// Health-Events: backend.down, mem.high, cert.expiring …). Läuft im
|
||||
// selben täglichen Tick wie der Audit-Cleanup. Fester Default, kein
|
||||
// Setup-Override (Events sind reine Diagnose-History).
|
||||
alertRetentionDays = 90
|
||||
|
||||
// backendDownCheckInterval — alle 2 Minuten HAProxy-Stats lesen und
|
||||
// prüfen ob ein Backend komplett ausgefallen ist (alle Server DOWN).
|
||||
// Dedupe 12h pro Backend → kein Alert-Spam. Frischer Alert wenn das
|
||||
// Backend nach 12h immer noch unten ist.
|
||||
backendDownCheckInterval = 2 * time.Minute
|
||||
|
||||
// memCheckInterval — alle 5 Minuten /proc/meminfo lesen. Schwellen
|
||||
// warning 85%, critical 95%. Dedupe 1h pro Severity damit bei einem
|
||||
// kurzfristigen Spike nicht jeder Tick feuert.
|
||||
memCheckInterval = 5 * time.Minute
|
||||
memWarnPct = 85.0
|
||||
memCriticalPct = 95.0
|
||||
|
||||
// conntrackCheckInterval — alle 2 Minuten /proc/sys/net/netfilter/
|
||||
// nf_conntrack_count+max lesen. Eine volle conntrack-Tabelle verwirft
|
||||
// alle neuen Verbindungen ohne jegliche Rückmeldung. 2-Minuten-Takt
|
||||
// erlaubt früh zu warnen bevor die Tabelle überläuft.
|
||||
// Schwellen analog Disk: 80% Warning, 90% Critical. Dedupe 1h.
|
||||
conntrackCheckInterval = 2 * time.Minute
|
||||
conntrackWarnPct = 80.0
|
||||
conntrackCriticalPct = 90.0
|
||||
|
||||
// ntpSyncCheckInterval — alle 10 Minuten chronyc tracking aufrufen.
|
||||
// Keine Sync bedeutet: Uhr driftet → TLS-Cert-Prüfung schlägt fehl
|
||||
// wenn die Abweichung > Toleranz des Gegenstücks (i.d.R. ±1 min),
|
||||
// JWT-Ablauf inconsistent, Cluster-Split-Brain möglich. Dedupe 1h
|
||||
// damit ein kurzer Upstream-Ausfall (Reboot, DHCP-Pause) keinen
|
||||
// Alert-Regen produziert.
|
||||
ntpSyncCheckInterval = 10 * time.Minute
|
||||
|
||||
// wgTunnelCheckInterval — alle 5 Minuten WireGuard-Client-Tunnels
|
||||
// auf Aktualität prüfen. Client-Tunnels (mode='client') haben genau
|
||||
// einen Peer; wenn dessen letzter Handshake älter als wgStaleSec ist,
|
||||
// ist der Tunnel effektiv tot — Traffic droht lautlos. Dedupe 30min
|
||||
// pro Tunnel damit schnell wiederhergestellte Tunnels nur einmal feuern.
|
||||
wgTunnelCheckInterval = 5 * time.Minute
|
||||
wgStaleSec = int64(5 * 60) // 5 Minuten ohne Handshake = tot
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -117,8 +167,10 @@ func main() {
|
||||
st, _ := setupStore.Load()
|
||||
|
||||
var renewer *certrenewer.Service
|
||||
var acmeIssuer *acme.Service
|
||||
if st != nil && st.ACMEEmail != "" {
|
||||
issuer := acme.New(st.ACMEEmail)
|
||||
acmeIssuer = issuer
|
||||
renewer = certrenewer.New(tlsRepo, issuer, certDir, 30*24*time.Hour)
|
||||
slog.Info("scheduler: ACME renewer enabled",
|
||||
"email", st.ACMEEmail, "tick", renewTickInterval, "threshold", "30d")
|
||||
@@ -141,9 +193,15 @@ func main() {
|
||||
auditRepo := audit.New(pool)
|
||||
alertDedupe := newDedupe(12 * time.Hour)
|
||||
|
||||
if renewer != nil {
|
||||
// ACME nur auf dem VIP-Master (siehe Tick-Kommentar unten).
|
||||
if renewer != nil && nodeHoldsVIP(ctx, pool) {
|
||||
runRenewer(ctx, renewer, alertSvc, alertDedupe)
|
||||
}
|
||||
// Das EIGENE Management-Zertifikat dagegen auf jedem Node — dessen FQDN
|
||||
// zeigt auf die eigene IP, nicht auf die VIP (siehe mgmtcert.go).
|
||||
if acmeIssuer != nil {
|
||||
runManagementCertRenew(ctx, setupStore, tlsRepo, acmeIssuer, alertSvc, alertDedupe)
|
||||
}
|
||||
runLicenseVerify(ctx, licClient, licKeyStore, licRepo, nodeID, alertSvc, alertDedupe)
|
||||
|
||||
// Lokale Node-ID für Heartbeat. EnsureNodeID liefert dieselbe ID
|
||||
@@ -180,12 +238,47 @@ func main() {
|
||||
auditTick := time.NewTicker(auditCleanupInterval)
|
||||
defer auditTick.Stop()
|
||||
|
||||
backendDownTick := time.NewTicker(backendDownCheckInterval)
|
||||
defer backendDownTick.Stop()
|
||||
// Initial-Check direkt beim Start — wenn ein Backend seit dem letzten
|
||||
// Scheduler-Restart down ist, brauchen wir nicht 2 Minuten zu warten.
|
||||
runBackendDownCheck(ctx, pool, alertSvc, alertDedupe)
|
||||
|
||||
memTick := time.NewTicker(memCheckInterval)
|
||||
defer memTick.Stop()
|
||||
runMemoryCheck(ctx, alertSvc, alertDedupe)
|
||||
|
||||
conntrackTick := time.NewTicker(conntrackCheckInterval)
|
||||
defer conntrackTick.Stop()
|
||||
runConntrackCheck(ctx, alertSvc, alertDedupe)
|
||||
|
||||
ntpSyncTick := time.NewTicker(ntpSyncCheckInterval)
|
||||
defer ntpSyncTick.Stop()
|
||||
// Kein Initial-Check bei Boot: chrony braucht nach dem Start
|
||||
// einige Sekunden bis zur ersten Synchronization — ein
|
||||
// sofortiger Check würde immer feuern.
|
||||
|
||||
wgTunnelTick := time.NewTicker(wgTunnelCheckInterval)
|
||||
defer wgTunnelTick.Stop()
|
||||
// Kein Initial-Check bei Boot: Tunnels brauchen nach dem Start
|
||||
// des wg-quick-Dienstes einen Moment für den ersten Handshake.
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-renewTick.C:
|
||||
if renewer != nil {
|
||||
// ACME-HTTP-01-Challenges laufen auf :80 der VIP → nur der
|
||||
// VIP-Master kann sie bestehen. Ein BACKUP-Node scheitert IMMER
|
||||
// mit 403 (invalid authorization) und setzt tls_certs.status lokal
|
||||
// auf "error" → Divergenz zur replizierten Row (Primary=active) →
|
||||
// Config-Drift-Banner + Log-Noise. Renewal daher nur am VIP-Master;
|
||||
// die Cert-Row/PEM repliziert von dort ohnehin auf den Standby.
|
||||
if renewer != nil && nodeHoldsVIP(ctx, pool) {
|
||||
runRenewer(ctx, renewer, alertSvc, alertDedupe)
|
||||
}
|
||||
// Eigenes Management-Cert: unabhaengig von der VIP, siehe oben.
|
||||
if acmeIssuer != nil {
|
||||
runManagementCertRenew(ctx, setupStore, tlsRepo, acmeIssuer, alertSvc, alertDedupe)
|
||||
}
|
||||
runCertExpiryCheck(ctx, tlsRepo, alertSvc, alertDedupe)
|
||||
case <-licTick.C:
|
||||
runLicenseVerify(ctx, licClient, licKeyStore, licRepo, nodeID, alertSvc, alertDedupe)
|
||||
@@ -203,6 +296,17 @@ func main() {
|
||||
runDiskCheck(ctx, alertSvc, alertDedupe)
|
||||
case <-auditTick.C:
|
||||
runAuditCleanup(ctx, auditRepo, setupStore)
|
||||
runAlertCleanup(ctx, alertSvc)
|
||||
case <-backendDownTick.C:
|
||||
runBackendDownCheck(ctx, pool, alertSvc, alertDedupe)
|
||||
case <-memTick.C:
|
||||
runMemoryCheck(ctx, alertSvc, alertDedupe)
|
||||
case <-conntrackTick.C:
|
||||
runConntrackCheck(ctx, alertSvc, alertDedupe)
|
||||
case <-ntpSyncTick.C:
|
||||
runNTPSyncCheck(ctx, alertSvc, alertDedupe)
|
||||
case <-wgTunnelTick.C:
|
||||
runWGClientTunnelCheck(ctx, pool, alertSvc, alertDedupe)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -238,6 +342,29 @@ func runAuditCleanup(ctx context.Context, r *audit.Repo, setupStore *setup.Store
|
||||
}
|
||||
}
|
||||
|
||||
// runAlertCleanup löscht alert_events älter als alertRetentionDays.
|
||||
// Schutz vor unbounded growth — auf einer aktiven Box feuern backend.down/
|
||||
// mem.high/cert.expiring über Monate tausende Rows (die Tabelle ist
|
||||
// node-lokal, wird also nirgends sonst abgeräumt). Best-effort: Fehler
|
||||
// werden nur geloggt.
|
||||
func runAlertCleanup(ctx context.Context, a *alerts.Service) {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
n, err := a.Cleanup(cctx, alertRetentionDays)
|
||||
if err != nil {
|
||||
slog.Warn("scheduler: alert cleanup failed",
|
||||
"keep_days", alertRetentionDays, "error", err)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
slog.Info("scheduler: alert cleanup",
|
||||
"deleted", n, "keep_days", alertRetentionDays)
|
||||
}
|
||||
}
|
||||
|
||||
// runDiskCheck prüft die Belegung von / via statfs. Fire-Schwellen:
|
||||
// - >= 90% → Critical (error). Box ist akut gefährdet — beim
|
||||
// nächsten Backup-Run oder größeren apt-Update droht "no space
|
||||
@@ -307,6 +434,457 @@ func runDiskCheck(ctx context.Context, a *alerts.Service, d *dedupe) {
|
||||
// remaining hat UND eine lokale CA existiert, wird automatisch neu
|
||||
// signiert. Restart-Hinweis als Info-Alert — wir starten edgeguard-api
|
||||
// nicht selbst neu, das passiert beim nächsten geplanten Update/Reboot.
|
||||
// runMemoryCheck liest /proc/meminfo und feuert bei hoher RAM-Belegung.
|
||||
// Schwellen: warning >= 85%, critical >= 95%. Dedupe 1h pro Severity
|
||||
// damit kurze Spikes (Backup, apt-Upgrade) keine Alert-Flut erzeugen.
|
||||
func runMemoryCheck(ctx context.Context, a *alerts.Service, d *dedupe) {
|
||||
if a == nil || d == nil {
|
||||
return
|
||||
}
|
||||
data, err := os.ReadFile("/proc/meminfo")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var memTotal, memAvail int64
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
var key string
|
||||
var val int64
|
||||
if _, err := fmt.Sscanf(line, "%s %d", &key, &val); err != nil {
|
||||
continue
|
||||
}
|
||||
switch key {
|
||||
case "MemTotal:":
|
||||
memTotal = val
|
||||
case "MemAvailable:":
|
||||
memAvail = val
|
||||
}
|
||||
}
|
||||
if memTotal <= 0 {
|
||||
return
|
||||
}
|
||||
usedPct := float64(memTotal-memAvail) * 100 / float64(memTotal)
|
||||
usedGB := float64(memTotal-memAvail) / 1024 / 1024
|
||||
totalGB := float64(memTotal) / 1024 / 1024
|
||||
|
||||
var key, title string
|
||||
var sev alerts.Severity
|
||||
switch {
|
||||
case usedPct >= memCriticalPct:
|
||||
key = "mem.high.critical"
|
||||
sev = alerts.SeverityError
|
||||
title = fmt.Sprintf("RAM kritisch hoch: %.0f%%", usedPct)
|
||||
case usedPct >= memWarnPct:
|
||||
key = "mem.high.warning"
|
||||
sev = alerts.SeverityWarning
|
||||
title = fmt.Sprintf("RAM-Belegung hoch: %.0f%%", usedPct)
|
||||
default:
|
||||
return
|
||||
}
|
||||
if !d.shouldFire(key) {
|
||||
return
|
||||
}
|
||||
desc := fmt.Sprintf(
|
||||
"RAM-Auslastung: %.1f%% — %.1f von %.1f GB belegt.\n\n"+
|
||||
"Häufige Ursachen:\n"+
|
||||
" • Unbound-Cache zu groß (rrset-cache-size in /etc/edgeguard/unbound/unbound.conf)\n"+
|
||||
" • Squid cache_mem zu groß (64 MB default)\n"+
|
||||
" • PostgreSQL shared_buffers (default ~128 MB)\n"+
|
||||
" • Prozesse prüfen: ps aux --sort=-%%mem | head -10",
|
||||
usedPct, usedGB, totalGB)
|
||||
if _, err := a.Fire(ctx, "mem.high", sev, title, desc); err != nil {
|
||||
slog.Warn("scheduler: memory-check alert fire failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// runConntrackCheck liest die conntrack-Tabellen-Belegung aus /proc und
|
||||
// feuert bei hoher Auslastung. Eine volle conntrack-Tabelle (100%)
|
||||
// verwirft alle neuen TCP/UDP-Verbindungen ohne ICMP-Rückmeldung —
|
||||
// der Operator sieht auf der Gegenstelle nur Timeouts.
|
||||
//
|
||||
// Schwellen: 80% Warning, 90% Critical (wie Disk, niedriger als RAM weil
|
||||
// der Impact sofortig ist). Dedupe 1h pro Severity.
|
||||
func runConntrackCheck(ctx context.Context, a *alerts.Service, d *dedupe) {
|
||||
if a == nil || d == nil {
|
||||
return
|
||||
}
|
||||
readInt := func(path string) int64 {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
v, _ := strconv.ParseInt(strings.TrimSpace(string(b)), 10, 64)
|
||||
return v
|
||||
}
|
||||
count := readInt("/proc/sys/net/netfilter/nf_conntrack_count")
|
||||
max := readInt("/proc/sys/net/netfilter/nf_conntrack_max")
|
||||
if max <= 0 {
|
||||
return
|
||||
}
|
||||
usedPct := float64(count) * 100 / float64(max)
|
||||
|
||||
var key, title string
|
||||
var sev alerts.Severity
|
||||
switch {
|
||||
case usedPct >= conntrackCriticalPct:
|
||||
key = "conntrack.high.critical"
|
||||
sev = alerts.SeverityError
|
||||
title = fmt.Sprintf("Conntrack-Tabelle kritisch voll: %.0f%%", usedPct)
|
||||
case usedPct >= conntrackWarnPct:
|
||||
key = "conntrack.high.warning"
|
||||
sev = alerts.SeverityWarning
|
||||
title = fmt.Sprintf("Conntrack-Tabelle fast voll: %.0f%%", usedPct)
|
||||
default:
|
||||
return
|
||||
}
|
||||
if !d.shouldFire(key) {
|
||||
return
|
||||
}
|
||||
desc := fmt.Sprintf(
|
||||
"Conntrack-Auslastung: %.1f%% — %d von %d Einträgen belegt.\n\n"+
|
||||
"Wenn die Tabelle auf 100%% steigt, werden alle neuen Verbindungen\n"+
|
||||
"ohne Fehlermeldung verworfen (Silent Drop).\n\n"+
|
||||
"Maßnahmen:\n"+
|
||||
" • Zeitweilige Spikes: nf_conntrack_max erhöhen\n"+
|
||||
" (sysctl net.netfilter.nf_conntrack_max)\n"+
|
||||
" • Leaks: conntrack -L | sort | head zeigt häufige Quellen\n"+
|
||||
" • Timeouts reduzieren (z.B. nf_conntrack_tcp_timeout_established)",
|
||||
usedPct, count, max)
|
||||
if _, err := a.Fire(ctx, "conntrack.high", sev, title, desc); err != nil {
|
||||
slog.Warn("scheduler: conntrack-check alert fire failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// runNTPSyncCheck ruft chronyc tracking auf und feuert einen Alert wenn
|
||||
// chrony keine synchronisierte Zeitquelle hat (Stratum 0 oder ≥ 16).
|
||||
// Zeitdrift > ~1 Minute führt zu TLS-Handshake-Fehlern, JWT-Ablauf-
|
||||
// Inkonsistenzen und möglichen Cluster-Problemen. Dedupe 1h.
|
||||
func runNTPSyncCheck(ctx context.Context, a *alerts.Service, d *dedupe) {
|
||||
if a == nil || d == nil {
|
||||
return
|
||||
}
|
||||
out, err := exec.Command("chronyc", "tracking").Output()
|
||||
if err != nil {
|
||||
// chrony nicht installiert oder nicht gestartet — kein Alert,
|
||||
// weil wir nicht wissen ob chrony hier überhaupt erwartet wird.
|
||||
return
|
||||
}
|
||||
synced, stratum, ref := parseChronyTrackingForAlert(string(out))
|
||||
if synced {
|
||||
return
|
||||
}
|
||||
const key = "ntp.unsync"
|
||||
if !d.shouldFire(key) {
|
||||
return
|
||||
}
|
||||
refStr := ref
|
||||
if refStr == "" {
|
||||
refStr = "(keine Referenz)"
|
||||
}
|
||||
title := fmt.Sprintf("NTP nicht synchronisiert (Stratum %d)", stratum)
|
||||
desc := fmt.Sprintf(
|
||||
"chrony hat keine synchronisierte Zeitquelle.\n"+
|
||||
"Referenz: %s Stratum: %d\n\n"+
|
||||
"Mögliche Ursachen:\n"+
|
||||
" • Upstream-NTP-Server nicht erreichbar (UDP/123 blockiert?)\n"+
|
||||
" • Pool-DNS-Einträge lösen nicht auf\n"+
|
||||
" • chrony läuft, braucht aber noch Zeit nach Boot (warten)\n\n"+
|
||||
"Prüfen: chronyc sources -v — chronyc tracking",
|
||||
refStr, stratum)
|
||||
if _, err := a.Fire(ctx, "ntp.unsync", alerts.SeverityWarning, title, desc); err != nil {
|
||||
slog.Warn("scheduler: ntp-sync-check alert fire failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// parseChronyTrackingForAlert ist eine schlanke Variante des NTP-Handler-
|
||||
// Parsers: liefert nur synced/stratum/reference ohne die vollen Felder.
|
||||
func parseChronyTrackingForAlert(out string) (synced bool, stratum int, reference string) {
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
key, val, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
val = strings.TrimSpace(val)
|
||||
switch key {
|
||||
case "Reference ID":
|
||||
if i := strings.Index(val, "("); i >= 0 {
|
||||
reference = strings.Trim(val[i:], "()")
|
||||
}
|
||||
if val != "00000000 ()" {
|
||||
synced = true
|
||||
}
|
||||
case "Stratum":
|
||||
_, _ = fmt.Sscanf(val, "%d", &stratum)
|
||||
if stratum > 0 && stratum < 16 {
|
||||
synced = true
|
||||
} else if stratum == 0 || stratum >= 16 {
|
||||
synced = false
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// runWGClientTunnelCheck prüft alle aktiven WireGuard-Client-Tunnels
|
||||
// (mode='client') auf Handshake-Aktualität. Ein Client-Tunnel hat genau
|
||||
// einen Peer; wenn dessen letzter Handshake älter als wgStaleSec oder
|
||||
// noch nie stattgefunden hat, ist der Tunnel tot — Traffic wird lautlos
|
||||
// verworfen (kein ICMP Unreachable). Dedupe 30min pro Tunnel damit
|
||||
// nach einer Selbstheilung nicht alle paar Minuten neu gefeuert wird.
|
||||
func runWGClientTunnelCheck(ctx context.Context, pool *pgxpool.Pool, a *alerts.Service, d *dedupe) {
|
||||
if a == nil || d == nil || pool == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Alle aktiven Client-Interfaces aus DB laden.
|
||||
type wgIface struct{ name string }
|
||||
rows, err := pool.Query(ctx,
|
||||
`SELECT name FROM wireguard_interfaces WHERE mode = 'client' AND active = true ORDER BY name`)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
var ifaces []wgIface
|
||||
for rows.Next() {
|
||||
var n string
|
||||
if err := rows.Scan(&n); err == nil {
|
||||
ifaces = append(ifaces, wgIface{n})
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
if len(ifaces) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
for _, ifc := range ifaces {
|
||||
out, err := exec.Command("wg", "show", ifc.name, "dump").Output()
|
||||
if err != nil {
|
||||
// Interface existiert nicht mehr im Kernel (wg-quick down) —
|
||||
// das ist selbst schon ein Problem; kein separater Alert hier,
|
||||
// da systemd-Restart-Policy das abdeckt.
|
||||
continue
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||
// Zeile 0 ist die Interface-Zeile (own key / pubkey / port / fwmark).
|
||||
// Zeile 1 ist die Peer-Zeile: pubkey psk endpoint allowed-ips last-hs rx tx keepalive
|
||||
if len(lines) < 2 {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(lines[1])
|
||||
if len(fields) < 5 {
|
||||
continue
|
||||
}
|
||||
lastHS, _ := strconv.ParseInt(fields[4], 10, 64)
|
||||
|
||||
stale := lastHS == 0 || (now-lastHS) > wgStaleSec
|
||||
if !stale {
|
||||
continue
|
||||
}
|
||||
key := "wg.tunnel.down." + ifc.name
|
||||
if !d.shouldFire(key) {
|
||||
continue
|
||||
}
|
||||
var detail string
|
||||
if lastHS == 0 {
|
||||
detail = "Noch kein Handshake — Tunnel wurde nie erfolgreich aufgebaut."
|
||||
} else {
|
||||
ageMin := (now - lastHS) / 60
|
||||
detail = fmt.Sprintf("Letzter Handshake: vor %d Minuten.", ageMin)
|
||||
}
|
||||
title := fmt.Sprintf("WireGuard-Tunnel %s ausgefallen", ifc.name)
|
||||
desc := fmt.Sprintf(
|
||||
"Client-Tunnel %s hat seit >5 Minuten keinen Handshake.\n%s\n\n"+
|
||||
"Traffic zu den RemoteAllowed-Netzen wird lautlos verworfen.\n\n"+
|
||||
"Mögliche Ursachen:\n"+
|
||||
" • Remote-Peer nicht erreichbar (Firewall, Routing)\n"+
|
||||
" • Remote-Server-Keypair geändert (Public-Key stimmt nicht mehr)\n"+
|
||||
" • UDP-Port des Peers geblockt\n"+
|
||||
" • wg-quick-Dienst auf dieser Box gestoppt: systemctl status wg-quick@%s",
|
||||
ifc.name, detail, ifc.name)
|
||||
if _, err := a.Fire(ctx, "wg.tunnel.down", alerts.SeverityError, title, desc); err != nil {
|
||||
slog.Warn("scheduler: wg-tunnel-check alert fire failed", "iface", ifc.name, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var egBackendRE = regexp.MustCompile(`^eg_backend_(\d+)$`)
|
||||
|
||||
// nodeHoldsVIP meldet true, wenn dieser Node aktuell mindestens eine
|
||||
// is_vip-Adresse lokal trägt — also der keepalived-MASTER ist. Nur der
|
||||
// Master hält die VLAN-Gateway-VIPs und erreicht damit die Backend-
|
||||
// Subnetze; ein BACKUP-Node hat KEINE VLAN-IP und sieht deshalb JEDES
|
||||
// Backend als L4-down. Spiegelt SystemHandler.VIPStatus (net.Interfaces,
|
||||
// kein Shell-out).
|
||||
func nodeHoldsVIP(ctx context.Context, pool *pgxpool.Pool) bool {
|
||||
if pool == nil {
|
||||
return false
|
||||
}
|
||||
rows, err := pool.Query(ctx,
|
||||
`SELECT address FROM ip_addresses WHERE is_vip = true AND active = true`)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer rows.Close()
|
||||
var vips []string
|
||||
for rows.Next() {
|
||||
var addr string
|
||||
if err := rows.Scan(&addr); err == nil {
|
||||
vips = append(vips, addr)
|
||||
}
|
||||
}
|
||||
if len(vips) == 0 {
|
||||
return false
|
||||
}
|
||||
local := make(map[string]bool)
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, ifc := range ifaces {
|
||||
addrs, err := ifc.Addrs()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, a := range addrs {
|
||||
if ipnet, ok := a.(*net.IPNet); ok {
|
||||
local[ipnet.IP.String()] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, v := range vips {
|
||||
if local[v] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// runBackendDownCheck liest HAProxy-Stats via Admin-Socket und feuert
|
||||
// einen Error-Alert für jedes Backend bei dem alle Server DOWN sind
|
||||
// (und mind. einer einen echten Health-Check hat). Dedupe 12h pro Backend.
|
||||
func runBackendDownCheck(ctx context.Context, pool *pgxpool.Pool, a *alerts.Service, d *dedupe) {
|
||||
if a == nil || d == nil {
|
||||
return
|
||||
}
|
||||
// Nur auf dem VIP-Master prüfen. Ein BACKUP-Node hält die VLAN-
|
||||
// Gateway-VIPs nicht und kann die Backend-Subnetze gar nicht erreichen
|
||||
// → jeder Health-Check läuft L4TOUT → Dauer-"backend.down"-Fehlalarm
|
||||
// (Hauptquelle des alert_events-Spams). Der Master bedient den Traffic
|
||||
// und sieht die echten Backend-States.
|
||||
if !nodeHoldsVIP(ctx, pool) {
|
||||
return
|
||||
}
|
||||
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 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
|
||||
}
|
||||
byBackend := map[string][]srvEntry{}
|
||||
colIdx := map[string]int{}
|
||||
scanner := bufio.NewScanner(conn)
|
||||
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
fields := strings.Split(line, ",")
|
||||
if strings.HasPrefix(line, "# ") {
|
||||
fields[0] = strings.TrimPrefix(fields[0], "# ")
|
||||
for i, name := range fields {
|
||||
colIdx[name] = i
|
||||
}
|
||||
continue
|
||||
}
|
||||
px := fieldAt(fields, colIdx["pxname"])
|
||||
sv := fieldAt(fields, colIdx["svname"])
|
||||
if !strings.HasPrefix(px, "eg_backend_") || sv == "BACKEND" || sv == "FRONTEND" || sv == "" {
|
||||
continue
|
||||
}
|
||||
status := fieldAt(fields, colIdx["status"])
|
||||
byBackend[px] = append(byBackend[px], srvEntry{
|
||||
status: status,
|
||||
hasCheck: status != "no check",
|
||||
})
|
||||
}
|
||||
if len(byBackend) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Friendly Backend-Namen aus DB — best-effort, Fehler = anonyme ID.
|
||||
bkRepo := backends.New(pool)
|
||||
bklist, _ := bkRepo.List(ctx)
|
||||
nameOf := func(id int64) string {
|
||||
for _, b := range bklist {
|
||||
if b.ID == id {
|
||||
return b.Name
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("#%d", id)
|
||||
}
|
||||
|
||||
for haName, servers := range byBackend {
|
||||
hasRealCheck, allDown := false, true
|
||||
for _, s := range servers {
|
||||
if s.hasCheck {
|
||||
hasRealCheck = true
|
||||
}
|
||||
if s.status == "UP" {
|
||||
allDown = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasRealCheck || !allDown {
|
||||
continue
|
||||
}
|
||||
m := egBackendRE.FindStringSubmatch(haName)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
id, _ := strconv.ParseInt(m[1], 10, 64)
|
||||
name := nameOf(id)
|
||||
|
||||
key := "backend.down." + haName
|
||||
if !d.shouldFire(key) {
|
||||
continue
|
||||
}
|
||||
msg := fmt.Sprintf(
|
||||
"Alle Server in Backend \"%s\" sind DOWN — HAProxy liefert 503 für alle Requests zu diesem Backend.\n\n"+
|
||||
"HAProxy-Backend-Name: %s\n\n"+
|
||||
"Nächste Schritte:\n"+
|
||||
" • Dienst auf Backend-Host prüfen (systemctl status / docker ps)\n"+
|
||||
" • Health-Check-Pfad erreichbar? (curl http://<server>:<port><path>)\n"+
|
||||
" • Firewall-Regeln zwischen EdgeGuard und Backend-Host prüfen",
|
||||
name, haName)
|
||||
if _, err := a.Fire(ctx, "backend.down", alerts.SeverityError,
|
||||
fmt.Sprintf("Backend DOWN: %s", name), msg); err != nil {
|
||||
slog.Warn("scheduler: backend-down alert fire failed",
|
||||
"backend", name, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fieldAt(fields []string, i int) string {
|
||||
if i < 0 || i >= len(fields) {
|
||||
return ""
|
||||
}
|
||||
return fields[i]
|
||||
}
|
||||
|
||||
func runClusterCertExpiryCheck(ctx context.Context, a *alerts.Service, d *dedupe) {
|
||||
if a == nil || d == nil {
|
||||
return
|
||||
@@ -548,7 +1126,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)
|
||||
@@ -594,11 +1172,18 @@ func runRenewer(ctx context.Context, r *certrenewer.Service, a *alerts.Service,
|
||||
slog.Info("scheduler: renewer pass complete",
|
||||
"checked", res.Checked, "renewed", res.Renewed,
|
||||
"failed", res.Failed, "skipped", res.Skipped)
|
||||
if a != nil && res.Failed > 0 && d != nil && d.shouldFire("cert.renew_failed") {
|
||||
_, _ = a.Fire(ctx, "cert.renew_failed", alerts.SeverityError,
|
||||
"Cert-Renewal teilweise fehlgeschlagen",
|
||||
fmt.Sprintf("Renewer-Cycle: %d checked, %d renewed, %d failed, %d skipped",
|
||||
res.Checked, res.Renewed, res.Failed, res.Skipped))
|
||||
if a != nil && d != nil {
|
||||
for _, domain := range res.FailedDomains {
|
||||
key := "cert.renew_failed:" + domain
|
||||
if !d.shouldFire(key) {
|
||||
continue
|
||||
}
|
||||
_, _ = a.Fire(ctx, "cert.renew_failed", alerts.SeverityError,
|
||||
"Cert-Renewal fehlgeschlagen: "+domain,
|
||||
"Let's Encrypt Erneuerung für "+domain+" ist fehlgeschlagen. "+
|
||||
"Prüfe ACME-Configuration und DNS-Erreichbarkeit. "+
|
||||
"Nächster Versuch beim nächsten Renewer-Tick (alle 6h).")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
180
cmd/edgeguard-scheduler/mgmtcert.go
Normal file
180
cmd/edgeguard-scheduler/mgmtcert.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/alerts"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/certstore"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
|
||||
)
|
||||
|
||||
// Node-lokale Erneuerung des eigenen Management-Zertifikats.
|
||||
//
|
||||
// Befund 2026-09-11: Auf utm-2 war das Zertifikat fuer die Management-UI
|
||||
// seit zwei Wochen abgelaufen und haette sich nie erneuert. Zwei Gruende
|
||||
// trafen zusammen:
|
||||
//
|
||||
// 1. Das FQDN eines per Join dazugekommenen Nodes landet in KEINER
|
||||
// tls_certs-Zeile — es wird beim Setup einmalig ausgestellt und danach
|
||||
// von niemandem mehr angefasst. certrenewer arbeitet ausschliesslich
|
||||
// die Tabelle ab und sieht es deshalb nie.
|
||||
// 2. Der Scheduler blockt auf einem Nicht-VIP-Master jede ACME-Erneuerung
|
||||
// (v1.3.20). Das ist fuer geteilte Domains richtig — die zeigen per DNS
|
||||
// auf die VIP, nur der Master kann die Challenge bestehen. Fuer das
|
||||
// eigene Management-FQDN stimmt es NICHT: das zeigt auf die eigene IP
|
||||
// des Nodes, der die HTTP-01-Challenge also selbst beantworten kann.
|
||||
//
|
||||
// Deshalb laeuft diese Pruefung auf JEDEM Node, unabhaengig von der VIP —
|
||||
// aber ausschliesslich fuer das eigene FQDN aus setup.json.
|
||||
//
|
||||
// Bewusst NICHT ueber die tls_certs-Tabelle: die ist eine replizierte
|
||||
// Shared-Table, und cluster-reconcile-replication TRUNCATEt solche Tabellen
|
||||
// beim Refresh. Eine lokal auf dem Subscriber eingefuegte Zeile waere beim
|
||||
// naechsten Paket-Upgrade wieder weg. Das Management-Zertifikat ist
|
||||
// node-lokale Infrastruktur (wie die cluster-tls-Certs) und wird auch so
|
||||
// behandelt: reine Datei unter certDir.
|
||||
//
|
||||
// Existiert dagegen eine tls_certs-Zeile fuer das eigene FQDN (so ist es
|
||||
// auf dem Primary, dessen FQDN beim Setup regulaer als Domain angelegt
|
||||
// wurde), bleibt alles beim Alten — dann macht certrenewer weiter seine
|
||||
// Arbeit und wir fassen nichts an. Sonst haetten wir zwei Mechanismen auf
|
||||
// derselben Datei.
|
||||
|
||||
// mgmtCertRenewThreshold: ab wann erneuert wird. Gleicher Wert wie der
|
||||
// certrenewer fuer die Domain-Certs.
|
||||
const mgmtCertRenewThreshold = 30 * 24 * time.Hour
|
||||
|
||||
// runManagementCertRenew prueft das eigene Management-Zertifikat und
|
||||
// erneuert es bei Bedarf. Best-effort: Fehler werden geloggt/gemeldet,
|
||||
// der Tick laeuft beim naechsten Zyklus erneut.
|
||||
func runManagementCertRenew(
|
||||
ctx context.Context,
|
||||
setupStore *setup.Store,
|
||||
tlsRepo *tlscerts.Repo,
|
||||
issuer interface {
|
||||
Issue(domain string) (string, string, string, error)
|
||||
},
|
||||
a *alerts.Service, d *dedupe,
|
||||
) {
|
||||
if setupStore == nil || issuer == nil {
|
||||
return
|
||||
}
|
||||
st, err := setupStore.Load()
|
||||
if err != nil || st == nil || st.FQDN == "" {
|
||||
return
|
||||
}
|
||||
fqdn := strings.ToLower(strings.TrimSpace(st.FQDN))
|
||||
|
||||
// Wird das FQDN bereits als regulaere Domain verwaltet, ist der
|
||||
// certrenewer zustaendig — nicht zusaetzlich hier anfassen.
|
||||
if tlsRepo != nil {
|
||||
if managed, err := mgmtCertIsManaged(ctx, tlsRepo, fqdn); err == nil && managed {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
path := filepath.Join(certDir, fqdn+".pem")
|
||||
remaining, err := certRemainingValidity(path)
|
||||
switch {
|
||||
case err != nil:
|
||||
slog.Info("scheduler: management cert missing/unreadable — issuing",
|
||||
"fqdn", fqdn, "path", path, "error", err)
|
||||
case remaining > mgmtCertRenewThreshold:
|
||||
return // noch lange gueltig
|
||||
default:
|
||||
slog.Info("scheduler: management cert expiring — renewing",
|
||||
"fqdn", fqdn, "remaining", remaining.Round(time.Hour).String())
|
||||
}
|
||||
|
||||
certPEM, chainPEM, keyPEM, err := issuer.Issue(fqdn)
|
||||
if err != nil {
|
||||
slog.Error("scheduler: management cert issue failed", "fqdn", fqdn, "error", err)
|
||||
if a != nil && d != nil && d.shouldFire("cert.mgmt_renew_failed:"+fqdn) {
|
||||
_, _ = a.Fire(ctx, "cert.mgmt_renew_failed", alerts.SeverityError,
|
||||
"Management-Zertifikat konnte nicht erneuert werden: "+fqdn,
|
||||
"Die HTTP-01-Challenge fuer das eigene Management-FQDN ist fehlgeschlagen. "+
|
||||
"Pruefe, ob "+fqdn+" auf die oeffentliche IP DIESES Nodes zeigt und Port 80 "+
|
||||
"von aussen erreichbar ist. Fehler: "+err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := certstore.WriteCombined(certDir, fqdn, certPEM, chainPEM, keyPEM); err != nil {
|
||||
slog.Error("scheduler: management cert write failed", "fqdn", fqdn, "error", err)
|
||||
return
|
||||
}
|
||||
if err := reloadHAProxyForMgmtCert(); err != nil {
|
||||
slog.Warn("scheduler: haproxy reload after management cert renewal failed", "error", err)
|
||||
}
|
||||
slog.Info("scheduler: management cert renewed", "fqdn", fqdn)
|
||||
}
|
||||
|
||||
// mgmtCertIsManaged sagt, ob fuer das FQDN bereits eine tls_certs-Zeile
|
||||
// existiert (dann gehoert es dem certrenewer).
|
||||
func mgmtCertIsManaged(ctx context.Context, repo *tlscerts.Repo, fqdn string) (bool, error) {
|
||||
rows, err := repo.List(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, r := range rows {
|
||||
if strings.EqualFold(strings.TrimSpace(r.Domain), fqdn) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// certRemainingValidity liest die Restlaufzeit des ersten Zertifikats in
|
||||
// einer kombinierten PEM-Datei. Fehler (Datei fehlt, unlesbar, kein
|
||||
// Zertifikat drin) bedeuten "muss ausgestellt werden".
|
||||
func certRemainingValidity(path string) (time.Duration, error) {
|
||||
raw, err := os.ReadFile(path) //nolint:gosec // fester Pfad aus certDir + eigenem FQDN
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rest := raw
|
||||
for {
|
||||
var block *pem.Block
|
||||
block, rest = pem.Decode(rest)
|
||||
if block == nil {
|
||||
return 0, os.ErrNotExist
|
||||
}
|
||||
if block.Type != "CERTIFICATE" {
|
||||
continue
|
||||
}
|
||||
crt, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return time.Until(crt.NotAfter), nil
|
||||
}
|
||||
}
|
||||
|
||||
// reloadHAProxyForMgmtCert: "haproxy.service" ausgeschrieben, weil die
|
||||
// sudoers-Regel im postinst exakt darauf gepinnt ist — ohne Suffix wuerde
|
||||
// sudo den Aufruf ablehnen. Gleicher Aufruf wie in certrenewer.
|
||||
func reloadHAProxyForMgmtCert() error {
|
||||
//nolint:noctx // System-Reload darf nicht am Tick-Context haengen
|
||||
out, err := exec.Command("sudo", "-n", "/usr/bin/systemctl", "reload", "haproxy.service").CombinedOutput()
|
||||
if err != nil {
|
||||
return &exitErr{msg: strings.TrimSpace(string(out)), err: err}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type exitErr struct {
|
||||
msg string
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *exitErr) Error() string { return e.err.Error() + ": " + e.msg }
|
||||
func (e *exitErr) Unwrap() error { return e.err }
|
||||
113
cmd/edgeguard-scheduler/mgmtcert_test.go
Normal file
113
cmd/edgeguard-scheduler/mgmtcert_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// certRemainingValidity entscheidet, ob ueberhaupt erneuert wird — ein
|
||||
// falsches Ergebnis heisst entweder "Zertifikat laeuft unbemerkt ab"
|
||||
// (genau der Befund auf utm-2) oder "wir erneuern bei jedem Tick".
|
||||
func writeTestPEM(t *testing.T, dir, name string, notAfter time.Time, withKey bool) string {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("key: %v", err)
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: name},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: notAfter,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("cert: %v", err)
|
||||
}
|
||||
var buf []byte
|
||||
// Reihenfolge wie certstore.WriteCombined: erst Cert(-Kette), dann Key.
|
||||
buf = append(buf, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})...)
|
||||
if withKey {
|
||||
kd, err := x509.MarshalECPrivateKey(key)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal key: %v", err)
|
||||
}
|
||||
buf = append(buf, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kd})...)
|
||||
}
|
||||
p := filepath.Join(dir, name+".pem")
|
||||
if err := os.WriteFile(p, buf, 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestCertRemainingValidity_LongLived(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := writeTestPEM(t, dir, "node.example.com", time.Now().Add(60*24*time.Hour), true)
|
||||
got, err := certRemainingValidity(p)
|
||||
if err != nil {
|
||||
t.Fatalf("unerwarteter Fehler: %v", err)
|
||||
}
|
||||
if got <= mgmtCertRenewThreshold {
|
||||
t.Errorf("60d-Cert muss ueber dem 30d-Schwellwert liegen, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertRemainingValidity_ExpiringSoon(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := writeTestPEM(t, dir, "node.example.com", time.Now().Add(5*24*time.Hour), true)
|
||||
got, err := certRemainingValidity(p)
|
||||
if err != nil {
|
||||
t.Fatalf("unerwarteter Fehler: %v", err)
|
||||
}
|
||||
if got > mgmtCertRenewThreshold {
|
||||
t.Errorf("5d-Cert muss unter dem Schwellwert liegen, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Der utm-2-Fall: bereits abgelaufen → negative Restlaufzeit, also
|
||||
// eindeutig unter dem Schwellwert und damit erneuerungspflichtig.
|
||||
func TestCertRemainingValidity_AlreadyExpired(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := writeTestPEM(t, dir, "node.example.com", time.Now().Add(-14*24*time.Hour), true)
|
||||
got, err := certRemainingValidity(p)
|
||||
if err != nil {
|
||||
t.Fatalf("unerwarteter Fehler: %v", err)
|
||||
}
|
||||
if got >= 0 {
|
||||
t.Errorf("abgelaufenes Cert muss negative Restlaufzeit liefern, got %v", got)
|
||||
}
|
||||
if got > mgmtCertRenewThreshold {
|
||||
t.Errorf("abgelaufenes Cert muss erneuert werden, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCertRemainingValidity_MissingFile(t *testing.T) {
|
||||
if _, err := certRemainingValidity(filepath.Join(t.TempDir(), "nope.pem")); err == nil {
|
||||
t.Error("fehlende Datei muss einen Fehler liefern (→ ausstellen)")
|
||||
}
|
||||
}
|
||||
|
||||
// Nur-Key-Datei: darf nicht als gueltiges Zertifikat durchgehen, sonst
|
||||
// wuerde ein kaputter Zustand nie repariert.
|
||||
func TestCertRemainingValidity_NoCertificateBlock(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "keyonly.pem")
|
||||
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
kd, _ := x509.MarshalECPrivateKey(key)
|
||||
if err := os.WriteFile(p, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kd}), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if _, err := certRemainingValidity(p); err == nil {
|
||||
t.Error("PEM ohne CERTIFICATE-Block muss einen Fehler liefern")
|
||||
}
|
||||
}
|
||||
107
cmd/edgeguard-waf/main.go
Normal file
107
cmd/edgeguard-waf/main.go
Normal file
@@ -0,0 +1,107 @@
|
||||
// Command edgeguard-waf is the per-domain WAF SPOE agent for EdgeGuard.
|
||||
// HAProxy connects to it via the SPOE protocol (127.0.0.1:9000).
|
||||
// It loads per-domain WAF configs from PostgreSQL and uses Coraza v3
|
||||
// with the OWASP Core Rule Set to inspect HTTP requests.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/waf"
|
||||
intwaf "git.netcell-it.de/projekte/edgeguard-native/internal/waf"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
dsn := database.ConnStringFromEnv()
|
||||
pool, err := database.Open(ctx, dsn)
|
||||
if err != nil {
|
||||
slog.Error("waf: db connect", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
if err := database.Migrate(ctx, ""); err != nil {
|
||||
slog.Error("waf: migrate", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
repo := waf.New(pool)
|
||||
|
||||
crsDir := os.Getenv("EDGEGUARD_WAF_CRS_DIR")
|
||||
if crsDir == "" {
|
||||
crsDir = intwaf.DefaultCRSDir
|
||||
}
|
||||
spoeAddr := os.Getenv("EDGEGUARD_WAF_ADDR")
|
||||
if spoeAddr == "" {
|
||||
spoeAddr = intwaf.DefaultSPOEAddr
|
||||
}
|
||||
|
||||
mgr := intwaf.NewManager(crsDir)
|
||||
|
||||
// Initial load.
|
||||
if err := reload(ctx, repo, mgr); err != nil {
|
||||
slog.Error("waf: initial load", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Periodic config refresh every 30 seconds.
|
||||
go func() {
|
||||
t := time.NewTicker(30 * time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
if err := reload(ctx, repo, mgr); err != nil {
|
||||
slog.Warn("waf: reload", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
alertWriter := intwaf.NewAlertWriter(pool, 2048)
|
||||
|
||||
agent := intwaf.SPOEAgent{
|
||||
Manager: mgr,
|
||||
AlertWriter: alertWriter,
|
||||
Addr: spoeAddr,
|
||||
}
|
||||
|
||||
slog.Info("waf: SPOE agent starting", "addr", spoeAddr, "crs", crsDir)
|
||||
if err := agent.ListenAndServe(ctx); err != nil && ctx.Err() == nil {
|
||||
slog.Error("waf: SPOE agent stopped", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
// Graceful shutdown (ctx canceled): gepufferte Alerts flushen.
|
||||
alertWriter.Close()
|
||||
}
|
||||
|
||||
// reload fetches all domain+waf_config pairs from DB and rebuilds engines.
|
||||
func reload(ctx context.Context, repo *waf.Repo, mgr *intwaf.Manager) error {
|
||||
configs, err := repo.ListAllWithDomain(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
domains := make([]intwaf.DomainConfig, 0, len(configs))
|
||||
for _, c := range configs {
|
||||
domains = append(domains, intwaf.DomainConfig{
|
||||
Hostname: c.Hostname,
|
||||
Config: c.Config,
|
||||
})
|
||||
}
|
||||
return mgr.Reload(domains)
|
||||
}
|
||||
|
||||
// Ensure models package is used (imported transitively via services/waf).
|
||||
var _ = models.WafConfig{}
|
||||
40
deploy/keepalived/keepalived.conf.tpl
Normal file
40
deploy/keepalived/keepalived.conf.tpl
Normal file
@@ -0,0 +1,40 @@
|
||||
global_defs {
|
||||
router_id {{ .RouterID }}
|
||||
script_user root
|
||||
enable_script_security
|
||||
vrrp_garp_interval 0
|
||||
vrrp_gna_interval 0
|
||||
}
|
||||
|
||||
vrrp_script chk_edgeguard {
|
||||
script "/usr/lib/edgeguard/keepalived-check.sh"
|
||||
interval 2
|
||||
weight -50
|
||||
fall 3
|
||||
rise 2
|
||||
}
|
||||
|
||||
vrrp_instance VI_1 {
|
||||
state {{ .State }}
|
||||
interface {{ .Interface }}
|
||||
virtual_router_id {{ .RouterID }}
|
||||
priority {{ .Priority }}
|
||||
advert_int 1
|
||||
{{ if .SrcIP }} unicast_src_ip {{ .SrcIP }}
|
||||
unicast_peer {
|
||||
{{ .PeerIP }}
|
||||
}
|
||||
{{ end }} authentication {
|
||||
auth_type PASS
|
||||
auth_pass {{ .AuthPass }}
|
||||
}
|
||||
virtual_ipaddress {
|
||||
{{ .VIP }}
|
||||
}
|
||||
track_script {
|
||||
chk_edgeguard
|
||||
}
|
||||
notify_master "/usr/lib/edgeguard/keepalived-master.sh"
|
||||
notify_backup "/usr/lib/edgeguard/keepalived-backup.sh"
|
||||
notify_fault "/usr/lib/edgeguard/keepalived-backup.sh"
|
||||
}
|
||||
@@ -41,7 +41,7 @@ SystemCallFilter=@system-service
|
||||
# direkt in den distro-Conf-Dir (chrony+unbound) bzw. legen Symlinks
|
||||
# nach /etc/edgeguard/wireguard (wg). Ohne diese Pfade scheitern alle
|
||||
# UI-Mutationen an DNS/NTP/WireGuard-Settings still mit EROFS.
|
||||
ReadWritePaths=/etc/edgeguard /var/lib/edgeguard /var/log/edgeguard /var/backups/edgeguard /var/lib/apt /var/cache/apt /etc/apt/apt.conf.d /etc/chrony/conf.d /etc/unbound/unbound.conf.d /etc/wireguard
|
||||
ReadWritePaths=/etc/edgeguard /var/lib/edgeguard /var/log/edgeguard /var/backups/edgeguard /var/lib/apt /var/cache/apt /etc/apt/apt.conf.d /etc/chrony/conf.d /etc/unbound/unbound.conf.d /etc/wireguard /var/lib/crowdsec
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
32
deploy/systemd/edgeguard-waf.service
Normal file
32
deploy/systemd/edgeguard-waf.service
Normal file
@@ -0,0 +1,32 @@
|
||||
[Unit]
|
||||
Description=EdgeGuard WAF SPOE Agent (Coraza/OWASP CRS)
|
||||
Documentation=https://git.netcell-it.de/projekte/edgeguard-native
|
||||
After=network-online.target postgresql.service edgeguard-api.service
|
||||
Wants=network-online.target
|
||||
Requires=postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=edgeguard
|
||||
Group=edgeguard
|
||||
ExecStart=/usr/bin/edgeguard-waf
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
# Hardening — WAF agent only needs DB access and one TCP listen socket.
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=true
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
SystemCallFilter=@system-service
|
||||
# CRS rules are read from /usr/share/edgeguard/waf/crs/ (read-only, OK).
|
||||
# Alerts/logs are written to /var/log/edgeguard/.
|
||||
ReadWritePaths=/var/log/edgeguard
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,6 +1,8 @@
|
||||
# EdgeGuard — Architektur
|
||||
|
||||
> Status: **v0.1 (Entwurf)** · Stand: 2026-05-08 · Ziel-Plattformen: Debian 13 (Trixie) + Ubuntu 24.04 LTS (Noble Numbat), Architekturen amd64 + arm64.
|
||||
> Status: **in Produktion** (v1.2.x) · Entwurf: 2026-05-08 · **Cluster/HA-Abschnitte an Ist-Stand angeglichen: 2026-06-06** · Plattform: Debian 13 (Trixie), amd64 + arm64.
|
||||
>
|
||||
> ⚠️ **Lesehinweis:** Dieses Dokument war ursprünglich ein Entwurf. Mehrere Cluster/HA-Annahmen wurden anders umgesetzt — maßgeblich sind die mit „**Ist-Stand**" markierten Stellen (§0, §6–§9). Kurzfassung: **Logical Replication** statt Streaming, **keepalived/VRRP** statt Hoster-Floating-IP, **KeyDB optional/ungenutzt** (Cluster-State in PostgreSQL), **kein Write-Proxy**.
|
||||
|
||||
EdgeGuard ist die native Neufassung des bisherigen Docker-basierten Reverse-Proxy/Loadbalancer/Forward-Proxy/VPN-Stacks. Vorbild für Architektur, Build-System und Cluster-Modell ist [`mail-gateway`](../../mail-gateway/docs/architecture.md) (`nmg`); UI-Pattern und Bootstrap-Onliner stammen aus [`netcell-webpanel`](../../netcell-webpanel/CLAUDE.md) (`enconf`).
|
||||
|
||||
@@ -8,14 +10,14 @@ EdgeGuard ist die native Neufassung des bisherigen Docker-basierten Reverse-Prox
|
||||
|
||||
## 0. Leitplanken (nicht verhandelbar)
|
||||
|
||||
- **Kein Docker.** Alle Dienste nativ unter `systemd`, installiert via `apt`. Distro-Pakete für Drittsoftware (HAProxy, Squid, WireGuard, Unbound, PostgreSQL, KeyDB, certbot), eigene `.deb`-Pakete für EdgeGuard-Code (api, ui, ctl).
|
||||
- **Plattform-Matrix:** Debian 13 (Trixie) **und** Ubuntu 24.04 LTS (Noble Numbat), je amd64 + arm64. Alle vier Targets gleichberechtigt.
|
||||
- **Kein Docker.** Alle Dienste nativ unter `systemd`, installiert via `apt`. Distro-Pakete für Drittsoftware (HAProxy, Squid, WireGuard, Unbound, PostgreSQL, keepalived, chrony, certbot), eigene `.deb`-Pakete für EdgeGuard-Code (api, ui, ctl).
|
||||
- **Plattform:** **Debian 13 (Trixie), amd64 + arm64.** Nur Trixie — die Build-/Publish-Pipeline (`Makefile`, `scripts/apt-repo/`) zielt ausschließlich auf `trixie`. _(Eine frühere Ubuntu-24.04-Matrix war geplant, ist aber nicht implementiert.)_
|
||||
- **Auslieferung:** signierte `.deb`-Pakete + Meta-Paket via APT. Bootstrap ist der enconf-analoge curl-Onliner `curl -fsSL https://get.edgeguard.netcell-it.de | sudo bash`.
|
||||
- **HA nativ als Cluster:** N symmetrische Peers, **KeyDB Active-Active** für Shared State + **PostgreSQL Streaming Replication** (single writer, transparenter API-Write-Proxy) + **Floating-IP des Hosters** für HTTP/HTTPS-Ingress (nicht VRRP, nicht DNS-RR).
|
||||
- **Kein WAF, kein IDS, kein DHCP, kein RADIUS, keine Mail-Funktion in v1.** Mail-Gateway ist eigenes Produkt (`nmg`); WAF/CrowdSec/Suricata kommen ggf. in v2.
|
||||
- **HA nativ als Cluster (Ist-Stand 2026-06):** N symmetrische Peers, **PostgreSQL Logical Replication** (ein Publisher/Primary `edgeguard_shared` → N Subscriber; node-lokale Tabellen ausgenommen) + **keepalived/VRRP** für den VIP-Failover (HTTP/HTTPS-Ingress) + **mTLS-Cluster-Agent** (:8443) für Heartbeat/Cert-Sync/Aggregation. **KeyDB ist optional** (`Recommends`) und im Code praktisch ungenutzt; ein Write-Proxy existiert nicht (Writes erfolgen am Primary). _(Der ursprüngliche Entwurf — KeyDB Active-Active, PG-Streaming-Replication mit transparentem Write-Proxy, Floating-IP-statt-VRRP — wurde so nicht umgesetzt; Details in §6–§9.)_
|
||||
- **WAF, IDS/IPS, DHCP, RADIUS inzwischen umgesetzt** (Stand 2026-06): WAF via Coraza+SPOE, IDS/IPS via CrowdSec, DHCP via Kea, RADIUS via FreeRADIUS. Mail-Funktion bleibt ausgeschlossen — Mail-Gateway ist eigenes Produkt (`nmg`).
|
||||
- **Migrations:** `goose` (SQL-Dateien), nicht GORM AutoMigrate.
|
||||
|
||||
**Nicht-Ziele (ausdrücklich):** kein WAF, kein Network-IDS (Suricata), kein IPS (CrowdSec), kein DHCP-Server (Kea), kein RADIUS, keine Mail-Verarbeitung, keine Multi-Tenant-GuardZones in v1, keine ISO-Builds (kein EdgeGuardOS-Klon — nur APT).
|
||||
**Nicht-Ziele (weiterhin):** kein Network-IDS via Suricata (IDS/IPS läuft über CrowdSec), keine Mail-Verarbeitung, keine Multi-Tenant-GuardZones, keine ISO-Builds (kein EdgeGuardOS-Klon — nur APT). _(Historisch waren auch WAF/DHCP/RADIUS/IPS Nicht-Ziele — siehe oben, inzwischen umgesetzt.)_
|
||||
|
||||
---
|
||||
|
||||
@@ -33,12 +35,13 @@ EdgeGuard ist die native Neufassung des bisherigen Docker-basierten Reverse-Prox
|
||||
|
||||
| Komponente | Rolle |
|
||||
|---|---|
|
||||
| `edgeguard-api` | Go/Gin REST-API, bindet `127.0.0.1:9443`, Reads aus lokaler PG, Writes an Cluster-Primary |
|
||||
| `edgeguard-scheduler` | Cron-artige Jobs (ACME-Renewal-Hook, Backup, Health-Aggregation, License-Heartbeat) |
|
||||
| `edgeguard-ctl` | CLI für Setup/Wartung (`initdb`, `migrate`, `cluster-join`, `promote`, `dump-config`) |
|
||||
| `edgeguard-api` | Go/Gin REST-API, bindet `127.0.0.1:9443`, Reads/Writes auf lokaler PG. Geteilte Tabellen werden vom Primary per Logical Replication an Subscriber verteilt; Writes sollen am Primary erfolgen (keine Write-Proxy-Umleitung im Code). |
|
||||
| `edgeguard-waf` | Coraza-WAF-Agent (HAProxy SPOE) — Binary im `edgeguard-api`-Paket, eigene systemd-Unit |
|
||||
| `edgeguard-scheduler` | Cron-artige Jobs (ACME-Renewal-Hook, Backup, Health-Aggregation, Stale-Node-Sweep, License-Heartbeat) |
|
||||
| `edgeguard-ctl` | CLI für Setup/Wartung (`initdb`, `migrate`, `cluster-join`, `promote`, `cluster-init-replication`, `cluster-setup-standby`, `dump-config`) |
|
||||
| `management-ui` | React 19 + AntD 6 + Vite, statisch unter `/usr/share/edgeguard/ui/`, von `edgeguard-api` per gin `StaticFS` ausgeliefert (HAProxy proxied Management-FQDN dorthin) |
|
||||
| **PostgreSQL 16** | Single Source of Truth — Domains, Backends, Routing-Rules, ACLs, Peers, etc. |
|
||||
| **KeyDB** (Redis-kompatibel) | Active-Active-Replication, Cluster-State, Locks, Rate-Counter, Pub/Sub für Config-Reload |
|
||||
| **PostgreSQL 16/17** | Single Source of Truth — Domains, Backends, Routing-Rules, ACLs, Peers, Cluster-State (`ha_nodes`), Lizenz etc. |
|
||||
| **KeyDB** (optional) | `Recommends`, im Code praktisch ungenutzt — kein Redis-Client in `go.mod`. Cluster-State/Heartbeat/Locks liegen in PostgreSQL, nicht in KeyDB. |
|
||||
|
||||
---
|
||||
|
||||
@@ -61,10 +64,15 @@ EdgeGuard ist die native Neufassung des bisherigen Docker-basierten Reverse-Prox
|
||||
│ ├── wireguard/ # WireGuard-Config-Generator (wg-quick + wg syncconf)
|
||||
│ ├── unbound/ # Unbound-Config-Generator (Forwarder + Cluster-DNS)
|
||||
│ ├── firewall/ # nftables-Ruleset-Generator
|
||||
│ ├── cluster/ # Join/Promote/Peer-Discovery, KeyDB-Replication-Setup, pg_basebackup
|
||||
│ ├── proxy/ # API-Write-Proxy-Middleware (Replica → Primary), mTLS-Calls
|
||||
│ ├── aggregator/ # Cluster-View-APIs (alle Backends, alle Peers, alle Health-States)
|
||||
│ └── license/ # License-Validation, License-Leader-Election (KeyDB-Lock)
|
||||
│ ├── cluster/ # Join/Promote/Peer-Discovery, Heartbeat, Logical-Replication-Setup, confighash
|
||||
│ ├── keepalived/ # keepalived/VRRP-Config-Generator (VIP-Failover)
|
||||
│ ├── chrony/ # chrony-Config-Generator (NTP)
|
||||
│ ├── kea/ # Kea-DHCP4-Config-Generator
|
||||
│ ├── freeradius/ # FreeRADIUS-Config-Generator (RADIUS)
|
||||
│ ├── crowdsec/ # CrowdSec-IDS/IPS-Management (managed-wenn-installiert)
|
||||
│ ├── waf/ # Coraza-WAF-Engine + SPOE-Agent-Logik
|
||||
│ ├── aggregator/ # Cluster-View-APIs via mTLS (read-only Fan-Out + Trigger-Actions)
|
||||
│ └── license/ # License-Validation (jeder Node verifiziert eigenständig — KEINE KeyDB-Leader-Election)
|
||||
├── management-ui/ # React 19 + AntD 6 + Vite (Struktur 1:1 wie netcell-webpanel/management-ui/)
|
||||
├── packaging/
|
||||
│ └── debian/
|
||||
@@ -94,17 +102,17 @@ EdgeGuard ist die native Neufassung des bisherigen Docker-basierten Reverse-Prox
|
||||
|
||||
## 3. Debian-Pakete
|
||||
|
||||
Drei Pakete + Meta — analog nmg, kein WAF-Paket weil kein WAF in v1.
|
||||
Drei Pakete + Meta — analog nmg. Der WAF-Agent `edgeguard-waf` ist **kein eigenes Paket**, sondern liegt als zusätzliches Binary im `edgeguard-api`-Paket (eigene systemd-Unit).
|
||||
|
||||
| Paket | Arch | Inhalt | Depends |
|
||||
|---|---|---|---|
|
||||
| `edgeguard-api` | amd64, arm64 | `/usr/bin/edgeguard-{api,scheduler,ctl}`, Unit-Files, Migrations, Default-Configs | `postgresql-16`, `keydb-server`, `haproxy`, `squid`, `wireguard-tools`, `unbound`, `nftables`, `certbot`, `openssl` |
|
||||
| `edgeguard-api` | amd64, arm64 | `/usr/bin/edgeguard-{api,scheduler,ctl,waf}`, Unit-Files, Migrations, Default-Configs | `postgresql-16 \| postgresql-17`, `haproxy (>=2.8)`, `squid`, `wireguard-tools`, `unbound`, `chrony`, `kea-dhcp4-server`, `freeradius`, `nftables`, `keepalived`, `certbot`, `openssl`, `sudo`, `adduser`, `systemd`, `ca-certificates`, `ulogd2`, `ulogd2-json` u. a. · _Recommends:_ `edgeguard-keydb`, `apparmor`, `fail2ban` · _CrowdSec: managed-wenn-installiert (kein Depends)_ |
|
||||
| `edgeguard-ui` | all | `/usr/share/edgeguard/ui/` (statische Build-Artefakte) | `edgeguard-api (= ${binary:Version})` |
|
||||
| `edgeguard-meta` | all | keine Dateien, nur `Depends` | `edgeguard-api`, `edgeguard-ui` |
|
||||
|
||||
Pro Release: 1 arch-spezifisch × 2 Dists × 2 Arches = 4 `.deb` + 2 arch-agnostische × 2 Dists = 4 `.deb` → **8 Artefakte je Release**.
|
||||
Pro Release: 1 arch-spezifisches Paket (`edgeguard-api`) × **1 Dist (trixie)** × 2 Arches = 2 `.deb` + 2 arch-agnostische (`edgeguard-ui`, `edgeguard-meta`) = **4 Artefakte je Release**. (Build/Publish-Pipeline zielt nur auf `trixie`.)
|
||||
|
||||
**KeyDB-Herkunft:** KeyDB ist weder in `trixie` noch `noble` in den offiziellen Repos. Wir bauen es aus Source (amd64 + arm64), veröffentlichen es parallel im eigenen APT-Repo. `edgeguard-api` `Depends: keydb-server` löst aus unserem Repo aus.
|
||||
**KeyDB-Herkunft:** KeyDB ist optional (`Recommends: edgeguard-keydb`), nicht in den offiziellen trixie-Repos. Falls genutzt, aus Source gebaut + im eigenen APT-Repo veröffentlicht. Im aktuellen Code wird KeyDB nicht benötigt — siehe §7.
|
||||
|
||||
**Build-Werkzeug:** **direkter `dpkg-deb`-Build** analog WebPanel/EdgeGuardOS-Pattern. **Nicht** `dh_make`/`debhelper`, **nicht** `fpm`. Konsistenz mit existierendem Workflow.
|
||||
|
||||
@@ -162,7 +170,8 @@ Entspricht FHS — keine Überraschungen für Admins, Lintian-clean.
|
||||
|
||||
| Unit | Typ | Depends-on | User | Restart |
|
||||
|---|---|---|---|---|
|
||||
| `edgeguard-api.service` | `simple` | `postgresql.service`, `keydb-server.service` | `edgeguard` | `on-failure`, `RestartSec=5` |
|
||||
| `edgeguard-api.service` | `simple` | `Requires=postgresql.service`; `After=`/`Wants=keydb-server.service` (KeyDB nur weich/optional) | `edgeguard` | `on-failure`, `RestartSec=5` |
|
||||
| `edgeguard-waf.service` | `simple` | `edgeguard-api.service` (Coraza SPOE-Agent) | `edgeguard` | `on-failure` |
|
||||
| `edgeguard-scheduler.service` | `simple` | `edgeguard-api.service` | `edgeguard` | `on-failure` |
|
||||
| `edgeguard-cert-deploy.path` | `path` | — | — | — |
|
||||
| `edgeguard-firewall.service` | `oneshot`, `RemainAfterExit=true` | — | root | — |
|
||||
@@ -183,7 +192,7 @@ SystemCallFilter=@system-service
|
||||
ReadWritePaths=/var/lib/edgeguard /var/log/edgeguard /etc/edgeguard
|
||||
```
|
||||
|
||||
Drittsoftware (HAProxy, Squid, WireGuard via `wg-quick@.service`, Unbound, nftables) läuft als **Distro-Units**. EdgeGuard generiert deren Config + signalisiert Reload, übernimmt aber die Service-Verwaltung **nicht**.
|
||||
Drittsoftware läuft als **Distro-Units** — EdgeGuard generiert deren Config + signalisiert Reload/Restart, übernimmt aber die Service-Verwaltung weitgehend nicht. Renderer existieren für: **HAProxy, Squid, WireGuard (`wg-quick@.service`), Unbound, nftables, keepalived, chrony, Kea (`kea-dhcp4-server`), FreeRADIUS** (letzte beide default-off). **CrowdSec** (`crowdsec` + `crowdsec-firewall-bouncer`) wird gemanagt, wenn installiert (kein Depends).
|
||||
|
||||
API bindet auf `127.0.0.1:9443` (nicht öffentlich). HAProxy terminiert TLS auf `:443`, leitet `/.well-known/acme-challenge/*` und Management-FQDN-Traffic an die API weiter, routet alle anderen Hosts per ACL an die User-Backends.
|
||||
|
||||
@@ -191,29 +200,28 @@ API bindet auf `127.0.0.1:9443` (nicht öffentlich). HAProxy terminiert TLS auf
|
||||
|
||||
## 6. Datenbank-Setup
|
||||
|
||||
- **PostgreSQL 16**, Distro-Paket `postgresql-16`.
|
||||
- **Verbindung:** Unix-Socket (`/var/run/postgresql`) für lokale Reads + Writes der API. TCP/5432 mit TLS-Client-Cert nur zwischen Cluster-Peers für Streaming Replication.
|
||||
- **Topologie:** **ein logischer Primary** zu jedem Zeitpunkt, N Read-Replicas. Lokale API liest immer aus lokaler PG; Writes routet die API-Write-Proxy-Middleware transparent an den aktuellen Primary (KeyDB-Key `cluster:pg-primary-url`).
|
||||
- **Migrations:** `goose` (SQL-Dateien in `internal/database/migrations/`, via `//go:embed` ins Binary gepackt). **Nicht** GORM AutoMigrate.
|
||||
- **PostgreSQL 16/17**, Distro-Paket `postgresql-16 | postgresql-17`.
|
||||
- **Verbindung:** Unix-Socket (`/var/run/postgresql`) für lokale Reads + Writes der API. TCP/5432 (Rolle `edgeguard_replicator`) nur zwischen Cluster-Peers für die Logical-Replication-Verbindung.
|
||||
- **Topologie (Ist-Stand):** **Logical Replication** — ein Primary publiziert `edgeguard_shared` (alle Tabellen außer `localOnlyTables`), N Subscriber (`edgeguard_sub`, `wal_level=logical`, Initialkopie via `copy_data=true`). Jeder Node hat eine **eigene beschreibbare** PG-Instanz; geteilte Config fließt vom Primary zu den Subscribern. **Es gibt keinen Write-Proxy** — Schreibzugriffe auf geteilte Tabellen müssen am Primary erfolgen; ein Subscriber-Write auf eine replizierte Tabelle würde nicht propagieren (Drift-Banner erkennt das via `config_hash`). Primary-Erkennung zuverlässig über `pg_publication`; der Standby-Bootstrap läuft per Logical Subscription (kein `pg_basebackup` im aktiven Pfad).
|
||||
- **node-lokale Tabellen** (nicht repliziert): `ha_nodes`, `network_interfaces`, `ip_addresses`, `static_routes`, `cluster_settings`, `dns_settings`, `ntp_settings`, `dhcp_settings`, `radius_settings`, `system_settings`, `join_tokens_used`, `audit_log`, `alert_events`, `backups`, `goose_db_version` (Liste: `cmd/edgeguard-ctl/cluster_replication.go` `localOnlyTables`).
|
||||
- **Migrations:** `goose` (SQL-Dateien in `internal/database/migrations/`, via `//go:embed`). **Nicht** GORM AutoMigrate.
|
||||
|
||||
GORM bleibt als ORM für Query-Komfort; nur das Schema-Management wechselt zu `goose`.
|
||||
GORM bleibt als ORM für Query-Komfort; Schema-Management läuft über `goose`.
|
||||
|
||||
---
|
||||
|
||||
## 7. KeyDB Active-Active
|
||||
## 7. Cluster-State & KeyDB (Ist-Stand: PostgreSQL-zentrisch)
|
||||
|
||||
KeyDB ersetzt Redis. **Active-Active Replication** (Multi-Master, operation-basiert, split-brain-tolerant).
|
||||
> **Hinweis:** Der ursprüngliche Entwurf sah KeyDB Active-Active als Cluster-State-Layer vor. **Im Code ist das nicht umgesetzt** — es gibt **keinen Redis/KeyDB-Client** (`go.mod` enthält nur `pgx`). KeyDB ist optional (`Recommends`) und wird vom laufenden System nicht benötigt.
|
||||
|
||||
**Verwendung:**
|
||||
- `cluster:pg-primary-url` — wer ist aktueller PG-Primary?
|
||||
- `cluster:license-leader` — Lock für License-Heartbeat (`SET … NX EX 60`)
|
||||
- `cluster:license-status` — Cache des Lizenz-Validate-Ergebnisses (TTL 24 h)
|
||||
- `cluster:nodes:<node-id>` — Heartbeat-Marker (TTL 2 min)
|
||||
- `ratelimit:<scope>:<key>` — Rate-Counter (HINCRBY-Ops mergen korrekt)
|
||||
- `acme:lock:<domain>` — verhindert Parallel-Issue auf zwei Nodes
|
||||
- Pub/Sub: `edgeguard:config-changed` — alle Nodes regenerieren Config
|
||||
**Wie Cluster-State tatsächlich gehalten wird:**
|
||||
- **PG-Primary** — über `pg_publication` (`edgeguard_shared`) ermittelt; die Peer-Adresse für Pushes stammt aus `setup.json` `PrimaryFQDN`.
|
||||
- **Node-Heartbeat/-Status** — Spalten `last_seen`/`status` in PG `ha_nodes`. Jeder Node bumpt seine Row alle 30s (`runClusterHeartbeat`); Secondary→Primary (`runPrimaryPush`) und Primary→Secondary (`runPeerPush`) pushen sich gegenseitig per mTLS (30s, bidirektional). `SweepStaleNodes` (Scheduler) flippt Peers nach 2 min ohne Heartbeat auf `offline`.
|
||||
- **Lizenz** — jeder Node verifiziert **eigenständig** gegen `license.netcell-it.com` (kein Leader-Lock); Ergebnis in PG `licenses`.
|
||||
- **ACME** — kein verteilter Issue-Lock implementiert (Single-Node-Default; bei Cluster Issue am aktiven/Primary-Node).
|
||||
- `cluster:pg-primary-url` in KeyDB wird von `edgeguard-ctl promote` **geschrieben, falls KeyDB läuft**, aber von der API **nie gelesen** (advisory/Altlast).
|
||||
|
||||
KeyDB hört nur auf `127.0.0.1:6379` für lokale Clients und `<node-ip>:16379` (TLS) für Peer-Replication.
|
||||
_Falls KeyDB künftig wieder eingeführt wird (Rate-Limiting-Counter, Pub/Sub-Config-Reload): hört auf `127.0.0.1:6379` lokal und `<node-ip>:16379` (TLS) für Peer-Replication. Derzeit ungenutzt._
|
||||
|
||||
---
|
||||
|
||||
@@ -234,7 +242,7 @@ Unbound erfüllt zwei Rollen, beide aus PG generiert:
|
||||
- **Local-Zone** `eg.cluster.` enthält A/AAAA-Records aller Cluster-Peers (Node-Hostnamen aus PG `ha_nodes`).
|
||||
- Beispiel: `node1.eg.cluster → 10.42.0.11`, `node2.eg.cluster → 10.42.0.12`.
|
||||
- Wird bei jedem Node-Join/-Leave aus PG regeneriert + via `edgeguard:config-changed` Pub/Sub auf allen Peers neu geladen (`unbound-control reload`).
|
||||
- Cluster-interner Traffic (PG-Replication, KeyDB-Replication, mTLS-API-Calls, Cert-Push) löst Peer-Adressen ausschließlich über diese Zone auf — kein DNS-Roundtrip ins öffentliche Internet, keine `/etc/hosts`-Synchronisation.
|
||||
- Cluster-interner Traffic (PG-Logical-Replication, mTLS-Agent-Calls auf :8443, Cert-Push) löst Peer-Adressen ausschließlich über diese Zone auf — kein DNS-Roundtrip ins öffentliche Internet, keine `/etc/hosts`-Synchronisation.
|
||||
- `<node-name>.eg.cluster` ist **nicht extern erreichbar** (nur über Unbound der Cluster-Peers).
|
||||
|
||||
### Config-Schichten
|
||||
@@ -253,54 +261,56 @@ Reload via `unbound-control reload` (kein Restart, keine Cache-Invalidierung au
|
||||
|
||||
## 8. Cluster-Topologie & HA pro Service
|
||||
|
||||
**N symmetrische Peers** (1 … N Nodes, jeder vollwertig). Keine VRRP, keine Master/Backup-Rollen für Daten-Services. Public-IP: **Floating-IP des Hosters** (siehe §9).
|
||||
**N symmetrische Peers** (1 … N Nodes, jeder vollwertig). Public-IP-Failover via **VIP/VRRP (keepalived)** — siehe §9 (der ursprünglich geplante „Floating-IP statt VRRP"-Ansatz wurde **nicht** umgesetzt).
|
||||
|
||||
| Service | HA-Strategie |
|
||||
|---|---|
|
||||
| **HAProxy** | stateless, pro Node identisch. Floating-IP zeigt zum aktuellen aktiven Node; bei Node-Ausfall API-Call zum Hoster (oder manueller Switch) reicht. ACME-Issue nur auf License-Leader (KeyDB-Lock); Zerts werden via PG/mTLS an alle verteilt. |
|
||||
| **Squid** | stateless (Cache lokal, kein Sync nötig). Pro Node identische ACL-Config. |
|
||||
| **VIP/keepalived** | VRRP (`vrrp_instance`), MASTER/BACKUP per `pg_role` (primary→prio 200/MASTER, standby→100/BACKUP). VIPs aus `ip_addresses` (`is_vip=true`). Trägt den HTTP/HTTPS-Ingress. |
|
||||
| **HAProxy** | stateless, pro Node identisch. Hört auf der VIP des aktiven Node. ACME-Issue ohne verteilten Lock (Single-/Primary-Node); Zerts werden via mTLS (`/agent/cluster/tls-certs`) an alle verteilt. |
|
||||
| **Squid** | stateless (Cache lokal). Pro Node identische ACL-Config. |
|
||||
| **WireGuard** | siehe §8.1 |
|
||||
| **Unbound** | stateless (Cache lokal). Pro Node identische Forwarder-Config + identische Cluster-internen Local-Zones (siehe §7.5). |
|
||||
| **nftables** | pro Node identisch, Ruleset aus PG generiert. `crowdsec_blocklist`/`threat_intel_blocklist`-Sets entfallen in v1 (kein CrowdSec). |
|
||||
| **edgeguard-api** | pro Node, Reads lokal, Writes via Proxy zu Primary. |
|
||||
| **edgeguard-ui** | statisch, pro Node identisch. |
|
||||
| **PostgreSQL** | Streaming Replication, manueller Promote (siehe nmg §6.2). |
|
||||
| **KeyDB** | Active-Active. |
|
||||
| **Unbound** | stateless (Cache lokal). Pro Node identische Forwarder-Config + Cluster-Local-Zones (§7.5). |
|
||||
| **nftables** | pro Node, Ruleset aus PG generiert. CrowdSec-Blocklist via `crowdsec-firewall-bouncer` (eigene Sets), wenn CrowdSec installiert. |
|
||||
| **edgeguard-api** | pro Node, Reads lokal. Writes auf geteilte Tabellen am Primary (kein Write-Proxy). |
|
||||
| **edgeguard-ui / edgeguard-waf** | statisch bzw. pro Node identisch. |
|
||||
| **PostgreSQL** | **Logical Replication** (Publisher→Subscriber), manueller Promote (§8.2). |
|
||||
| **KeyDB** | optional/ungenutzt (§7). |
|
||||
|
||||
### 8.1 WireGuard im Cluster
|
||||
|
||||
Drei Optionen, für v1 wählen wir **Option A**:
|
||||
|
||||
- **A — Geteilte Server-Identität (gewählt):** alle Peers haben **denselben** Server-Privatkey + dasselbe Listen-Port. Floating-IP routet UDP zum aktiven Node. Bei Failover: Floating-IP wandert, Clients schicken Pakete zum neuen Node, neuer Handshake (~1–2s Latenz beim ersten Paket). Replay-Protection-Counter werden nicht repliziert — beim Failover macht der Client neuen Handshake, alte Counter sind irrelevant.
|
||||
- **A — Geteilte Server-Identität (gewählt):** alle Peers haben **denselben** Server-Privatkey + dasselbe Listen-Port. Die **VIP (keepalived)** trägt das WireGuard-UDP zum aktiven Node. Bei Failover: VIP wandert, Clients schicken Pakete zum neuen Node, neuer Handshake (~1–2s Latenz beim ersten Paket). Replay-Protection-Counter werden nicht repliziert — beim Failover macht der Client neuen Handshake, alte Counter sind irrelevant.
|
||||
- B — Pro Node eigene Identität, Client kennt alle: Client-Configs haben mehrere `[Peer]`-Blöcke. Aufwendiger zu provisionieren, kein Failover-Vorteil.
|
||||
- C — Aktiv/Standby per License-Leader-Pattern: nur ein Node hat WireGuard aktiv, andere idle. Verschwendet Kapazität.
|
||||
|
||||
**Begründung A:** Privatkey ist in PG (verschlüsselt mit `edgeguard.key`), wird beim Cluster-Join an neue Peers verteilt. WireGuard handelt selbständig neue Sessions aus, kein State-Sync nötig. Operation-Tools (Peer hinzufügen/entfernen) wirken auf alle Nodes via `edgeguard:config-changed` Pub/Sub + lokales `wg syncconf`.
|
||||
**Begründung A:** Privatkey liegt verschlüsselt in PG, wird per Logical Replication an die Peers verteilt. WireGuard handelt selbständig neue Sessions aus, kein State-Sync nötig. Peer-Änderungen propagieren über die Logical Replication; Secondaries erkennen die Änderung am `config_hash` (`runSecondaryConfigRender`, 5-min-Tick) und re-rendern lokal → `wg syncconf`.
|
||||
|
||||
### 8.2 Manual Promote (PG-Primary-Failover)
|
||||
|
||||
1:1 nmg-Pattern (siehe `mail-gateway/docs/architecture.md` §6.2). Bei Ausfall des Primary antworten Config-Writes mit `503 + actionable Error`. Admin promotet via UI/CLI. Datenebene (HAProxy/Squid/WireGuard/Unbound) läuft unbeeinträchtigt weiter, weil jeder Node eine lokale PG-Replica hat.
|
||||
Bei Ausfall des Primary läuft die Datenebene (HAProxy/Squid/WireGuard/Unbound) weiter, weil jeder Node eine lokale, lesbare PG-Instanz (Logical-Subscriber) hat. Schreibzugriffe auf geteilte Config müssen am Primary erfolgen — fällt der Primary aus, promotet der Admin manuell via **`edgeguard-ctl promote`**. Das ist Logical-Replication-aware: es löst die Subscription zum toten Primary (`DISABLE` + `slot_name=NONE` + `DROP`, hängt also nicht am toten Publisher), richtet die Node via `setupReplicationPrimary` als Publisher ein (Rolle/Secret/`wal_level=logical` inkl. **PG-Restart** falls nötig/Publication), setzt `ha_nodes.pg_role='primary'` und rendert keepalived (→ MASTER, übernimmt die VIP). Erholte Nodes danach mit `edgeguard-ctl cluster-setup-standby <neuer-primary>` zurückhängen. **Achtung:** echtes Cross-Node-Failover ist nur im Drill testbar — die Bausteine (Drop-Subscription, Publication, Restart) sind dieselben wie in `cluster-init-replication`/`cluster-setup-standby`.
|
||||
|
||||
### 8.3 License-Leader-Election
|
||||
### 8.3 License-Verifikation
|
||||
|
||||
Ein einziger Node kontaktiert `license.netcell-it.com` (KeyDB-Lock, 60-s-TTL). Ergebnis cluster-weit in `cluster:license-status` (TTL 24 h). `active_servers`-Verbrauchswert = Count der Peers mit Heartbeat < 2 min.
|
||||
**Kein Leader-Election** (anders als ursprünglich geplant). Jeder Node verifiziert **eigenständig** gegen `license.netcell-it.com` (Scheduler-Tick), Ergebnis in PG `licenses`. `active_servers` = Anzahl Peers mit Heartbeat < 2 min (aus `ha_nodes`). Ein KeyDB-Lock existiert nicht.
|
||||
|
||||
---
|
||||
|
||||
## 9. Public-Ingress — Floating-IP statt VRRP
|
||||
## 9. Public-Ingress — VIP via keepalived/VRRP
|
||||
|
||||
**Problem:** HTTP-Clients machen kein automatisches Failover bei DNS-RR (anders als MTAs). Ein toter A-Record = 50% Fehler bis DNS-TTL.
|
||||
> **Ist-Stand:** Umgesetzt ist **VIP-Failover über keepalived (VRRP)** — nicht der ursprünglich angedachte „Floating-IP des Hosters"-Ansatz. Es gibt **keinen** Hoster-API-Code und **keinen** `POST /cluster/promote-this-node`-Endpoint.
|
||||
|
||||
**Entscheidung:** **Floating-IP des Hosters**. Der Hoster bietet eine API zum Umroute der IP zwischen Servern (z. B. via REST oder DNS-Update bei dynamischer Anycast-Lösung). Failover dauert Sekunden, kein VRRP-Drama, kein "VIP verschwindet"-Problem aus dem alten Setup.
|
||||
**Mechanik (`internal/keepalived`):**
|
||||
- Renderer erzeugt `/etc/keepalived/keepalived.conf` mit `vrrp_instance` (unicast peer, `virtual_router_id`, `authentication`).
|
||||
- **State/Priorität aus `pg_role`:** Primary → `state MASTER`, `priority 200`; Standby → `state BACKUP`, `priority 100`.
|
||||
- **VIPs** kommen aus `ip_addresses` (`is_vip=true`, `active=true`), inkl. Interface; managed via `systemctl reload-or-restart keepalived`.
|
||||
- Bei Node-/PG-Ausfall übernimmt VRRP die VIP auf den verbleibenden Node (Sekundenbereich).
|
||||
|
||||
Optionen pro Hoster:
|
||||
1. **Provider-Floating-IP** (gewünscht): API-Call schaltet IP um. EdgeGuard exponiert `POST /api/v1/cluster/promote-this-node`, das die Hoster-API aufruft.
|
||||
2. **DNS-RR mit kurzer TTL (60s)** als Notlösung wenn keine Floating-IP verfügbar.
|
||||
3. **Anycast/BGP** als Premium-Variante (für Enterprise).
|
||||
**Tooling:** `GET/PUT /cluster/vip-settings`, `GET /cluster/vip-status`, `POST /cluster/vip-test` (Letzteres bewegt eine VIP testweise per `ip addr add/del` zwischen Nodes — kein Hoster-Call).
|
||||
|
||||
**v1-Default:** Single-Node mit fest zugewiesener Floating-IP. Cluster-Erweiterung kommt mit Phase 2.
|
||||
**v1-Default:** Single-Node. Im Cluster trägt der MASTER (Primary) die VIP.
|
||||
|
||||
⚑ **OFFEN:** Welcher Hoster ist Standard? API-Spec dokumentieren sobald geklärt.
|
||||
⚑ **OFFEN (Altlast-Bereinigung):** Doku-Abschnitte/Code, die noch „Floating-IP des Hosters" implizieren, sind historisch — der reale Pfad ist keepalived/VRRP.
|
||||
|
||||
---
|
||||
|
||||
@@ -312,7 +322,7 @@ curl -fsSL https://get.edgeguard.netcell-it.de | sudo bash
|
||||
|
||||
Schritte (idempotent, analog `netcell-webpanel/install.sh`):
|
||||
|
||||
1. **OS-Detection** (`/etc/os-release`): nur Trixie *oder* Noble, sonst Abbruch.
|
||||
1. **OS-Detection** (`/etc/os-release`): nur Debian 13 (Trixie), sonst Abbruch.
|
||||
2. **Arch-Detection**: nur amd64 *oder* arm64.
|
||||
3. **Base-Deps:** `curl gnupg ca-certificates apt-transport-https`.
|
||||
4. **APT-Keyrings:**
|
||||
@@ -329,7 +339,14 @@ curl -fsSL https://get.edgeguard.netcell-it.de | sudo bash -s -- \
|
||||
--token <cluster-join-token>
|
||||
```
|
||||
|
||||
`edgeguard-ctl cluster-join` führt aus: PG-Basebackup vom Primary, KeyDB-Replication-Setup, Node-Registrierung in `ha_nodes`, TLS-Cert-Pull via mTLS, Config-Regeneration, Service-Start.
|
||||
`edgeguard-ctl cluster-join` führt aus: TLS-Cert-Pull via mTLS (CSR→issue-cert) und Node-Registrierung in `ha_nodes` (`autoRegister`) — **mehr nicht**. Die Logical Replication ist ein eigener Schritt (`cluster-setup-standby`: `CREATE SUBSCRIPTION … copy_data=true`, Initialkopie der geteilten Tabellen, Master-Key-Sync, Config-Regeneration). _(Kein `pg_basebackup`, kein KeyDB-Setup — beides war nur im ursprünglichen Entwurf.)_
|
||||
|
||||
**Join über den Setup-Wizard (empfohlener Weg) macht beides automatisch:**
|
||||
|
||||
1. Auf dem Primary erzeugt `POST /cluster/join-tokens` den Token — und stellt dabei vorher via `cluster-init-replication` sicher, dass die Publisher-Seite steht (Replikations-Rolle + Secret, `wal_level=logical`, `pg_hba`, PUBLICATION). Ein frisch installierter Single-Node hat das alles noch nicht; ohne diesen Schritt liefe das spätere `CREATE SUBSCRIPTION` in ein 404. Idempotent; der einmalige PG-Restart (`wal_level` ist ein postmaster-Parameter) passiert bewusst hier, solange noch kein zweiter Node Traffic erwartet.
|
||||
2. Auf dem neuen Node startet `POST /setup/join-cluster` nach erfolgreichem Join `cluster-setup-standby` detached (via `sudo`, da root nötig). Fortschritt pollbar über `GET /setup/replication-status` (`running`/`done`/`failed`); der Wizard zeigt ihn an und gibt bei Fehlschlag das manuelle Kommando aus.
|
||||
|
||||
Der reine CLI-Pfad (`cluster-join`) bleibt der manuelle Weg und erfordert `cluster-setup-standby` weiterhin explizit.
|
||||
|
||||
---
|
||||
|
||||
@@ -337,7 +354,7 @@ curl -fsSL https://get.edgeguard.netcell-it.de | sudo bash -s -- \
|
||||
|
||||
- **Primärquelle:** Gitea Package Registry (`https://git.netcell-it.de/api/packages/projekte/debian`).
|
||||
- **Kunden-Mirror:** `https://apt.netcell-it.de/edgeguard/` (rsync von Gitea).
|
||||
- **Suiten:** `stable` · `testing` · `security` — pro Codename (`trixie`, `noble`).
|
||||
- **Suiten:** `stable` · `testing` · `security` — Codename `trixie`.
|
||||
- **Signatur:** GPG-Key `netcell-edgeguard-signing`, ausgeliefert in `/etc/apt/keyrings/`.
|
||||
- **Update-Check-API:** `GET /api/v1/system/package-versions` → pro `edgeguard-*`-Paket `{name, installed, available, reboot_required}`.
|
||||
- **Upgrade-Trigger:** `POST /api/v1/system/upgrade` startet `systemd-run --unit=edgeguard-upgrade.service --collect …` (HTTP-Response geht VOR dem Upgrade raus, weil API beim Self-Update stirbt — Pattern aus `netcell-webpanel/management-agent/internal/handlers/update.go:105`).
|
||||
@@ -355,13 +372,13 @@ Build-/Release-Scripts identisch zu `mail-gateway/scripts/apt-repo/`.
|
||||
- **Lizenzserver:** `https://license.netcell-it.com` (öffentlich, kein API-Key).
|
||||
- **Verify-Endpoint:** `GET /api/v1/licenses/{key}/verify?system_id={fp}&system_name={host}&active_domains={n}`.
|
||||
- **Fingerprint:** `SHA256(/etc/machine-id + erste-aktive-MAC + hostname)`.
|
||||
- **Caching:** Live → KeyDB `cluster:license-status` (TTL 24h) → `/var/lib/edgeguard/trial.json` (30 Tage) → `expired`.
|
||||
- **Leader-Election** wie nmg §6.3.
|
||||
- **Caching:** Live-Verify → Ergebnis in PG `licenses` → `/var/lib/edgeguard/trial.json` (30-Tage-Trial-Fallback) → `expired`.
|
||||
- **Keine Leader-Election** — jeder Node verifiziert eigenständig (§8.3).
|
||||
|
||||
### 12.2 ACME
|
||||
|
||||
- **certbot** (Distro-Paket) mit `--webroot=/var/lib/edgeguard/acme` — HAProxy ACL `path_beg /.well-known/acme-challenge/` proxied diese Pfade an `edgeguard-api`, das die Challenge-Tokens aus der Webroot-Dir ausliefert.
|
||||
- **Lock vor Issue:** `acme:lock:<domain>` in KeyDB verhindert Parallel-Issue auf zwei Nodes.
|
||||
- **Cluster-Locking:** derzeit **kein** verteilter Issue-Lock implementiert (Single-Node-Default; im Cluster sollte ACME am Primary/aktiven Node laufen). _(Der ursprünglich geplante KeyDB-`acme:lock:<domain>` existiert nicht.)_
|
||||
- **Deploy-Hook:** schreibt fertiges PEM (cert+chain+key kombiniert) nach `/etc/edgeguard/tls/<domain>.pem` und triggert `systemctl reload haproxy`. HAProxy lädt den `crt /etc/edgeguard/tls/`-Verzeichnisinhalt neu.
|
||||
- **Cert-Verteilung im Cluster:** Issuing-Node pushed via mTLS-API an alle Peers, Zerts landen in `/etc/edgeguard/tls/`.
|
||||
|
||||
@@ -384,14 +401,12 @@ Komponentenbibliothek, Theme, Layouts, Navigations-Struktur, Form-Patterns, i18n
|
||||
|
||||
## 14. Plattform-Matrix
|
||||
|
||||
| Distribution | Codename | Arch | Status v1 |
|
||||
| Distribution | Codename | Arch | Status |
|
||||
|---|---|---|---|
|
||||
| Debian 13 | trixie | amd64 | Tier 1 |
|
||||
| Debian 13 | trixie | arm64 | Tier 1 |
|
||||
| Ubuntu 24.04 LTS | noble | amd64 | Tier 1 |
|
||||
| Ubuntu 24.04 LTS | noble | arm64 | Tier 1 |
|
||||
|
||||
Andere Distributionen (Debian 12, Ubuntu 22.04, RHEL/Rocky) sind **nicht unterstützt**. Installer bricht hart ab.
|
||||
**Nur Debian 13 (Trixie).** Die Build-/Publish-Pipeline (`Makefile`, `scripts/apt-repo/`) zielt ausschließlich auf `trixie`; der Installer bricht auf anderem OS hart ab. _(Eine ursprünglich geplante Ubuntu-24.04-„noble"-Matrix ist nicht implementiert.)_ Andere Distributionen (Debian 12, Ubuntu, RHEL/Rocky) sind **nicht unterstützt**.
|
||||
|
||||
---
|
||||
|
||||
@@ -402,7 +417,7 @@ EdgeGuard-Native ist eigenes Repo (`git.netcell-it.de/projekte/edgeguard-native`
|
||||
1. **Frische Installation** auf Test-VM via `install.sh`.
|
||||
2. **Config-Export** aus altem Stack (`edgeguard-ctl export --from-docker`) — liest aus alter PG, schreibt in neues Format.
|
||||
3. **Validierung** Side-by-Side (alter Stack auf einem Server, neuer Stack auf anderem, Traffic vergleichen).
|
||||
4. **Cutover** via Floating-IP-Switch.
|
||||
4. **Cutover** via VIP-Umzug (keepalived) bzw. DNS-Umstellung.
|
||||
|
||||
Der alte `proxy-lb-waf`-Code bleibt für Bestandskunden im Wartungsmodus, keine neuen Features.
|
||||
|
||||
@@ -410,6 +425,6 @@ Der alte `proxy-lb-waf`-Code bleibt für Bestandskunden im Wartungsmodus, keine
|
||||
|
||||
## Offene Punkte
|
||||
|
||||
- **Hoster + Floating-IP-API** (§9): Spec dokumentieren.
|
||||
- **WireGuard-State-Replication** in der Praxis testen (Handshake-Latenz nach Floating-IP-Switch messen).
|
||||
- **Failover-Drill:** `edgeguard-ctl promote` (Logical-aware) + anschließendes `cluster-setup-standby` in einem echten 2-Node-Failover durchspielen (inkl. VIP-Umzug, WireGuard-Handshake-Latenz). _(Code-Altlasten `internal/proxy`-Stub und `promote.go`-`standby.signal` wurden 2026-06 bereinigt.)_
|
||||
- **Optional KeyDB** (Rate-Limit-Counter, Pub/Sub-Config-Reload) — falls je benötigt; aktuell ungenutzt.
|
||||
- **`get.edgeguard.netcell-it.de`** anlegen oder Übergangs-URL auf `apt.netcell-it.de/edgeguard/install.sh` nutzen.
|
||||
|
||||
86
go.mod
86
go.mod
@@ -1,71 +1,91 @@
|
||||
module git.netcell-it.de/projekte/edgeguard-native
|
||||
|
||||
go 1.26.0
|
||||
go 1.27.1
|
||||
|
||||
require (
|
||||
github.com/corazawaf/coraza/v3 v3.7.0
|
||||
github.com/coreos/go-oidc/v3 v3.21.0
|
||||
github.com/dropmorepackets/haproxy-go v0.1.1
|
||||
github.com/fsnotify/fsnotify v1.10.1
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/go-acme/lego/v4 v4.35.2
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
github.com/pressly/goose/v3 v3.27.1
|
||||
github.com/jackc/pgx/v5 v5.11.0
|
||||
github.com/minio/minio-go/v7 v7.3.0
|
||||
github.com/pkg/sftp v1.13.11
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/pressly/goose/v3 v3.28.0
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
golang.org/x/crypto v0.51.0
|
||||
golang.org/x/crypto v0.57.0
|
||||
golang.org/x/oauth2 v0.37.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/boombuler/barcode v1.0.1 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/corazawaf/libinjection-go v0.3.2 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-ini/ini v1.67.0 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.23.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gotnospirit/makeplural v0.0.0-20180622080156-a5f48d94d976 // indirect
|
||||
github.com/gotnospirit/messageformat v0.0.0-20221001023931-dfe49f1eb092 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
|
||||
github.com/kaptinlin/go-i18n v0.1.4 // indirect
|
||||
github.com/kaptinlin/jsonschema v0.4.6 // indirect
|
||||
github.com/klauspost/compress v1.19.2 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
|
||||
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||
github.com/kr/fs v0.1.0 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.21 // indirect
|
||||
github.com/magefile/mage v1.17.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||
github.com/miekg/dns v1.1.72 // indirect
|
||||
github.com/minio/crc64nvme v1.1.1 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/minio/minio-go/v7 v7.1.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.3.1 // indirect
|
||||
github.com/petar-dambovaliev/aho-corasick v0.0.0-20250424160509-463d218d4745 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/pkg/sftp v1.13.10 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.1 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||
github.com/tinylib/msgp v1.6.1 // indirect
|
||||
github.com/sethvargo/go-retry v0.4.0 // indirect
|
||||
github.com/tidwall/gjson v1.18.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tinylib/msgp v1.6.4 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
github.com/valllabh/ocsf-schema-golang v1.0.3 // indirect
|
||||
github.com/zeebo/xxh3 v1.1.0 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
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/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.44.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
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/mod v0.41.0 // indirect
|
||||
golang.org/x/net v0.58.0 // indirect
|
||||
golang.org/x/sync v0.23.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
golang.org/x/tools v0.49.0 // indirect
|
||||
google.golang.org/protobuf v1.36.12 // indirect
|
||||
gopkg.in/ini.v1 v1.67.3 // indirect
|
||||
rsc.io/binaryregexp v0.2.0 // indirect
|
||||
)
|
||||
|
||||
216
go.sum
216
go.sum
@@ -1,34 +1,46 @@
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/boombuler/barcode v1.0.1 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs=
|
||||
github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/corazawaf/coraza-coreruleset v0.0.0-20240226094324-415b1017abdc h1:OlJhrgI3I+FLUCTI3JJW8MoqyM78WbqJjecqMnqG+wc=
|
||||
github.com/corazawaf/coraza-coreruleset v0.0.0-20240226094324-415b1017abdc/go.mod h1:7rsocqNDkTCira5T0M7buoKR2ehh7YZiPkzxRuAgvVU=
|
||||
github.com/corazawaf/coraza/v3 v3.7.0 h1:LIQqu1r+l6e/U/gyiZeykWaNNBY1TzRLz+aaI+QYEEM=
|
||||
github.com/corazawaf/coraza/v3 v3.7.0/go.mod h1:dOSt5evqC7EstouEv6ghhui01+oVUwp9X1vybWwqTlo=
|
||||
github.com/corazawaf/libinjection-go v0.3.2 h1:9rrKt0lpg4WvUXt+lwS06GywfqRXXsa/7JcOw5cQLwI=
|
||||
github.com/corazawaf/libinjection-go v0.3.2/go.mod h1:Ik/+w3UmTWH9yn366RgS9D95K3y7Atb5m/H/gXzzPCk=
|
||||
github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
|
||||
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dropmorepackets/haproxy-go v0.1.1 h1:qYovzYpGHanQCBQW5k92uJqIdjcwSJQHZ0VFRiI3FJ0=
|
||||
github.com/dropmorepackets/haproxy-go v0.1.1/go.mod h1:4a2AmmVjvg2zPNdizGZrMN8ZSUpj90U43VlcdbOIBnU=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/foxcpp/go-mockdns v1.1.0 h1:jI0rD8M0wuYAxL7r/ynTrCQQq0BVqfB99Vgk7DlmewI=
|
||||
github.com/foxcpp/go-mockdns v1.1.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk=
|
||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-acme/lego/v4 v4.35.2 h1:uVQg+KC/yj9R2g7Q9W5wDqhvQvxV5SMu5eqFVoN5xZU=
|
||||
github.com/go-acme/lego/v4 v4.35.2/go.mod h1:pX2jN5n8OphMGY1IaMjYm5DAEzguBaKRt8AvJAgJXpc=
|
||||
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
@@ -37,10 +49,12 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o=
|
||||
github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
@@ -48,37 +62,41 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gotnospirit/makeplural v0.0.0-20180622080156-a5f48d94d976 h1:b70jEaX2iaJSPZULSUxKtm73LBfsCrMsIlYCUgNGSIs=
|
||||
github.com/gotnospirit/makeplural v0.0.0-20180622080156-a5f48d94d976/go.mod h1:ZGQeOwybjD8lkCjIyJfqR5LD2wMVHJ31d6GdPxoTsWY=
|
||||
github.com/gotnospirit/messageformat v0.0.0-20221001023931-dfe49f1eb092 h1:c7gcNWTSr1gtLp6PyYi3wzvFCEcHJ4YRobDgqmIgf7Q=
|
||||
github.com/gotnospirit/messageformat v0.0.0-20221001023931-dfe49f1eb092/go.mod h1:ZZAN4fkkful3l1lpJwF8JbW41ZiG9TwJ2ZlqzQovBNU=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
|
||||
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jcchavezs/mergefs v0.1.1 h1:D45R17m6dHnSVZefnhynoeZvcK2Uw0oTrRfoUOQ0S5Y=
|
||||
github.com/jcchavezs/mergefs v0.1.1/go.mod h1:eRLTrsA+vFwQZ48hj8p8gki/5v9C2bFtHH5Mnn4bcGk=
|
||||
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 h1:9Nu54bhS/H/Kgo2/7xNSUuC5G28VR8ljfrLKU2G4IjU=
|
||||
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12/go.mod h1:TBzl5BIHNXfS9+C35ZyJaklL7mLDbgUkcgXzSLa8Tk0=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/kaptinlin/go-i18n v0.1.4 h1:wCiwAn1LOcvymvWIVAM4m5dUAMiHunTdEubLDk4hTGs=
|
||||
github.com/kaptinlin/go-i18n v0.1.4/go.mod h1:g1fn1GvTgT4CiLE8/fFE1hboHWJ6erivrDpiDtCcFKg=
|
||||
github.com/kaptinlin/jsonschema v0.4.6 h1:vOSFg5tjmfkOdKg+D6Oo4fVOM/pActWu/ntkPsI1T64=
|
||||
github.com/kaptinlin/jsonschema v0.4.6/go.mod h1:1DUd7r5SdyB2ZnMtyB7uLv64dE3zTFTiYytDCd+AEL0=
|
||||
github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8=
|
||||
github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
|
||||
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
|
||||
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
|
||||
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
|
||||
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
|
||||
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
|
||||
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/magefile/mage v1.17.0 h1:dS4tkq997Ism03akafC8509iqDjeE7TNTexI25Y7sXM=
|
||||
github.com/magefile/mage v1.17.0/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||
@@ -87,8 +105,8 @@ github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI
|
||||
github.com/minio/crc64nvme v1.1.1/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
|
||||
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
|
||||
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
|
||||
github.com/minio/minio-go/v7 v7.1.0 h1:QEt5IStDpxgGjEdtOgpiZ5QhmSl3ax7qy61vi2SwHO8=
|
||||
github.com/minio/minio-go/v7 v7.1.0/go.mod h1:Dm7WS1AgLmBa0NcQD6SeJnJf+K/EUW3GR7Ks6olB3OA=
|
||||
github.com/minio/minio-go/v7 v7.3.0 h1:HM4pFCSQq/TK+j0/zmorSh5ddh81iDgRgU0BG0Vz/YU=
|
||||
github.com/minio/minio-go/v7 v7.3.0/go.mod h1:KUPWdecEO1LWyUz+sTGXAuf2jZHrPh5fCsRH86QbPfk=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@@ -97,26 +115,29 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
|
||||
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/petar-dambovaliev/aho-corasick v0.0.0-20250424160509-463d218d4745 h1:Vpr4VgAizEgEZsaMohpw6JYDP+i9Of9dmdY4ufNP6HI=
|
||||
github.com/petar-dambovaliev/aho-corasick v0.0.0-20250424160509-463d218d4745/go.mod h1:EHPiTAKtiFmrMldLUNswFwfZ2eJIYBHktdaUTZxYWRw=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
|
||||
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
|
||||
github.com/pkg/sftp v1.13.11 h1:0N92SLTB8JqASJB14ZLHHzFnBV8mG9zw4K7jghEFWuE=
|
||||
github.com/pkg/sftp v1.13.11/go.mod h1:uNkH9roSXglNJqM+glJJi+TQXQUm0fXFWqCFmT8hsN0=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
|
||||
github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
|
||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/pressly/goose/v3 v3.28.0 h1:D2M+iL31GmpZxSHOhX8mqyqAT3CXnokUmm0eKoSP+Vc=
|
||||
github.com/pressly/goose/v3 v3.28.0/go.mod h1:v26MOuB8bL3kzzrt3Vqhb3R0PRVsl8hFQKdrht/L6Rk=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
|
||||
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
||||
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
||||
github.com/sethvargo/go-retry v0.4.0 h1:9qy1OoIAxBL+gBYnkTnTnWle5wlfsXQlwRzIbbpdqPw=
|
||||
github.com/sethvargo/go-retry v0.4.0/go.mod h1:tvsjdKG6xfiCx4LSiUZ06kcv38xvdVQwv8R6/VnnVWg=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
@@ -127,56 +148,73 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
|
||||
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
|
||||
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/valllabh/ocsf-schema-golang v1.0.3 h1:eR8k/3jP/OOqB8LRCtdJ4U+vlgd/gk5y3KMXoodrsrw=
|
||||
github.com/valllabh/ocsf-schema-golang v1.0.3/go.mod h1:sZ3as9xqm1SSK5feFWIR2CuGeGRhsM7TR1MbpBctzPk=
|
||||
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
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/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/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
|
||||
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
|
||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
|
||||
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
|
||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
||||
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw=
|
||||
gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0=
|
||||
modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY=
|
||||
modernc.org/libc v1.75.6 h1:yKk8qo+Di4gkmvRboK8ocCqH22FiUCR6jRy2OwtCRus=
|
||||
modernc.org/libc v1.75.6/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U=
|
||||
modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
|
||||
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg=
|
||||
modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
|
||||
rsc.io/binaryregexp v0.2.0 h1:HfqmD5MEmC0zvwBuF187nq9mdnXjXsSivRiXN7SmRkE=
|
||||
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
|
||||
|
||||
@@ -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)))
|
||||
@@ -151,7 +151,19 @@ func (a *Aggregator) callPeer(ctx context.Context, p models.HANode, path string)
|
||||
return res
|
||||
}
|
||||
res.OK = true
|
||||
res.Data = body
|
||||
// Agent-Endpoints liefern die Standard-API-Envelope zurück
|
||||
// ({"data": {...}, "error": null, "message": "ok"}). Wir entpacken
|
||||
// das `data`-Feld so dass der Aufrufer direkt die Nutzlast bekommt —
|
||||
// konsistent mit dem Lokal-Pfad (der marshalt die Struct direkt ohne
|
||||
// Envelope).
|
||||
var env struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &env); err == nil && len(env.Data) > 0 {
|
||||
res.Data = env.Data
|
||||
} else {
|
||||
res.Data = body
|
||||
}
|
||||
res.Duration = time.Since(start).Milliseconds()
|
||||
return res
|
||||
}
|
||||
@@ -180,6 +192,80 @@ func agentURL(apiURL string, agentPort int, path string) (string, error) {
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
// PostPeer sendet einen POST-Request an einen einzelnen Peer.
|
||||
// Wird vom Rolling-Update-Orchestrator genutzt um /agent/cluster/trigger-update
|
||||
// auf dem Secondary auszulösen.
|
||||
func (a *Aggregator) PostPeer(ctx context.Context, p models.HANode, path string) PeerResult {
|
||||
start := time.Now()
|
||||
res := PeerResult{NodeID: p.ID, FQDN: p.FQDN}
|
||||
target, err := agentURL(p.APIURL, a.AgentPort, path)
|
||||
if err != nil {
|
||||
res.Err = "bad api_url: " + err.Error()
|
||||
return res
|
||||
}
|
||||
reqCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, target, nil)
|
||||
if err != nil {
|
||||
res.Err = err.Error()
|
||||
return res
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := a.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
res.Err = err.Error()
|
||||
res.Duration = time.Since(start).Milliseconds()
|
||||
return res
|
||||
}
|
||||
defer 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)))
|
||||
res.Duration = time.Since(start).Milliseconds()
|
||||
return res
|
||||
}
|
||||
res.OK = true
|
||||
res.Duration = time.Since(start).Milliseconds()
|
||||
return res
|
||||
}
|
||||
|
||||
// PostPeerWithBody sendet einen POST-Request mit JSON-Body an einen Peer.
|
||||
// Wird für VIP-Schwenk-Tests genutzt (/agent/cluster/vip-cmd).
|
||||
func (a *Aggregator) PostPeerWithBody(ctx context.Context, p models.HANode, path string, body []byte) PeerResult {
|
||||
start := time.Now()
|
||||
res := PeerResult{NodeID: p.ID, FQDN: p.FQDN}
|
||||
target, err := agentURL(p.APIURL, a.AgentPort, path)
|
||||
if err != nil {
|
||||
res.Err = "bad api_url: " + err.Error()
|
||||
return res
|
||||
}
|
||||
reqCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, target, strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
res.Err = err.Error()
|
||||
return res
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := a.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
res.Err = err.Error()
|
||||
res.Duration = time.Since(start).Milliseconds()
|
||||
return res
|
||||
}
|
||||
defer 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)))
|
||||
res.Duration = time.Since(start).Milliseconds()
|
||||
return res
|
||||
}
|
||||
res.OK = true
|
||||
res.Data = respBody
|
||||
res.Duration = time.Since(start).Milliseconds()
|
||||
return res
|
||||
}
|
||||
|
||||
// Compile-time check dass cluster importiert wird (für Drift-Detection
|
||||
// vom hashSpec — die Aggregator-Resultate werden parallel im Drift-
|
||||
// Banner mitverarbeitet). Nicht runtime-essentiell, aber dokumentiert
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -49,6 +49,30 @@ func New(pool *pgxpool.Pool) *Generator {
|
||||
|
||||
func (g *Generator) Name() string { return "chrony" }
|
||||
|
||||
// RenderToString renders the chrony config to a string without writing
|
||||
// to disk or reloading the service. Used by the config-preview endpoint.
|
||||
func (g *Generator) RenderToString(ctx context.Context) (string, error) {
|
||||
settings, err := g.Repo.GetSettings(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("settings: %w", err)
|
||||
}
|
||||
pools, err := g.Repo.ListPools(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pools: %w", err)
|
||||
}
|
||||
view := View{
|
||||
Settings: settings,
|
||||
Pools: pools,
|
||||
ListenAddresses: filterNonLoopback(splitCSV(settings.ListenAddresses)),
|
||||
AllowACLs: splitCSV(settings.AllowACL),
|
||||
}
|
||||
var body bytes.Buffer
|
||||
if err := tpl.Execute(&body, view); err != nil {
|
||||
return "", fmt.Errorf("template: %w", err)
|
||||
}
|
||||
return body.String(), nil
|
||||
}
|
||||
|
||||
func (g *Generator) Render(ctx context.Context) error {
|
||||
settings, err := g.Repo.GetSettings(ctx)
|
||||
if err != nil {
|
||||
@@ -97,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 {
|
||||
|
||||
53
internal/chrony/chrony_test.go
Normal file
53
internal/chrony/chrony_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -35,10 +35,13 @@ import (
|
||||
|
||||
// hashTable beschreibt eine Tabelle die in den config-hash einfließt.
|
||||
type hashTable struct {
|
||||
Name string
|
||||
Singleton bool // dns_settings, ntp_settings → eine row, id=1
|
||||
ExtraExclude []string // Spalten die zusätzlich aus to_jsonb gefiltert werden
|
||||
SkipUpdatedAt bool // setze true wenn updated_at semantisch relevant ist
|
||||
Name string
|
||||
Singleton bool // dns_settings, ntp_settings → eine row, id=1
|
||||
ExtraExclude []string // Spalten die zusätzlich aus to_jsonb gefiltert werden
|
||||
SkipUpdatedAt bool // setze true wenn updated_at semantisch relevant ist
|
||||
MigrationDefault bool // Tabelle hat migrations-erzeugte Default-Rows (firewall_zones, ntp_pools…)
|
||||
// → zählt nicht als "user hat config" bei der Empty-DB-Erkennung
|
||||
CustomSQL string // wenn gesetzt: direkt als Hash-Query verwenden (überschreibt hashSQL)
|
||||
}
|
||||
|
||||
// hashSpec ist die Reihenfolge-stabile Liste. NEUE Tabellen hier
|
||||
@@ -49,15 +52,13 @@ var hashSpec = []hashTable{
|
||||
{Name: "backends"},
|
||||
{Name: "backend_servers"},
|
||||
{Name: "routing_rules"},
|
||||
{Name: "network_interfaces"},
|
||||
{Name: "ip_addresses"},
|
||||
{Name: "tls_certs", ExtraExclude: []string{"last_renewed_at", "last_error"}},
|
||||
|
||||
{Name: "firewall_zones"},
|
||||
{Name: "firewall_zones", MigrationDefault: true},
|
||||
{Name: "firewall_address_objects"},
|
||||
{Name: "firewall_address_groups"},
|
||||
{Name: "firewall_services"},
|
||||
{Name: "firewall_service_groups"},
|
||||
{Name: "firewall_services", MigrationDefault: true},
|
||||
{Name: "firewall_service_groups", MigrationDefault: true},
|
||||
{Name: "firewall_rules"},
|
||||
{Name: "firewall_nat_rules"},
|
||||
|
||||
@@ -67,12 +68,29 @@ var hashSpec = []hashTable{
|
||||
|
||||
{Name: "dns_zones"},
|
||||
{Name: "dns_records"},
|
||||
{Name: "dns_settings", Singleton: true},
|
||||
|
||||
{Name: "ntp_pools"},
|
||||
{Name: "ntp_settings", Singleton: true},
|
||||
{Name: "ntp_pools", MigrationDefault: true},
|
||||
|
||||
{Name: "static_routes"},
|
||||
// DHCP: Subnets + Reservierungen sind geteilte Config (repliziert).
|
||||
// dhcp_settings ist node-lokal (ob DIESE Node DHCP betreibt) → NICHT hier.
|
||||
{Name: "dhcp_subnets"},
|
||||
{Name: "dhcp_reservations"},
|
||||
|
||||
// RADIUS: Clients + Users sind geteilte Config (repliziert).
|
||||
// radius_settings ist node-lokal → NICHT hier.
|
||||
{Name: "radius_clients"},
|
||||
{Name: "radius_users"},
|
||||
|
||||
// network_interfaces + ip_addresses sind BEWUSST NICHT im Drift-Hash.
|
||||
// Sie stehen in cluster_replication.go localOnlyTables, werden also NICHT
|
||||
// repliziert und sind per Design node-spezifisch (jede Node hat eigene
|
||||
// Mgmt-/Host-IPs, z.B. utm-1=.6, utm-2=.8). Würde man sie hashen, wäre
|
||||
// der config_hash zwischen zwei Nodes ZWANGSLÄUFIG dauerhaft verschieden
|
||||
// → Drift-Banner, das kein Resync je beheben kann (Resync kopiert nur
|
||||
// replizierte Tabellen). Migration 0030 wollte sie zwar replizieren,
|
||||
// localOnlyTables schließt sie aber weiter aus → wir hashen sie nicht.
|
||||
//
|
||||
// static_routes, dns_settings, ntp_settings bleiben ebenfalls node-spezifisch.
|
||||
}
|
||||
|
||||
// hashSQL rendert die SHA-Input-SQL für eine Tabelle.
|
||||
@@ -100,22 +118,39 @@ func hashSQL(t hashTable) string {
|
||||
// ComputeConfigHash gibt den 16-hex-char-Hash über alle Spec-Tabellen
|
||||
// zurück. Fehlende Tabellen (transienter schema-flux) werden als
|
||||
// leerer Per-Table-Hash behandelt — kein Abbruch.
|
||||
//
|
||||
// Gibt "" zurück wenn alle user-konfigurierbaren Tabellen leer sind
|
||||
// (Singleton- und MigrationDefault-Tabellen zählen nicht als User-Config).
|
||||
// Das verhindert False-Positive-Drift-Banner auf frisch gejointen Secondaries.
|
||||
func ComputeConfigHash(ctx context.Context, pool *pgxpool.Pool) (string, error) {
|
||||
if pool == nil {
|
||||
return "", fmt.Errorf("nil pool")
|
||||
}
|
||||
h := sha256.New()
|
||||
hasUserConfig := false
|
||||
for _, t := range hashSpec {
|
||||
var s string
|
||||
if err := pool.QueryRow(ctx, hashSQL(t)).Scan(&s); err != nil {
|
||||
sql := t.CustomSQL
|
||||
if sql == "" {
|
||||
sql = hashSQL(t)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, sql).Scan(&s); err != nil {
|
||||
// Migration fehlt o.ä. → leeren string nehmen, weiter.
|
||||
s = ""
|
||||
}
|
||||
if s != "" && !t.Singleton && !t.MigrationDefault {
|
||||
hasUserConfig = true
|
||||
}
|
||||
h.Write([]byte(t.Name))
|
||||
h.Write([]byte{0})
|
||||
h.Write([]byte(s))
|
||||
h.Write([]byte{0})
|
||||
}
|
||||
if !hasUserConfig {
|
||||
// Frisch gejoincter Secondary oder komplett leere DB →
|
||||
// leerer String signalisiert "kein Drift prüfen" im Status-Handler.
|
||||
return "", nil
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))[:16], nil
|
||||
}
|
||||
|
||||
|
||||
@@ -61,12 +61,19 @@ type Service struct {
|
||||
}
|
||||
|
||||
func New(pool *pgxpool.Pool, getCAFinger func() (string, error)) *Service {
|
||||
return &Service{
|
||||
s := &Service{
|
||||
Pool: pool,
|
||||
SecretPath: DefaultSecretPath,
|
||||
GetCAFinger: getCAFinger,
|
||||
TTL: DefaultTTL,
|
||||
}
|
||||
// Ensure the HMAC secret file exists at startup so Consume() never
|
||||
// fails with "no such file" on the first join attempt.
|
||||
if _, err := s.ensureSecret(); err != nil {
|
||||
// Non-fatal: generate won't work either, but we log and continue.
|
||||
_ = err
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// payload ist das JSON inside-the-token.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -115,6 +115,16 @@ func (s *Store) Delete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeletePlaceholdersByFQDN removes all ha_nodes rows that share the given
|
||||
// FQDN but do NOT have the specified keepID. Used to clean up pre-registered
|
||||
// placeholder rows (both old "pre-{timestamp}" and new "prenode-{fqdn}"
|
||||
// style) after a real autoRegister arrives via mTLS.
|
||||
func (s *Store) DeletePlaceholdersByFQDN(ctx context.Context, fqdn, keepID string) error {
|
||||
_, err := s.Pool.Exec(ctx,
|
||||
`DELETE FROM ha_nodes WHERE fqdn = $1 AND id != $2`, fqdn, keepID)
|
||||
return err
|
||||
}
|
||||
|
||||
// EnsureSelfRegistered mints the node-id if needed, builds the row
|
||||
// from setup.json + os.Hostname + node.conf, and upserts it. Called
|
||||
// on edgeguard-api boot AFTER the DB pool is reachable.
|
||||
|
||||
@@ -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 {
|
||||
@@ -99,6 +99,33 @@ func RestartService(name string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopService runs `sudo -n systemctl stop <name>.service`.
|
||||
func StopService(name string) error {
|
||||
cmd := exec.Command("sudo", "-n", "/usr/bin/systemctl", "stop", name+".service")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("sudo systemctl stop %s.service: %w (output: %s)", name, err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnableService runs `sudo -n systemctl enable <name>.service` (boot-persistent).
|
||||
func EnableService(name string) error {
|
||||
cmd := exec.Command("sudo", "-n", "/usr/bin/systemctl", "enable", name+".service")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("sudo systemctl enable %s.service: %w (output: %s)", name, err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DisableService runs `sudo -n systemctl disable <name>.service`.
|
||||
func DisableService(name string) error {
|
||||
cmd := exec.Command("sudo", "-n", "/usr/bin/systemctl", "disable", name+".service")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("sudo systemctl disable %s.service: %w (output: %s)", name, err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EtcEdgeguard is the on-target config root. Templated path used by
|
||||
// all renderers — never let renderers hard-code their own.
|
||||
const EtcEdgeguard = "/etc/edgeguard"
|
||||
|
||||
424
internal/crowdsec/service.go
Normal file
424
internal/crowdsec/service.go
Normal file
@@ -0,0 +1,424 @@
|
||||
// Package crowdsec wraps sudo /usr/bin/cscli calls for the edgeguard
|
||||
// management API. All list operations use -o json. Mutation operations
|
||||
// (add/delete) use the appropriate cscli sub-commands.
|
||||
//
|
||||
// edgeguard runs as a non-root system user; every cscli call goes
|
||||
// through sudo (allowed entries are in /etc/sudoers.d/edgeguard).
|
||||
package crowdsec
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrNotInstalled is returned when /usr/bin/cscli is not found.
|
||||
var ErrNotInstalled = errors.New("crowdsec not installed")
|
||||
|
||||
// IsInstalled checks whether /usr/bin/cscli exists on this host.
|
||||
func IsInstalled() bool {
|
||||
_, err := os.Stat("/usr/bin/cscli")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// ---------- Types -----------------------------------------------------------
|
||||
|
||||
// Decision represents a single IP decision (ban/captcha/etc.) in CrowdSec.
|
||||
type Decision struct {
|
||||
ID int64 `json:"id"`
|
||||
Origin string `json:"origin"`
|
||||
Type string `json:"type"`
|
||||
Scope string `json:"scope"`
|
||||
Value string `json:"value"`
|
||||
Duration string `json:"duration"`
|
||||
Reason string `json:"reason"`
|
||||
Country string `json:"country,omitempty"`
|
||||
AS string `json:"as,omitempty"`
|
||||
}
|
||||
|
||||
// Alert represents a CrowdSec alert with associated decisions.
|
||||
type Alert struct {
|
||||
ID int64 `json:"id"`
|
||||
Scenario string `json:"scenario"`
|
||||
EventsCount int `json:"events_count"`
|
||||
Source AlertSource `json:"source"`
|
||||
StartAt string `json:"start_at"`
|
||||
StopAt string `json:"stop_at"`
|
||||
Decisions []Decision `json:"decisions,omitempty"`
|
||||
}
|
||||
|
||||
// AlertSource holds the source IP/range info for an alert.
|
||||
type AlertSource struct {
|
||||
IP string `json:"ip"`
|
||||
Country string `json:"cn,omitempty"`
|
||||
ASName string `json:"as_name,omitempty"`
|
||||
Range string `json:"range,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Value string `json:"value,omitempty"`
|
||||
}
|
||||
|
||||
// Bouncer represents a registered CrowdSec bouncer.
|
||||
type Bouncer struct {
|
||||
Name string `json:"name"`
|
||||
IPAddress string `json:"ip_address,omitempty"`
|
||||
Revoked bool `json:"revoked"`
|
||||
LastPull string `json:"last_pull,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
AuthType string `json:"auth_type,omitempty"`
|
||||
}
|
||||
|
||||
// Machine represents a registered CrowdSec agent/machine.
|
||||
type Machine struct {
|
||||
MachineID string `json:"machineId"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
LastPush string `json:"last_push,omitempty"`
|
||||
IsValidated bool `json:"isValidated"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// HubItem represents a CrowdSec hub item (collection, parser, scenario, etc.).
|
||||
type HubItem struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Status string `json:"status"`
|
||||
LocalVersion string `json:"local_version,omitempty"`
|
||||
LocalPath string `json:"local_path,omitempty"`
|
||||
Author string `json:"author,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
}
|
||||
|
||||
// Status summarizes the runtime state of the CrowdSec stack on this node.
|
||||
type Status struct {
|
||||
Installed bool `json:"installed"`
|
||||
AgentRunning bool `json:"agent_running"`
|
||||
BouncerRunning bool `json:"bouncer_running"`
|
||||
Version string `json:"version,omitempty"`
|
||||
DecisionCount int `json:"decision_count"`
|
||||
AlertCount int `json:"alert_count"`
|
||||
BouncerCount int `json:"bouncer_count"`
|
||||
MachineCount int `json:"machine_count"`
|
||||
}
|
||||
|
||||
// ---------- Helpers ---------------------------------------------------------
|
||||
|
||||
// sudoCscli executes `sudo -n /usr/bin/cscli <args...>` and returns stdout.
|
||||
func sudoCscli(ctx context.Context, args ...string) ([]byte, error) {
|
||||
full := append([]string{"-n", "/usr/bin/cscli"}, args...)
|
||||
cmd := exec.CommandContext(ctx, "sudo", full...)
|
||||
var out, errBuf bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &errBuf
|
||||
if err := cmd.Run(); err != nil {
|
||||
slog.Error("crowdsec: sudoCscli failed", "args", args, "error", err, "stderr", errBuf.String())
|
||||
return nil, err
|
||||
}
|
||||
if errBuf.Len() > 0 {
|
||||
slog.Warn("crowdsec: sudoCscli stderr", "args", args, "stderr", errBuf.String())
|
||||
}
|
||||
slog.Debug("crowdsec: sudoCscli ok", "args", args[0], "bytes", out.Len())
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
// systemctlActive returns true when the named unit is "active".
|
||||
func systemctlActive(ctx context.Context, unit string) bool {
|
||||
cmd := exec.CommandContext(ctx, "systemctl", "is-active", "--quiet", unit)
|
||||
return cmd.Run() == nil
|
||||
}
|
||||
|
||||
// unmarshalSlice unmarshals JSON that may be "null" (cscli returns null
|
||||
// instead of [] when no items exist). Returns an empty slice in that case.
|
||||
func unmarshalSlice[T any](data []byte) ([]T, error) {
|
||||
data = bytes.TrimSpace(data)
|
||||
if bytes.Equal(data, []byte("null")) || len(data) == 0 {
|
||||
return []T{}, nil
|
||||
}
|
||||
var result []T
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ---------- ServiceStatus ---------------------------------------------------
|
||||
|
||||
// ServiceStatus returns a Status struct describing the current state of the
|
||||
// CrowdSec agent and bouncer on this node. Does NOT need cscli installed —
|
||||
// it uses systemctl for the running-state checks. Version is extracted via
|
||||
// `cscli version` when available.
|
||||
func ServiceStatus(ctx context.Context) Status {
|
||||
st := Status{
|
||||
Installed: IsInstalled(),
|
||||
AgentRunning: systemctlActive(ctx, "crowdsec"),
|
||||
BouncerRunning: systemctlActive(ctx, "crowdsec-firewall-bouncer"),
|
||||
}
|
||||
|
||||
if st.Installed {
|
||||
// Grab version from `sudo -n /usr/bin/cscli version` — first line only.
|
||||
// Output is not JSON; it looks like "version: v1.6.3-..."
|
||||
if out, err := sudoCscli(ctx, "version"); err == nil {
|
||||
scanner := bufio.NewScanner(bytes.NewReader(out))
|
||||
if scanner.Scan() {
|
||||
st.Version = strings.TrimSpace(scanner.Text())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only query cscli data endpoints when the agent is running — cscli
|
||||
// hangs on its local socket when the agent is stopped, which would
|
||||
// block the entire status response and leave the UI with no data.
|
||||
if st.AgentRunning {
|
||||
if decisions, err := Decisions(ctx); err == nil {
|
||||
st.DecisionCount = len(decisions)
|
||||
}
|
||||
if alerts, err := Alerts(ctx, 500); err == nil {
|
||||
st.AlertCount = len(alerts)
|
||||
}
|
||||
if bouncers, err := Bouncers(ctx); err == nil {
|
||||
st.BouncerCount = len(bouncers)
|
||||
}
|
||||
if machines, err := Machines(ctx); err == nil {
|
||||
st.MachineCount = len(machines)
|
||||
}
|
||||
}
|
||||
|
||||
return st
|
||||
}
|
||||
|
||||
// ---------- Decisions -------------------------------------------------------
|
||||
|
||||
// cscli decisions list -o json returns alert-level objects with nested
|
||||
// decisions[] arrays. These intermediate types are used only for parsing.
|
||||
type cscliDecisionRaw struct {
|
||||
ID int64 `json:"id"`
|
||||
Duration string `json:"duration"`
|
||||
Origin string `json:"origin"`
|
||||
Scope string `json:"scope"`
|
||||
Type string `json:"type"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type cscliAlertRaw struct {
|
||||
Scenario string `json:"scenario"`
|
||||
Decisions []cscliDecisionRaw `json:"decisions"`
|
||||
Source struct {
|
||||
IP string `json:"ip"`
|
||||
CN string `json:"cn"`
|
||||
ASName string `json:"as_name"`
|
||||
} `json:"source"`
|
||||
}
|
||||
|
||||
// Decisions lists all active decisions by flattening the alert-level JSON
|
||||
// that cscli emits (each alert contains a nested decisions[] array).
|
||||
func Decisions(ctx context.Context) ([]Decision, error) {
|
||||
if !IsInstalled() {
|
||||
return nil, ErrNotInstalled
|
||||
}
|
||||
out, err := sudoCscli(ctx, "decisions", "list", "-o", "json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
alerts, err := unmarshalSlice[cscliAlertRaw](out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result []Decision
|
||||
for _, a := range alerts {
|
||||
for _, d := range a.Decisions {
|
||||
result = append(result, Decision{
|
||||
ID: d.ID,
|
||||
Origin: d.Origin,
|
||||
Type: d.Type,
|
||||
Scope: d.Scope,
|
||||
Value: d.Value,
|
||||
Duration: d.Duration,
|
||||
Reason: a.Scenario,
|
||||
Country: a.Source.CN,
|
||||
AS: a.Source.ASName,
|
||||
})
|
||||
}
|
||||
}
|
||||
if result == nil {
|
||||
result = []Decision{}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// AddDecision creates a new ban/captcha decision for the given IP.
|
||||
func AddDecision(ctx context.Context, ip, duration, reason, typ string) error {
|
||||
if !IsInstalled() {
|
||||
return ErrNotInstalled
|
||||
}
|
||||
_, err := sudoCscli(ctx, "decisions", "add",
|
||||
"--ip", ip,
|
||||
"--duration", duration,
|
||||
"--reason", reason,
|
||||
"--type", typ,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteDecisionByIP removes all decisions for a given IP address.
|
||||
func DeleteDecisionByIP(ctx context.Context, ip string) error {
|
||||
if !IsInstalled() {
|
||||
return ErrNotInstalled
|
||||
}
|
||||
_, err := sudoCscli(ctx, "decisions", "delete", "--ip", ip)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteDecisionByID removes a single decision by its numeric ID.
|
||||
func DeleteDecisionByID(ctx context.Context, id string) error {
|
||||
if !IsInstalled() {
|
||||
return ErrNotInstalled
|
||||
}
|
||||
_, err := sudoCscli(ctx, "decisions", "delete", "--id", id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------- Alerts ----------------------------------------------------------
|
||||
|
||||
// Alerts lists recent alerts (up to limit).
|
||||
func Alerts(ctx context.Context, limit int) ([]Alert, error) {
|
||||
if !IsInstalled() {
|
||||
return nil, ErrNotInstalled
|
||||
}
|
||||
out, err := sudoCscli(ctx, "alerts", "list", "-o", "json",
|
||||
"-l", fmt.Sprintf("%d", limit))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return unmarshalSlice[Alert](out)
|
||||
}
|
||||
|
||||
// DeleteAlert discards (deletes) a single alert by its ID.
|
||||
func DeleteAlert(ctx context.Context, id string) error {
|
||||
if !IsInstalled() {
|
||||
return ErrNotInstalled
|
||||
}
|
||||
_, err := sudoCscli(ctx, "alerts", "delete", "--id", id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------- Bouncers --------------------------------------------------------
|
||||
|
||||
// Bouncers lists all registered bouncers.
|
||||
func Bouncers(ctx context.Context) ([]Bouncer, error) {
|
||||
if !IsInstalled() {
|
||||
return nil, ErrNotInstalled
|
||||
}
|
||||
out, err := sudoCscli(ctx, "bouncers", "list", "-o", "json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return unmarshalSlice[Bouncer](out)
|
||||
}
|
||||
|
||||
// DeleteBouncer removes a bouncer by name.
|
||||
func DeleteBouncer(ctx context.Context, name string) error {
|
||||
if !IsInstalled() {
|
||||
return ErrNotInstalled
|
||||
}
|
||||
_, err := sudoCscli(ctx, "bouncers", "delete", name)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------- Machines --------------------------------------------------------
|
||||
|
||||
// cscliMachineRaw mirrors the actual cscli JSON with its mixed camelCase /
|
||||
// snake_case field names. Only used inside Machines().
|
||||
type cscliMachineRaw struct {
|
||||
MachineID string `json:"machineId"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
LastPush string `json:"last_push"`
|
||||
IsValidated bool `json:"isValidated"`
|
||||
Version string `json:"version"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// Machines lists all registered machines/agents.
|
||||
func Machines(ctx context.Context) ([]Machine, error) {
|
||||
if !IsInstalled() {
|
||||
return nil, ErrNotInstalled
|
||||
}
|
||||
out, err := sudoCscli(ctx, "machines", "list", "-o", "json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := unmarshalSlice[cscliMachineRaw](out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]Machine, len(raw))
|
||||
for i, r := range raw {
|
||||
result[i] = Machine(r)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// DeleteMachine removes a machine by its machine ID.
|
||||
func DeleteMachine(ctx context.Context, id string) error {
|
||||
if !IsInstalled() {
|
||||
return ErrNotInstalled
|
||||
}
|
||||
_, err := sudoCscli(ctx, "machines", "delete", "--machine-id", id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------- Collections -----------------------------------------------------
|
||||
|
||||
// Collections lists installed/available hub collections.
|
||||
// cscli returns {"collections": [...]} (not a flat array) — we unwrap the key.
|
||||
func Collections(ctx context.Context) ([]HubItem, error) {
|
||||
if !IsInstalled() {
|
||||
return nil, ErrNotInstalled
|
||||
}
|
||||
out, err := sudoCscli(ctx, "collections", "list", "-o", "json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = bytes.TrimSpace(out)
|
||||
if bytes.Equal(out, []byte("null")) || len(out) == 0 {
|
||||
return []HubItem{}, nil
|
||||
}
|
||||
// cscli wraps collections in {"collections": [...]}
|
||||
var wrapper struct {
|
||||
Collections []HubItem `json:"collections"`
|
||||
}
|
||||
if err := json.Unmarshal(out, &wrapper); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if wrapper.Collections == nil {
|
||||
return []HubItem{}, nil
|
||||
}
|
||||
return wrapper.Collections, nil
|
||||
}
|
||||
|
||||
// InstallCollection installs a hub collection by name (--force to upgrade).
|
||||
func InstallCollection(ctx context.Context, name string) error {
|
||||
if !IsInstalled() {
|
||||
return ErrNotInstalled
|
||||
}
|
||||
_, err := sudoCscli(ctx, "collections", "install", name, "--force")
|
||||
return err
|
||||
}
|
||||
|
||||
// RemoveCollection removes a hub collection by name.
|
||||
func RemoveCollection(ctx context.Context, name string) error {
|
||||
if !IsInstalled() {
|
||||
return ErrNotInstalled
|
||||
}
|
||||
_, err := sudoCscli(ctx, "collections", "remove", name)
|
||||
return err
|
||||
}
|
||||
107
internal/crowdsec/whitelist.go
Normal file
107
internal/crowdsec/whitelist.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package crowdsec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
|
||||
)
|
||||
|
||||
// WhitelistPath ist die aus dem Domain-Flag crowdsec_trusted gerenderte
|
||||
// CrowdSec-Parser-Whitelist. s02-enrich läuft vor den Scenarios, sodass
|
||||
// whitelisted Events gar nicht erst in http-crawl-non_statics o. Ä. zählen.
|
||||
const WhitelistPath = "/etc/crowdsec/parsers/s02-enrich/edgeguard-admin-hosts-whitelist.yaml"
|
||||
|
||||
// WhitelistGenerator rendert eine host-genaue CrowdSec-Whitelist aus allen
|
||||
// Domains mit crowdsec_trusted=true. Vertrauenswürdige Admin-Panels (SPAs, die
|
||||
// pro Aktion viele /api/-Requests feuern) würden sonst das Scenario
|
||||
// http-crawl-non_statics auslösen und die Admin-IP bannen. No-op, wenn CrowdSec auf diesem Node nicht
|
||||
// installiert ist (managed-wenn-installiert).
|
||||
type WhitelistGenerator struct {
|
||||
pool *pgxpool.Pool
|
||||
SkipReload bool
|
||||
}
|
||||
|
||||
func NewWhitelistGenerator(pool *pgxpool.Pool) *WhitelistGenerator {
|
||||
return &WhitelistGenerator{pool: pool}
|
||||
}
|
||||
|
||||
func (g *WhitelistGenerator) Name() string { return "crowdsec-whitelist" }
|
||||
|
||||
func (g *WhitelistGenerator) Render(ctx context.Context) error {
|
||||
// Managed-wenn-installiert: ohne CrowdSec kein Whitelist-File.
|
||||
if !IsInstalled() {
|
||||
return nil
|
||||
}
|
||||
hosts, err := g.trustedHosts(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("crowdsec-whitelist: query: %w", err)
|
||||
}
|
||||
// Direktes Schreiben (kein tmp+rename): /etc/crowdsec/parsers/... ist
|
||||
// root-owned, edgeguard darf nur die eine (postinst-chownte) Datei
|
||||
// überschreiben — analog chrony/unbound.
|
||||
if err := os.WriteFile(WhitelistPath, renderWhitelist(hosts), 0o644); err != nil {
|
||||
return fmt.Errorf("crowdsec-whitelist: write %s: %w", WhitelistPath, err)
|
||||
}
|
||||
if g.SkipReload {
|
||||
return nil
|
||||
}
|
||||
return configgen.ReloadService("crowdsec")
|
||||
}
|
||||
|
||||
// RenderToString gibt die gerenderte Whitelist zurück (Config-Preview), ohne zu
|
||||
// schreiben oder zu reloaden.
|
||||
func (g *WhitelistGenerator) RenderToString(ctx context.Context) (string, error) {
|
||||
hosts, err := g.trustedHosts(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(renderWhitelist(hosts)), nil
|
||||
}
|
||||
|
||||
func (g *WhitelistGenerator) trustedHosts(ctx context.Context) ([]string, error) {
|
||||
rows, err := g.pool.Query(ctx,
|
||||
`SELECT name FROM domains WHERE crowdsec_trusted = true AND active = true ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var hosts []string
|
||||
for rows.Next() {
|
||||
var n string
|
||||
if err := rows.Scan(&n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hosts = append(hosts, n)
|
||||
}
|
||||
return hosts, rows.Err()
|
||||
}
|
||||
|
||||
// renderWhitelist baut die CrowdSec-Parser-Whitelist-YAML. Ohne vertrauens-
|
||||
// würdige Hosts bleibt die Ausdrucksliste leer → `in []` matcht nie → es wird
|
||||
// nichts whitelisted (Datei bleibt gültig). Pure Funktion (testbar).
|
||||
func renderWhitelist(hosts []string) []byte {
|
||||
quoted := make([]string, 0, len(hosts))
|
||||
for _, h := range hosts {
|
||||
h = strings.TrimSpace(h)
|
||||
if h == "" {
|
||||
continue
|
||||
}
|
||||
// Einfachquote + eingebettete Quotes verdoppeln (expr-String-Literal).
|
||||
quoted = append(quoted, "'"+strings.ReplaceAll(h, "'", "''")+"'")
|
||||
}
|
||||
var b bytes.Buffer
|
||||
b.WriteString("# Generated by edgeguard-api from domains.crowdsec_trusted. DO NOT EDIT.\n")
|
||||
b.WriteString("name: edgeguard/admin-hosts-whitelist\n")
|
||||
b.WriteString("description: Trusted admin panels (SPA fires many /api/ requests) exempted from CrowdSec - not a crawl.\n")
|
||||
b.WriteString("whitelist:\n")
|
||||
b.WriteString(" reason: edgeguard trusted admin host (SPA, not crawl/probing)\n")
|
||||
b.WriteString(" expression:\n")
|
||||
fmt.Fprintf(&b, " - \"evt.Parsed.http_host in [%s]\"\n", strings.Join(quoted, ", "))
|
||||
return b.Bytes()
|
||||
}
|
||||
35
internal/crowdsec/whitelist_test.go
Normal file
35
internal/crowdsec/whitelist_test.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package crowdsec
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderWhitelist(t *testing.T) {
|
||||
t.Run("hosts werden host-genau eingetragen", func(t *testing.T) {
|
||||
out := string(renderWhitelist([]string{"control.netcell-it.de", "admin.example.com"}))
|
||||
if !strings.Contains(out, "evt.Parsed.http_host in ['control.netcell-it.de', 'admin.example.com']") {
|
||||
t.Fatalf("erwartete host-Liste fehlt:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "name: edgeguard/admin-hosts-whitelist") {
|
||||
t.Fatalf("Parser-Name fehlt:\n%s", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("leere Liste → in [] (matcht nie, Datei gültig)", func(t *testing.T) {
|
||||
out := string(renderWhitelist(nil))
|
||||
if !strings.Contains(out, "evt.Parsed.http_host in []") {
|
||||
t.Fatalf("erwarte leeres in []:\n%s", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("leere/whitespace-Hosts werden gefiltert, Quotes escaped", func(t *testing.T) {
|
||||
out := string(renderWhitelist([]string{" ", "a'b.de", ""}))
|
||||
if !strings.Contains(out, "'a''b.de'") {
|
||||
t.Fatalf("Quote-Escaping falsch:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "'', ") || strings.Contains(out, "[''") {
|
||||
t.Fatalf("leere Hosts nicht gefiltert:\n%s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -15,8 +15,8 @@ CREATE TABLE IF NOT EXISTS ha_nodes (
|
||||
name TEXT NOT NULL,
|
||||
fqdn TEXT NOT NULL,
|
||||
api_url TEXT NOT NULL,
|
||||
public_ip INET,
|
||||
internal_ip INET,
|
||||
public_ip TEXT,
|
||||
internal_ip TEXT,
|
||||
role TEXT NOT NULL DEFAULT 'peer',
|
||||
last_seen TIMESTAMPTZ,
|
||||
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
ALTER TABLE ha_nodes
|
||||
ADD COLUMN IF NOT EXISTS version TEXT,
|
||||
ADD COLUMN IF NOT EXISTS config_hash TEXT,
|
||||
ADD COLUMN IF NOT EXISTS mgmt_ip INET,
|
||||
ADD COLUMN IF NOT EXISTS mgmt_ip TEXT,
|
||||
ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'unknown';
|
||||
|
||||
ALTER TABLE ha_nodes
|
||||
@@ -33,7 +33,7 @@ ALTER TABLE ha_nodes
|
||||
DROP CONSTRAINT IF EXISTS ha_nodes_status_check;
|
||||
ALTER TABLE ha_nodes
|
||||
DROP COLUMN IF EXISTS status,
|
||||
DROP COLUMN IF EXISTS mgmt_ip,
|
||||
DROP COLUMN IF EXISTS mgmt_ip, -- TEXT
|
||||
DROP COLUMN IF EXISTS config_hash,
|
||||
DROP COLUMN IF EXISTS version;
|
||||
-- +goose StatementEnd
|
||||
|
||||
20
internal/database/migrations/0028_ha_nodes_ip_text.sql
Normal file
20
internal/database/migrations/0028_ha_nodes_ip_text.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
-- pgx scannt INET-Spalten nicht direkt in *string (OID 869-Fehler).
|
||||
-- Go-Modell speichert IPs als string — TEXT ist hier korrekt.
|
||||
-- Bestehende Werte bleiben erhalten (USING public_ip::TEXT).
|
||||
ALTER TABLE ha_nodes
|
||||
ALTER COLUMN public_ip TYPE TEXT USING public_ip::TEXT,
|
||||
ALTER COLUMN internal_ip TYPE TEXT USING internal_ip::TEXT,
|
||||
ALTER COLUMN mgmt_ip TYPE TEXT USING mgmt_ip::TEXT;
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE ha_nodes
|
||||
ALTER COLUMN public_ip TYPE INET USING public_ip::INET,
|
||||
ALTER COLUMN internal_ip TYPE INET USING internal_ip::INET,
|
||||
ALTER COLUMN mgmt_ip TYPE INET USING mgmt_ip::INET;
|
||||
-- +goose StatementEnd
|
||||
36
internal/database/migrations/0029_cluster_vip.sql
Normal file
36
internal/database/migrations/0029_cluster_vip.sql
Normal file
@@ -0,0 +1,36 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
-- pg_role: Rolle dieser Node in der PG-Replikation.
|
||||
-- "standalone" = kein Streaming-Replication-Setup
|
||||
-- "primary" = WAL-Sender, repliziert an Standby(s)
|
||||
-- "standby" = Hot-Standby, liest WAL vom Primary
|
||||
ALTER TABLE ha_nodes ADD COLUMN IF NOT EXISTS pg_role TEXT NOT NULL DEFAULT 'standalone';
|
||||
|
||||
-- cluster_settings: VIP + VRRP-Konfiguration (Singleton, id=1).
|
||||
-- vip_address = die virtuelle IP-Adresse (z.B. "89.163.205.10")
|
||||
-- vip_interface = Netzwerk-Interface (z.B. "eth0")
|
||||
-- vip_auth_pass = VRRP-Authentication-Passwort (max. 8 Zeichen, Keepalived-Limit)
|
||||
-- vrrp_router_id = VRRP Virtual Router ID (1–255, muss im Subnetz eindeutig sein)
|
||||
CREATE TABLE IF NOT EXISTS cluster_settings (
|
||||
id INTEGER PRIMARY KEY DEFAULT 1,
|
||||
vip_address TEXT,
|
||||
vip_interface TEXT,
|
||||
vip_auth_pass TEXT,
|
||||
vrrp_router_id INTEGER NOT NULL DEFAULT 51,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT cluster_settings_singleton CHECK (id = 1)
|
||||
);
|
||||
|
||||
INSERT INTO cluster_settings (id) VALUES (1) ON CONFLICT DO NOTHING;
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
|
||||
DROP TABLE IF EXISTS cluster_settings;
|
||||
ALTER TABLE ha_nodes DROP COLUMN IF EXISTS pg_role;
|
||||
|
||||
-- +goose StatementEnd
|
||||
@@ -0,0 +1,21 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
-- HINWEIS (korrigiert v1.2.89): Diese Migration war urspr. dafür gedacht,
|
||||
-- network_interfaces und ip_addresses in die Cluster-Replikation aufzunehmen.
|
||||
-- Das wurde NICHT umgesetzt und ist auch NICHT gewollt: beide Tabellen sind
|
||||
-- node-spezifisch (jede Node hat eigene Mgmt-/Host-IPs) und stehen weiterhin
|
||||
-- in cluster_replication.go localOnlyTables → sie werden bewusst NICHT
|
||||
-- repliziert. Sie sind auch aus dem Drift-Hash (confighash.go) entfernt,
|
||||
-- da sie sonst dauerhaften False-Positive-Drift erzeugen.
|
||||
-- Diese Migration ist ein No-op / reiner Versions-Marker für goose.
|
||||
SELECT 1;
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
|
||||
SELECT 1;
|
||||
|
||||
-- +goose StatementEnd
|
||||
25
internal/database/migrations/0031_forward_proxy_settings.sql
Normal file
25
internal/database/migrations/0031_forward_proxy_settings.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
-- forward_proxy_settings — Singleton-Row für globale Squid-Einstellungen.
|
||||
-- listen_addresses: Komma-separierte IPs auf denen Squid lauscht.
|
||||
-- Leer = alle Interfaces (http_port 3128). Typisch: LAN/VLAN-Gateway-IPs.
|
||||
CREATE TABLE IF NOT EXISTS forward_proxy_settings (
|
||||
id INTEGER PRIMARY KEY DEFAULT 1,
|
||||
listen_addresses TEXT NOT NULL DEFAULT '',
|
||||
listen_port INTEGER NOT NULL DEFAULT 3128,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT forward_proxy_settings_singleton CHECK (id = 1)
|
||||
);
|
||||
|
||||
INSERT INTO forward_proxy_settings (id) VALUES (1) ON CONFLICT DO NOTHING;
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
|
||||
DROP TABLE IF EXISTS forward_proxy_settings;
|
||||
|
||||
-- +goose StatementEnd
|
||||
@@ -0,0 +1,37 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
ALTER TABLE forward_proxy_settings
|
||||
ADD COLUMN IF NOT EXISTS cache_mem_mb INTEGER NOT NULL DEFAULT 64,
|
||||
ADD COLUMN IF NOT EXISTS cache_dir_mb INTEGER NOT NULL DEFAULT 100,
|
||||
ADD COLUMN IF NOT EXISTS max_obj_size_mb INTEGER NOT NULL DEFAULT 4,
|
||||
ADD COLUMN IF NOT EXISTS connect_timeout INTEGER NOT NULL DEFAULT 60,
|
||||
ADD COLUMN IF NOT EXISTS read_timeout INTEGER NOT NULL DEFAULT 300,
|
||||
ADD COLUMN IF NOT EXISTS request_timeout INTEGER NOT NULL DEFAULT 300;
|
||||
|
||||
ALTER TABLE dns_settings
|
||||
ADD COLUMN IF NOT EXISTS prefetch BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS serve_expired BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS msg_cache_size_mb INTEGER NOT NULL DEFAULT 64,
|
||||
ADD COLUMN IF NOT EXISTS rrset_cache_size_mb INTEGER NOT NULL DEFAULT 128;
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
|
||||
ALTER TABLE forward_proxy_settings
|
||||
DROP COLUMN IF EXISTS cache_mem_mb,
|
||||
DROP COLUMN IF EXISTS cache_dir_mb,
|
||||
DROP COLUMN IF EXISTS max_obj_size_mb,
|
||||
DROP COLUMN IF EXISTS connect_timeout,
|
||||
DROP COLUMN IF EXISTS read_timeout,
|
||||
DROP COLUMN IF EXISTS request_timeout;
|
||||
|
||||
ALTER TABLE dns_settings
|
||||
DROP COLUMN IF EXISTS prefetch,
|
||||
DROP COLUMN IF EXISTS serve_expired,
|
||||
DROP COLUMN IF EXISTS msg_cache_size_mb,
|
||||
DROP COLUMN IF EXISTS rrset_cache_size_mb;
|
||||
|
||||
-- +goose StatementEnd
|
||||
@@ -0,0 +1,18 @@
|
||||
-- +goose Up
|
||||
-- Dual-path VRRP + Gateway-Tracking für Split-Brain-Schutz.
|
||||
-- hb_* = zweite VRRP-Instanz (VI_HB) auf dediziertem Heartbeat-Interface.
|
||||
-- gw_check_ip = Gateway-IP die von chk_gateway angepingt wird (weight -110).
|
||||
ALTER TABLE cluster_settings
|
||||
ADD COLUMN IF NOT EXISTS hb_interface VARCHAR,
|
||||
ADD COLUMN IF NOT EXISTS hb_src_ip VARCHAR,
|
||||
ADD COLUMN IF NOT EXISTS hb_peer_ip VARCHAR,
|
||||
ADD COLUMN IF NOT EXISTS hb_router_id INTEGER NOT NULL DEFAULT 52,
|
||||
ADD COLUMN IF NOT EXISTS gw_check_ip VARCHAR;
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE cluster_settings
|
||||
DROP COLUMN IF EXISTS hb_interface,
|
||||
DROP COLUMN IF EXISTS hb_src_ip,
|
||||
DROP COLUMN IF EXISTS hb_peer_ip,
|
||||
DROP COLUMN IF EXISTS hb_router_id,
|
||||
DROP COLUMN IF EXISTS gw_check_ip;
|
||||
9
internal/database/migrations/0034_totp.sql
Normal file
9
internal/database/migrations/0034_totp.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE users
|
||||
ADD COLUMN totp_secret TEXT,
|
||||
ADD COLUMN totp_enabled BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE users
|
||||
DROP COLUMN totp_secret,
|
||||
DROP COLUMN totp_enabled;
|
||||
17
internal/database/migrations/0035_firewall_note_labels.sql
Normal file
17
internal/database/migrations/0035_firewall_note_labels.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE firewall_rules
|
||||
ADD COLUMN IF NOT EXISTS note TEXT,
|
||||
ADD COLUMN IF NOT EXISTS labels TEXT[] NOT NULL DEFAULT '{}';
|
||||
|
||||
ALTER TABLE firewall_nat_rules
|
||||
ADD COLUMN IF NOT EXISTS note TEXT,
|
||||
ADD COLUMN IF NOT EXISTS labels TEXT[] NOT NULL DEFAULT '{}';
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE firewall_rules
|
||||
DROP COLUMN IF EXISTS note,
|
||||
DROP COLUMN IF EXISTS labels;
|
||||
|
||||
ALTER TABLE firewall_nat_rules
|
||||
DROP COLUMN IF EXISTS note,
|
||||
DROP COLUMN IF EXISTS labels;
|
||||
12
internal/database/migrations/0036_crowdsec.sql
Normal file
12
internal/database/migrations/0036_crowdsec.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE IF NOT EXISTS crowdsec_settings (
|
||||
id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
simulation_mode BOOLEAN NOT NULL DEFAULT false,
|
||||
collections TEXT[] NOT NULL DEFAULT '{"crowdsecurity/linux","crowdsecurity/haproxy"}',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
INSERT INTO crowdsec_settings (id) VALUES (1) ON CONFLICT DO NOTHING;
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS crowdsec_settings;
|
||||
18
internal/database/migrations/0037_waf.sql
Normal file
18
internal/database/migrations/0037_waf.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE IF NOT EXISTS waf_configs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
domain_id BIGINT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
||||
enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
mode TEXT NOT NULL DEFAULT 'detection'
|
||||
CHECK (mode IN ('detection','blocking')),
|
||||
paranoia_level INT NOT NULL DEFAULT 1
|
||||
CHECK (paranoia_level BETWEEN 1 AND 4),
|
||||
rule_exclusions TEXT[] NOT NULL DEFAULT '{}',
|
||||
trusted_proxies TEXT[] NOT NULL DEFAULT '{}',
|
||||
custom_rules TEXT NOT NULL DEFAULT '',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT waf_configs_domain_unique UNIQUE (domain_id)
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS waf_configs;
|
||||
20
internal/database/migrations/0038_waf_alerts.sql
Normal file
20
internal/database/migrations/0038_waf_alerts.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE IF NOT EXISTS waf_alerts (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
domain_id BIGINT REFERENCES domains(id) ON DELETE CASCADE,
|
||||
hostname TEXT NOT NULL,
|
||||
client_ip TEXT NOT NULL,
|
||||
method TEXT NOT NULL,
|
||||
uri TEXT NOT NULL,
|
||||
rule_id INT NOT NULL DEFAULT 0,
|
||||
rule_msg TEXT NOT NULL DEFAULT '',
|
||||
severity TEXT NOT NULL DEFAULT '',
|
||||
action TEXT NOT NULL, -- 'detected' | 'blocked'
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS waf_alerts_domain_created ON waf_alerts(domain_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS waf_alerts_created ON waf_alerts(created_at DESC);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS waf_alerts;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- +goose Up
|
||||
ALTER TABLE waf_configs
|
||||
ADD COLUMN IF NOT EXISTS exclusion_notes JSONB NOT NULL DEFAULT '{}';
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE waf_configs DROP COLUMN IF EXISTS exclusion_notes;
|
||||
35
internal/database/migrations/0040_oidc_settings.sql
Normal file
35
internal/database/migrations/0040_oidc_settings.sql
Normal file
@@ -0,0 +1,35 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
-- OIDC / Keycloak SSO — Singleton-Settings (analog forward_proxy_settings).
|
||||
-- client_secret_enc: secrets.Box.Seal-Output (AES-256-GCM), NULL = nicht gesetzt.
|
||||
-- Rolle kommt bewusst NICHT aus dem Token, daher keine group/role-claim-Spalten.
|
||||
CREATE TABLE IF NOT EXISTS oidc_settings (
|
||||
id INTEGER PRIMARY KEY DEFAULT 1,
|
||||
enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
issuer_url TEXT NOT NULL DEFAULT '',
|
||||
client_id TEXT NOT NULL DEFAULT '',
|
||||
client_secret_enc BYTEA,
|
||||
scopes TEXT NOT NULL DEFAULT 'openid email profile',
|
||||
email_claim TEXT NOT NULL DEFAULT 'email',
|
||||
button_label TEXT NOT NULL DEFAULT 'Sign in with SSO',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT oidc_settings_singleton CHECK (id = 1)
|
||||
);
|
||||
INSERT INTO oidc_settings (id) VALUES (1) ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Opportunistisches Linking: beim ersten SSO-Login wird der OIDC-'sub'
|
||||
-- gespeichert; weicht er später ab, wird der Login abgelehnt. Nullable,
|
||||
-- kein Backfill (Match-Schlüssel bleibt die verifizierte E-Mail).
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS oidc_subject TEXT;
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
|
||||
ALTER TABLE users DROP COLUMN IF EXISTS oidc_subject;
|
||||
DROP TABLE IF EXISTS oidc_settings;
|
||||
|
||||
-- +goose StatementEnd
|
||||
62
internal/database/migrations/0041_dhcp.sql
Normal file
62
internal/database/migrations/0041_dhcp.sql
Normal file
@@ -0,0 +1,62 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
-- DHCP (Kea) — globale Singleton-Settings (node-lokal, wie dns_settings/
|
||||
-- ntp_settings: ob DIESE Node DHCP betreibt). Subnets/Reservierungen sind
|
||||
-- geteilte Config und werden repliziert.
|
||||
CREATE TABLE IF NOT EXISTS dhcp_settings (
|
||||
id INTEGER PRIMARY KEY DEFAULT 1,
|
||||
enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
default_lease INTEGER NOT NULL DEFAULT 3600,
|
||||
max_lease INTEGER NOT NULL DEFAULT 7200,
|
||||
domain_name TEXT NOT NULL DEFAULT '',
|
||||
dns_servers TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT dhcp_settings_singleton CHECK (id = 1)
|
||||
);
|
||||
INSERT INTO dhcp_settings (id) VALUES (1) ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Subnets: an ein Interface per NAME gebunden (nicht per node-lokaler FK,
|
||||
-- damit die Replikation nicht an divergierenden interface_id bricht).
|
||||
CREATE TABLE IF NOT EXISTS dhcp_subnets (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
interface_name TEXT NOT NULL,
|
||||
subnet_cidr TEXT NOT NULL,
|
||||
pool_start TEXT NOT NULL DEFAULT '',
|
||||
pool_end TEXT NOT NULL DEFAULT '',
|
||||
gateway TEXT NOT NULL DEFAULT '',
|
||||
dns_servers TEXT NOT NULL DEFAULT '',
|
||||
lease_time INTEGER,
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT dhcp_subnets_name_unique UNIQUE (name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS dhcp_reservations (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
subnet_id BIGINT NOT NULL REFERENCES dhcp_subnets(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
mac_address TEXT NOT NULL,
|
||||
ip_address TEXT NOT NULL,
|
||||
hostname TEXT NOT NULL DEFAULT '',
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT dhcp_reservations_subnet_mac_unique UNIQUE (subnet_id, mac_address)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_dhcp_reservations_subnet ON dhcp_reservations(subnet_id);
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
|
||||
DROP TABLE IF EXISTS dhcp_reservations;
|
||||
DROP TABLE IF EXISTS dhcp_subnets;
|
||||
DROP TABLE IF EXISTS dhcp_settings;
|
||||
|
||||
-- +goose StatementEnd
|
||||
51
internal/database/migrations/0042_radius.sql
Normal file
51
internal/database/migrations/0042_radius.sql
Normal file
@@ -0,0 +1,51 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
-- RADIUS (FreeRADIUS) — node-lokale Singleton-Settings (ob DIESE Node
|
||||
-- RADIUS betreibt + Listen). Clients/Users sind geteilte Config (repliziert).
|
||||
CREATE TABLE IF NOT EXISTS radius_settings (
|
||||
id INTEGER PRIMARY KEY DEFAULT 1,
|
||||
enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
listen_addresses TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT radius_settings_singleton CHECK (id = 1)
|
||||
);
|
||||
INSERT INTO radius_settings (id) VALUES (1) ON CONFLICT DO NOTHING;
|
||||
|
||||
-- NAS-Clients (Geräte, die RADIUS-Requests senden): IP/CIDR + Shared Secret
|
||||
-- (verschlüsselt via secrets.Box).
|
||||
CREATE TABLE IF NOT EXISTS radius_clients (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
ipaddr TEXT NOT NULL,
|
||||
secret_enc BYTEA,
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT radius_clients_name_unique UNIQUE (name)
|
||||
);
|
||||
|
||||
-- Benutzer (PAP/CHAP): Name + Passwort (verschlüsselt; Cleartext nur zur
|
||||
-- Render-Zeit in die freeradius-lesbare authorize-Datei).
|
||||
CREATE TABLE IF NOT EXISTS radius_users (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password_enc BYTEA,
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT radius_users_username_unique UNIQUE (username)
|
||||
);
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
|
||||
DROP TABLE IF EXISTS radius_users;
|
||||
DROP TABLE IF EXISTS radius_clients;
|
||||
DROP TABLE IF EXISTS radius_settings;
|
||||
|
||||
-- +goose StatementEnd
|
||||
@@ -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;
|
||||
27
internal/database/migrations/0044_backend_server_timeout.sql
Normal file
27
internal/database/migrations/0044_backend_server_timeout.sql
Normal file
@@ -0,0 +1,27 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
-- Per-Backend `timeout server` (Sekunden). NULL = defaults-Timeout (60s,
|
||||
-- siehe haproxy.cfg.tpl). Gedacht für Upstreams die LANGE für die Antwort
|
||||
-- brauchen und dabei NICHT streamen — z. B. KI-/Inferenz-Server, die eine
|
||||
-- gepufferte Antwort erst nach Minuten schicken. Ohne Override kappt der
|
||||
-- 60s-defaults-Timeout diese Requests.
|
||||
--
|
||||
-- Bewusst NULL-per-default: Backends ohne Langläufer-Workload behalten den
|
||||
-- kurzen Timeout (Connection-Hygiene / Slowloris-Schutz, vgl. v1.3.2).
|
||||
-- Der Renderer setzt `timeout server <N>s` NUR wenn ein Wert gesetzt ist.
|
||||
--
|
||||
-- CHECK 1..86400: mind. 1s, max. 24h — verhindert 0/negativ (würde HAProxy-
|
||||
-- Config sprengen bzw. „unendlich" bedeuten) und absurd hohe Werte.
|
||||
ALTER TABLE backends
|
||||
ADD COLUMN IF NOT EXISTS server_timeout_seconds INTEGER
|
||||
CONSTRAINT backends_server_timeout_range
|
||||
CHECK (server_timeout_seconds IS NULL
|
||||
OR (server_timeout_seconds BETWEEN 1 AND 86400));
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE backends DROP COLUMN IF EXISTS server_timeout_seconds;
|
||||
-- +goose StatementEnd
|
||||
@@ -0,0 +1,22 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
-- Quittieren von Alarmen. acknowledged_at = NULL → offen (zählt im Dashboard).
|
||||
-- Gesetzt → quittiert (bleibt als History sichtbar, zählt aber nicht mehr auf
|
||||
-- der Startseiten-Karte "Aktuelle Alerts"). alert_events ist node-lokal
|
||||
-- (localOnlyTables) → kein Replikations-Effekt.
|
||||
ALTER TABLE alert_events
|
||||
ADD COLUMN IF NOT EXISTS acknowledged_at TIMESTAMPTZ;
|
||||
|
||||
-- Teil-Index für den Dashboard-Query (nur offene, newest-first).
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_events_open
|
||||
ON alert_events (fired_at DESC)
|
||||
WHERE acknowledged_at IS NULL;
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP INDEX IF EXISTS idx_alert_events_open;
|
||||
ALTER TABLE alert_events DROP COLUMN IF EXISTS acknowledged_at;
|
||||
-- +goose StatementEnd
|
||||
19
internal/database/migrations/0046_waf_crs_plugins.sql
Normal file
19
internal/database/migrations/0046_waf_crs_plugins.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
-- CRS-App-Exclusion-Plugins pro Domain (OWASP-CRS-Plugin-System). Liste von
|
||||
-- Plugin-Namen (z. B. 'nextcloud','wordpress','drupal'). Der WAF-Renderer
|
||||
-- inkludiert je gewähltem Plugin dessen config/before/after-Dateien aus
|
||||
-- <crsDir>/plugins/ an den korrekten Punkten (config+before VOR den CRS-Rules,
|
||||
-- after DANACH) → pfad-genaue, upstream-gepflegte App-Ausnahmen statt manueller
|
||||
-- SecRuleRemoveById-IDs. waf_configs ist repliziert; der Renderer läuft pro
|
||||
-- Node lokal, daher kein Cross-Node-Effekt außer der Config selbst.
|
||||
ALTER TABLE waf_configs
|
||||
ADD COLUMN IF NOT EXISTS crs_plugins TEXT[] NOT NULL DEFAULT '{}';
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE waf_configs DROP COLUMN IF EXISTS crs_plugins;
|
||||
-- +goose StatementEnd
|
||||
34
internal/database/migrations/0047_waf_app_profiles.sql
Normal file
34
internal/database/migrations/0047_waf_app_profiles.sql
Normal file
@@ -0,0 +1,34 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
-- Benutzerdefinierte WAF-App-Profile: benannte, wiederverwendbare Bündel von
|
||||
-- CRS-Rule-Exclusions (reine Rule-IDs/Ranges — keine SecLang-Ausführung, sicher).
|
||||
-- Wirken wie die eingebauten OWASP-Plugins, sind aber im UI erstellbar/editierbar
|
||||
-- und werden pro Domain zugewiesen (waf_configs.app_profiles). Die Auflösung in
|
||||
-- effektive SecRuleRemoveById-Zeilen passiert im WAF-Agent (ListAllWithDomain).
|
||||
--
|
||||
-- Repliziert (Config, kein node-lokaler Zustand) → vom cluster-reconcile
|
||||
-- automatisch in edgeguard_shared aufgenommen (nicht in localOnlyTables).
|
||||
CREATE TABLE IF NOT EXISTS waf_app_profiles (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
rule_exclusions TEXT[] NOT NULL DEFAULT '{}',
|
||||
builtin BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Zuweisung Profil→Domain: Liste von Profil-Namen je waf_config. Beim Bauen der
|
||||
-- Engine werden ihre rule_exclusions in die effektiven Ausnahmen der Domain
|
||||
-- gemischt (zusätzlich zu den domain-eigenen rule_exclusions).
|
||||
ALTER TABLE waf_configs
|
||||
ADD COLUMN IF NOT EXISTS app_profiles TEXT[] NOT NULL DEFAULT '{}';
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE waf_configs DROP COLUMN IF EXISTS app_profiles;
|
||||
DROP TABLE IF EXISTS waf_app_profiles;
|
||||
-- +goose StatementEnd
|
||||
@@ -0,0 +1,18 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
-- Pro-Domain-Flag: vertrauenswuerdiges Admin-Panel → von CrowdSec ausnehmen.
|
||||
-- Admin-SPAs feuern viele /api/-Requests pro Aktion und triggern sonst das
|
||||
-- Scenario http-crawl-non_statics (False-Positive-Ban der Admin-IP, die oft
|
||||
-- dynamisch ist). Der crowdsec-Whitelist-Renderer schreibt aus allen Domains
|
||||
-- mit crowdsec_trusted=true eine host-genaue CrowdSec-Parser-Whitelist
|
||||
-- (evt.Parsed.http_host). Repliziert (Config, node-lokal gerendert) → ueberlebt
|
||||
-- auch einen Node-Neuaufbau, weil aus der DB gerendert.
|
||||
ALTER TABLE domains ADD COLUMN IF NOT EXISTS crowdsec_trusted BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE domains DROP COLUMN IF EXISTS crowdsec_trusted;
|
||||
-- +goose StatementEnd
|
||||
@@ -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).
|
||||
|
||||
@@ -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 +
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -138,6 +139,7 @@ type View struct {
|
||||
type WGSiteMasqEntry struct {
|
||||
Iface string // wg interface name, e.g. "wg7"
|
||||
VPNNet string // network CIDR of the VPN subnet, e.g. "192.168.99.0/24"
|
||||
L3 string // "ip" oder "ip6" — Familie von VPNNet
|
||||
}
|
||||
|
||||
// AutoFWRule is one auto-emitted inbound rule. Proto is "tcp" or
|
||||
@@ -148,10 +150,12 @@ type AutoFWRule struct {
|
||||
Proto string
|
||||
Port int
|
||||
DstIP string
|
||||
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
|
||||
@@ -162,21 +166,25 @@ type RuleLeg struct {
|
||||
DstIfaces []string
|
||||
SrcAddrs []string
|
||||
DstAddrs []string
|
||||
Service ResolvedService // Proto="" → no service match (any)
|
||||
// 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
|
||||
Service ResolvedService // Proto="" → no service match (any)
|
||||
}
|
||||
|
||||
// ResolvedRule has all addresses + services already expanded so the
|
||||
// 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
|
||||
@@ -184,25 +192,31 @@ 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
|
||||
Comment string
|
||||
// L3 ist "ip" oder "ip6" — Adressfamilie der Regel (aus SrcCIDR/
|
||||
// DstCIDR/TargetAddr abgeleitet). TargetHost ist TargetAddr, bei
|
||||
// IPv6 MIT Port in eckigen Klammern ("[2001:db8::1]") für korrekte
|
||||
// nft-dnat-Syntax.
|
||||
L3 string
|
||||
TargetHost 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) {
|
||||
@@ -255,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 {
|
||||
@@ -280,26 +319,15 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Expand to one Leg per (rule × service); rules without a service
|
||||
// produce one leg with empty Proto.
|
||||
// Expand to one Leg per (rule × service × address-family). Rules
|
||||
// without a service produce one leg-set with empty Proto.
|
||||
for _, r := range rules {
|
||||
if len(r.Services) == 0 {
|
||||
view.Legs = append(view.Legs, RuleLeg{
|
||||
RuleID: r.ID, Action: r.Action, Log: r.Log, Name: r.Name,
|
||||
Comment: r.Comment,
|
||||
SrcIfaces: r.SrcIfaces, DstIfaces: r.DstIfaces,
|
||||
SrcAddrs: r.SrcAddrs, DstAddrs: r.DstAddrs,
|
||||
})
|
||||
view.Legs = append(view.Legs, expandFamilyLegs(r, ResolvedService{}, false)...)
|
||||
continue
|
||||
}
|
||||
for _, svc := range r.Services {
|
||||
view.Legs = append(view.Legs, RuleLeg{
|
||||
RuleID: r.ID, Action: r.Action, Log: r.Log, Name: r.Name,
|
||||
Comment: r.Comment,
|
||||
SrcIfaces: r.SrcIfaces, DstIfaces: r.DstIfaces,
|
||||
SrcAddrs: r.SrcAddrs, DstAddrs: r.DstAddrs,
|
||||
Service: svc,
|
||||
})
|
||||
view.Legs = append(view.Legs, expandFamilyLegs(r, svc, true)...)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,9 +351,14 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) {
|
||||
if wgRows.Scan(&name, &cidr) == nil {
|
||||
view.WGServerIfaces = append(view.WGServerIfaces, name)
|
||||
if _, ipNet, err := net.ParseCIDR(cidr); err == nil {
|
||||
l3 := addrFamily(ipNet.String())
|
||||
if l3 == "" {
|
||||
l3 = "ip"
|
||||
}
|
||||
view.WGSiteMasq = append(view.WGSiteMasq, WGSiteMasqEntry{
|
||||
Iface: name,
|
||||
VPNNet: ipNet.String(),
|
||||
L3: l3,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -363,11 +396,24 @@ func (g *Generator) loadAutoRules(ctx context.Context) []AutoFWRule {
|
||||
}
|
||||
}
|
||||
|
||||
// Squid Forward-Proxy: wenn ≥1 aktive ACL → tcp 3128 inbound
|
||||
// (squid bindet aktuell 0.0.0.0:3128, daher kein DstIP-Filter).
|
||||
var aclCount int
|
||||
if err := g.Pool.QueryRow(ctx, `SELECT count(*) FROM forward_proxy_acls WHERE active`).Scan(&aclCount); err == nil && aclCount > 0 {
|
||||
out = append(out, AutoFWRule{Proto: "tcp", Port: 3128, Comment: "Forward-Proxy (Squid)"})
|
||||
// Squid Forward-Proxy: lese Port + Listen-Adressen aus
|
||||
// forward_proxy_settings. Für jede nicht-loopback IP eine
|
||||
// Auto-Rule; leere Liste = alle Interfaces (generische Regel).
|
||||
var squidAddrs string
|
||||
var squidPort int
|
||||
if err := g.Pool.QueryRow(ctx,
|
||||
`SELECT listen_addresses, listen_port FROM forward_proxy_settings WHERE id=1`,
|
||||
).Scan(&squidAddrs, &squidPort); err == nil && squidPort > 0 {
|
||||
addrs := splitCSV(squidAddrs)
|
||||
if len(addrs) == 0 {
|
||||
out = append(out, AutoFWRule{Proto: "tcp", Port: squidPort, Comment: "Forward-Proxy (Squid)"})
|
||||
} else {
|
||||
for _, ip := range addrs {
|
||||
if !isLoopback(ip) {
|
||||
out = append(out, AutoFWRule{Proto: "tcp", Port: squidPort, DstIP: ip, Comment: "Forward-Proxy (Squid) auf " + ip})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WireGuard server-mode: udp <listen_port> pro aktive iface.
|
||||
@@ -398,7 +444,60 @@ func (g *Generator) loadAutoRules(ctx context.Context) []AutoFWRule {
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
// DHCP (Kea): wenn auf DIESER Node aktiviert → udp/67 pro aktivem
|
||||
// Subnet-Interface (gescopt auf die LAN-iface, NICHT global/WAN).
|
||||
var dhcpEnabled bool
|
||||
if err := g.Pool.QueryRow(ctx, `SELECT enabled FROM dhcp_settings WHERE id=1`).Scan(&dhcpEnabled); err == nil && dhcpEnabled {
|
||||
rows, err := g.Pool.Query(ctx, `SELECT DISTINCT interface_name FROM dhcp_subnets WHERE active AND interface_name <> ''`)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var iface string
|
||||
if rows.Scan(&iface) == nil && iface != "" {
|
||||
out = append(out, AutoFWRule{Proto: "udp", Port: 67, Iface: iface, Comment: "DHCP (Kea) auf " + iface})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RADIUS (FreeRADIUS): wenn aktiviert → udp 1812 (auth) + 1813 (acct).
|
||||
// Pro listen-IP, sonst global. FreeRADIUS verwirft unbekannte Clients selbst.
|
||||
var radiusEnabled bool
|
||||
var radiusListen string
|
||||
if err := g.Pool.QueryRow(ctx, `SELECT enabled, listen_addresses FROM radius_settings WHERE id=1`).Scan(&radiusEnabled, &radiusListen); err == nil && radiusEnabled {
|
||||
ips := splitCSV(radiusListen)
|
||||
emit := func(ip string) {
|
||||
out = append(out,
|
||||
AutoFWRule{Proto: "udp", Port: 1812, DstIP: ip, Comment: "RADIUS-Auth (FreeRADIUS)"},
|
||||
AutoFWRule{Proto: "udp", Port: 1813, DstIP: ip, Comment: "RADIUS-Acct (FreeRADIUS)"},
|
||||
)
|
||||
}
|
||||
if len(ips) == 0 {
|
||||
emit("")
|
||||
} else {
|
||||
for _, ip := range ips {
|
||||
if !isLoopback(ip) && ip != "0.0.0.0" && ip != "::" {
|
||||
emit(ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Familien-Tag (ip/ip6) für DstIP-basierte Auto-Rules setzen; eine
|
||||
// IPv6-Listen-Adresse muss `ip6 daddr` ergeben (sonst lehnt nft das
|
||||
// gesamte Ruleset ab). Unparsebare DstIPs werden verworfen.
|
||||
tagged := out[:0]
|
||||
for _, r := range out {
|
||||
if r.DstIP != "" {
|
||||
fam := addrFamily(r.DstIP)
|
||||
if fam == "" {
|
||||
continue
|
||||
}
|
||||
r.L3 = fam
|
||||
}
|
||||
tagged = append(tagged, r)
|
||||
}
|
||||
return tagged
|
||||
}
|
||||
|
||||
// splitCSV — wie in den Service-renderern.
|
||||
@@ -422,6 +521,131 @@ func isLoopback(ip string) bool {
|
||||
return strings.HasPrefix(ip, "127.")
|
||||
}
|
||||
|
||||
// addrFamily klassifiziert einen nft-Adressausdruck (host, CIDR oder
|
||||
// range "a-b") als "ip" (IPv4), "ip6" (IPv6) oder "" (unbestimmt, z.B.
|
||||
// FQDN-Platzhalter). Adressen enthalten selbst kein '-', daher trennt der
|
||||
// erste Bindestrich sicher eine Range in ihr erstes Element.
|
||||
func addrFamily(expr string) string {
|
||||
expr = strings.TrimSpace(expr)
|
||||
if expr == "" {
|
||||
return ""
|
||||
}
|
||||
if i := strings.IndexByte(expr, '-'); i > 0 {
|
||||
expr = strings.TrimSpace(expr[:i])
|
||||
}
|
||||
if i := strings.IndexByte(expr, '/'); i > 0 {
|
||||
expr = expr[:i]
|
||||
}
|
||||
ip := net.ParseIP(expr)
|
||||
if ip == nil {
|
||||
return ""
|
||||
}
|
||||
if ip.To4() != nil {
|
||||
return "ip"
|
||||
}
|
||||
return "ip6"
|
||||
}
|
||||
|
||||
// splitByFamily teilt eine Liste von nft-Adressausdrücken in v4 und v6.
|
||||
// Unbestimmte (FQDN o.ä.) werden verworfen.
|
||||
func splitByFamily(exprs []string) (v4, v6 []string) {
|
||||
for _, e := range exprs {
|
||||
switch addrFamily(e) {
|
||||
case "ip":
|
||||
v4 = append(v4, e)
|
||||
case "ip6":
|
||||
v6 = append(v6, e)
|
||||
}
|
||||
}
|
||||
return v4, v6
|
||||
}
|
||||
|
||||
// serviceL3: icmp ist v4-only, icmpv6 v6-only, tcp/udp/leer agnostic.
|
||||
func serviceL3(svc ResolvedService) string {
|
||||
switch svc.Proto {
|
||||
case "icmp":
|
||||
return "ip"
|
||||
case "icmpv6":
|
||||
return "ip6"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// natFamily ermittelt die Adressfamilie einer NAT-Regel aus ihren
|
||||
// Adressen. ok=false bei gemischten v4/v6-Adressen (ungültig → die Regel
|
||||
// muss übersprungen werden, sonst bricht `nft -f` das gesamte Ruleset).
|
||||
func natFamily(r ResolvedNATRule) (fam string, ok bool) {
|
||||
for _, a := range []string{r.SrcCIDR, r.DstCIDR, r.TargetAddr} {
|
||||
f := addrFamily(a)
|
||||
if f == "" {
|
||||
continue
|
||||
}
|
||||
if fam == "" {
|
||||
fam = f
|
||||
} else if fam != f {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
if fam == "" {
|
||||
fam = "ip" // keine Adressen (reine iface/proto-Regel) → v4-Default
|
||||
}
|
||||
return fam, true
|
||||
}
|
||||
|
||||
// expandFamilyLegs materialisiert die nft-Zeilen für eine Regel + optional
|
||||
// einen Service, getrennt nach Adressfamilie. Adresslose Regeln ergeben eine
|
||||
// einzige familienagnostische Zeile (unverändertes v4-Verhalten, greift
|
||||
// zugleich für v6). Regeln mit Adressen werden pro Familie als separate
|
||||
// Zeile emittiert — ein nft-Paket ist immer entweder v4 oder v6.
|
||||
func expandFamilyLegs(r ResolvedRule, svc ResolvedService, hasSvc bool) []RuleLeg {
|
||||
base := RuleLeg{
|
||||
RuleID: r.ID, Action: r.Action, Log: r.Log, Name: r.Name, Comment: r.Comment,
|
||||
SrcIfaces: r.SrcIfaces, DstIfaces: r.DstIfaces,
|
||||
}
|
||||
if hasSvc {
|
||||
base.Service = svc
|
||||
}
|
||||
|
||||
if len(r.SrcAddrs) == 0 && len(r.DstAddrs) == 0 {
|
||||
// Kein Address-Match → eine Zeile, L3 leer. Die Proto-Render-Logik
|
||||
// im Template setzt icmp/icmpv6 selbst familienkorrekt.
|
||||
return []RuleLeg{base}
|
||||
}
|
||||
|
||||
src4, src6 := splitByFamily(r.SrcAddrs)
|
||||
dst4, dst6 := splitByFamily(r.DstAddrs)
|
||||
svcFam := ""
|
||||
if hasSvc {
|
||||
svcFam = serviceL3(svc)
|
||||
}
|
||||
|
||||
var legs []RuleLeg
|
||||
for _, fam := range []string{"ip", "ip6"} {
|
||||
if svcFam != "" && svcFam != fam {
|
||||
continue // icmp nur auf v4, icmpv6 nur auf v6
|
||||
}
|
||||
srcF, dstF := src4, dst4
|
||||
if fam == "ip6" {
|
||||
srcF, dstF = src6, dst6
|
||||
}
|
||||
// Eine eingeschränkte Seite ohne Mitglied dieser Familie → die
|
||||
// Zeile würde nichts (oder Falsches) matchen → überspringen.
|
||||
if len(r.SrcAddrs) > 0 && len(srcF) == 0 {
|
||||
continue
|
||||
}
|
||||
if len(r.DstAddrs) > 0 && len(dstF) == 0 {
|
||||
continue
|
||||
}
|
||||
leg := base
|
||||
leg.L3 = fam
|
||||
leg.SrcAddrs = srcF
|
||||
leg.DstAddrs = dstF
|
||||
legs = append(legs, leg)
|
||||
}
|
||||
return legs
|
||||
}
|
||||
|
||||
// addrObjMap is keyed by id; value is the nft expression for that
|
||||
// object (e.g. "1.2.3.4", "10.0.0.0/24", "1.2.3.4-1.2.3.10").
|
||||
type addrObjMap map[int64]string
|
||||
@@ -578,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,
|
||||
@@ -643,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,
|
||||
@@ -662,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 {
|
||||
@@ -680,6 +904,19 @@ ORDER BY priority DESC, id ASC`)
|
||||
if outZone != nil {
|
||||
r.OutIfaces = zoneIfaces[*outZone]
|
||||
}
|
||||
fam, ok := natFamily(r)
|
||||
if !ok {
|
||||
// Gemischte v4/v6-Adressen → ungültige NAT-Regel. Überspringen
|
||||
// statt das gesamte Ruleset mit `nft -f` zu brechen.
|
||||
slog.Warn("firewall: NAT-Regel mit gemischten v4/v6-Adressen übersprungen", "id", r.ID)
|
||||
continue
|
||||
}
|
||||
r.L3 = fam
|
||||
r.TargetHost = r.TargetAddr
|
||||
if fam == "ip6" && r.TargetAddr != "" && r.TargetPortStart > 0 {
|
||||
// nft braucht [v6]:port für dnat-Targets mit Port.
|
||||
r.TargetHost = "[" + r.TargetAddr + "]"
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
|
||||
64
internal/firewall/firewall_autorule_test.go
Normal file
64
internal/firewall/firewall_autorule_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package firewall
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestTemplate_autoRuleIface prüft, dass eine Auto-Rule mit Iface als
|
||||
// `iifname "<x>"`-gescopte Zeile rendert (DHCP udp/67 auf LAN) und dass
|
||||
// DstIP-basierte Auto-Rules unverändert bleiben.
|
||||
func TestTemplate_autoRuleIface(t *testing.T) {
|
||||
view := &View{
|
||||
AutoRules: []AutoFWRule{
|
||||
{Proto: "udp", Port: 67, Iface: "eth1", Comment: "DHCP (Kea) auf eth1"},
|
||||
{Proto: "udp", Port: 53, DstIP: "10.0.0.1", L3: "ip", Comment: "DNS"},
|
||||
{Proto: "udp", Port: 53, DstIP: "2001:db8::1", L3: "ip6", Comment: "DNS v6"},
|
||||
},
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := tpl.Execute(&buf, view); err != nil {
|
||||
t.Fatalf("template execute: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
|
||||
if !strings.Contains(out, `iifname "eth1" udp dport 67 accept comment "auto: DHCP (Kea) auf eth1"`) {
|
||||
t.Errorf("missing iface-scoped DHCP auto-rule\n----\n%s", out)
|
||||
}
|
||||
// v4-DstIP-Auto-Rule: ip daddr.
|
||||
if !strings.Contains(out, `ip daddr 10.0.0.1 udp dport 53 accept`) {
|
||||
t.Errorf("v4 DstIP auto-rule wrong\n----\n%s", out)
|
||||
}
|
||||
// Fix #5: v6-DstIP muss `ip6 daddr` ergeben (sonst bricht nft das Ruleset).
|
||||
if !strings.Contains(out, `ip6 daddr 2001:db8::1 udp dport 53 accept`) {
|
||||
t.Errorf("v6 DstIP auto-rule must use ip6 daddr\n----\n%s", out)
|
||||
}
|
||||
|
||||
// Echte nft-Syntaxvalidierung (braucht root → via sudo, sonst skip).
|
||||
nft, err := exec.LookPath("nft")
|
||||
if err != nil {
|
||||
t.Skip("nft not in PATH")
|
||||
}
|
||||
f, err := os.CreateTemp(t.TempDir(), "autorule-*.nft")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = f.WriteString(out)
|
||||
_ = f.Close()
|
||||
var cmd *exec.Cmd
|
||||
if os.Geteuid() == 0 {
|
||||
cmd = exec.Command(nft, "-c", "-f", f.Name())
|
||||
} else {
|
||||
cmd = exec.Command("sudo", "-n", nft, "-c", "-f", f.Name())
|
||||
}
|
||||
if combined, err := cmd.CombinedOutput(); err != nil {
|
||||
msg := string(combined)
|
||||
if strings.Contains(msg, "Operation not permitted") || strings.Contains(msg, "password is required") {
|
||||
t.Skipf("nft -c needs root: %s", strings.TrimSpace(msg))
|
||||
}
|
||||
t.Fatalf("nft -c rejected ruleset: %v\n%s\n----\n%s", err, combined, out)
|
||||
}
|
||||
}
|
||||
142
internal/firewall/firewall_e2e_test.go
Normal file
142
internal/firewall/firewall_e2e_test.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package firewall
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
|
||||
)
|
||||
|
||||
// TestE2E_IPv6Render fährt den ECHTEN Generator gegen eine Test-DB:
|
||||
// alle Migrations + v4/v6-Seed + RenderToString + nft -c. Nur aktiv, wenn
|
||||
// EG_FWTEST_DSN gesetzt ist (sonst Skip — `go test ./...` bleibt DB-frei).
|
||||
func TestE2E_IPv6Render(t *testing.T) {
|
||||
dsn := os.Getenv("EG_FWTEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set EG_FWTEST_DSN to run the firewall end-to-end test")
|
||||
}
|
||||
ctx := context.Background()
|
||||
// Retry: goose-Erst-Apply ist nicht concurrency-safe, wenn mehrere
|
||||
// guarded Test-Pakete dieselbe frische DB parallel migrieren.
|
||||
var mErr error
|
||||
for i := 0; i < 3; i++ {
|
||||
if mErr = database.Migrate(ctx, dsn); mErr == nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(700 * time.Millisecond)
|
||||
}
|
||||
if mErr != nil {
|
||||
t.Fatalf("migrate: %v", mErr)
|
||||
}
|
||||
pool, err := database.Open(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
for _, tbl := range []string{
|
||||
"firewall_nat_rules", "firewall_rules",
|
||||
"firewall_address_group_members", "firewall_address_groups",
|
||||
"firewall_address_objects", "network_interfaces",
|
||||
} {
|
||||
if _, err := pool.Exec(ctx, "DELETE FROM "+tbl); err != nil {
|
||||
t.Fatalf("clean %s: %v", tbl, err)
|
||||
}
|
||||
}
|
||||
|
||||
mustExec := func(sql string, args ...any) {
|
||||
t.Helper()
|
||||
if _, err := pool.Exec(ctx, sql, args...); err != nil {
|
||||
t.Fatalf("seed failed (%s): %v", sql, err)
|
||||
}
|
||||
}
|
||||
insID := func(sql string, args ...any) int64 {
|
||||
t.Helper()
|
||||
var id int64
|
||||
if err := pool.QueryRow(ctx, sql, args...).Scan(&id); err != nil {
|
||||
t.Fatalf("seed-id failed (%s): %v", sql, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
mustExec(`INSERT INTO network_interfaces (name,type,role) VALUES ('eth0','ethernet','wan'),('eth1','ethernet','lan')`)
|
||||
|
||||
v4net := insID(`INSERT INTO firewall_address_objects (name,kind,value) VALUES ('v4net','network','10.0.0.0/24') RETURNING id`)
|
||||
v6net := insID(`INSERT INTO firewall_address_objects (name,kind,value) VALUES ('v6net','network','2001:db8:1::/64') RETURNING id`)
|
||||
v6host := insID(`INSERT INTO firewall_address_objects (name,kind,value) VALUES ('v6host','host','2001:db8:2::5') RETURNING id`)
|
||||
v6range := insID(`INSERT INTO firewall_address_objects (name,kind,value) VALUES ('v6range','range','2001:db8:3::1-2001:db8:3::9') RETURNING id`)
|
||||
|
||||
// Gemischte Gruppe (v4 + v6) → muss in zwei Familien-Zeilen splitten.
|
||||
grp := insID(`INSERT INTO firewall_address_groups (name) VALUES ('mixed') RETURNING id`)
|
||||
mustExec(`INSERT INTO firewall_address_group_members (group_id,object_id) VALUES ($1,$2),($1,$3)`, grp, v4net, v6net)
|
||||
|
||||
httpsSvc := insID(`INSERT INTO firewall_services (name,proto,port_start,port_end,builtin,description) VALUES ('t-https','tcp',443,443,false,'')
|
||||
ON CONFLICT (name) DO UPDATE SET proto=excluded.proto RETURNING id`)
|
||||
var pingV6 int64
|
||||
_ = pool.QueryRow(ctx, `SELECT id FROM firewall_services WHERE proto='icmpv6' LIMIT 1`).Scan(&pingV6)
|
||||
|
||||
// (1) gemischte Gruppe + tcp443 → je eine ip- und ip6-Zeile.
|
||||
mustExec(`INSERT INTO firewall_rules (name,action,src_zone,src_address_group_id,service_object_id) VALUES ('mixed-https','accept','any',$1,$2)`, grp, httpsSvc)
|
||||
// (2) v6-host + icmpv6 → eine ip6-Zeile.
|
||||
if pingV6 != 0 {
|
||||
mustExec(`INSERT INTO firewall_rules (name,action,src_address_object_id,service_object_id) VALUES ('v6-ping','accept',$1,$2)`, v6host, pingV6)
|
||||
}
|
||||
// (3) v6-range src + v6net dst (kein Service).
|
||||
mustExec(`INSERT INTO firewall_rules (name,action,src_address_object_id,dst_address_object_id) VALUES ('v6-range','drop',$1,$2)`, v6range, v6net)
|
||||
|
||||
// (a) v6-DNAT mit Port → dnat to [..]:port.
|
||||
mustExec(`INSERT INTO firewall_nat_rules (name,kind,proto,match_dst_cidr,match_dport_start,target_addr,target_port_start) VALUES ('v6-dnat','dnat','tcp','2001:db8:9::/64',80,'2001:db8:9::2',8080)`)
|
||||
// (b) v4-DNAT (Regression).
|
||||
mustExec(`INSERT INTO firewall_nat_rules (name,kind,proto,match_dst_cidr,match_dport_start,target_addr,target_port_start) VALUES ('v4-dnat','dnat','tcp','1.2.3.4',80,'10.0.0.5',80)`)
|
||||
// (c) gemischte Familie (v4 src, v6 target) → MUSS übersprungen werden.
|
||||
mustExec(`INSERT INTO firewall_nat_rules (name,kind,proto,match_src_cidr,target_addr) VALUES ('mixed-snat','snat','any','10.0.0.0/24','2001:db8::99')`)
|
||||
|
||||
out, err := New(pool).RenderToString(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
|
||||
for _, w := range []string{
|
||||
"ip saddr { 10.0.0.0/24 }",
|
||||
"ip6 saddr { 2001:db8:1::/64 }",
|
||||
"ip6 nexthdr icmpv6",
|
||||
"ip6 saddr { 2001:db8:3::1-2001:db8:3::9 }",
|
||||
"dnat to [2001:db8:9::2]:8080",
|
||||
"dnat to 10.0.0.5:80",
|
||||
} {
|
||||
if !strings.Contains(out, w) {
|
||||
t.Errorf("rendered output missing %q\n----\n%s", w, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "2001:db8::99") {
|
||||
t.Errorf("mixed-family NAT rule was not skipped\n----\n%s", out)
|
||||
}
|
||||
|
||||
nft, err := exec.LookPath("nft")
|
||||
if err != nil {
|
||||
t.Skip("nft not in PATH — skipping syntax check")
|
||||
}
|
||||
f, err := os.CreateTemp(t.TempDir(), "e2e-*.nft")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = f.WriteString(out)
|
||||
_ = f.Close()
|
||||
var cmd *exec.Cmd
|
||||
if os.Geteuid() == 0 {
|
||||
cmd = exec.Command(nft, "-c", "-f", f.Name())
|
||||
} else {
|
||||
cmd = exec.Command("sudo", "-n", nft, "-c", "-f", f.Name())
|
||||
}
|
||||
if combined, err := cmd.CombinedOutput(); err != nil {
|
||||
msg := string(combined)
|
||||
if strings.Contains(msg, "Operation not permitted") || strings.Contains(msg, "password is required") {
|
||||
t.Skipf("nft -c needs root (no usable sudo): %s", strings.TrimSpace(msg))
|
||||
}
|
||||
t.Fatalf("nft -c rejected the real-rendered ruleset: %v\n%s\n----\n%s", err, combined, out)
|
||||
}
|
||||
}
|
||||
183
internal/firewall/firewall_ipv6_test.go
Normal file
183
internal/firewall/firewall_ipv6_test.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package firewall
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAddrFamily(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"1.2.3.4": "ip",
|
||||
"10.0.0.0/24": "ip",
|
||||
"1.2.3.4-1.2.3.10": "ip",
|
||||
"2001:db8::1": "ip6",
|
||||
"fd00::/64": "ip6",
|
||||
"2001:db8::1-2001:db8::5": "ip6",
|
||||
"example.com": "",
|
||||
"": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := addrFamily(in); got != want {
|
||||
t.Errorf("addrFamily(%q)=%q want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandFamilyLegs_splitsByFamily(t *testing.T) {
|
||||
r := ResolvedRule{
|
||||
ID: 1, Action: "accept",
|
||||
SrcAddrs: []string{"10.0.0.0/24", "fd00::/64"},
|
||||
DstAddrs: []string{"1.2.3.4", "2001:db8::1"},
|
||||
}
|
||||
legs := expandFamilyLegs(r, ResolvedService{}, false)
|
||||
if len(legs) != 2 {
|
||||
t.Fatalf("want 2 legs (v4+v6), got %d", len(legs))
|
||||
}
|
||||
var v4, v6 *RuleLeg
|
||||
for i := range legs {
|
||||
switch legs[i].L3 {
|
||||
case "ip":
|
||||
v4 = &legs[i]
|
||||
case "ip6":
|
||||
v6 = &legs[i]
|
||||
}
|
||||
}
|
||||
if v4 == nil || v6 == nil {
|
||||
t.Fatalf("missing family leg: %+v", legs)
|
||||
}
|
||||
if len(v4.SrcAddrs) != 1 || v4.SrcAddrs[0] != "10.0.0.0/24" || v4.DstAddrs[0] != "1.2.3.4" {
|
||||
t.Errorf("v4 leg wrong: src=%v dst=%v", v4.SrcAddrs, v4.DstAddrs)
|
||||
}
|
||||
if len(v6.SrcAddrs) != 1 || v6.SrcAddrs[0] != "fd00::/64" || v6.DstAddrs[0] != "2001:db8::1" {
|
||||
t.Errorf("v6 leg wrong: src=%v dst=%v", v6.SrcAddrs, v6.DstAddrs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandFamilyLegs_addresslessIsAgnostic(t *testing.T) {
|
||||
legs := expandFamilyLegs(ResolvedRule{ID: 2, Action: "accept"}, ResolvedService{}, false)
|
||||
if len(legs) != 1 || legs[0].L3 != "" {
|
||||
t.Fatalf("addressless rule must be a single agnostic leg, got %d legs L3=%q", len(legs), legs[0].L3)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandFamilyLegs_oneFamilyOnly(t *testing.T) {
|
||||
// src nur v4, dst nur v4 → genau eine v4-Zeile (kein leerer v6-Leg).
|
||||
r := ResolvedRule{ID: 3, Action: "drop", SrcAddrs: []string{"10.0.0.0/8"}}
|
||||
legs := expandFamilyLegs(r, ResolvedService{}, false)
|
||||
if len(legs) != 1 || legs[0].L3 != "ip" {
|
||||
t.Fatalf("v4-only rule want 1 ip leg, got %+v", legs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandFamilyLegs_icmpFamilyMatch(t *testing.T) {
|
||||
r6 := ResolvedRule{ID: 4, Action: "accept", SrcAddrs: []string{"fd00::/64"}}
|
||||
if legs := expandFamilyLegs(r6, ResolvedService{Proto: "icmpv6"}, true); len(legs) != 1 || legs[0].L3 != "ip6" {
|
||||
t.Fatalf("icmpv6+v6 want 1 ip6 leg, got %+v", legs)
|
||||
}
|
||||
if legs := expandFamilyLegs(r6, ResolvedService{Proto: "icmp"}, true); len(legs) != 0 {
|
||||
t.Fatalf("icmp on v6-only addrs want 0 legs, got %+v", legs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNatFamily(t *testing.T) {
|
||||
if _, ok := natFamily(ResolvedNATRule{SrcCIDR: "10.0.0.0/24", TargetAddr: "2001:db8::1"}); ok {
|
||||
t.Error("mixed v4/v6 NAT must be rejected (ok=false)")
|
||||
}
|
||||
if fam, ok := natFamily(ResolvedNATRule{TargetAddr: "2001:db8::1"}); !ok || fam != "ip6" {
|
||||
t.Errorf("v6 NAT: fam=%q ok=%v want ip6/true", fam, ok)
|
||||
}
|
||||
if fam, ok := natFamily(ResolvedNATRule{SrcCIDR: "10.0.0.0/24"}); !ok || fam != "ip" {
|
||||
t.Errorf("v4 NAT: fam=%q ok=%v want ip/true", fam, ok)
|
||||
}
|
||||
if fam, ok := natFamily(ResolvedNATRule{}); !ok || fam != "ip" {
|
||||
t.Errorf("addressless NAT: fam=%q ok=%v want ip/true (v4 default)", fam, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// renderView ist ein gemischter v4/v6-View, der alle geänderten
|
||||
// Template-Zweige berührt.
|
||||
func renderView(t *testing.T) string {
|
||||
t.Helper()
|
||||
view := &View{
|
||||
PeerIPv4: []string{"10.0.0.1"},
|
||||
PeerIPv6: []string{"fd00::1"},
|
||||
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, 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},
|
||||
{ID: 6, Kind: "snat", L3: "ip6", SrcCIDR: "fd00::/64", TargetAddr: "2001:db8::99"},
|
||||
{ID: 7, Kind: "dnat", L3: "ip", DstCIDR: "1.2.3.4", Proto: "tcp", DPortStart: 80, TargetAddr: "10.0.0.5", TargetHost: "10.0.0.5", TargetPortStart: 80},
|
||||
},
|
||||
WGSiteMasq: []WGSiteMasqEntry{{Iface: "wg7", VPNNet: "fd00:99::/64", L3: "ip6"}},
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := tpl.Execute(&buf, view); err != nil {
|
||||
t.Fatalf("template execute: %v", err)
|
||||
}
|
||||
return buf.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
|
||||
"ip6 saddr fd00::/64 snat to 2001:db8::99",
|
||||
`oifname "wg7" ip6 saddr fd00:99::/64 masquerade`,
|
||||
}
|
||||
for _, w := range mustContain {
|
||||
if !strings.Contains(out, w) {
|
||||
t.Errorf("output missing %q\n----\n%s", w, out)
|
||||
}
|
||||
}
|
||||
// v6-Adressen dürfen NIEMALS in einem ip-saddr/daddr-Set landen.
|
||||
if strings.Contains(out, "ip saddr { fd00") || strings.Contains(out, "ip daddr { fd00") ||
|
||||
strings.Contains(out, "ip saddr { 2001") {
|
||||
t.Errorf("v6 address leaked into IPv4 match\n----\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemplate_nftSyntax validiert das gerenderte Ruleset mit `nft -c -f`
|
||||
// (Check-Modus, kein Apply). Wird übersprungen, wenn nft nicht installiert
|
||||
// ist (z.B. CI ohne nft).
|
||||
func TestTemplate_nftSyntax(t *testing.T) {
|
||||
nft, err := exec.LookPath("nft")
|
||||
if err != nil {
|
||||
t.Skip("nft binary not available — skipping syntax check")
|
||||
}
|
||||
out := renderView(t)
|
||||
f, err := os.CreateTemp(t.TempDir(), "ruleset-*.nft")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := f.WriteString(out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = 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).
|
||||
var cmd *exec.Cmd
|
||||
if os.Geteuid() == 0 {
|
||||
cmd = exec.Command(nft, "-c", "-f", f.Name())
|
||||
} else {
|
||||
cmd = exec.Command("sudo", "-n", nft, "-c", "-f", f.Name())
|
||||
}
|
||||
combined, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
msg := string(combined)
|
||||
if strings.Contains(msg, "Operation not permitted") || strings.Contains(msg, "a password is required") || strings.Contains(msg, "may not run sudo") {
|
||||
t.Skipf("nft -c needs root (no usable sudo): %s", strings.TrimSpace(msg))
|
||||
}
|
||||
t.Fatalf("nft -c -f rejected the generated ruleset: %v\n%s\n----\n%s", err, combined, out)
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,8 @@
|
||||
# Source: internal/firewall/firewall.go.
|
||||
# Re-generate via `edgeguard-ctl render-config` or via API mutations.
|
||||
|
||||
flush ruleset
|
||||
add table inet edgeguard
|
||||
flush table inet edgeguard
|
||||
|
||||
table inet edgeguard {
|
||||
set peer_ipv4 {
|
||||
@@ -49,13 +50,25 @@ table inet edgeguard {
|
||||
# Cluster-internal: peers reach edgeguard-api over mTLS on :8443
|
||||
tcp dport 8443 ip saddr @peer_ipv4 accept
|
||||
tcp dport 8443 ip6 saddr @peer_ipv6 accept
|
||||
# Cluster-internal: PG Logical Replication (:5432) + KeyDB Active-Active (:6379)
|
||||
tcp dport 5432 ip saddr @peer_ipv4 accept
|
||||
tcp dport 5432 ip6 saddr @peer_ipv6 accept
|
||||
tcp dport 6379 ip saddr @peer_ipv4 accept
|
||||
tcp dport 6379 ip6 saddr @peer_ipv6 accept
|
||||
# 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
|
||||
# editiert diese nicht. Wenn der Service entfernt/disabled
|
||||
# wird, ist die Rule beim nächsten Render weg.
|
||||
{{range .AutoRules}}
|
||||
{{if .DstIP}}ip daddr {{.DstIP}} {{end}}{{.Proto}} dport {{.Port}} accept comment "auto: {{.Comment}}"
|
||||
{{if .Iface}}iifname "{{.Iface}}" {{end}}{{if .DstIP}}{{.L3}} daddr {{.DstIP}} {{end}}{{.Proto}} dport {{.Port}} accept comment "auto: {{.Comment}}"
|
||||
{{end}}
|
||||
|
||||
# ── Operator-defined rules ──
|
||||
@@ -65,7 +78,7 @@ table inet edgeguard {
|
||||
die Comment-Zeile angehängt — sonst frisst nft die rule
|
||||
als Teil des # Kommentars). */ -}}
|
||||
{{""}}
|
||||
{{if .SrcIfaces}}iifname { {{join .SrcIfaces ", "}} } {{end}}{{if .DstIfaces}}oifname { {{join .DstIfaces ", "}} } {{end}}{{if .SrcAddrs}}ip saddr { {{join .SrcAddrs ", "}} } {{end}}{{if .DstAddrs}}ip daddr { {{join .DstAddrs ", "}} } {{end}}{{with .Service}}{{if and (or (eq .Proto "tcp") (eq .Proto "udp")) .PortStart}}{{.Proto}} dport {{.PortStart}}{{if and .PortEnd (ne .PortEnd .PortStart)}}-{{.PortEnd}}{{end}} {{else if eq .Proto "icmp"}}ip protocol icmp {{else if eq .Proto "icmpv6"}}ip6 nexthdr icmpv6 {{end}}{{end}}{{if .Log}}log prefix "edgeguard:{{.RuleID}} " group 0 {{end}}counter {{.Action}} comment "egid:{{.RuleID}}"
|
||||
{{if .SrcIfaces}}iifname { {{join .SrcIfaces ", "}} } {{end}}{{if .DstIfaces}}oifname { {{join .DstIfaces ", "}} } {{end}}{{if .SrcAddrs}}{{.L3}} saddr { {{join .SrcAddrs ", "}} } {{end}}{{if .DstAddrs}}{{.L3}} daddr { {{join .DstAddrs ", "}} } {{end}}{{with .Service}}{{if and (or (eq .Proto "tcp") (eq .Proto "udp")) .PortStart}}{{.Proto}} dport {{.PortStart}}{{if and .PortEnd (ne .PortEnd .PortStart)}}-{{.PortEnd}}{{end}} {{else if eq .Proto "icmp"}}ip protocol icmp {{else if eq .Proto "icmpv6"}}ip6 nexthdr icmpv6 {{end}}{{end}}{{if .Log}}log prefix "edgeguard:{{.RuleID}} " group 0 {{end}}counter {{.Action}} comment "egid:{{.RuleID}}"
|
||||
{{end}}
|
||||
|
||||
# ── DEFAULT-DROP LOGGING ───────────────────────────────────────
|
||||
@@ -95,7 +108,7 @@ table inet edgeguard {
|
||||
# nach und erlauben new-state-Pakete von dort. Return-Pakete
|
||||
# gehen via ct state established schon durch.
|
||||
{{range .NATRules}}{{if or (eq .Kind "snat") (eq .Kind "masquerade")}}{{if .SrcCIDR}}
|
||||
ip saddr {{.SrcCIDR}} ct state new accept comment "auto-forward for NAT rule {{.ID}}"
|
||||
{{.L3}} saddr {{.SrcCIDR}} ct state new accept comment "auto-forward for NAT rule {{.ID}}"
|
||||
{{end}}{{end}}{{end}}
|
||||
|
||||
# Auto-Forward für WireGuard-Server-Interfaces: Peer-to-Peer-
|
||||
@@ -122,7 +135,7 @@ table inet edgeguard {
|
||||
{{""}}
|
||||
{{/* nft-Syntax: erst L3-match (ip saddr/daddr), DANN L4 (tcp/udp dport).
|
||||
Sonst quittiert der parser '... unexpected ip' an dieser Stelle. */}}
|
||||
{{if .InIfaces}}iifname { {{join .InIfaces ", "}} } {{end}}{{if .SrcCIDR}}ip saddr {{.SrcCIDR}} {{end}}{{if .DstCIDR}}ip daddr {{.DstCIDR}} {{end}}{{if and .Proto (ne .Proto "any")}}{{.Proto}} {{else}}meta l4proto { tcp, udp } {{end}}{{if .DPortStart}}dport {{.DPortStart}}{{if and .DPortEnd (ne .DPortEnd .DPortStart)}}-{{.DPortEnd}}{{end}} {{end}}{{if .TargetAddr}}dnat to {{.TargetAddr}}{{if .TargetPortStart}}:{{.TargetPortStart}}{{if and .TargetPortEnd (ne .TargetPortEnd .TargetPortStart)}}-{{.TargetPortEnd}}{{end}}{{end}}{{end}}
|
||||
{{if .InIfaces}}iifname { {{join .InIfaces ", "}} } {{end}}{{if .SrcCIDR}}{{.L3}} saddr {{.SrcCIDR}} {{end}}{{if .DstCIDR}}{{.L3}} daddr {{.DstCIDR}} {{end}}{{if and .Proto (ne .Proto "any")}}{{.Proto}} {{else}}meta l4proto { tcp, udp } {{end}}{{if .DPortStart}}dport {{.DPortStart}}{{if and .DPortEnd (ne .DPortEnd .DPortStart)}}-{{.DPortEnd}}{{end}} {{end}}{{if .TargetAddr}}dnat to {{.TargetHost}}{{if .TargetPortStart}}:{{.TargetPortStart}}{{if and .TargetPortEnd (ne .TargetPortEnd .TargetPortStart)}}-{{.TargetPortEnd}}{{end}}{{end}}{{end}}
|
||||
{{end}}{{end}}
|
||||
}
|
||||
|
||||
@@ -146,16 +159,16 @@ table inet edgeguard {
|
||||
# Masquerade schreibt die Source auf die lokale Tunnel-IP um; Return-Traffic
|
||||
# findet so den Weg zurück durch den Tunnel.
|
||||
{{range .WGSiteMasq}}
|
||||
oifname "{{.Iface}}" ip saddr {{.VPNNet}} masquerade comment "auto: WireGuard site-to-site masquerade {{.Iface}}"
|
||||
oifname "{{.Iface}}" {{.L3}} saddr {{.VPNNet}} masquerade comment "auto: WireGuard site-to-site masquerade {{.Iface}}"
|
||||
{{end}}
|
||||
{{range .NATRules}}{{if eq .Kind "snat"}}
|
||||
# NAT {{.ID}} (snat{{if .Comment}} — {{.Comment}}{{end}})
|
||||
{{""}}
|
||||
{{if .OutIfaces}}oifname { {{join .OutIfaces ", "}} } {{end}}{{if .SrcCIDR}}ip saddr {{.SrcCIDR}} {{end}}{{if .TargetAddr}}snat to {{.TargetAddr}}{{end}}
|
||||
{{if .OutIfaces}}oifname { {{join .OutIfaces ", "}} } {{end}}{{if .SrcCIDR}}{{.L3}} saddr {{.SrcCIDR}} {{end}}{{if .TargetAddr}}snat to {{.TargetAddr}}{{end}}
|
||||
{{end}}{{if eq .Kind "masquerade"}}
|
||||
# NAT {{.ID}} (masquerade{{if .Comment}} — {{.Comment}}{{end}})
|
||||
{{""}}
|
||||
{{if .OutIfaces}}oifname { {{join .OutIfaces ", "}} } {{end}}{{if .SrcCIDR}}ip saddr {{.SrcCIDR}} {{end}}masquerade
|
||||
{{if .OutIfaces}}oifname { {{join .OutIfaces ", "}} } {{end}}{{if .SrcCIDR}}{{.L3}} saddr {{.SrcCIDR}} {{end}}masquerade
|
||||
{{end}}{{end}}
|
||||
}
|
||||
}
|
||||
|
||||
157
internal/freeradius/freeradius.go
Normal file
157
internal/freeradius/freeradius.go
Normal file
@@ -0,0 +1,157 @@
|
||||
// Package freeradius renders the FreeRADIUS client + user files from the
|
||||
// radius_* tables and manages the freeradius service lifecycle.
|
||||
//
|
||||
// Two files are rendered (mirrors the multi-file WireGuard renderer):
|
||||
// - clients.conf — NAS clients (ipaddr + shared secret)
|
||||
// - authorize — users file ("name" Cleartext-Password := "pw")
|
||||
// Both managed under /etc/edgeguard/freeradius/ and symlinked from the
|
||||
// distro paths by postinst. Shared secrets / passwords are decrypted via
|
||||
// secrets.Box at render time. Service runs ONLY when radius_settings.enabled
|
||||
// is true on this node (default off).
|
||||
package freeradius
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
|
||||
radiussvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/radius"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
|
||||
)
|
||||
|
||||
const (
|
||||
ConfDir = configgen.EtcEdgeguard + "/freeradius"
|
||||
ClientsPath = ConfDir + "/clients.conf"
|
||||
AuthorizePath = ConfDir + "/authorize"
|
||||
serviceName = "freeradius"
|
||||
)
|
||||
|
||||
type Generator struct {
|
||||
Pool *pgxpool.Pool
|
||||
Repo *radiussvc.Repo
|
||||
Box *secrets.Box
|
||||
SkipReload bool
|
||||
}
|
||||
|
||||
func New(pool *pgxpool.Pool, box *secrets.Box) *Generator {
|
||||
return &Generator{Pool: pool, Repo: radiussvc.New(pool, box), Box: box}
|
||||
}
|
||||
|
||||
func (g *Generator) Name() string { return "freeradius" }
|
||||
|
||||
// confEscape escaped FreeRADIUS-double-quoted-Strings (Backslash + Quote)
|
||||
// und strippt Steuerzeichen (CR/LF) als Defense-in-Depth gegen Zeilen-
|
||||
// Injection — die Werte werden zwar schon im Handler validiert.
|
||||
func confEscape(s string) string {
|
||||
s = strings.ReplaceAll(s, "\r", "")
|
||||
s = strings.ReplaceAll(s, "\n", "")
|
||||
s = strings.ReplaceAll(s, `\`, `\\`)
|
||||
s = strings.ReplaceAll(s, `"`, `\"`)
|
||||
return s
|
||||
}
|
||||
|
||||
// buildClients rendert clients.conf. mask=true ersetzt Secrets durch *** (Preview).
|
||||
func (g *Generator) buildClients(ctx context.Context, mask bool) (string, error) {
|
||||
clients, err := g.Repo.ListClients(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var b bytes.Buffer
|
||||
b.WriteString("# Generated by edgeguard-api — DO NOT EDIT.\n\n")
|
||||
for _, c := range clients {
|
||||
if !c.Active {
|
||||
continue
|
||||
}
|
||||
secret := "***"
|
||||
if !mask {
|
||||
pt, err := g.Box.Open(c.SecretEnc)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("open secret for client %s: %w", c.Name, err)
|
||||
}
|
||||
secret = string(pt)
|
||||
}
|
||||
fmt.Fprintf(&b, "client %s {\n ipaddr = %s\n secret = \"%s\"\n shortname = %s\n}\n\n",
|
||||
c.Name, c.IPAddr, confEscape(secret), c.Name)
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// buildAuthorize rendert die Users-Datei. mask=true ersetzt Passwörter durch ***.
|
||||
func (g *Generator) buildAuthorize(ctx context.Context, mask bool) (string, error) {
|
||||
users, err := g.Repo.ListUsers(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var b bytes.Buffer
|
||||
b.WriteString("# Generated by edgeguard-api — DO NOT EDIT.\n\n")
|
||||
for _, u := range users {
|
||||
if !u.Active {
|
||||
continue
|
||||
}
|
||||
pw := "***"
|
||||
if !mask {
|
||||
pt, err := g.Box.Open(u.PasswordEnc)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("open password for user %s: %w", u.Username, err)
|
||||
}
|
||||
pw = string(pt)
|
||||
}
|
||||
fmt.Fprintf(&b, "\"%s\" Cleartext-Password := \"%s\"\n", confEscape(u.Username), confEscape(pw))
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// RenderToString liefert beide Dateien (Secrets maskiert) für die Preview.
|
||||
func (g *Generator) RenderToString(ctx context.Context) (string, error) {
|
||||
clients, err := g.buildClients(ctx, true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
authorize, err := g.buildAuthorize(ctx, true)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "# ── clients.conf ──\n" + clients + "\n# ── authorize ──\n" + authorize, nil
|
||||
}
|
||||
|
||||
func (g *Generator) Render(ctx context.Context) error {
|
||||
settings, err := g.Repo.GetSettings(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get radius settings: %w", err)
|
||||
}
|
||||
|
||||
if !settings.Enabled {
|
||||
if g.SkipReload {
|
||||
return nil
|
||||
}
|
||||
_ = configgen.DisableService(serviceName)
|
||||
_ = configgen.StopService(serviceName)
|
||||
return nil
|
||||
}
|
||||
|
||||
clients, err := g.buildClients(ctx, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
authorize, err := g.buildAuthorize(ctx, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := configgen.AtomicWrite(ClientsPath, []byte(clients), 0o640); err != nil {
|
||||
return fmt.Errorf("write clients.conf: %w", err)
|
||||
}
|
||||
if err := configgen.AtomicWrite(AuthorizePath, []byte(authorize), 0o640); err != nil {
|
||||
return fmt.Errorf("write authorize: %w", err)
|
||||
}
|
||||
if g.SkipReload {
|
||||
return nil
|
||||
}
|
||||
if err := configgen.EnableService(serviceName); err != nil {
|
||||
return err
|
||||
}
|
||||
return configgen.RestartService(serviceName)
|
||||
}
|
||||
83
internal/freeradius/freeradius_test.go
Normal file
83
internal/freeradius/freeradius_test.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package freeradius
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
|
||||
radiussvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/radius"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
|
||||
)
|
||||
|
||||
// Guarded integration test: set EG_FWTEST_DSN (sonst skip).
|
||||
func TestRender_ClientsAndUsers(t *testing.T) {
|
||||
dsn := os.Getenv("EG_FWTEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set EG_FWTEST_DSN to run the freeradius renderer test")
|
||||
}
|
||||
ctx := context.Background()
|
||||
var mErr error
|
||||
for i := 0; i < 3; i++ {
|
||||
if mErr = database.Migrate(ctx, dsn); mErr == nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(700 * time.Millisecond)
|
||||
}
|
||||
if mErr != nil {
|
||||
t.Fatalf("migrate: %v", mErr)
|
||||
}
|
||||
pool, err := database.Open(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
for _, q := range []string{`DELETE FROM radius_clients`, `DELETE FROM radius_users`} {
|
||||
if _, err := pool.Exec(ctx, q); err != nil {
|
||||
t.Fatalf("clean: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
box := secrets.New(t.TempDir() + "/master_key")
|
||||
repo := radiussvc.New(pool, box)
|
||||
if _, err := repo.CreateClient(ctx, "testnas", "10.0.0.0/24", `s3c"ret\x`, true, "lab"); err != nil {
|
||||
t.Fatalf("create client: %v", err)
|
||||
}
|
||||
if _, err := repo.CreateUser(ctx, "alice", "alicepw", true); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
|
||||
g := New(pool, box)
|
||||
|
||||
clients, err := g.buildClients(ctx, false)
|
||||
if err != nil {
|
||||
t.Fatalf("buildClients: %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"client testnas {",
|
||||
"ipaddr = 10.0.0.0/24",
|
||||
`secret = "s3c\"ret\\x"`, // " und \ escaped → Secret-Roundtrip + Escaping
|
||||
"shortname = testnas",
|
||||
} {
|
||||
if !strings.Contains(clients, want) {
|
||||
t.Errorf("clients.conf missing %q\n----\n%s", want, clients)
|
||||
}
|
||||
}
|
||||
|
||||
authorize, err := g.buildAuthorize(ctx, false)
|
||||
if err != nil {
|
||||
t.Fatalf("buildAuthorize: %v", err)
|
||||
}
|
||||
if !strings.Contains(authorize, `"alice" Cleartext-Password := "alicepw"`) {
|
||||
t.Errorf("authorize missing alice entry\n----\n%s", authorize)
|
||||
}
|
||||
|
||||
// Maskierte Preview enthält keine echten Secrets.
|
||||
masked, _ := g.buildClients(ctx, true)
|
||||
if strings.Contains(masked, "s3c") {
|
||||
t.Errorf("masked preview leaked secret:\n%s", masked)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -18,7 +18,10 @@ import (
|
||||
// PUT /api/v1/alerts/channels/:id
|
||||
// DELETE /api/v1/alerts/channels/:id
|
||||
// POST /api/v1/alerts/test — Test-Event in alle aktiven Channels
|
||||
// GET /api/v1/alerts/events?limit=N — History
|
||||
// GET /api/v1/alerts/events?limit=N&open=true — History (open=nur offene)
|
||||
// POST /api/v1/alerts/events/acknowledge — Bulk-Quittieren {ids:[…]}
|
||||
// POST /api/v1/alerts/events/acknowledge-all — alle offenen quittieren
|
||||
// POST /api/v1/alerts/events/delete — Bulk-Löschen {ids:[…]}
|
||||
type AlertsHandler struct {
|
||||
Service *alerts.Service
|
||||
Audit *audit.Repo
|
||||
@@ -37,6 +40,9 @@ func (h *AlertsHandler) Register(rg *gin.RouterGroup) {
|
||||
g.DELETE("/channels/:id", h.DeleteChannel)
|
||||
g.POST("/test", h.TestFire)
|
||||
g.GET("/events", h.ListEvents)
|
||||
g.POST("/events/acknowledge", h.AcknowledgeEvents)
|
||||
g.POST("/events/acknowledge-all", h.AcknowledgeAllEvents)
|
||||
g.POST("/events/delete", h.DeleteEvents)
|
||||
}
|
||||
|
||||
func (h *AlertsHandler) ListChannels(c *gin.Context) {
|
||||
@@ -125,10 +131,64 @@ func (h *AlertsHandler) ListEvents(c *gin.Context) {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
out, err := h.Service.ListEvents(c.Request.Context(), limit)
|
||||
// ?open=true → nur offene (nicht quittierte) Events. Nutzt die
|
||||
// Dashboard-Karte, damit Quittieren die Meldung verschwinden lässt.
|
||||
openOnly := c.Query("open") == "true"
|
||||
out, err := h.Service.ListEvents(c.Request.Context(), limit, openOnly)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"events": out})
|
||||
}
|
||||
|
||||
// eventIDsRequest ist der Body für Bulk-Quittieren/-Löschen.
|
||||
type eventIDsRequest struct {
|
||||
IDs []int64 `json:"ids"`
|
||||
}
|
||||
|
||||
// AcknowledgeEvents quittiert die übergebenen Event-IDs.
|
||||
func (h *AlertsHandler) AcknowledgeEvents(c *gin.Context) {
|
||||
var req eventIDsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
n, err := h.Service.Acknowledge(c.Request.Context(), req.IDs)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "alert.events.acknowledge",
|
||||
strconv.Itoa(len(req.IDs)), gin.H{"ids": req.IDs, "acknowledged": n}, h.NodeID)
|
||||
response.OK(c, gin.H{"acknowledged": n})
|
||||
}
|
||||
|
||||
// AcknowledgeAllEvents quittiert alle offenen Events.
|
||||
func (h *AlertsHandler) AcknowledgeAllEvents(c *gin.Context) {
|
||||
n, err := h.Service.AcknowledgeAll(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "alert.events.acknowledge_all",
|
||||
"all", gin.H{"acknowledged": n}, h.NodeID)
|
||||
response.OK(c, gin.H{"acknowledged": n})
|
||||
}
|
||||
|
||||
// DeleteEvents löscht die übergebenen Event-IDs endgültig.
|
||||
func (h *AlertsHandler) DeleteEvents(c *gin.Context) {
|
||||
var req eventIDsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
n, err := h.Service.DeleteEvents(c.Request.Context(), req.IDs)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "alert.events.delete",
|
||||
strconv.Itoa(len(req.IDs)), gin.H{"ids": req.IDs, "deleted": n}, h.NodeID)
|
||||
response.OK(c, gin.H{"deleted": n})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/clustertls"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/session"
|
||||
@@ -21,11 +27,12 @@ import (
|
||||
// the account is auto-migrated into the DB (Upsert) so it shows up in
|
||||
// user management from that point on.
|
||||
type AuthHandler struct {
|
||||
Setup *setup.Store
|
||||
Signer *session.Signer
|
||||
Audit *audit.Repo
|
||||
NodeID string
|
||||
Users *usersvc.Repo // optional — nil on first boot before DB is ready
|
||||
Setup *setup.Store
|
||||
Signer *session.Signer
|
||||
Audit *audit.Repo
|
||||
NodeID string
|
||||
Users *usersvc.Repo // optional — nil on first boot before DB is ready
|
||||
ClusterTLS *clustertls.Store // optional — enables auth federation on cluster nodes
|
||||
}
|
||||
|
||||
func NewAuthHandler(s *setup.Store, sig *session.Signer) *AuthHandler {
|
||||
@@ -46,15 +53,29 @@ func (h *AuthHandler) WithUsers(u *usersvc.Repo) *AuthHandler {
|
||||
return h
|
||||
}
|
||||
|
||||
// WithClusterTLS enables auth federation: when local auth fails on a
|
||||
// cluster node, Login tries the primary via mTLS /agent/auth/check.
|
||||
func (h *AuthHandler) WithClusterTLS(store *clustertls.Store) *AuthHandler {
|
||||
h.ClusterTLS = store
|
||||
return h
|
||||
}
|
||||
|
||||
const totpPendingCookie = "edgeguard_totp_pending"
|
||||
|
||||
// Register mounts /auth/login + /logout (public) and /auth/me
|
||||
// (gated by requireAuth, passed in as a per-route middleware).
|
||||
func (h *AuthHandler) Register(rg *gin.RouterGroup, requireAuth gin.HandlerFunc) {
|
||||
g := rg.Group("/auth")
|
||||
g.POST("/login", h.Login)
|
||||
g.POST("/logout", h.Logout)
|
||||
g.POST("/totp-verify", h.TOTPVerify)
|
||||
g.GET("/me", requireAuth, h.Me)
|
||||
g.POST("/reset-password", h.ResetPassword)
|
||||
g.POST("/change-password", requireAuth, h.ChangePassword)
|
||||
// TOTP self-service (authenticated user manages own 2FA)
|
||||
g.POST("/totp/setup", requireAuth, h.TOTPSetup)
|
||||
g.POST("/totp/confirm", requireAuth, h.TOTPConfirm)
|
||||
g.DELETE("/totp", requireAuth, h.TOTPDisable)
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
@@ -63,9 +84,10 @@ type loginRequest struct {
|
||||
}
|
||||
|
||||
type loginResponse struct {
|
||||
Actor string `json:"actor"`
|
||||
Role string `json:"role"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Actor string `json:"actor"`
|
||||
Role string `json:"role"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
TOTPRequired bool `json:"totp_required,omitempty"`
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Login(c *gin.Context) {
|
||||
@@ -86,38 +108,93 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
|
||||
email := strings.TrimSpace(req.Email)
|
||||
actor, role := "", "admin"
|
||||
remote := c.ClientIP()
|
||||
var totpEnabled bool
|
||||
var viaDB bool // true wenn Rolle/TOTP bereits aus der DB-Row stammen
|
||||
|
||||
// 1. Try DB users table first.
|
||||
if h.Users != nil {
|
||||
u, hash, dbErr := h.Users.FindByEmail(c.Request.Context(), email)
|
||||
ai, dbErr := h.Users.FindForAuth(c.Request.Context(), email)
|
||||
if dbErr == nil {
|
||||
if !u.Active {
|
||||
if !ai.Active {
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), email, "auth.login.failed",
|
||||
email, gin.H{"reason": "account_disabled", "remote": remote}, h.NodeID)
|
||||
}
|
||||
response.Unauthorized(c, errors.New("account_disabled"))
|
||||
return
|
||||
}
|
||||
if !usersvc.VerifyPassword(hash, req.Password) {
|
||||
if !usersvc.VerifyPassword(ai.PasswordHash, req.Password) {
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), email, "auth.login.failed",
|
||||
email, gin.H{"reason": "invalid_credentials", "remote": remote}, h.NodeID)
|
||||
}
|
||||
response.Unauthorized(c, errors.New("invalid_credentials"))
|
||||
return
|
||||
}
|
||||
actor = u.Email
|
||||
role = u.Role
|
||||
h.Users.RecordLogin(c.Request.Context(), u.ID)
|
||||
actor = ai.Email
|
||||
role = ai.Role
|
||||
totpEnabled = ai.TOTPEnabled
|
||||
viaDB = true
|
||||
h.Users.RecordLogin(c.Request.Context(), ai.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fallback: setup-store admin (backwards compat for pre-DB installs).
|
||||
if actor == "" && st.AdminEmail != "" {
|
||||
if strings.EqualFold(st.AdminEmail, email) && st.VerifyAdminPassword(req.Password) {
|
||||
actor = st.AdminEmail
|
||||
role = "admin"
|
||||
if h.Users != nil {
|
||||
_, _ = h.Users.Upsert(c.Request.Context(), st.AdminEmail, req.Password, "admin", true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Auth federation: cluster nodes forward failed auth to the primary.
|
||||
if actor == "" && st.IsClusterNode && st.PrimaryFQDN != "" && h.ClusterTLS != nil {
|
||||
if a, r, err := h.checkWithPrimary(c.Request.Context(), st.PrimaryFQDN, email, req.Password); err == nil {
|
||||
actor = a
|
||||
role = r
|
||||
} else {
|
||||
slog.Debug("auth: primary auth check failed", "primary", st.PrimaryFQDN, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
if actor == "" {
|
||||
if !strings.EqualFold(st.AdminEmail, email) || !st.VerifyAdminPassword(req.Password) {
|
||||
response.Unauthorized(c, errors.New("invalid_credentials"))
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), email, "auth.login.failed",
|
||||
email, gin.H{"reason": "invalid_credentials", "remote": remote}, h.NodeID)
|
||||
}
|
||||
response.Unauthorized(c, errors.New("invalid_credentials"))
|
||||
return
|
||||
}
|
||||
|
||||
// Bei Fallback (Setup-Store) / Federation (Primary) stammen role/TOTP
|
||||
// NICHT aus der DB. Rolle + TOTP-Status autoritativ aus der lokalen
|
||||
// (replizierten) users-Row ableiten — damit 2FA greift und die Rolle
|
||||
// nie aus einer Remote-Payload kommt. Ist der User lokal (noch) nicht
|
||||
// vorhanden (Replikations-Lag/DB aus), bleibt es beim Fallback-Wert.
|
||||
if actor != "" && !viaDB && h.Users != nil {
|
||||
if ai, err := h.Users.FindForAuth(c.Request.Context(), actor); err == nil {
|
||||
role = ai.Role
|
||||
totpEnabled = ai.TOTPEnabled
|
||||
}
|
||||
}
|
||||
|
||||
// TOTP gate: password OK but 2FA required → issue a short-lived pending
|
||||
// cookie and tell the UI to show the TOTP input.
|
||||
if totpEnabled {
|
||||
pending, ptok, err := h.Signer.IssueWithRoleTTL(actor, "totp_pending", 2*time.Minute)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
actor = st.AdminEmail
|
||||
role = "admin"
|
||||
// Auto-migrate: insert the setup-store admin into the DB so it
|
||||
// shows up in user management from this point on.
|
||||
if h.Users != nil {
|
||||
_, _ = h.Users.Upsert(c.Request.Context(), st.AdminEmail, req.Password, "admin", true)
|
||||
}
|
||||
c.SetSameSite(http.SameSiteStrictMode)
|
||||
c.SetCookie(totpPendingCookie, pending, int(2*time.Minute/time.Second), "/", "", true, true)
|
||||
_ = ptok
|
||||
response.OK(c, loginResponse{TOTPRequired: true})
|
||||
return
|
||||
}
|
||||
|
||||
raw, tok, err := h.Signer.IssueWithRole(actor, role)
|
||||
@@ -127,6 +204,10 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
}
|
||||
setSessionCookie(c, raw, tok.Exp)
|
||||
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), actor, "auth.login.success",
|
||||
actor, gin.H{"role": role, "remote": remote}, h.NodeID)
|
||||
}
|
||||
response.OK(c, loginResponse{
|
||||
Actor: tok.Actor,
|
||||
Role: tok.Role,
|
||||
@@ -134,6 +215,146 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
type totpVerifyRequest struct {
|
||||
Code string `json:"code" binding:"required"`
|
||||
}
|
||||
|
||||
// TOTPVerify completes the two-step login: verifies the TOTP code from the
|
||||
// pending cookie and, on success, issues a full session JWT.
|
||||
func (h *AuthHandler) TOTPVerify(c *gin.Context) {
|
||||
var req totpVerifyRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
pendingRaw, err := c.Cookie(totpPendingCookie)
|
||||
if err != nil || pendingRaw == "" {
|
||||
response.Unauthorized(c, errors.New("no_pending_totp"))
|
||||
return
|
||||
}
|
||||
ptok, err := h.Signer.Verify(pendingRaw)
|
||||
if err != nil || ptok.Role != "totp_pending" {
|
||||
response.Unauthorized(c, errors.New("invalid_pending_token"))
|
||||
return
|
||||
}
|
||||
|
||||
if h.Users == nil {
|
||||
response.Internal(c, errors.New("users repo unavailable"))
|
||||
return
|
||||
}
|
||||
ai, err := h.Users.FindForAuth(c.Request.Context(), ptok.Actor)
|
||||
if err != nil || !ai.TOTPEnabled || ai.TOTPSecret == nil {
|
||||
response.Unauthorized(c, errors.New("totp_not_configured"))
|
||||
return
|
||||
}
|
||||
if !usersvc.VerifyTOTP(*ai.TOTPSecret, req.Code) {
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), ptok.Actor, "auth.totp.failed",
|
||||
ptok.Actor, gin.H{"remote": c.ClientIP()}, h.NodeID)
|
||||
}
|
||||
response.Unauthorized(c, errors.New("invalid_totp_code"))
|
||||
return
|
||||
}
|
||||
|
||||
// Clear pending cookie, issue full session.
|
||||
c.SetSameSite(http.SameSiteStrictMode)
|
||||
c.SetCookie(totpPendingCookie, "", -1, "/", "", true, true)
|
||||
|
||||
raw, tok, err := h.Signer.IssueWithRole(ptok.Actor, ai.Role)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
setSessionCookie(c, raw, tok.Exp)
|
||||
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), ptok.Actor, "auth.login.success",
|
||||
ptok.Actor, gin.H{"role": ai.Role, "remote": c.ClientIP(), "totp": true}, h.NodeID)
|
||||
}
|
||||
response.OK(c, loginResponse{
|
||||
Actor: tok.Actor,
|
||||
Role: tok.Role,
|
||||
ExpiresAt: time.Unix(tok.Exp, 0).UTC(),
|
||||
})
|
||||
}
|
||||
|
||||
// TOTPSetup generates a new TOTP secret for the authenticated user and returns
|
||||
// the provisioning URI (renders as QR code in the UI). Secret is not saved yet.
|
||||
func (h *AuthHandler) TOTPSetup(c *gin.Context) {
|
||||
tok := CurrentToken(c)
|
||||
if tok == nil {
|
||||
response.Unauthorized(c, nil)
|
||||
return
|
||||
}
|
||||
secret, uri, err := usersvc.GenerateTOTPSecret(tok.Actor)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"secret": secret, "uri": uri})
|
||||
}
|
||||
|
||||
type totpConfirmRequest struct {
|
||||
Secret string `json:"secret" binding:"required"`
|
||||
Code string `json:"code" binding:"required"`
|
||||
}
|
||||
|
||||
// TOTPConfirm verifies the code against the provisioned secret and, on success,
|
||||
// enables TOTP for the user.
|
||||
func (h *AuthHandler) TOTPConfirm(c *gin.Context) {
|
||||
var req totpConfirmRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
tok := CurrentToken(c)
|
||||
if tok == nil || h.Users == nil {
|
||||
response.Unauthorized(c, nil)
|
||||
return
|
||||
}
|
||||
u, _, err := h.Users.FindByEmail(c.Request.Context(), tok.Actor)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
if err := h.Users.ConfirmTOTP(c.Request.Context(), u.ID, req.Secret, req.Code); err != nil {
|
||||
if err.Error() == "invalid_totp_code" {
|
||||
response.Err(c, http.StatusUnprocessableEntity, err)
|
||||
return
|
||||
}
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), tok.Actor, "auth.totp.enabled",
|
||||
tok.Actor, nil, h.NodeID)
|
||||
}
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// TOTPDisable disables TOTP for the authenticated user.
|
||||
func (h *AuthHandler) TOTPDisable(c *gin.Context) {
|
||||
tok := CurrentToken(c)
|
||||
if tok == nil || h.Users == nil {
|
||||
response.Unauthorized(c, nil)
|
||||
return
|
||||
}
|
||||
u, _, err := h.Users.FindByEmail(c.Request.Context(), tok.Actor)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
if err := h.Users.DisableTOTP(c.Request.Context(), u.ID); err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), tok.Actor, "auth.totp.disabled",
|
||||
tok.Actor, nil, h.NodeID)
|
||||
}
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Logout(c *gin.Context) {
|
||||
clearSessionCookie(c)
|
||||
response.OK(c, gin.H{"logged_out": true})
|
||||
@@ -194,12 +415,49 @@ type changePasswordRequest struct {
|
||||
// braucht das hier das current_password als Confirmation — verhindert
|
||||
// dass eine kompromittierte Session den Account übernimmt ohne dass
|
||||
// das alte Passwort bekannt ist.
|
||||
//
|
||||
// Lookup-Reihenfolge: 1) DB users-Tabelle (alle multi-user-Accounts),
|
||||
// 2) setup-store Admin-Fallback (Legacy / pre-DB). Beim Setup-Admin
|
||||
// werden beide Stores synchron gehalten.
|
||||
func (h *AuthHandler) ChangePassword(c *gin.Context) {
|
||||
var req changePasswordRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
tok := CurrentToken(c)
|
||||
if tok == nil {
|
||||
response.Unauthorized(c, nil)
|
||||
return
|
||||
}
|
||||
|
||||
// 1. DB-backed user (alle via User-Management erstellten Accounts).
|
||||
if h.Users != nil {
|
||||
u, hash, dbErr := h.Users.FindByEmail(c.Request.Context(), tok.Actor)
|
||||
if dbErr == nil {
|
||||
if !usersvc.VerifyPassword(hash, req.CurrentPassword) {
|
||||
response.Unauthorized(c, errors.New("invalid_current_password"))
|
||||
return
|
||||
}
|
||||
if err := h.Users.SetPassword(c.Request.Context(), u.ID, req.NewPassword); err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
// Setup-Store-Admin synchron halten, falls gleiche E-Mail.
|
||||
if st, _ := h.Setup.Load(); st != nil && strings.EqualFold(st.AdminEmail, tok.Actor) {
|
||||
_ = h.Setup.SetAdminPassword(req.NewPassword)
|
||||
}
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "auth.password.change",
|
||||
tok.Actor, gin.H{"actor": actorOf(c)}, h.NodeID)
|
||||
}
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fallback: setup-store Admin (vor DB-Migration oder nicht migriert).
|
||||
st, err := h.Setup.Load()
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
@@ -209,10 +467,6 @@ func (h *AuthHandler) ChangePassword(c *gin.Context) {
|
||||
response.Err(c, http.StatusServiceUnavailable, errors.New("setup_required"))
|
||||
return
|
||||
}
|
||||
// Authorisierte Session ist nicht automatisch der Admin (Phase 4
|
||||
// admin_users-Tabelle könnte mehrere Rollen haben). v1: aktuell
|
||||
// nur der eine Admin-User; trotzdem prüfen wir das current_password
|
||||
// gegen die persistierte Hash.
|
||||
if !st.VerifyAdminPassword(req.CurrentPassword) {
|
||||
response.Unauthorized(c, errors.New("invalid_current_password"))
|
||||
return
|
||||
@@ -225,13 +479,51 @@ func (h *AuthHandler) ChangePassword(c *gin.Context) {
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "auth.password.change",
|
||||
st.AdminEmail, gin.H{"actor": actorOf(c)}, h.NodeID)
|
||||
}
|
||||
// Neue Session ausstellen — alte Cookie zeigt auf ein Token das
|
||||
// noch gültig ist; das ist OK für UX (kein erzwungener Logout),
|
||||
// sicherheitsbewusster: clearSession + force re-login. Wir
|
||||
// halten's hier ruhig.
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// checkWithPrimary verifies credentials against the primary node via mTLS.
|
||||
// Returns actor+role on success, error on failure.
|
||||
func (h *AuthHandler) checkWithPrimary(ctx context.Context, primaryFQDN, email, password string) (string, string, error) {
|
||||
clientTLS, err := h.ClusterTLS.ClientTLSConfig()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
tr := &http.Transport{TLSClientConfig: clientTLS, TLSHandshakeTimeout: 5 * time.Second}
|
||||
client := &http.Client{Transport: tr, Timeout: 8 * time.Second}
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"email": email, "password": password})
|
||||
reqURL := "https://" + primaryFQDN + ":8443/agent/auth/check"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer 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)))
|
||||
}
|
||||
var env struct {
|
||||
Data struct {
|
||||
Actor string `json:"actor"`
|
||||
Role string `json:"role"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if env.Data.Actor == "" {
|
||||
return "", "", errors.New("primary returned empty actor")
|
||||
}
|
||||
return env.Data.Actor, env.Data.Role, nil
|
||||
}
|
||||
|
||||
func setSessionCookie(c *gin.Context, raw string, expUnix int64) {
|
||||
maxAge := int(time.Until(time.Unix(expUnix, 0)).Seconds())
|
||||
if maxAge < 0 {
|
||||
|
||||
@@ -117,6 +117,10 @@ func (h *BackendsHandler) Delete(c *gin.Context) {
|
||||
response.NotFound(c, err)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, backends.ErrInUse) {
|
||||
response.Conflict(c, err)
|
||||
return
|
||||
}
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,8 +2,15 @@ package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -14,6 +21,8 @@ import (
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/jointoken"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
aptsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/apt"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
|
||||
)
|
||||
|
||||
// ClusterHandler exposes cluster-state endpoints. /status ist die
|
||||
@@ -27,6 +36,7 @@ type ClusterHandler struct {
|
||||
Store *cluster.Store
|
||||
LocalID string
|
||||
Aggregator *aggregator.Aggregator
|
||||
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.
|
||||
@@ -36,13 +46,26 @@ type ClusterHandler struct {
|
||||
// PeerReloader: optional, gesetzt bei Phase 3.5. Nach Auto-Register
|
||||
// triggert das den firewall-Render damit peer_ipv4 frisch ist.
|
||||
PeerReloader PeerReloader
|
||||
|
||||
// Audit + NodeID: optional, gesetzt via WithAudit. Nötig für
|
||||
// protokollierte, mutierende Aktionen wie den Replication-Repair.
|
||||
Audit *audit.Repo
|
||||
NodeID string
|
||||
}
|
||||
|
||||
const (
|
||||
// pgPublicationName + pgReplicationSecretPath spiegeln die Werte aus
|
||||
// cmd/edgeguard-ctl (egPubName / egReplSecret) — beide Seiten muessen
|
||||
// dasselbe meinen.
|
||||
pgPublicationName = "edgeguard_shared"
|
||||
pgReplicationSecretPath = "/var/lib/edgeguard/pg-replication-secret"
|
||||
)
|
||||
|
||||
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
|
||||
@@ -57,12 +80,29 @@ func (h *ClusterHandler) WithJoinFlow(store *clustertls.Store, tokens *jointoken
|
||||
return h
|
||||
}
|
||||
|
||||
// WithAudit setzt den Audit-Repo + NodeID für protokollierte Aktionen.
|
||||
func (h *ClusterHandler) WithAudit(a *audit.Repo, nodeID string) *ClusterHandler {
|
||||
h.Audit = a
|
||||
h.NodeID = nodeID
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *ClusterHandler) Register(rg *gin.RouterGroup) {
|
||||
g := rg.Group("/cluster")
|
||||
g.GET("/nodes", h.ListNodes)
|
||||
g.GET("/status", h.Status)
|
||||
g.GET("/system/load", h.SystemLoad)
|
||||
g.DELETE("/nodes/:id", h.DeleteNode)
|
||||
g.GET("/vip-settings", h.GetVIPSettings)
|
||||
g.PUT("/vip-settings", h.UpdateVIPSettings)
|
||||
g.POST("/rolling-update", h.RollingUpdate)
|
||||
g.GET("/rolling-update/status", h.RollingUpdateStatus)
|
||||
g.POST("/repair-replication", h.RepairReplication)
|
||||
g.GET("/repair-replication/status", h.RepairReplicationStatus)
|
||||
g.GET("/vip-status", h.VIPStatus)
|
||||
g.POST("/vip-test", h.VIPTest)
|
||||
g.GET("/update-channel", h.UpdateChannel)
|
||||
g.POST("/update-channel", h.SetUpdateChannel)
|
||||
if h.TLSStore != nil {
|
||||
g.GET("/cert-status", h.CertStatus)
|
||||
g.POST("/renew-self", h.RenewSelf)
|
||||
@@ -75,7 +115,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
|
||||
@@ -111,6 +151,85 @@ func (h *ClusterHandler) DeleteNode(c *gin.Context) {
|
||||
response.NoContent(c)
|
||||
}
|
||||
|
||||
// GetVIPSettings liest die cluster_settings-Singleton-Row (VIP/VRRP-Config).
|
||||
func (h *ClusterHandler) GetVIPSettings(c *gin.Context) {
|
||||
if h.Store == nil {
|
||||
response.NotFound(c, simpleError("cluster store not available"))
|
||||
return
|
||||
}
|
||||
var cs vipSettingsRow
|
||||
row := h.Store.Pool.QueryRow(c.Request.Context(), `
|
||||
SELECT vip_address, vip_interface, vip_auth_pass, vrrp_router_id,
|
||||
hb_interface, hb_src_ip, hb_peer_ip, hb_router_id, gw_check_ip
|
||||
FROM cluster_settings WHERE id = 1`)
|
||||
if err := row.Scan(&cs.VIPAddress, &cs.VIPInterface, &cs.VIPAuthPass, &cs.VRRPRouterID,
|
||||
&cs.HBInterface, &cs.HBSrcIP, &cs.HBPeerIP, &cs.HBRouterID, &cs.GWCheckIP); err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, cs)
|
||||
}
|
||||
|
||||
// UpdateVIPSettings speichert die VIP/VRRP-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) {
|
||||
var req vipSettingsRow
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if h.Store == nil {
|
||||
response.NotFound(c, simpleError("cluster store not available"))
|
||||
return
|
||||
}
|
||||
_, err := h.Store.Pool.Exec(c.Request.Context(), `
|
||||
UPDATE cluster_settings
|
||||
SET vip_address=$1, vip_interface=$2, vip_auth_pass=$3, vrrp_router_id=$4,
|
||||
hb_interface=$5, hb_src_ip=$6, hb_peer_ip=$7, hb_router_id=$8, gw_check_ip=$9,
|
||||
updated_at=NOW()
|
||||
WHERE id=1`,
|
||||
nullIfEmpty(req.VIPAddress), nullIfEmpty(req.VIPInterface),
|
||||
nullIfEmpty(req.VIPAuthPass), req.VRRPRouterID,
|
||||
nullIfEmpty(req.HBInterface), nullIfEmpty(req.HBSrcIP),
|
||||
nullIfEmpty(req.HBPeerIP), req.HBRouterID, nullIfEmpty(req.GWCheckIP))
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
slog.Info("cluster: VIP settings updated", "vip", req.VIPAddress, "actor", actorOf(c))
|
||||
// Keepalived-Config asynchron neu rendern
|
||||
if h.PeerReloader != nil {
|
||||
go func() {
|
||||
rctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
if err := h.PeerReloader(rctx); err != nil {
|
||||
slog.Warn("cluster: keepalived render after VIP update failed", "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
response.NoContent(c)
|
||||
}
|
||||
|
||||
type vipSettingsRow struct {
|
||||
VIPAddress *string `json:"vip_address"`
|
||||
VIPInterface *string `json:"vip_interface"`
|
||||
VIPAuthPass *string `json:"vip_auth_pass"`
|
||||
VRRPRouterID int `json:"vrrp_router_id"`
|
||||
HBInterface *string `json:"hb_interface"`
|
||||
HBSrcIP *string `json:"hb_src_ip"`
|
||||
HBPeerIP *string `json:"hb_peer_ip"`
|
||||
HBRouterID int `json:"hb_router_id"`
|
||||
GWCheckIP *string `json:"gw_check_ip"`
|
||||
}
|
||||
|
||||
func nullIfEmpty(s *string) *string {
|
||||
if s == nil || *s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// RegisterPublic mountet die public (unauth) Endpoints — joining Peers
|
||||
// haben noch keine Session/Cert, deshalb läuft /issue-cert vor der
|
||||
// requireAuth-Middleware. Aufrufer muss diesen Group auf /api/v1 setzen
|
||||
@@ -134,6 +253,90 @@ func (h *ClusterHandler) RegisterPublic(rg *gin.RouterGroup) {
|
||||
func (h *ClusterHandler) RegisterAgent(rg *gin.RouterGroup) {
|
||||
g := rg.Group("/agent/cluster")
|
||||
g.POST("/peers", h.AgentRegisterPeer)
|
||||
g.GET("/identity", h.AgentIdentity)
|
||||
g.GET("/pg-replication-info", h.AgentPGReplicationInfo)
|
||||
g.GET("/master-key", h.AgentMasterKey)
|
||||
g.GET("/version", h.AgentVersion)
|
||||
g.POST("/trigger-update", h.AgentTriggerUpdate)
|
||||
g.POST("/set-channel", h.AgentSetChannel)
|
||||
g.GET("/channel", h.AgentChannel)
|
||||
g.GET("/active-ips", h.AgentActiveIPs)
|
||||
g.POST("/vip-cmd", h.AgentVIPCmd)
|
||||
g.GET("/tls-certs", h.AgentTLSCerts)
|
||||
g.POST("/repair-replication", h.AgentRepairReplication)
|
||||
g.GET("/repair-replication/status", h.AgentRepairReplicationStatus)
|
||||
}
|
||||
|
||||
// AgentIdentity gibt die eigene ha_nodes-Row zurück. Wird vom Primary
|
||||
// genutzt um joining-Peers aktiv zu reconcilen wenn autoRegister (Push)
|
||||
// fehlgeschlagen ist — Pull-Fallback.
|
||||
func (h *ClusterHandler) AgentIdentity(c *gin.Context) {
|
||||
if h.Store == nil || h.LocalID == "" {
|
||||
response.NotFound(c, simpleError("node not registered"))
|
||||
return
|
||||
}
|
||||
node, err := h.Store.Get(c.Request.Context(), h.LocalID)
|
||||
if err != nil {
|
||||
if err == cluster.ErrNotFound {
|
||||
response.NotFound(c, simpleError("local node not in ha_nodes yet"))
|
||||
return
|
||||
}
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, node)
|
||||
}
|
||||
|
||||
// AgentPGReplicationInfo gibt die Replication-Credentials für pg_basebackup
|
||||
// zurück. Nur über den mTLS-Agent-Listener erreichbar. Liest das Passwort
|
||||
// aus /var/lib/edgeguard/pg-replication-secret. Gibt 404 zurück wenn die
|
||||
// Datei fehlt (cluster-init-replication noch nicht ausgeführt).
|
||||
func (h *ClusterHandler) AgentPGReplicationInfo(c *gin.Context) {
|
||||
pass, err := readFileString(pgReplicationSecretPath)
|
||||
if err != nil {
|
||||
response.NotFound(c, simpleError("pg-replication-secret nicht gefunden — cluster-init-replication auf dem Primary ausführen"))
|
||||
return
|
||||
}
|
||||
// Host = eigene Public-IP aus ha_nodes (oder Fallback: FQDN)
|
||||
host := ""
|
||||
if h.Store != nil && h.LocalID != "" {
|
||||
if node, err := h.Store.Get(c.Request.Context(), h.LocalID); err == nil {
|
||||
if node.PublicIP != nil && *node.PublicIP != "" {
|
||||
host = *node.PublicIP
|
||||
}
|
||||
if host == "" {
|
||||
host = node.FQDN
|
||||
}
|
||||
}
|
||||
}
|
||||
response.OK(c, gin.H{
|
||||
"host": host,
|
||||
"port": 5432,
|
||||
"user": "edgeguard_replicator",
|
||||
"password": strings.TrimSpace(pass),
|
||||
})
|
||||
}
|
||||
|
||||
// AgentMasterKey gibt den Secrets-Master-Key zurück, damit cluster-setup-standby
|
||||
// ihn auf dem Secondary synchronisieren kann. Nur über den mTLS-Agent-Listener
|
||||
// erreichbar. Ohne gemeinsamen Master-Key können replizierte verschlüsselte
|
||||
// Felder (WireGuard private keys, PSKs) auf dem Secondary nicht entschlüsselt werden.
|
||||
func (h *ClusterHandler) AgentMasterKey(c *gin.Context) {
|
||||
const keyPath = "/var/lib/edgeguard/.master_key"
|
||||
data, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
response.NotFound(c, simpleError("master key nicht gefunden"))
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"key_hex": fmt.Sprintf("%x", data)})
|
||||
}
|
||||
|
||||
func readFileString(path string) (string, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// PeerReloader: optionale Funktion die nach einem Auto-Register
|
||||
@@ -147,6 +350,12 @@ func (h *ClusterHandler) WithPeerReloader(r PeerReloader) *ClusterHandler {
|
||||
return h
|
||||
}
|
||||
|
||||
// WithVersion: setzt die laufende Binary-Version für Rolling-Update-Coordination.
|
||||
func (h *ClusterHandler) WithVersion(v string) *ClusterHandler {
|
||||
h.Version = v
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *ClusterHandler) ListNodes(c *gin.Context) {
|
||||
nodes, err := h.Store.List(c.Request.Context())
|
||||
if err != nil {
|
||||
@@ -162,7 +371,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"`
|
||||
@@ -208,6 +417,23 @@ func (h *ClusterHandler) Status(c *gin.Context) {
|
||||
if len(out.Peers) > 0 {
|
||||
out.Mode = "cluster"
|
||||
}
|
||||
|
||||
// Pull-Reconcile für joining-Peers: wenn ein Peer via Aggregator
|
||||
// erreichbar ist aber noch mit Placeholder-ID in ha_nodes steht,
|
||||
// holen wir seine echte Identity aktiv ab. Best-effort goroutine —
|
||||
// blockiert die Status-Response nicht.
|
||||
if h.Aggregator != nil && h.Store != nil {
|
||||
var joining []models.HANode
|
||||
for _, p := range out.Peers {
|
||||
if p.Status == "joining" || p.Status == "pending" {
|
||||
joining = append(joining, p)
|
||||
}
|
||||
}
|
||||
if len(joining) > 0 {
|
||||
go h.reconcileJoiningPeers(joining)
|
||||
}
|
||||
}
|
||||
|
||||
// Drift-Detection: jeder peer mit anderem config_hash als unser
|
||||
// lokaler → Banner-Trigger im UI.
|
||||
if localHash != nil && *localHash != "" {
|
||||
@@ -289,29 +515,78 @@ func localSystemLoad() any {
|
||||
// ── Phase 3.4: Cluster-Join Token Flow ────────────────────────────────
|
||||
|
||||
// GenerateJoinToken — Admin generiert einen one-shot Bootstrap-Token
|
||||
// für einen neuen Peer. Token wird NUR EINMAL zurückgegeben; Server
|
||||
// speichert keinen Klartext, beim Re-Use blockt der nonce-Tracker.
|
||||
// für einen neuen Peer. Der FQDN des neuen Nodes wird vorab übergeben
|
||||
// so dass er sofort in ha_nodes vorregistriert und im UI angezeigt werden
|
||||
// kann. Token wird NUR EINMAL zurückgegeben.
|
||||
func (h *ClusterHandler) GenerateJoinToken(c *gin.Context) {
|
||||
var req struct {
|
||||
NodeFQDN string `json:"node_fqdn"`
|
||||
}
|
||||
// Body optional — wenn leer, läuft der Flow ohne Pre-Register.
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
|
||||
// Publisher-Seite sicherstellen, BEVOR ein Token rausgeht. Ein frisch
|
||||
// installierter Single-Node hat weder Replikations-Rolle noch
|
||||
// PUBLICATION noch wal_level=logical — der beitretende Node bekaeme
|
||||
// beim CREATE SUBSCRIPTION nur ein 404 ("pg-replication-secret nicht
|
||||
// gefunden") und stuende ohne replizierte Config da. Idempotent; der
|
||||
// PG-Restart (nur beim allerersten Mal noetig, wal_level ist ein
|
||||
// postmaster-Parameter) passiert hier bewusst, solange der Admin
|
||||
// danebensteht und noch kein zweiter Node Traffic erwartet.
|
||||
if err := h.ensureReplicationPublisher(c.Request.Context()); err != nil {
|
||||
slog.Error("cluster: publisher setup before join-token failed", "error", err)
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
token, exp, err := h.Tokens.Generate()
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
// Wir liefern auch die primary-fqdn + ca-fingerprint mit, damit
|
||||
// das UI den Join-Befehl als kompletten curl/CLI-String anzeigen
|
||||
// kann.
|
||||
caCert, _, err := h.TLSStore.LoadCA()
|
||||
caFP := ""
|
||||
if err == nil && caCert != nil {
|
||||
caFP = jointoken.CAFingerprint16(caCert.Raw)
|
||||
}
|
||||
|
||||
// Pre-register: wenn ein FQDN übergeben wurde, schon jetzt in
|
||||
// ha_nodes anlegen (status='pending') + Firewall-Reload. So ist
|
||||
// der Node bekannt bevor er überhaupt antwortet, und @peer_ipv4
|
||||
// wird beim issue-cert (wo wir die IP haben) nur noch updaten.
|
||||
if req.NodeFQDN != "" && h.Store != nil {
|
||||
go h.preRegisterByFQDN(req.NodeFQDN)
|
||||
}
|
||||
|
||||
response.OK(c, gin.H{
|
||||
"token": token,
|
||||
"expires_at": exp.UTC().Format(time.RFC3339),
|
||||
"ca_fingerprint": caFP,
|
||||
"node_fqdn": req.NodeFQDN,
|
||||
})
|
||||
}
|
||||
|
||||
// preRegisterByFQDN legt einen ha_nodes-Eintrag mit status='pending' an
|
||||
// bevor der Joiner überhaupt die Verbindung aufbaut. Idempotent dank
|
||||
// ON CONFLICT. Kein Firewall-Reload hier — die IP ist noch unbekannt;
|
||||
// das erledigt preRegisterJoiner wenn die issue-cert-Anfrage eintrifft.
|
||||
func (h *ClusterHandler) preRegisterByFQDN(fqdn string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
nodeID := fmt.Sprintf("prenode-%s", strings.ReplaceAll(fqdn, ".", "-"))
|
||||
n := models.HANode{
|
||||
ID: nodeID,
|
||||
Name: fqdn,
|
||||
FQDN: fqdn,
|
||||
APIURL: "https://" + fqdn + ":3443",
|
||||
Role: "peer",
|
||||
Status: "pending",
|
||||
}
|
||||
if _, err := h.Store.UpsertSelf(ctx, n); err != nil {
|
||||
slog.Warn("cluster: pre-register by FQDN failed", "fqdn", fqdn, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// IssueCert — Joining Peer POSTet seinen CSR + den Token. Wir verifizieren
|
||||
// + konsumieren den Token, signieren den CSR mit unserer Cluster-CA und
|
||||
// liefern {ca_cert, peer_cert} zurück. PUBLIC Endpoint — keine Session-
|
||||
@@ -337,8 +612,8 @@ func (h *ClusterHandler) IssueCert(c *gin.Context) {
|
||||
}
|
||||
// consumedBy → Remote-IP. Audit-Trail wenn jemand Tokens stiehlt
|
||||
// und vom falschen Host einlöst.
|
||||
consumedBy := c.ClientIP()
|
||||
if _, err := h.Tokens.Consume(c.Request.Context(), req.Token, consumedBy); err != nil {
|
||||
clientIP := c.ClientIP()
|
||||
if _, err := h.Tokens.Consume(c.Request.Context(), req.Token, clientIP); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
@@ -353,12 +628,321 @@ func (h *ClusterHandler) IssueCert(c *gin.Context) {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Pre-register the joining node SYNCHRONOUSLY before returning the
|
||||
// cert so that nftables @peer_ipv4 already contains the joiner's IP
|
||||
// by the time they call autoRegister on port 8443. A goroutine here
|
||||
// caused a race: cert returned → joiner calls autoRegister → nftables
|
||||
// not updated yet → connection refused → status stays "joining".
|
||||
if h.Store != nil && h.PeerReloader != nil {
|
||||
h.preRegisterJoiner(c.Request.Context(), clientIP, req.CSR)
|
||||
}
|
||||
|
||||
response.OK(c, issueCertResponse{
|
||||
CACert: caPEM,
|
||||
PeerCert: peerCert,
|
||||
})
|
||||
}
|
||||
|
||||
// preRegisterJoiner inserts a minimal ha_nodes row for the joining peer
|
||||
// (using the CSR CN as FQDN and the HTTP client IP as public_ip), then
|
||||
// triggers a firewall reload so @peer_ipv4 contains the new IP before
|
||||
// the peer tries to call /agent/cluster/peers on port 8443.
|
||||
// Uses a stable deterministic ID so re-joins are idempotent.
|
||||
func (h *ClusterHandler) preRegisterJoiner(parent context.Context, clientIP, csrPEM string) {
|
||||
ctx, cancel := context.WithTimeout(parent, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
fqdn := cnFromCSR(csrPEM)
|
||||
if fqdn == "" {
|
||||
fqdn = "joining-" + clientIP
|
||||
}
|
||||
nodeID := fmt.Sprintf("prenode-%s", strings.ReplaceAll(fqdn, ".", "-"))
|
||||
|
||||
n := models.HANode{
|
||||
ID: nodeID,
|
||||
Name: fqdn,
|
||||
FQDN: fqdn,
|
||||
APIURL: "https://" + fqdn + ":3443",
|
||||
Role: "peer",
|
||||
Status: "joining",
|
||||
}
|
||||
n.PublicIP = &clientIP
|
||||
|
||||
if _, err := h.Store.UpsertSelf(ctx, n); err != nil {
|
||||
slog.Warn("cluster: pre-register joiner failed", "fqdn", fqdn, "ip", clientIP, "error", err)
|
||||
return
|
||||
}
|
||||
if err := h.PeerReloader(ctx); err != nil {
|
||||
slog.Warn("cluster: PeerReloader failed after pre-register", "error", err)
|
||||
return
|
||||
}
|
||||
slog.Info("cluster: joiner pre-registered, firewall updated", "fqdn", fqdn, "ip", clientIP)
|
||||
}
|
||||
|
||||
// ptrStr dereferences a *string safely for comparison; nil → "".
|
||||
func ptrStr(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
// cnFromCSR extracts the Subject Common Name from a PEM-encoded CSR.
|
||||
// Returns empty string on any parse error.
|
||||
func cnFromCSR(csrPEM string) string {
|
||||
block, _ := pem.Decode([]byte(csrPEM))
|
||||
if block == nil {
|
||||
return ""
|
||||
}
|
||||
csr, err := x509.ParseCertificateRequest(block.Bytes)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return csr.Subject.CommonName
|
||||
}
|
||||
|
||||
// reconcileJoiningPeers versucht für jeden Peer im Status "joining" oder
|
||||
// "pending" die echte Node-ID via /agent/cluster/identity zu holen und
|
||||
// ihn in ha_nodes mit der richtigen ID einzutragen. Self-Healing-
|
||||
// Fallback wenn autoRegister (Push von joining-Peer zu Primary) wegen
|
||||
// eines temporären Netzwerkproblems fehlgeschlagen ist.
|
||||
//
|
||||
// Die public_ip des Placeholder-Rows wird in der neuen Row übernommen
|
||||
// damit @peer_ipv4 (nftables) korrekt bleibt.
|
||||
func (h *ClusterHandler) reconcileJoiningPeers(placeholders []models.HANode) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
|
||||
results := h.Aggregator.FanOut(ctx, placeholders, "/agent/cluster/identity", h.LocalID)
|
||||
changed := false
|
||||
for i, res := range results {
|
||||
if !res.OK || len(res.Data) == 0 {
|
||||
continue
|
||||
}
|
||||
var identity models.HANode
|
||||
if err := json.Unmarshal(res.Data, &identity); err != nil || identity.ID == "" {
|
||||
continue
|
||||
}
|
||||
placeholder := placeholders[i]
|
||||
if identity.ID == placeholder.ID {
|
||||
continue // ID bereits korrekt
|
||||
}
|
||||
// Echte ID gefunden — row mit realer ID anlegen, public_ip aus
|
||||
// dem Placeholder-Row übernehmen damit nftables korrekt bleibt.
|
||||
n := identity
|
||||
n.Status = "online"
|
||||
if n.PublicIP == nil {
|
||||
n.PublicIP = placeholder.PublicIP
|
||||
}
|
||||
if n.InternalIP == nil {
|
||||
n.InternalIP = placeholder.InternalIP
|
||||
}
|
||||
// Placeholder zuerst löschen: ha_nodes hat UNIQUE(fqdn). Ohne
|
||||
// dieses Delete würde UpsertSelf (ON CONFLICT(id)) mit fqdn-
|
||||
// unique-Violation scheitern.
|
||||
_ = h.Store.DeletePlaceholdersByFQDN(ctx, n.FQDN, n.ID)
|
||||
out, err := h.Store.UpsertSelf(ctx, n)
|
||||
if err != nil {
|
||||
slog.Warn("cluster: reconcile joining peer: upsert failed",
|
||||
"fqdn", n.FQDN, "real_id", n.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
changed = true
|
||||
slog.Info("cluster: joining peer reconciled via identity pull",
|
||||
"id", out.ID, "fqdn", out.FQDN, "placeholder_id", placeholder.ID)
|
||||
}
|
||||
if changed && h.PeerReloader != nil {
|
||||
rctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := h.PeerReloader(rctx); err != nil {
|
||||
slog.Warn("cluster: PeerReloader failed after reconcile", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AgentVersion gibt die laufende Binary-Version zurück. Wird vom Rolling-
|
||||
// Update-Orchestrator gepollt um zu erkennen wann der Secondary die neue
|
||||
// Version hat.
|
||||
func (h *ClusterHandler) AgentVersion(c *gin.Context) {
|
||||
response.OK(c, gin.H{"version": h.Version})
|
||||
}
|
||||
|
||||
// AgentTriggerUpdate startet den Upgrade-Prozess auf diesem Node via
|
||||
// systemd-run (detached). Wird vom Primary via mTLS aufgerufen um den
|
||||
// Secondary zuerst zu aktualisieren (Rolling-Update). Pattern identisch
|
||||
// zu /system/upgrade — nutzt dieselbe Sudoers-Whitelist aus dem postinst.
|
||||
func (h *ClusterHandler) AgentTriggerUpdate(c *gin.Context) {
|
||||
const scriptPath = "/var/lib/edgeguard/upgrade.sh"
|
||||
const script = `#!/bin/bash
|
||||
set -e
|
||||
sleep 2
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
dpkg --configure -a || true
|
||||
retry_apt() {
|
||||
local attempt=0 max=3 wait_for=15
|
||||
while [ $attempt -lt $max ]; do
|
||||
attempt=$((attempt + 1))
|
||||
apt-get update -qq || true
|
||||
# --allow-downgrades: nur relevant nach testing→stable-Kanalwechsel
|
||||
# (Testing-Versionen sortieren datumsbasiert höher als Stable-Semver).
|
||||
# No-Op im Normalfall, da die Candidate sonst immer >= installed ist.
|
||||
if apt-get install -y -qq --allow-downgrades -o Dpkg::Options::=--force-confold \
|
||||
edgeguard-api edgeguard-ui edgeguard; then return 0; fi
|
||||
[ $attempt -lt $max ] && sleep $wait_for && wait_for=$((wait_for * 2))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
retry_apt
|
||||
echo "[upgrade] complete"
|
||||
rm -f /var/lib/edgeguard/upgrade.sh
|
||||
`
|
||||
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
const unitName = "edgeguard-upgrade.service"
|
||||
_ = exec.Command("sudo", "-n", "/usr/bin/systemctl", "reset-failed", unitName).Run()
|
||||
cmd := exec.Command("sudo", "-n", "/usr/bin/systemd-run",
|
||||
"--unit="+unitName,
|
||||
"--description=EdgeGuard self-upgrade",
|
||||
"--collect",
|
||||
"bash", scriptPath)
|
||||
if err := cmd.Run(); err != nil {
|
||||
slog.Warn("cluster: AgentTriggerUpdate: systemd-run failed", "error", err)
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
slog.Info("cluster: rolling update triggered on this node by primary mTLS call",
|
||||
"client", c.ClientIP())
|
||||
c.JSON(http.StatusAccepted, gin.H{"status": "upgrading"})
|
||||
}
|
||||
|
||||
// ── Update-Kanal (stable/testing) ──────────────────────────────────────
|
||||
//
|
||||
// Kanal-Modell wie enconf (Suite=Codename, Komponente=Kanal, siehe
|
||||
// internal/services/apt.Channel/SetChannel) — an EdgeGuards fixes
|
||||
// Primary/Standby-Paar angepasst statt generischer Server-Flotte: der
|
||||
// Kanal wird auf beiden Nodes synchron gehalten (wie config_hash),
|
||||
// kein Node-Override. Reines Umschreiben der sources.list + `apt-get
|
||||
// update` ist risikofrei (kein Service-Restart, keine VIP-Auswirkung)
|
||||
// — das eigentliche Downgrade/Upgrade auf die neue Kanal-Version läuft
|
||||
// danach ganz normal über den bestehenden (sicheren, Standby-zuerst)
|
||||
// Rolling-Update-Flow, der --allow-downgrades jetzt mit unterstützt.
|
||||
|
||||
type updateChannelResponse struct {
|
||||
Channel string `json:"channel"`
|
||||
PeerChannel string `json:"peer_channel,omitempty"`
|
||||
PeerReached bool `json:"peer_reached"`
|
||||
PeerDrifted bool `json:"peer_drifted"`
|
||||
}
|
||||
|
||||
// UpdateChannel liefert den lokalen Kanal + (falls Cluster) den Kanal
|
||||
// des Peers zur Drift-Erkennung — analog zum config_hash-Vergleich.
|
||||
func (h *ClusterHandler) UpdateChannel(c *gin.Context) {
|
||||
resp := updateChannelResponse{Channel: aptsvc.Channel()}
|
||||
peer := h.peerNode(c.Request.Context())
|
||||
if peer != nil && h.Aggregator != nil {
|
||||
results := h.Aggregator.FanOut(c.Request.Context(), []models.HANode{*peer}, "/agent/cluster/channel", h.LocalID)
|
||||
if len(results) > 0 && results[0].OK {
|
||||
var body struct {
|
||||
Channel string `json:"channel"`
|
||||
}
|
||||
if json.Unmarshal(results[0].Data, &body) == nil {
|
||||
resp.PeerReached = true
|
||||
resp.PeerChannel = body.Channel
|
||||
resp.PeerDrifted = body.Channel != resp.Channel
|
||||
}
|
||||
}
|
||||
}
|
||||
response.OK(c, resp)
|
||||
}
|
||||
|
||||
// SetUpdateChannel setzt den Kanal lokal und — falls ein Peer existiert
|
||||
// — synchron auch auf dem Peer via mTLS. Löst KEIN Paket-Update aus;
|
||||
// das übernimmt der Admin danach ganz normal über den Update-Banner /
|
||||
// Rolling-Update, der die neue Candidate-Version dann bereits sieht.
|
||||
func (h *ClusterHandler) SetUpdateChannel(c *gin.Context) {
|
||||
var req struct {
|
||||
Channel string `json:"channel"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if req.Channel != "stable" && req.Channel != "testing" {
|
||||
response.BadRequest(c, fmt.Errorf("channel must be 'stable' or 'testing'"))
|
||||
return
|
||||
}
|
||||
if err := aptsvc.SetChannel(c.Request.Context(), req.Channel); err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
resp := updateChannelResponse{Channel: req.Channel}
|
||||
if peer := h.peerNode(c.Request.Context()); peer != nil && h.Aggregator != nil {
|
||||
body, _ := json.Marshal(req)
|
||||
result := h.Aggregator.PostPeerWithBody(c.Request.Context(), *peer, "/agent/cluster/set-channel", body)
|
||||
resp.PeerReached = result.OK
|
||||
if !result.OK {
|
||||
slog.Warn("cluster: set-channel on peer failed", "peer", peer.FQDN, "error", result.Err)
|
||||
}
|
||||
}
|
||||
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "system.update_channel.set",
|
||||
"", gin.H{"channel": req.Channel}, h.NodeID)
|
||||
}
|
||||
response.OK(c, resp)
|
||||
}
|
||||
|
||||
// AgentChannel: mTLS-Peer-Read des lokalen Kanals (für Drift-Anzeige).
|
||||
func (h *ClusterHandler) AgentChannel(c *gin.Context) {
|
||||
response.OK(c, gin.H{"channel": aptsvc.Channel()})
|
||||
}
|
||||
|
||||
// AgentSetChannel: mTLS-Peer-Write — wird vom Primary aufgerufen um den
|
||||
// Kanal auf diesem (Standby-)Node synchron zu setzen.
|
||||
func (h *ClusterHandler) AgentSetChannel(c *gin.Context) {
|
||||
var req struct {
|
||||
Channel string `json:"channel"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if req.Channel != "stable" && req.Channel != "testing" {
|
||||
response.BadRequest(c, fmt.Errorf("channel must be 'stable' or 'testing'"))
|
||||
return
|
||||
}
|
||||
if err := aptsvc.SetChannel(c.Request.Context(), req.Channel); err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
slog.Info("cluster: update channel set on this node by primary mTLS call",
|
||||
"channel", req.Channel, "client", c.ClientIP())
|
||||
response.OK(c, gin.H{"channel": req.Channel})
|
||||
}
|
||||
|
||||
// peerNode liefert die einzige andere ha_nodes-Row (best-effort, nil
|
||||
// wenn Standalone oder Store fehlt) — gleiches Muster wie in
|
||||
// RollingUpdate für die Secondary-Ermittlung.
|
||||
func (h *ClusterHandler) peerNode(ctx context.Context) *models.HANode {
|
||||
if h.Store == nil {
|
||||
return nil
|
||||
}
|
||||
nodes, err := h.Store.List(ctx)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
for i := range nodes {
|
||||
if nodes[i].ID != h.LocalID {
|
||||
return &nodes[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var errInvalidJoinRequest = simpleError("missing token or csr")
|
||||
|
||||
type simpleError string
|
||||
@@ -392,7 +976,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() {
|
||||
@@ -427,14 +1011,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
|
||||
Version string `json:"version"`
|
||||
ID string `json:"id"` // Joiner's eigene node-id
|
||||
Name string `json:"name"` // hostname
|
||||
FQDN string `json:"fqdn"` // sollte mit Client-Cert-CN matchen
|
||||
APIURL string `json:"api_url"` // https://<fqdn>
|
||||
PublicIP string `json:"public_ip"` // optional
|
||||
InternalIP string `json:"internal_ip"` // mTLS-Listener-IP (für peer_ipv4-Set)
|
||||
MgmtIP string `json:"mgmt_ip"` // optional
|
||||
Version string `json:"version"`
|
||||
ConfigHash *string `json:"config_hash"` // nil=absent (don't change), ""=no user config
|
||||
Role string `json:"role"` // "" → "peer" (joining peer); "primary" beim Push des Primary
|
||||
}
|
||||
|
||||
// AgentRegisterPeer: vom Joiner nach issue-cert via mTLS aufgerufen.
|
||||
@@ -473,17 +1059,34 @@ func (h *ClusterHandler) AgentRegisterPeer(c *gin.Context) {
|
||||
// Node, hier ist der „Self" der joining-Peer auf dieser Primary-Seite.
|
||||
// Der Name passt nicht 100% semantisch, aber das SQL ist exakt das was
|
||||
// wir brauchen.)
|
||||
// Rolle aus dem Request (default "peer"). Ein joining-Peer sendet keine
|
||||
// Rolle → "peer". Der Primary-Push sendet "primary", damit die vom
|
||||
// Secondary ausgelieferte UI den Primary korrekt als primary zeigt.
|
||||
// Cert-CN authentifiziert die FQDN; role ist node-lokal/Anzeige (echte
|
||||
// Rollenerkennung läuft über pg_publication).
|
||||
role := strings.TrimSpace(req.Role)
|
||||
if role == "" {
|
||||
role = "peer"
|
||||
}
|
||||
n := models.HANode{
|
||||
ID: req.ID,
|
||||
Name: req.Name,
|
||||
FQDN: req.FQDN,
|
||||
APIURL: req.APIURL,
|
||||
Role: "peer",
|
||||
Status: "joining",
|
||||
Role: role,
|
||||
Status: "online", // peer IS online — it just connected via mTLS
|
||||
}
|
||||
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
|
||||
@@ -497,17 +1100,34 @@ func (h *ClusterHandler) AgentRegisterPeer(c *gin.Context) {
|
||||
v := req.Version
|
||||
n.Version = &v
|
||||
}
|
||||
if req.ConfigHash != nil {
|
||||
n.ConfigHash = req.ConfigHash
|
||||
}
|
||||
// Placeholder zuerst löschen: ha_nodes hat UNIQUE(fqdn). Der INSERT
|
||||
// in UpsertSelf verwendet ON CONFLICT(id) — greift NICHT bei fqdn-
|
||||
// Konflikten. Ohne das Delete würde der INSERT mit "duplicate key on
|
||||
// ha_nodes_fqdn_unique" scheitern und der Peer bliebe ewig "joining".
|
||||
_ = h.Store.DeletePlaceholdersByFQDN(c.Request.Context(), req.FQDN, req.ID)
|
||||
|
||||
// Snapshot der aktuellen IPs VOR dem Upsert — zum Vergleich danach.
|
||||
// Nur wenn sich public_ip oder internal_ip ändert, müssen wir nftables
|
||||
// neu laden (@peer_ipv4-Set). Periodische Pushes vom Secondary (alle
|
||||
// 5 min) ändern nur version/config_hash, nicht die IPs → kein Reset.
|
||||
existing, _ := h.Store.Get(c.Request.Context(), req.ID)
|
||||
|
||||
out, err := h.Store.UpsertSelf(c.Request.Context(), n)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Firewall-Reload damit peer_ipv4-Set die neue IP aufnimmt. Best-
|
||||
// effort: Fehler loggen, Response weiter durchreichen — der Peer
|
||||
// hat seine Identity erfolgreich registriert, Operator kann manuell
|
||||
// nachrendern.
|
||||
if h.PeerReloader != nil {
|
||||
// Firewall-Reload nur wenn sich die Peer-IP geändert hat oder der
|
||||
// Peer neu eingetragen wurde. Verhindert Counter-Reset alle 5 min
|
||||
// durch den periodischen Secondary-Push (runPrimaryPush).
|
||||
ipChanged := existing == nil ||
|
||||
ptrStr(existing.PublicIP) != ptrStr(out.PublicIP) ||
|
||||
ptrStr(existing.InternalIP) != ptrStr(out.InternalIP)
|
||||
if ipChanged && h.PeerReloader != nil {
|
||||
go func() {
|
||||
rctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
@@ -517,8 +1137,55 @@ func (h *ClusterHandler) AgentRegisterPeer(c *gin.Context) {
|
||||
}()
|
||||
}
|
||||
|
||||
slog.Info("cluster: peer registered via mTLS",
|
||||
// Bei neuem Peer / IP-Wechsel als Info loggen (relevantes Ereignis),
|
||||
// sonst Debug — die periodischen 30s-Pushes (runPrimaryPush/runPeerPush)
|
||||
// würden sonst das Log fluten.
|
||||
logFn := slog.Debug
|
||||
if ipChanged {
|
||||
logFn = slog.Info
|
||||
}
|
||||
logFn("cluster: peer registered via mTLS",
|
||||
"id", out.ID, "fqdn", out.FQDN, "role", out.Role, "status", out.Status,
|
||||
"client_cn", cn, "remote", c.ClientIP())
|
||||
response.OK(c, out)
|
||||
}
|
||||
|
||||
// ensureReplicationPublisher richtet die lokale PG-Instanz als Logical-
|
||||
// Replication-Publisher ein (Rolle + Secret, wal_level=logical, pg_hba,
|
||||
// Grants, PUBLICATION). Idempotent — auf einem bereits eingerichteten
|
||||
// Primary ist es ein No-Op.
|
||||
//
|
||||
// Braucht root (psql als postgres, pg_hba schreiben, ggf. PG-Restart), die
|
||||
// API laeuft als unprivilegierter `edgeguard` → Aufruf via sudo mit
|
||||
// gepinnter Regel, wie bei den uebrigen privilegierten Operationen.
|
||||
func (h *ClusterHandler) ensureReplicationPublisher(ctx context.Context) error {
|
||||
// WICHTIG: nur ausfuehren wenn die Publisher-Seite noch NICHT steht.
|
||||
// setupReplicationPrimary generiert bei JEDEM Lauf ein neues
|
||||
// Replikations-Passwort (ALTER ROLE … PASSWORD). Auf einem Cluster mit
|
||||
// bereits angebundenem Subscriber wuerde dessen gespeicherter
|
||||
// Connection-String damit ungueltig und die Replikation bliebe still
|
||||
// stehen — ein zweiter Token-Klick duerfte das niemals ausloesen.
|
||||
// Das Passwort laesst sich nicht wiederverwenden (in PG nur gehasht),
|
||||
// deshalb ist "schon eingerichtet" hier ein hartes Abbruchkriterium.
|
||||
if h.Store != nil {
|
||||
var hasPub bool
|
||||
if err := h.Store.Pool.QueryRow(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM pg_publication WHERE pubname = $1)`,
|
||||
pgPublicationName).Scan(&hasPub); err == nil && hasPub {
|
||||
if _, err := os.Stat(pgReplicationSecretPath); err == nil {
|
||||
slog.Info("cluster: replication publisher already set up — skipping init")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cmd := exec.Command("sudo", "-n", "/usr/bin/edgeguard-ctl", //nolint:noctx // System-Setup, darf nicht am Request-Context haengen
|
||||
"cluster-init-replication")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("cluster-init-replication: %w: %s",
|
||||
err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
slog.Info("cluster: replication publisher ensured")
|
||||
return nil
|
||||
}
|
||||
|
||||
152
internal/handlers/cluster_certsync.go
Normal file
152
internal/handlers/cluster_certsync.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/aggregator"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
)
|
||||
|
||||
const tlsCertDir = "/etc/edgeguard/tls"
|
||||
|
||||
// AgentTLSCerts liefert alle .pem-Dateien aus /etc/edgeguard/tls/ als
|
||||
// Base64-Map. Wird vom Secondary via mTLS aufgerufen um Zertifikate
|
||||
// des Primary zu spiegeln.
|
||||
func (h *ClusterHandler) AgentTLSCerts(c *gin.Context) {
|
||||
entries, err := os.ReadDir(tlsCertDir)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
certs := make(map[string]string, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".pem") {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(tlsCertDir, e.Name()))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
certs[e.Name()] = base64.StdEncoding.EncodeToString(data)
|
||||
}
|
||||
response.OK(c, gin.H{"certs": certs})
|
||||
}
|
||||
|
||||
// SyncTLSCertsFromPrimary holt alle TLS-Zertifikate vom Primary via mTLS
|
||||
// und schreibt geänderte Dateien nach /etc/edgeguard/tls/. Relädt HAProxy
|
||||
// wenn mindestens ein Zertifikat aktualisiert wurde.
|
||||
//
|
||||
// Läuft auf dem Secondary bei jedem runSecondaryConfigRender-Tick —
|
||||
// nicht hash-gated, da certbot-Renewals den config_hash nicht ändern.
|
||||
func SyncTLSCertsFromPrimary(ctx context.Context, pool *pgxpool.Pool, agg *aggregator.Aggregator, localID string) error {
|
||||
if agg == nil {
|
||||
return nil
|
||||
}
|
||||
// Primary-Peer aus ha_nodes ermitteln
|
||||
rows, err := pool.Query(ctx,
|
||||
`SELECT id, fqdn, api_url FROM ha_nodes WHERE id != $1 LIMIT 1`, localID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
var primary *models.HANode
|
||||
for rows.Next() {
|
||||
n := &models.HANode{}
|
||||
if err := rows.Scan(&n.ID, &n.FQDN, &n.APIURL); err != nil {
|
||||
continue
|
||||
}
|
||||
primary = n
|
||||
}
|
||||
if primary == nil {
|
||||
return nil // kein Peer → Single-Node
|
||||
}
|
||||
|
||||
results := agg.FanOut(ctx, []models.HANode{*primary}, "/agent/cluster/tls-certs", localID)
|
||||
if len(results) == 0 || !results[0].OK {
|
||||
return nil // Primary nicht erreichbar — nächster Tick
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Certs map[string]string `json:"certs"`
|
||||
}
|
||||
if err := json.Unmarshal(results[0].Data, &payload); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(tlsCertDir, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
changed := false
|
||||
for name, b64 := range payload.Certs {
|
||||
data, err := base64.StdEncoding.DecodeString(b64)
|
||||
if err != nil {
|
||||
slog.Warn("cert-sync: base64 decode failed", "file", name, "error", err)
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(tlsCertDir, name)
|
||||
existing, readErr := os.ReadFile(path)
|
||||
if readErr == nil && bytes.Equal(existing, data) {
|
||||
continue // unverändert
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o640); err != nil {
|
||||
slog.Warn("cert-sync: write failed", "file", name, "error", err)
|
||||
continue
|
||||
}
|
||||
changed = true
|
||||
slog.Info("cert-sync: updated", "file", name)
|
||||
}
|
||||
|
||||
// Prune: lokale .pem entfernen, die der Primary NICHT (mehr) hat.
|
||||
// Ohne diesen Schritt bleiben Zertifikate gelöschter Domains auf dem
|
||||
// Secondary als Waisen liegen — der Sync oben ist write-only, „nicht
|
||||
// mitgeschickt" ≠ „gelöscht". Geschützt bleiben:
|
||||
// _default.pem — Self-Signed-Fallback
|
||||
// <lokaler-FQDN>.pem — eigener Node-Cert (steht NICHT im Primary-Payload)
|
||||
// Nur prunen wenn der Payload nicht leer ist — Schutz gegen ein
|
||||
// versehentliches Leerräumen bei unvollständiger Primary-Antwort.
|
||||
if len(payload.Certs) > 0 {
|
||||
protected := map[string]bool{"_default.pem": true}
|
||||
var localFQDN string
|
||||
if err := pool.QueryRow(ctx,
|
||||
`SELECT fqdn FROM ha_nodes WHERE id = $1`, localID).Scan(&localFQDN); err == nil && localFQDN != "" {
|
||||
protected[localFQDN+".pem"] = true
|
||||
}
|
||||
if entries, err := os.ReadDir(tlsCertDir); err == nil {
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if e.IsDir() || !strings.HasSuffix(name, ".pem") || protected[name] {
|
||||
continue
|
||||
}
|
||||
if _, ok := payload.Certs[name]; ok {
|
||||
continue // vom Primary gepflegt — behalten
|
||||
}
|
||||
if err := os.Remove(filepath.Join(tlsCertDir, name)); err != nil {
|
||||
slog.Warn("cert-sync: prune failed", "file", name, "error", err)
|
||||
continue
|
||||
}
|
||||
changed = true
|
||||
slog.Info("cert-sync: pruned orphan", "file", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if changed {
|
||||
if err := exec.Command("sudo", "-n", "/usr/bin/systemctl", "reload", "haproxy.service").Run(); err != nil {
|
||||
slog.Warn("cert-sync: haproxy reload failed", "error", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
403
internal/handlers/cluster_repair.go
Normal file
403
internal/handlers/cluster_repair.go
Normal file
@@ -0,0 +1,403 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
)
|
||||
|
||||
// Replication-Repair ("Resync erzwingen") für das Config-Drift-Banner.
|
||||
//
|
||||
// Drift entsteht, wenn ein Peer einen anderen config_hash hat als dieser
|
||||
// Node — entweder weil die Logical-Replication-Subscription gestört ist
|
||||
// oder weil direkt in die DB des Subscribers geschrieben wurde. Die
|
||||
// Reparatur baut die Subscription neu auf und kopiert alle geteilten
|
||||
// Tabellen frisch vom Primary (einseitig: Primary = Source of Truth).
|
||||
//
|
||||
// Rollen-Erkennung: NICHT über ha_nodes.role/pg_role — die sind je Node
|
||||
// lokal und unzuverlässig (jede Node markiert sich selbst, pg_role bleibt
|
||||
// 'standalone' bis `promote`). Verlässlich ist die PUBLICATION: nur der
|
||||
// Primary hat `edgeguard_shared` (pg_publication ist für jeden DB-User
|
||||
// lesbar). Der Subscriber hat sie nicht → er ist das Resync-Ziel.
|
||||
//
|
||||
// Ablauf:
|
||||
// - Klick auf dem Primary → Dispatch via mTLS an den Peer
|
||||
// (POST /agent/cluster/repair-replication) mit der eigenen Adresse als
|
||||
// primary_host; der Peer resynct von dort.
|
||||
// - Klick direkt auf dem Subscriber → läuft lokal (Quelle = der Peer).
|
||||
//
|
||||
// Die eigentliche Arbeit läuft — analog zum Rolling-Update — in einer
|
||||
// transienten systemd-Unit, die `edgeguard-ctl cluster-setup-standby
|
||||
// <primary>` ausführt.
|
||||
|
||||
const (
|
||||
repairUnitName = "edgeguard-repair-replication.service"
|
||||
repairScriptPath = "/var/lib/edgeguard/repair-replication.sh"
|
||||
repairAgentPath = "/agent/cluster/repair-replication"
|
||||
repairPubName = "edgeguard_shared" // muss zu cmd/edgeguard-ctl egPubName passen
|
||||
)
|
||||
|
||||
// validRepairHost erlaubt nur IPv4/IPv6/Hostnamen — der Wert landet in
|
||||
// einem Bash-Script das als root läuft, also strikt validieren.
|
||||
var validRepairHost = regexp.MustCompile(`^[A-Za-z0-9._:-]{1,253}$`)
|
||||
|
||||
// repairDispatchBody ist der Body des Agent-Dispatch: der Primary teilt
|
||||
// dem Subscriber seine Adresse mit, von der resynct werden soll.
|
||||
type repairDispatchBody struct {
|
||||
PrimaryHost string `json:"primary_host"`
|
||||
}
|
||||
|
||||
// RepairReplication ist der UI-Endpoint. Hat dieser Node die Publication
|
||||
// (= Primary), wird der Resync an den Peer delegiert; sonst (Subscriber)
|
||||
// läuft er lokal mit dem Peer als Quelle.
|
||||
func (h *ClusterHandler) RepairReplication(c *gin.Context) {
|
||||
if h.Store == nil {
|
||||
response.Internal(c, errors.New("cluster store unavailable"))
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
all, err := h.Store.List(ctx)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
local := findNode(all, h.LocalID)
|
||||
peer := findOtherPeer(all, h.LocalID)
|
||||
if peer == nil {
|
||||
response.BadRequest(c, errors.New("kein Peer-Node im Cluster — nichts zu resyncen"))
|
||||
return
|
||||
}
|
||||
|
||||
isPrimary, err := h.nodeHasPublication(ctx)
|
||||
if err != nil {
|
||||
// Primary/Subscriber-Status nicht ermittelbar → NICHT raten
|
||||
// (sonst Resync auf dem falschen Node). Abbrechen.
|
||||
response.Internal(c, fmt.Errorf("primary-status nicht ermittelbar: %w", err))
|
||||
return
|
||||
}
|
||||
if isPrimary {
|
||||
// Primary → an den Subscriber-Peer delegieren, mit eigener Adresse.
|
||||
if h.Aggregator == nil {
|
||||
response.BadRequest(c, errors.New("kein mTLS-Aggregator verfügbar — Resync nicht delegierbar"))
|
||||
return
|
||||
}
|
||||
primaryHost := pickPrimaryHost(local)
|
||||
if primaryHost == "" || !validRepairHost.MatchString(primaryHost) {
|
||||
response.BadRequest(c, errors.New("eigene Primary-Adresse (Mgmt/Internal/Public-IP/FQDN) fehlt oder ist ungültig"))
|
||||
return
|
||||
}
|
||||
body, _ := json.Marshal(repairDispatchBody{PrimaryHost: primaryHost})
|
||||
res := h.Aggregator.PostPeerWithBody(ctx, *peer, repairAgentPath, body)
|
||||
if !res.OK {
|
||||
response.Internal(c, fmt.Errorf("resync auf %s anstoßen: %s", peer.FQDN, res.Err))
|
||||
return
|
||||
}
|
||||
slog.Info("cluster: replication repair delegated", "target", peer.FQDN, "primary_host", primaryHost)
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(ctx, actorOf(c), "cluster.repair-replication",
|
||||
peer.FQDN, gin.H{"target": "peer", "peer": peer.FQDN, "primary_host": primaryHost}, h.NodeID)
|
||||
}
|
||||
response.Accepted(c, gin.H{"dispatched": true, "target": "peer", "peer_fqdn": peer.FQDN})
|
||||
return
|
||||
}
|
||||
|
||||
// Subscriber → lokal ausführen, Quelle = der Peer (Primary).
|
||||
host := pickPrimaryHost(peer)
|
||||
if err := h.startResync(ctx, host); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(ctx, actorOf(c), "cluster.repair-replication",
|
||||
host, gin.H{"target": "local", "primary": host}, h.NodeID)
|
||||
}
|
||||
response.Accepted(c, gin.H{"dispatched": true, "target": "local", "primary": host})
|
||||
}
|
||||
|
||||
// AgentRepairReplication wird vom Primary via mTLS auf dem Subscriber
|
||||
// aufgerufen und startet dort den lokalen Resync von primary_host.
|
||||
func (h *ClusterHandler) AgentRepairReplication(c *gin.Context) {
|
||||
if h.Store == nil {
|
||||
response.Internal(c, errors.New("cluster store unavailable"))
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
var body repairDispatchBody
|
||||
_ = c.ShouldBindJSON(&body) // best-effort; Fallback unten
|
||||
|
||||
host := strings.TrimSpace(body.PrimaryHost)
|
||||
if host == "" {
|
||||
// Fallback: Quelle aus ha_nodes (der andere Node).
|
||||
if all, err := h.Store.List(ctx); err == nil {
|
||||
host = pickPrimaryHost(findOtherPeer(all, h.LocalID))
|
||||
}
|
||||
}
|
||||
if err := h.startResync(ctx, host); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
slog.Info("cluster: replication repair triggered by peer", "primary", host, "node", h.LocalID)
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(ctx, "cluster-peer", "cluster.repair-replication",
|
||||
host, gin.H{"target": "local", "primary": host, "via": "agent"}, h.NodeID)
|
||||
}
|
||||
response.Accepted(c, gin.H{"dispatched": true, "primary": host})
|
||||
}
|
||||
|
||||
// startResync schreibt das Repair-Script und startet die transiente
|
||||
// systemd-Unit. Safety-Guard: läuft NIE auf dem Publication-Primary.
|
||||
func (h *ClusterHandler) startResync(ctx context.Context, primaryHost string) error {
|
||||
primaryHost = strings.TrimSpace(primaryHost)
|
||||
if primaryHost == "" {
|
||||
return errors.New("keine Primary-Adresse für den Resync ermittelbar")
|
||||
}
|
||||
if !validRepairHost.MatchString(primaryHost) {
|
||||
return fmt.Errorf("ungültige Primary-Adresse: %q", primaryHost)
|
||||
}
|
||||
// Niemals auf dem Primary (Publication-Quelle) resyncen — würde die
|
||||
// eigene Config mit sich selbst überschreiben bzw. ist sinnlos.
|
||||
// Bei Statusfehler fail-closed (NICHT resyncen).
|
||||
isPrimary, err := h.nodeHasPublication(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("publication-status nicht ermittelbar: %w", err)
|
||||
}
|
||||
if isPrimary {
|
||||
return errors.New("dieser Node ist der Publication-Primary — Resync läuft nur auf einem Subscriber")
|
||||
}
|
||||
if st := repairUnitState(); st == "activating" || st == "active" {
|
||||
return errors.New("resync läuft bereits")
|
||||
}
|
||||
|
||||
script := fmt.Sprintf(`#!/bin/bash
|
||||
set -uo pipefail
|
||||
echo "[repair] resync der Logical-Replication-Subscription von Primary %[1]s"
|
||||
/usr/bin/edgeguard-ctl cluster-setup-standby %[1]s
|
||||
rc=$?
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
echo "[repair] cluster-setup-standby fehlgeschlagen (rc=$rc)"
|
||||
exit "$rc"
|
||||
fi
|
||||
echo "[repair] abgeschlossen — config_hash wird beim nächsten Cluster-Status neu berechnet"
|
||||
rm -f %[2]s
|
||||
`, primaryHost, repairScriptPath)
|
||||
|
||||
if err := os.WriteFile(repairScriptPath, []byte(script), 0o755); err != nil {
|
||||
return fmt.Errorf("write repair script: %w", err)
|
||||
}
|
||||
_ = exec.Command("sudo", "-n", "/usr/bin/systemctl", "reset-failed", repairUnitName).Run()
|
||||
cmd := exec.Command("sudo", "-n", "/usr/bin/systemd-run",
|
||||
"--unit="+repairUnitName,
|
||||
"--description=EdgeGuard replication repair",
|
||||
"--collect",
|
||||
"bash", repairScriptPath)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("systemd-run failed: %w", err)
|
||||
}
|
||||
slog.Info("cluster: replication repair dispatched (local)", "primary", primaryHost, "node", h.LocalID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// nodeHasPublication prüft, ob dieser Node die Replikations-Publication
|
||||
// besitzt — das verlässliche Primary-Signal. pg_publication ist für jeden
|
||||
// DB-User lesbar (anders als pg_subscription).
|
||||
func (h *ClusterHandler) nodeHasPublication(ctx context.Context) (bool, error) {
|
||||
if h.Store == nil || h.Store.Pool == nil {
|
||||
return false, errors.New("no db pool")
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||
defer cancel()
|
||||
var exists bool
|
||||
if err := h.Store.Pool.QueryRow(cctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM pg_publication WHERE pubname = $1)`, repairPubName,
|
||||
).Scan(&exists); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
// repairStatusResponse spiegelt den Zustand der transienten Repair-Unit.
|
||||
type repairStatusResponse struct {
|
||||
Phase string `json:"phase"` // idle | running | success | failed
|
||||
State string `json:"state"`
|
||||
Result string `json:"result"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
StartedAt string `json:"started_at,omitempty"`
|
||||
FinishedAt string `json:"finished_at,omitempty"`
|
||||
Log []string `json:"log"`
|
||||
}
|
||||
|
||||
// RepairReplicationStatus liest den Job-Zustand. Auf dem Primary wird der
|
||||
// Status vom Subscriber-Peer geholt (dort läuft der Job); sonst lokal.
|
||||
func (h *ClusterHandler) RepairReplicationStatus(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
// Status-Poll: bei Fehler kein 500 — einfach lokalen Status liefern.
|
||||
isPrimary, _ := h.nodeHasPublication(ctx)
|
||||
if h.Store != nil && isPrimary && h.Aggregator != nil {
|
||||
if all, err := h.Store.List(ctx); err == nil {
|
||||
if peer := findOtherPeer(all, h.LocalID); peer != nil {
|
||||
results := h.Aggregator.FanOut(ctx,
|
||||
[]models.HANode{*peer}, repairAgentPath+"/status", h.LocalID)
|
||||
if len(results) == 1 && results[0].OK && len(results[0].Data) > 0 {
|
||||
c.Data(200, "application/json", wrapEnvelope(results[0].Data))
|
||||
return
|
||||
}
|
||||
// Peer nicht erreichbar → idle statt Fehler, damit das
|
||||
// UI-Polling nicht hart abbricht.
|
||||
response.OK(c, repairStatusResponse{Phase: "idle", Log: []string{}})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
response.OK(c, localRepairStatus())
|
||||
}
|
||||
|
||||
// AgentRepairReplicationStatus liefert den lokalen Job-Zustand an den
|
||||
// abfragenden Primary.
|
||||
func (h *ClusterHandler) AgentRepairReplicationStatus(c *gin.Context) {
|
||||
response.OK(c, localRepairStatus())
|
||||
}
|
||||
|
||||
// wrapEnvelope verpackt eine bereits entpackte data-Payload wieder in die
|
||||
// Standard-Envelope, damit das UI (isEnvelope) sie konsistent liest.
|
||||
func wrapEnvelope(data []byte) []byte {
|
||||
out := []byte(`{"data":`)
|
||||
out = append(out, data...)
|
||||
out = append(out, []byte(`,"error":null,"message":"ok"}`)...)
|
||||
return out
|
||||
}
|
||||
|
||||
// localRepairStatus liest den Zustand der lokalen Repair-Unit aus systemd
|
||||
// (analog UpgradeStatus). Quelle der Wahrheit für Job-Ende ist die Unit.
|
||||
func localRepairStatus() repairStatusResponse {
|
||||
out := repairStatusResponse{Phase: "idle", Log: []string{}}
|
||||
|
||||
if data, err := exec.Command("systemctl", "show", repairUnitName,
|
||||
"--no-page",
|
||||
"-p", "ActiveState",
|
||||
"-p", "Result",
|
||||
"-p", "ExecMainStatus",
|
||||
"-p", "ExecMainStartTimestamp",
|
||||
"-p", "ExecMainExitTimestamp",
|
||||
).CombinedOutput(); err == nil {
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
kv := strings.SplitN(strings.TrimSpace(line), "=", 2)
|
||||
if len(kv) != 2 {
|
||||
continue
|
||||
}
|
||||
switch kv[0] {
|
||||
case "ActiveState":
|
||||
out.State = kv[1]
|
||||
case "Result":
|
||||
out.Result = kv[1]
|
||||
case "ExecMainStatus":
|
||||
out.ExitCode, _ = strconv.Atoi(kv[1])
|
||||
case "ExecMainStartTimestamp":
|
||||
if t, err := time.Parse("Mon 2006-01-02 15:04:05 MST", kv[1]); err == nil {
|
||||
out.StartedAt = t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
case "ExecMainExitTimestamp":
|
||||
if t, err := time.Parse("Mon 2006-01-02 15:04:05 MST", kv[1]); err == nil {
|
||||
out.FinishedAt = t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch out.State {
|
||||
case "activating", "active", "deactivating":
|
||||
out.Phase = "running"
|
||||
case "failed":
|
||||
out.Phase = "failed"
|
||||
case "inactive":
|
||||
if out.Result == "success" && out.ExitCode == 0 && out.FinishedAt != "" {
|
||||
out.Phase = "success"
|
||||
} else if out.Result != "" && out.Result != "success" {
|
||||
out.Phase = "failed"
|
||||
}
|
||||
}
|
||||
|
||||
if data, err := exec.Command("journalctl",
|
||||
"-u", repairUnitName,
|
||||
"--no-pager", "-n", "100", "-o", "cat",
|
||||
).CombinedOutput(); err == nil {
|
||||
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
|
||||
if len(lines) != 1 || (lines[0] != "" && !strings.HasPrefix(lines[0], "-- No entries")) {
|
||||
out.Log = lines
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// findNode liefert die ha_nodes-Row mit der gegebenen ID.
|
||||
func findNode(nodes []models.HANode, id string) *models.HANode {
|
||||
for i := range nodes {
|
||||
if nodes[i].ID == id {
|
||||
return &nodes[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// findOtherPeer liefert den (einen) anderen Node im 2-Node-Cluster.
|
||||
// Bevorzugt einen online erreichbaren Peer.
|
||||
func findOtherPeer(nodes []models.HANode, localID string) *models.HANode {
|
||||
var fallback *models.HANode
|
||||
for i := range nodes {
|
||||
n := &nodes[i]
|
||||
if n.ID == localID {
|
||||
continue
|
||||
}
|
||||
if n.Status == "online" {
|
||||
return n
|
||||
}
|
||||
if fallback == nil {
|
||||
fallback = n
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// pickPrimaryHost wählt die beste erreichbare Adresse eines Node:
|
||||
// Mgmt-IP → Internal-IP → Public-IP → FQDN. Strippt eine etwaige
|
||||
// CIDR-Maske (inet-Spalten können "10.0.0.5/32" liefern).
|
||||
func pickPrimaryHost(n *models.HANode) string {
|
||||
if n == nil {
|
||||
return ""
|
||||
}
|
||||
for _, cand := range []*string{n.MgmtIP, n.InternalIP, n.PublicIP} {
|
||||
if cand != nil {
|
||||
if h := strings.TrimSpace(strings.SplitN(*cand, "/", 2)[0]); h != "" {
|
||||
return h
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(n.FQDN)
|
||||
}
|
||||
|
||||
// repairUnitState gibt den ActiveState der Repair-Unit zurück ("" wenn
|
||||
// unbekannt). Für den Doppelstart-Schutz.
|
||||
func repairUnitState() string {
|
||||
out, err := exec.Command("systemctl", "show", repairUnitName, "--no-page", "-p", "ActiveState").CombinedOutput()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
if kv := strings.SplitN(strings.TrimSpace(line), "=", 2); len(kv) == 2 && kv[0] == "ActiveState" {
|
||||
return kv[1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
342
internal/handlers/cluster_rollingupdate.go
Normal file
342
internal/handlers/cluster_rollingupdate.go
Normal file
@@ -0,0 +1,342 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
aptsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/apt"
|
||||
)
|
||||
|
||||
// ruStateMu serialisiert Lesen/Schreiben der Rolling-Update-State-Datei
|
||||
// (HTTP-Handler + Hintergrund-Goroutine greifen gleichzeitig zu).
|
||||
var ruStateMu sync.Mutex
|
||||
|
||||
const rollingUpdateStateFile = "/var/lib/edgeguard/rolling-update-state.json"
|
||||
|
||||
const (
|
||||
phaseIdle = "idle"
|
||||
phaseUpdatingSecondary = "updating-secondary"
|
||||
phaseWaitingSecondary = "waiting-secondary"
|
||||
phaseUpdatingPrimary = "updating-primary"
|
||||
phaseDone = "done"
|
||||
phaseFailed = "failed"
|
||||
)
|
||||
|
||||
// FinishRollingUpdateIfPending wird beim API-Start aufgerufen.
|
||||
// - "updating-primary": der Primary ist gerade erfolgreich neugestartet →
|
||||
// Update abgeschlossen → "done".
|
||||
// - "updating-secondary"/"waiting-secondary": die orchestrierende Goroutine
|
||||
// lief in DIESEM (jetzt neu gestarteten) Prozess und ist mit ihm gestorben.
|
||||
// Die Phase kann nicht weiterlaufen → auf "idle" zurücksetzen, sonst zeigt
|
||||
// die UI ewig "Rolling Update läuft". (Vorher blieb so ein Stand hängen.)
|
||||
func FinishRollingUpdateIfPending() {
|
||||
st := readRollingUpdateState()
|
||||
switch st.Phase {
|
||||
case phaseUpdatingPrimary:
|
||||
writeRollingUpdateState(RollingUpdateState{
|
||||
Phase: phaseDone,
|
||||
SecondaryID: st.SecondaryID,
|
||||
SecondaryFQDN: st.SecondaryFQDN,
|
||||
})
|
||||
case phaseUpdatingSecondary, phaseWaitingSecondary:
|
||||
writeRollingUpdateState(RollingUpdateState{Phase: phaseIdle})
|
||||
}
|
||||
}
|
||||
|
||||
// RollingUpdateState hält den Fortschritt des Rolling-Updates.
|
||||
// Persistiert in rollingUpdateStateFile damit der Status über
|
||||
// einen kurzen API-Neustart hinaus lesbar bleibt.
|
||||
type RollingUpdateState struct {
|
||||
Phase string `json:"phase"`
|
||||
SecondaryID string `json:"secondary_id,omitempty"`
|
||||
SecondaryFQDN string `json:"secondary_fqdn,omitempty"`
|
||||
StartedAt time.Time `json:"started_at,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func readRollingUpdateState() RollingUpdateState {
|
||||
ruStateMu.Lock()
|
||||
defer ruStateMu.Unlock()
|
||||
data, err := os.ReadFile(rollingUpdateStateFile)
|
||||
if err != nil {
|
||||
return RollingUpdateState{Phase: phaseIdle, UpdatedAt: time.Now()}
|
||||
}
|
||||
var s RollingUpdateState
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return RollingUpdateState{Phase: phaseIdle, UpdatedAt: time.Now()}
|
||||
}
|
||||
// Terminale Zustände altern aus (statt Mutation-on-GET): nach 10 min
|
||||
// gilt done/failed als idle — so verliert kein paralleler Poller das
|
||||
// Ergebnis und ein alter Stand bleibt nicht hängen.
|
||||
if (s.Phase == phaseDone || s.Phase == phaseFailed) && !s.UpdatedAt.IsZero() &&
|
||||
time.Since(s.UpdatedAt) > 10*time.Minute {
|
||||
return RollingUpdateState{Phase: phaseIdle, UpdatedAt: time.Now()}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func writeRollingUpdateState(s RollingUpdateState) {
|
||||
s.UpdatedAt = time.Now()
|
||||
data, err := json.Marshal(s)
|
||||
if err != nil {
|
||||
slog.Warn("rolling-update: failed to marshal state", "error", err)
|
||||
return
|
||||
}
|
||||
ruStateMu.Lock()
|
||||
defer ruStateMu.Unlock()
|
||||
// AtomicWrite (temp+rename) → Leser sehen nie einen partiellen Stand.
|
||||
if err := configgen.AtomicWrite(rollingUpdateStateFile, data, 0o600); err != nil {
|
||||
slog.Warn("rolling-update: failed to write state file", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// RollingUpdate startet den Rolling-Update-Prozess:
|
||||
// 1. Secondary aktualisieren (via mTLS /agent/cluster/trigger-update)
|
||||
// 2. Warten bis Secondary neue Version meldet
|
||||
// 3. Primary (dieser Node) aktualisieren (wie /system/upgrade)
|
||||
//
|
||||
// Kein Cluster vorhanden → 409 zurück damit der Client auf /system/upgrade
|
||||
// ausweichen kann. Wenn bereits ein Rolling-Update läuft → aktuellen State.
|
||||
func (h *ClusterHandler) RollingUpdate(c *gin.Context) {
|
||||
if h.Aggregator == nil || h.Store == nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "no cluster — use /system/upgrade"})
|
||||
return
|
||||
}
|
||||
|
||||
st := readRollingUpdateState()
|
||||
if st.Phase != phaseIdle && st.Phase != phaseFailed && st.Phase != phaseDone {
|
||||
response.OK(c, st)
|
||||
return
|
||||
}
|
||||
|
||||
nodes, err := h.Store.List(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
var secondary *models.HANode
|
||||
for i := range nodes {
|
||||
if nodes[i].ID != h.LocalID {
|
||||
secondary = &nodes[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if secondary == nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "no peer node — use /system/upgrade"})
|
||||
return
|
||||
}
|
||||
|
||||
newState := RollingUpdateState{
|
||||
Phase: phaseUpdatingSecondary,
|
||||
SecondaryID: secondary.ID,
|
||||
SecondaryFQDN: secondary.FQDN,
|
||||
StartedAt: time.Now(),
|
||||
}
|
||||
writeRollingUpdateState(newState)
|
||||
slog.Info("rolling-update: started", "secondary", secondary.FQDN)
|
||||
|
||||
go h.runRollingUpdate(secondary)
|
||||
|
||||
c.JSON(http.StatusAccepted, newState)
|
||||
}
|
||||
|
||||
// RollingUpdateStatus gibt den aktuellen Rolling-Update-State zurück.
|
||||
// Read-only — terminale Zustände altern in readRollingUpdateState aus
|
||||
// (kein Reset-on-GET mehr, das parallelen Pollern das "done" wegnahm).
|
||||
func (h *ClusterHandler) RollingUpdateStatus(c *gin.Context) {
|
||||
response.OK(c, readRollingUpdateState())
|
||||
}
|
||||
|
||||
func (h *ClusterHandler) runRollingUpdate(secondary *models.HANode) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Zielversion = das verfügbare apt-Candidate (worauf wir hochziehen) und
|
||||
// die aktuelle Secondary-Version als Baseline. Beides steuert, ob der
|
||||
// Secondary überhaupt etwas zu tun hat.
|
||||
candidate := rollingCandidateVersion(ctx)
|
||||
baseline := secondaryVersion(ctx, h, secondary)
|
||||
|
||||
// Ist der Secondary bereits auf der Zielversion, gibt es nichts
|
||||
// hochzuziehen — KEIN Trigger, KEIN Warten. Sonst würde auf einen
|
||||
// Version-Flip gewartet, der nie kommt → 10-min-Timeout (der frühere Bug,
|
||||
// wenn beide Nodes schon aktuell waren).
|
||||
secondaryUpToDate := candidate != "" && baseline != "" && baseline == candidate
|
||||
if secondaryUpToDate {
|
||||
slog.Info("rolling-update: secondary already at target — skipping secondary step",
|
||||
"version", candidate)
|
||||
} else {
|
||||
// 1. Secondary 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 {
|
||||
writeRollingUpdateState(RollingUpdateState{
|
||||
Phase: phaseFailed,
|
||||
SecondaryID: secondary.ID,
|
||||
SecondaryFQDN: secondary.FQDN,
|
||||
Error: "trigger-update failed: " + result.Err,
|
||||
})
|
||||
slog.Warn("rolling-update: secondary trigger failed", "error", result.Err)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Secondary-Version pollen — der Secondary restartet nach dem
|
||||
// Upgrade, danach zeigt /agent/cluster/version eine neue Version.
|
||||
writeRollingUpdateState(RollingUpdateState{
|
||||
Phase: phaseWaitingSecondary,
|
||||
SecondaryID: secondary.ID,
|
||||
SecondaryFQDN: secondary.FQDN,
|
||||
})
|
||||
slog.Info("rolling-update: waiting for secondary version flip",
|
||||
"baseline", baseline, "candidate", candidate)
|
||||
|
||||
// Kurze Wartezeit damit apt auf dem Secondary erst losläuft
|
||||
time.Sleep(20 * time.Second)
|
||||
|
||||
deadline := time.Now().Add(10 * time.Minute)
|
||||
versionFlipped := false
|
||||
for time.Now().Before(deadline) {
|
||||
results := h.Aggregator.FanOut(ctx, []models.HANode{*secondary}, "/agent/cluster/version", h.LocalID)
|
||||
if len(results) > 0 && results[0].OK {
|
||||
var ver struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if err := json.Unmarshal(results[0].Data, &ver); err == nil {
|
||||
slog.Info("rolling-update: secondary version", "version", ver.Version,
|
||||
"baseline", baseline, "candidate", candidate)
|
||||
// Erfolg = Secondary hat die Zielversion erreicht (candidate)
|
||||
// ODER hat sich gegenüber der Baseline überhaupt bewegt
|
||||
// (Fallback, wenn candidate nicht ermittelbar war).
|
||||
if ver.Version != "" &&
|
||||
((candidate != "" && ver.Version == candidate) || ver.Version != baseline) {
|
||||
versionFlipped = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
time.Sleep(10 * time.Second)
|
||||
}
|
||||
|
||||
if !versionFlipped {
|
||||
writeRollingUpdateState(RollingUpdateState{
|
||||
Phase: phaseFailed,
|
||||
SecondaryID: secondary.ID,
|
||||
SecondaryFQDN: secondary.FQDN,
|
||||
Error: "timeout (10 min) waiting for secondary version flip",
|
||||
})
|
||||
slog.Warn("rolling-update: secondary version flip timeout")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Primary (uns selbst) aktualisieren — identisch zu /system/upgrade.
|
||||
// Ist der Primary bereits auf der Zielversion (z. B. beide Nodes schon
|
||||
// aktuell), gibt es nichts zu tun → direkt "done". Sonst liefe ein
|
||||
// apt-Lauf ohne Paket-Wechsel → kein Restart → Phase hinge ewig in
|
||||
// "updating-primary".
|
||||
if candidate != "" && h.Version == candidate {
|
||||
slog.Info("rolling-update: primary already at target — nothing to upgrade", "version", candidate)
|
||||
writeRollingUpdateState(RollingUpdateState{
|
||||
Phase: phaseDone,
|
||||
SecondaryID: secondary.ID,
|
||||
SecondaryFQDN: secondary.FQDN,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeRollingUpdateState(RollingUpdateState{
|
||||
Phase: phaseUpdatingPrimary,
|
||||
SecondaryID: secondary.ID,
|
||||
SecondaryFQDN: secondary.FQDN,
|
||||
})
|
||||
slog.Info("rolling-update: triggering primary self-upgrade")
|
||||
|
||||
const scriptPath = "/var/lib/edgeguard/upgrade.sh"
|
||||
const script = `#!/bin/bash
|
||||
set -e
|
||||
sleep 2
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
dpkg --configure -a || true
|
||||
retry_apt() {
|
||||
local attempt=0 max=3 wait_for=15
|
||||
while [ $attempt -lt $max ]; do
|
||||
attempt=$((attempt + 1))
|
||||
apt-get update -qq || true
|
||||
# --allow-downgrades: nur relevant nach testing→stable-Kanalwechsel
|
||||
# (Testing-Versionen sortieren datumsbasiert höher als Stable-Semver).
|
||||
# No-Op im Normalfall, da die Candidate sonst immer >= installed ist.
|
||||
if apt-get install -y -qq --allow-downgrades -o Dpkg::Options::=--force-confold \
|
||||
edgeguard-api edgeguard-ui edgeguard; then return 0; fi
|
||||
[ $attempt -lt $max ] && sleep $wait_for && wait_for=$((wait_for * 2))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
retry_apt
|
||||
echo "[upgrade] complete"
|
||||
rm -f /var/lib/edgeguard/upgrade.sh
|
||||
`
|
||||
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
|
||||
writeRollingUpdateState(RollingUpdateState{
|
||||
Phase: phaseFailed,
|
||||
SecondaryID: secondary.ID,
|
||||
SecondaryFQDN: secondary.FQDN,
|
||||
Error: "write upgrade script: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const unitName = "edgeguard-upgrade.service"
|
||||
_ = exec.Command("sudo", "-n", "/usr/bin/systemctl", "reset-failed", unitName).Run()
|
||||
cmd := exec.Command("sudo", "-n", "/usr/bin/systemd-run",
|
||||
"--unit="+unitName,
|
||||
"--description=EdgeGuard self-upgrade",
|
||||
"--collect",
|
||||
"bash", scriptPath)
|
||||
if err := cmd.Run(); err != nil {
|
||||
writeRollingUpdateState(RollingUpdateState{
|
||||
Phase: phaseFailed,
|
||||
SecondaryID: secondary.ID,
|
||||
SecondaryFQDN: secondary.FQDN,
|
||||
Error: "systemd-run failed: " + err.Error(),
|
||||
})
|
||||
slog.Warn("rolling-update: primary systemd-run failed", "error", err)
|
||||
return
|
||||
}
|
||||
// State bleibt "updating-primary" — der Primary restartet gleich.
|
||||
// UI erkennt Version-Flip via /system/health und schließt den Flow.
|
||||
slog.Info("rolling-update: primary upgrade dispatched, process will restart")
|
||||
}
|
||||
|
||||
// rollingCandidateVersion liefert best-effort die verfügbare apt-Candidate-
|
||||
// Version des Meta-Pakets "edgeguard" — also die Version, auf die das Rolling-
|
||||
// Update hochzieht. Leerer String, wenn apt sie nicht ermitteln kann (dann
|
||||
// fällt runRollingUpdate auf reine Baseline-Flip-Erkennung zurück).
|
||||
func rollingCandidateVersion(ctx context.Context) string {
|
||||
vers := aptsvc.PackageVersions(ctx, false)
|
||||
return vers["edgeguard_available"]
|
||||
}
|
||||
|
||||
// secondaryVersion holt best-effort die laufende Version des Peers via mTLS.
|
||||
func secondaryVersion(ctx context.Context, h *ClusterHandler, secondary *models.HANode) string {
|
||||
results := h.Aggregator.FanOut(ctx, []models.HANode{*secondary}, "/agent/cluster/version", h.LocalID)
|
||||
if len(results) > 0 && results[0].OK {
|
||||
var ver struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if json.Unmarshal(results[0].Data, &ver) == nil {
|
||||
return ver.Version
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
319
internal/handlers/cluster_viptest.go
Normal file
319
internal/handlers/cluster_viptest.go
Normal file
@@ -0,0 +1,319 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
)
|
||||
|
||||
// vipInfo enthält die für einen VIP-Schwenk notwendigen Daten.
|
||||
type vipInfo struct {
|
||||
ID int64 `json:"id"`
|
||||
Address string `json:"address"`
|
||||
Prefix int `json:"prefix"`
|
||||
Device string `json:"device"`
|
||||
}
|
||||
|
||||
// VIPStatusEntry kombiniert einen VIP mit den Nodes die ihn gerade halten.
|
||||
type VIPStatusEntry struct {
|
||||
VIP vipInfo `json:"vip"`
|
||||
ActiveOn []string `json:"active_on"` // FQDNs der Nodes mit diesem VIP
|
||||
}
|
||||
|
||||
// AgentActiveIPs gibt alle aktiven IPv4-Adressen dieses Nodes zurück.
|
||||
// Wird vom Primary genutzt um zu prüfen welcher Node welchen VIP hält.
|
||||
func (h *ClusterHandler) AgentActiveIPs(c *gin.Context) {
|
||||
ips, err := localActiveIPs()
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"ips": ips})
|
||||
}
|
||||
|
||||
// vipCmdRequest ist der Body für den AgentVIPCmd-Endpoint.
|
||||
type vipCmdRequest struct {
|
||||
Action string `json:"action"` // "add" | "del"
|
||||
Address string `json:"address"` // z.B. "10.0.5.1"
|
||||
Prefix int `json:"prefix"` // z.B. 24
|
||||
Device string `json:"device"` // z.B. "vlan100"
|
||||
}
|
||||
|
||||
// AgentVIPCmd führt `ip addr add/del` auf diesem Node aus.
|
||||
// Wird vom Primary via mTLS für VIP-Schwenk-Tests aufgerufen.
|
||||
func (h *ClusterHandler) AgentVIPCmd(c *gin.Context) {
|
||||
var req vipCmdRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if req.Action != "add" && req.Action != "del" {
|
||||
response.BadRequest(c, simpleError("action must be 'add' or 'del'"))
|
||||
return
|
||||
}
|
||||
if req.Address == "" || req.Device == "" || req.Prefix <= 0 || req.Prefix > 128 {
|
||||
response.BadRequest(c, simpleError("address, device, prefix required"))
|
||||
return
|
||||
}
|
||||
if err := runVIPCmd(req.Action, req.Address, req.Prefix, req.Device); err != nil {
|
||||
slog.Warn("cluster: agent vip-cmd failed",
|
||||
"action", req.Action, "addr", req.Address, "dev", req.Device, "error", err)
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
slog.Info("cluster: agent vip-cmd ok",
|
||||
"action", req.Action, "addr", req.Address, "prefix", req.Prefix,
|
||||
"dev", req.Device, "caller", c.ClientIP())
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// VIPStatus liest alle VIPs (is_vip=true) aus der DB und fragt alle Nodes
|
||||
// welche davon sie gerade aktiv haben. Nur sinnvoll im Cluster-Modus.
|
||||
func (h *ClusterHandler) VIPStatus(c *gin.Context) {
|
||||
vips, err := loadVIPs(c.Request.Context(), h.Store.Pool)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
nodeIPs := h.collectActiveIPs(c.Request.Context())
|
||||
result := make([]VIPStatusEntry, 0, len(vips))
|
||||
for _, v := range vips {
|
||||
entry := VIPStatusEntry{VIP: v}
|
||||
for fqdn, ips := range nodeIPs {
|
||||
for _, ip := range ips {
|
||||
if ip == v.Address {
|
||||
entry.ActiveOn = append(entry.ActiveOn, fqdn)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
result = append(result, entry)
|
||||
}
|
||||
response.OK(c, gin.H{"vips": result})
|
||||
}
|
||||
|
||||
// vipTestRequest steuert einen VIP-Schwenk.
|
||||
type vipTestRequest struct {
|
||||
IPAddressID int64 `json:"ip_address_id"`
|
||||
Action string `json:"action"` // "to_secondary" | "restore"
|
||||
}
|
||||
|
||||
// vipTestStep beschreibt einen Schritt des Schwenk-Prozesses.
|
||||
type vipTestStep struct {
|
||||
Step string `json:"step"`
|
||||
OK bool `json:"ok"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// VIPTest schwenkt einen VIP vom Primary auf den Secondary ("to_secondary")
|
||||
// oder zurück ("restore"). Nur vom Primary aufzurufen.
|
||||
func (h *ClusterHandler) VIPTest(c *gin.Context) {
|
||||
var req vipTestRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if req.Action != "to_secondary" && req.Action != "restore" {
|
||||
response.BadRequest(c, simpleError("action must be 'to_secondary' or 'restore'"))
|
||||
return
|
||||
}
|
||||
|
||||
vips, err := loadVIPs(c.Request.Context(), h.Store.Pool)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
var target *vipInfo
|
||||
for i := range vips {
|
||||
if vips[i].ID == req.IPAddressID {
|
||||
target = &vips[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if target == nil {
|
||||
response.NotFound(c, simpleError("VIP not found or not marked as VIP"))
|
||||
return
|
||||
}
|
||||
|
||||
all, err := h.Store.List(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
var peer *models.HANode
|
||||
for i := range all {
|
||||
if all[i].ID != h.LocalID {
|
||||
peer = &all[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if peer == nil {
|
||||
response.BadRequest(c, simpleError("kein Secondary-Peer gefunden"))
|
||||
return
|
||||
}
|
||||
|
||||
var steps []vipTestStep
|
||||
addrPfx := fmt.Sprintf("%s/%d", target.Address, target.Prefix)
|
||||
|
||||
if req.Action == "to_secondary" {
|
||||
// 1. VIP auf Secondary via mTLS hinzufügen
|
||||
steps = append(steps, h.peerVIPCmd(c.Request.Context(), *peer, target, "add",
|
||||
fmt.Sprintf("add %s dev %s auf %s", addrPfx, target.Device, peer.FQDN)))
|
||||
// 2. VIP vom Primary entfernen (nur wenn Secondary-Add erfolgreich)
|
||||
if steps[0].OK {
|
||||
steps = append(steps, localVIPStep(target, "del",
|
||||
fmt.Sprintf("del %s dev %s lokal", addrPfx, target.Device)))
|
||||
}
|
||||
} else {
|
||||
// 1. VIP auf Primary zurückholen
|
||||
steps = append(steps, localVIPStep(target, "add",
|
||||
fmt.Sprintf("add %s dev %s lokal", addrPfx, target.Device)))
|
||||
// 2. VIP auf Secondary entfernen
|
||||
steps = append(steps, h.peerVIPCmd(c.Request.Context(), *peer, target, "del",
|
||||
fmt.Sprintf("del %s dev %s auf %s", addrPfx, target.Device, peer.FQDN)))
|
||||
}
|
||||
|
||||
slog.Info("cluster: vip-test", "action", req.Action, "vip", target.Address,
|
||||
"dev", target.Device, "peer", peer.FQDN, "actor", actorOf(c))
|
||||
response.OK(c, gin.H{"steps": steps})
|
||||
}
|
||||
|
||||
// ── Hilfsfunktionen ───────────────────────────────────────────────────────
|
||||
|
||||
func loadVIPs(ctx context.Context, pool *pgxpool.Pool) ([]vipInfo, error) {
|
||||
rows, err := pool.Query(ctx, `
|
||||
SELECT ia.id, ia.address, ia.prefix, ni.name
|
||||
FROM ip_addresses ia
|
||||
JOIN network_interfaces ni ON ni.id = ia.interface_id
|
||||
WHERE ia.is_vip = true AND ia.active = true
|
||||
ORDER BY ni.name, ia.address`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []vipInfo
|
||||
for rows.Next() {
|
||||
var v vipInfo
|
||||
if err := rows.Scan(&v.ID, &v.Address, &v.Prefix, &v.Device); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// collectActiveIPs fragt alle Cluster-Nodes (lokal + Peers via mTLS) nach
|
||||
// ihren aktiven IPv4-Adressen und gibt eine Map[fqdn][]ip zurück.
|
||||
func (h *ClusterHandler) collectActiveIPs(ctx context.Context) map[string][]string {
|
||||
result := make(map[string][]string)
|
||||
if h.Store == nil {
|
||||
return result
|
||||
}
|
||||
all, err := h.Store.List(ctx)
|
||||
if err != nil {
|
||||
return result
|
||||
}
|
||||
// Lokaler Node
|
||||
if ips, err := localActiveIPs(); err == nil {
|
||||
for _, n := range all {
|
||||
if n.ID == h.LocalID {
|
||||
result[n.FQDN] = ips
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// Peers via mTLS-Aggregator
|
||||
if h.Aggregator != nil {
|
||||
var peers []models.HANode
|
||||
for _, n := range all {
|
||||
if n.ID != h.LocalID {
|
||||
peers = append(peers, n)
|
||||
}
|
||||
}
|
||||
if len(peers) > 0 {
|
||||
peerResults := h.Aggregator.FanOut(ctx, peers, "/agent/cluster/active-ips", h.LocalID)
|
||||
for _, pr := range peerResults {
|
||||
if !pr.OK || len(pr.Data) == 0 {
|
||||
continue
|
||||
}
|
||||
var payload struct {
|
||||
IPs []string `json:"ips"`
|
||||
}
|
||||
if err := json.Unmarshal(pr.Data, &payload); err == nil {
|
||||
result[pr.FQDN] = payload.IPs
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// localActiveIPs liest alle aktiven IPv4-Adressen des lokalen Nodes via `ip`.
|
||||
func localActiveIPs() ([]string, error) {
|
||||
out, err := exec.Command("ip", "-4", "-o", "addr", "show").Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var ips []string
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
parts := strings.Fields(line)
|
||||
for i, p := range parts {
|
||||
if p == "inet" && i+1 < len(parts) {
|
||||
addr := strings.SplitN(parts[i+1], "/", 2)[0]
|
||||
ips = append(ips, addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ips, nil
|
||||
}
|
||||
|
||||
// peerVIPCmd ruft AgentVIPCmd auf dem Peer via mTLS auf.
|
||||
func (h *ClusterHandler) peerVIPCmd(ctx context.Context, peer models.HANode, vip *vipInfo, action, stepLabel string) vipTestStep {
|
||||
step := vipTestStep{Step: stepLabel}
|
||||
if h.Aggregator == nil {
|
||||
step.Message = "aggregator nicht verfügbar"
|
||||
return step
|
||||
}
|
||||
body, _ := json.Marshal(vipCmdRequest{
|
||||
Action: action,
|
||||
Address: vip.Address,
|
||||
Prefix: vip.Prefix,
|
||||
Device: vip.Device,
|
||||
})
|
||||
res := h.Aggregator.PostPeerWithBody(ctx, peer, "/agent/cluster/vip-cmd", body)
|
||||
step.OK = res.OK
|
||||
if !res.OK {
|
||||
step.Message = res.Err
|
||||
}
|
||||
return step
|
||||
}
|
||||
|
||||
// localVIPStep führt ip addr add/del auf dem lokalen Node aus.
|
||||
func localVIPStep(vip *vipInfo, action, stepLabel string) vipTestStep {
|
||||
step := vipTestStep{Step: stepLabel}
|
||||
if err := runVIPCmd(action, vip.Address, vip.Prefix, vip.Device); err != nil {
|
||||
step.Message = err.Error()
|
||||
return step
|
||||
}
|
||||
step.OK = true
|
||||
return step
|
||||
}
|
||||
|
||||
// runVIPCmd führt `sudo /usr/lib/edgeguard/vip-cmd.sh {action} {addr/prefix} {dev}` aus.
|
||||
func runVIPCmd(action, address string, prefix int, device string) error {
|
||||
addrPfx := fmt.Sprintf("%s/%d", address, prefix)
|
||||
out, err := exec.Command("sudo", "-n", "/usr/lib/edgeguard/vip-cmd.sh", action, addrPfx, device).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("vip-cmd.sh %s %s %s: %s", action, addrPfx, device, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
285
internal/handlers/crowdsec.go
Normal file
285
internal/handlers/crowdsec.go
Normal file
@@ -0,0 +1,285 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
crowdsec "git.netcell-it.de/projekte/edgeguard-native/internal/crowdsec"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
|
||||
)
|
||||
|
||||
// CrowdSecHandler exposes the CrowdSec IDS/IPS management REST API:
|
||||
//
|
||||
// GET /crowdsec/status
|
||||
// GET /crowdsec/decisions
|
||||
// POST /crowdsec/decisions
|
||||
// DELETE /crowdsec/decisions (?ip=<ip> or ?id=<id>)
|
||||
// GET /crowdsec/alerts
|
||||
// DELETE /crowdsec/alerts/:id
|
||||
// GET /crowdsec/bouncers
|
||||
// DELETE /crowdsec/bouncers/:name
|
||||
// GET /crowdsec/machines
|
||||
// DELETE /crowdsec/machines/:id
|
||||
// GET /crowdsec/collections
|
||||
// POST /crowdsec/collections/:name/install
|
||||
// DELETE /crowdsec/collections/:name
|
||||
type CrowdSecHandler struct {
|
||||
Audit *audit.Repo
|
||||
NodeID string
|
||||
}
|
||||
|
||||
// NewCrowdSecHandler returns a CrowdSecHandler wired with audit and node-id.
|
||||
func NewCrowdSecHandler(a *audit.Repo, nodeID string) *CrowdSecHandler {
|
||||
return &CrowdSecHandler{Audit: a, NodeID: nodeID}
|
||||
}
|
||||
|
||||
// Register mounts all CrowdSec routes onto the provided authenticated router
|
||||
// group.
|
||||
func (h *CrowdSecHandler) Register(rg *gin.RouterGroup) {
|
||||
g := rg.Group("/crowdsec")
|
||||
g.GET("/status", h.Status)
|
||||
g.GET("/decisions", h.ListDecisions)
|
||||
g.POST("/decisions", h.AddDecision)
|
||||
g.DELETE("/decisions", h.DeleteDecision)
|
||||
g.GET("/alerts", h.ListAlerts)
|
||||
g.DELETE("/alerts/:id", h.DeleteAlert)
|
||||
g.GET("/bouncers", h.ListBouncers)
|
||||
g.DELETE("/bouncers/:name", h.DeleteBouncer)
|
||||
g.GET("/machines", h.ListMachines)
|
||||
g.DELETE("/machines/:id", h.DeleteMachine)
|
||||
g.GET("/collections", h.ListCollections)
|
||||
g.POST("/collections/:name/install", h.InstallCollection)
|
||||
g.DELETE("/collections/:name", h.RemoveCollection)
|
||||
}
|
||||
|
||||
// csNotInstalled responds with 503 when cscli is absent.
|
||||
func csNotInstalled(c *gin.Context) {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "crowdsec not installed"})
|
||||
}
|
||||
|
||||
// ---------- Status ----------------------------------------------------------
|
||||
|
||||
// Status returns live status of the CrowdSec agent + bouncer.
|
||||
// Does NOT require cscli — uses systemctl for running-state checks.
|
||||
func (h *CrowdSecHandler) Status(c *gin.Context) {
|
||||
st := crowdsec.ServiceStatus(c.Request.Context())
|
||||
response.OK(c, st)
|
||||
}
|
||||
|
||||
// ---------- Decisions -------------------------------------------------------
|
||||
|
||||
// ListDecisions returns all active decisions.
|
||||
func (h *CrowdSecHandler) ListDecisions(c *gin.Context) {
|
||||
if !crowdsec.IsInstalled() {
|
||||
csNotInstalled(c)
|
||||
return
|
||||
}
|
||||
list, err := crowdsec.Decisions(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"decisions": list})
|
||||
}
|
||||
|
||||
// addDecisionBody is the expected JSON body for POST /crowdsec/decisions.
|
||||
type addDecisionBody struct {
|
||||
IP string `json:"ip" binding:"required"`
|
||||
Duration string `json:"duration" binding:"required"`
|
||||
Reason string `json:"reason"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// AddDecision creates a new ban/captcha decision.
|
||||
func (h *CrowdSecHandler) AddDecision(c *gin.Context) {
|
||||
if !crowdsec.IsInstalled() {
|
||||
csNotInstalled(c)
|
||||
return
|
||||
}
|
||||
var body addDecisionBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if body.Reason == "" {
|
||||
body.Reason = "manual ban"
|
||||
}
|
||||
if body.Type == "" {
|
||||
body.Type = "ban"
|
||||
}
|
||||
if err := crowdsec.AddDecision(c.Request.Context(), body.IP, body.Duration, body.Reason, body.Type); err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "crowdsec.decision.add", body.IP,
|
||||
gin.H{"duration": body.Duration, "type": body.Type, "reason": body.Reason}, h.NodeID)
|
||||
response.Created(c, gin.H{"ip": body.IP, "duration": body.Duration, "type": body.Type})
|
||||
}
|
||||
|
||||
// DeleteDecision removes a decision by IP (?ip=) or by ID (?id=).
|
||||
func (h *CrowdSecHandler) DeleteDecision(c *gin.Context) {
|
||||
if !crowdsec.IsInstalled() {
|
||||
csNotInstalled(c)
|
||||
return
|
||||
}
|
||||
ip := c.Query("ip")
|
||||
id := c.Query("id")
|
||||
if ip == "" && id == "" {
|
||||
response.BadRequest(c, errors.New("query parameter 'ip' or 'id' required"))
|
||||
return
|
||||
}
|
||||
var err error
|
||||
var target string
|
||||
if ip != "" {
|
||||
err = crowdsec.DeleteDecisionByIP(c.Request.Context(), ip)
|
||||
target = ip
|
||||
} else {
|
||||
err = crowdsec.DeleteDecisionByID(c.Request.Context(), id)
|
||||
target = id
|
||||
}
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "crowdsec.decision.delete", target, nil, h.NodeID)
|
||||
response.OK(c, gin.H{"deleted": target})
|
||||
}
|
||||
|
||||
// ---------- Alerts ----------------------------------------------------------
|
||||
|
||||
// ListAlerts returns recent CrowdSec alerts.
|
||||
func (h *CrowdSecHandler) ListAlerts(c *gin.Context) {
|
||||
if !crowdsec.IsInstalled() {
|
||||
csNotInstalled(c)
|
||||
return
|
||||
}
|
||||
list, err := crowdsec.Alerts(c.Request.Context(), 200)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"alerts": list})
|
||||
}
|
||||
|
||||
// DeleteAlert discards a single alert.
|
||||
func (h *CrowdSecHandler) DeleteAlert(c *gin.Context) {
|
||||
if !crowdsec.IsInstalled() {
|
||||
csNotInstalled(c)
|
||||
return
|
||||
}
|
||||
id := c.Param("id")
|
||||
if err := crowdsec.DeleteAlert(c.Request.Context(), id); err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"deleted": id})
|
||||
}
|
||||
|
||||
// ---------- Bouncers --------------------------------------------------------
|
||||
|
||||
// ListBouncers returns all registered bouncers.
|
||||
func (h *CrowdSecHandler) ListBouncers(c *gin.Context) {
|
||||
if !crowdsec.IsInstalled() {
|
||||
csNotInstalled(c)
|
||||
return
|
||||
}
|
||||
list, err := crowdsec.Bouncers(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"bouncers": list})
|
||||
}
|
||||
|
||||
// DeleteBouncer removes a bouncer by name.
|
||||
func (h *CrowdSecHandler) DeleteBouncer(c *gin.Context) {
|
||||
if !crowdsec.IsInstalled() {
|
||||
csNotInstalled(c)
|
||||
return
|
||||
}
|
||||
name := c.Param("name")
|
||||
if err := crowdsec.DeleteBouncer(c.Request.Context(), name); err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "crowdsec.bouncer.delete", name, nil, h.NodeID)
|
||||
response.OK(c, gin.H{"deleted": name})
|
||||
}
|
||||
|
||||
// ---------- Machines --------------------------------------------------------
|
||||
|
||||
// ListMachines returns all registered machines.
|
||||
func (h *CrowdSecHandler) ListMachines(c *gin.Context) {
|
||||
if !crowdsec.IsInstalled() {
|
||||
csNotInstalled(c)
|
||||
return
|
||||
}
|
||||
list, err := crowdsec.Machines(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"machines": list})
|
||||
}
|
||||
|
||||
// DeleteMachine removes a machine by ID.
|
||||
func (h *CrowdSecHandler) DeleteMachine(c *gin.Context) {
|
||||
if !crowdsec.IsInstalled() {
|
||||
csNotInstalled(c)
|
||||
return
|
||||
}
|
||||
id := c.Param("id")
|
||||
if err := crowdsec.DeleteMachine(c.Request.Context(), id); err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "crowdsec.machine.delete", id, nil, h.NodeID)
|
||||
response.OK(c, gin.H{"deleted": id})
|
||||
}
|
||||
|
||||
// ---------- Collections -----------------------------------------------------
|
||||
|
||||
// ListCollections returns all hub collections and their install status.
|
||||
func (h *CrowdSecHandler) ListCollections(c *gin.Context) {
|
||||
if !crowdsec.IsInstalled() {
|
||||
csNotInstalled(c)
|
||||
return
|
||||
}
|
||||
list, err := crowdsec.Collections(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"collections": list})
|
||||
}
|
||||
|
||||
// InstallCollection installs a hub collection by name.
|
||||
func (h *CrowdSecHandler) InstallCollection(c *gin.Context) {
|
||||
if !crowdsec.IsInstalled() {
|
||||
csNotInstalled(c)
|
||||
return
|
||||
}
|
||||
name := c.Param("name")
|
||||
if err := crowdsec.InstallCollection(c.Request.Context(), name); err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.Created(c, gin.H{"installed": name})
|
||||
}
|
||||
|
||||
// RemoveCollection removes a hub collection by name.
|
||||
func (h *CrowdSecHandler) RemoveCollection(c *gin.Context) {
|
||||
if !crowdsec.IsInstalled() {
|
||||
csNotInstalled(c)
|
||||
return
|
||||
}
|
||||
name := c.Param("name")
|
||||
if err := crowdsec.RemoveCollection(c.Request.Context(), name); err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"removed": name})
|
||||
}
|
||||
346
internal/handlers/dhcp.go
Normal file
346
internal/handlers/dhcp.go
Normal file
@@ -0,0 +1,346 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
|
||||
dhcpsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/dhcp"
|
||||
)
|
||||
|
||||
// DHCPHandler exposes /api/v1/dhcp/{settings,subnets,reservations} for
|
||||
// the Kea DHCPv4 server.
|
||||
type DHCPHandler struct {
|
||||
Repo *dhcpsvc.Repo
|
||||
Audit *audit.Repo
|
||||
NodeID string
|
||||
Reloader func(ctx context.Context) error
|
||||
}
|
||||
|
||||
func NewDHCPHandler(repo *dhcpsvc.Repo, a *audit.Repo, nodeID string, reloader func(context.Context) error) *DHCPHandler {
|
||||
return &DHCPHandler{Repo: repo, Audit: a, NodeID: nodeID, Reloader: reloader}
|
||||
}
|
||||
|
||||
func (h *DHCPHandler) reload(ctx context.Context, op string) {
|
||||
if h.Reloader == nil {
|
||||
return
|
||||
}
|
||||
if err := h.Reloader(ctx); err != nil {
|
||||
slog.Warn("kea: reload after mutation failed", "op", op, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *DHCPHandler) Register(rg *gin.RouterGroup) {
|
||||
g := rg.Group("/dhcp")
|
||||
g.GET("/settings", h.GetSettings)
|
||||
g.PUT("/settings", h.UpdateSettings)
|
||||
|
||||
s := g.Group("/subnets")
|
||||
s.GET("", h.ListSubnets)
|
||||
s.POST("", h.CreateSubnet)
|
||||
s.GET("/:id", h.GetSubnet)
|
||||
s.PUT("/:id", h.UpdateSubnet)
|
||||
s.DELETE("/:id", h.DeleteSubnet)
|
||||
s.GET("/:id/reservations", h.ListReservationsForSubnet)
|
||||
s.POST("/:id/reservations", h.CreateReservation)
|
||||
|
||||
r := g.Group("/reservations")
|
||||
r.GET("", h.ListAllReservations)
|
||||
r.GET("/:id", h.GetReservation)
|
||||
r.PUT("/:id", h.UpdateReservation)
|
||||
r.DELETE("/:id", h.DeleteReservation)
|
||||
}
|
||||
|
||||
// ── Settings ─────────────────────────────────────────────────────────
|
||||
|
||||
func (h *DHCPHandler) GetSettings(c *gin.Context) {
|
||||
s, err := h.Repo.GetSettings(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, s)
|
||||
}
|
||||
|
||||
func (h *DHCPHandler) UpdateSettings(c *gin.Context) {
|
||||
var req models.DHCPSettings
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if req.DefaultLease <= 0 {
|
||||
req.DefaultLease = 3600
|
||||
}
|
||||
if req.MaxLease < req.DefaultLease {
|
||||
req.MaxLease = req.DefaultLease
|
||||
}
|
||||
if err := validateIPList(req.DNSServers); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
out, err := h.Repo.UpdateSettings(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dhcp.settings.update", "",
|
||||
gin.H{"enabled": out.Enabled}, h.NodeID)
|
||||
response.OK(c, out)
|
||||
h.reload(c.Request.Context(), "settings.update")
|
||||
}
|
||||
|
||||
// ── Subnets ──────────────────────────────────────────────────────────
|
||||
|
||||
func (h *DHCPHandler) ListSubnets(c *gin.Context) {
|
||||
out, err := h.Repo.ListSubnets(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"subnets": out})
|
||||
}
|
||||
|
||||
func (h *DHCPHandler) GetSubnet(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
s, err := h.Repo.GetSubnet(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
h.subnetErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, s)
|
||||
}
|
||||
|
||||
func (h *DHCPHandler) CreateSubnet(c *gin.Context) {
|
||||
var req models.DHCPSubnet
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if err := h.validateSubnet(c, &req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
out, err := h.Repo.CreateSubnet(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dhcp.subnet.create", out.Name, out, h.NodeID)
|
||||
response.Created(c, out)
|
||||
h.reload(c.Request.Context(), "subnet.create")
|
||||
}
|
||||
|
||||
func (h *DHCPHandler) UpdateSubnet(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req models.DHCPSubnet
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if err := h.validateSubnet(c, &req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
out, err := h.Repo.UpdateSubnet(c.Request.Context(), id, req)
|
||||
if err != nil {
|
||||
h.subnetErr(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dhcp.subnet.update", out.Name, out, h.NodeID)
|
||||
response.OK(c, out)
|
||||
h.reload(c.Request.Context(), "subnet.update")
|
||||
}
|
||||
|
||||
func (h *DHCPHandler) DeleteSubnet(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.Repo.DeleteSubnet(c.Request.Context(), id); err != nil {
|
||||
h.subnetErr(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dhcp.subnet.delete", "", gin.H{"id": id}, h.NodeID)
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
h.reload(c.Request.Context(), "subnet.delete")
|
||||
}
|
||||
|
||||
// ── Reservations ─────────────────────────────────────────────────────
|
||||
|
||||
func (h *DHCPHandler) ListAllReservations(c *gin.Context) {
|
||||
out, err := h.Repo.ListAllReservations(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"reservations": out})
|
||||
}
|
||||
|
||||
func (h *DHCPHandler) ListReservationsForSubnet(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
out, err := h.Repo.ListReservationsForSubnet(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"reservations": out})
|
||||
}
|
||||
|
||||
func (h *DHCPHandler) GetReservation(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
v, err := h.Repo.GetReservation(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
h.resvErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, v)
|
||||
}
|
||||
|
||||
func (h *DHCPHandler) CreateReservation(c *gin.Context) {
|
||||
subnetID, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req models.DHCPReservation
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
req.SubnetID = subnetID
|
||||
if err := validateReservation(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
out, err := h.Repo.CreateReservation(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dhcp.reservation.create", out.MACAddress, out, h.NodeID)
|
||||
response.Created(c, out)
|
||||
h.reload(c.Request.Context(), "reservation.create")
|
||||
}
|
||||
|
||||
func (h *DHCPHandler) UpdateReservation(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req models.DHCPReservation
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if err := validateReservation(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
out, err := h.Repo.UpdateReservation(c.Request.Context(), id, req)
|
||||
if err != nil {
|
||||
h.resvErr(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dhcp.reservation.update", out.MACAddress, out, h.NodeID)
|
||||
response.OK(c, out)
|
||||
h.reload(c.Request.Context(), "reservation.update")
|
||||
}
|
||||
|
||||
func (h *DHCPHandler) DeleteReservation(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.Repo.DeleteReservation(c.Request.Context(), id); err != nil {
|
||||
h.resvErr(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dhcp.reservation.delete", "", gin.H{"id": id}, h.NodeID)
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
h.reload(c.Request.Context(), "reservation.delete")
|
||||
}
|
||||
|
||||
// ── Validation + error mapping ───────────────────────────────────────
|
||||
|
||||
func (h *DHCPHandler) validateSubnet(c *gin.Context, s *models.DHCPSubnet) error {
|
||||
s.Name = strings.TrimSpace(s.Name)
|
||||
s.InterfaceName = strings.TrimSpace(s.InterfaceName)
|
||||
if s.Name == "" {
|
||||
return errors.New("name ist erforderlich")
|
||||
}
|
||||
if s.InterfaceName == "" {
|
||||
return errors.New("interface_name ist erforderlich")
|
||||
}
|
||||
if exists, err := h.Repo.InterfaceExists(c.Request.Context(), s.InterfaceName); err == nil && !exists {
|
||||
return errors.New("interface_name existiert nicht: " + s.InterfaceName)
|
||||
}
|
||||
if _, _, err := net.ParseCIDR(s.SubnetCIDR); err != nil {
|
||||
return errors.New("subnet_cidr ist kein gültiges CIDR: " + s.SubnetCIDR)
|
||||
}
|
||||
if (s.PoolStart == "") != (s.PoolEnd == "") {
|
||||
return errors.New("pool_start und pool_end müssen beide gesetzt sein (oder beide leer)")
|
||||
}
|
||||
for _, ip := range []string{s.PoolStart, s.PoolEnd, s.Gateway} {
|
||||
if ip != "" && net.ParseIP(ip) == nil {
|
||||
return errors.New("ungültige IP-Adresse: " + ip)
|
||||
}
|
||||
}
|
||||
return validateIPList(s.DNSServers)
|
||||
}
|
||||
|
||||
func validateReservation(r *models.DHCPReservation) error {
|
||||
r.MACAddress = strings.TrimSpace(strings.ToLower(r.MACAddress))
|
||||
r.IPAddress = strings.TrimSpace(r.IPAddress)
|
||||
if _, err := net.ParseMAC(r.MACAddress); err != nil {
|
||||
return errors.New("mac_address ist ungültig: " + r.MACAddress)
|
||||
}
|
||||
if net.ParseIP(r.IPAddress) == nil {
|
||||
return errors.New("ip_address ist ungültig: " + r.IPAddress)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateIPList prüft eine optionale Komma-Liste von IPs.
|
||||
func validateIPList(csv string) error {
|
||||
for _, p := range strings.Split(csv, ",") {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" && net.ParseIP(p) == nil {
|
||||
return errors.New("ungültige IP in dns_servers: " + p)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *DHCPHandler) subnetErr(c *gin.Context, err error) {
|
||||
if errors.Is(err, dhcpsvc.ErrSubnetNotFound) {
|
||||
response.NotFound(c, err)
|
||||
return
|
||||
}
|
||||
response.Internal(c, err)
|
||||
}
|
||||
|
||||
func (h *DHCPHandler) resvErr(c *gin.Context, err error) {
|
||||
if errors.Is(err, dhcpsvc.ErrReservationNotFound) {
|
||||
response.NotFound(c, err)
|
||||
return
|
||||
}
|
||||
response.Internal(c, err)
|
||||
}
|
||||
@@ -3,7 +3,12 @@ package handlers
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -54,6 +59,8 @@ func (h *DNSHandler) Register(rg *gin.RouterGroup) {
|
||||
|
||||
g.GET("/settings", h.GetSettings)
|
||||
g.PUT("/settings", h.UpdateSettings)
|
||||
g.GET("/stats", h.Stats)
|
||||
g.POST("/flush-cache", h.FlushCache)
|
||||
}
|
||||
|
||||
// ── Zones ──────────────────────────────────────────────────────
|
||||
@@ -145,6 +152,8 @@ func (h *DNSHandler) DeleteZone(c *gin.Context) {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dns.zone.delete",
|
||||
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
|
||||
response.NoContent(c)
|
||||
h.reload(c.Request.Context(), "zone.delete")
|
||||
}
|
||||
@@ -256,6 +265,8 @@ func (h *DNSHandler) DeleteRecord(c *gin.Context) {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dns.record.delete",
|
||||
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
|
||||
response.NoContent(c)
|
||||
h.reload(c.Request.Context(), "record.delete")
|
||||
}
|
||||
@@ -277,6 +288,10 @@ func (h *DNSHandler) UpdateSettings(c *gin.Context) {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if err := validateSettings(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
out, err := h.Repo.UpdateSettings(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
@@ -287,8 +302,68 @@ func (h *DNSHandler) UpdateSettings(c *gin.Context) {
|
||||
h.reload(c.Request.Context(), "settings.update")
|
||||
}
|
||||
|
||||
// FlushCache runs `unbound-control flush_zone .` which discards all
|
||||
// cached RRs from the resolver. Useful after DNS propagation or when
|
||||
// stale records need to be evicted immediately.
|
||||
func (h *DNSHandler) FlushCache(c *gin.Context) {
|
||||
out, err := exec.CommandContext(c.Request.Context(), "/usr/sbin/unbound-control", "flush_zone", ".").CombinedOutput()
|
||||
if err != nil {
|
||||
slog.Error("dns flush-cache failed", "err", err, "out", string(out))
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dns.flush-cache", "unbound", nil, h.NodeID)
|
||||
response.OK(c, gin.H{"message": "cache flushed", "output": string(out)})
|
||||
}
|
||||
|
||||
// ── Validation ─────────────────────────────────────────────────
|
||||
|
||||
// validateSettings checks user-supplied DNS global settings before they
|
||||
// reach unbound. A malformed upstream IP or CIDR would cause unbound to
|
||||
// fail on the next reload without any visible error.
|
||||
func validateSettings(s *models.DNSSettings) error {
|
||||
if s.ListenPort < 1 || s.ListenPort > 65535 {
|
||||
return fmt.Errorf("listen_port %d out of range (1-65535)", s.ListenPort)
|
||||
}
|
||||
if s.CacheMaxTTL < s.CacheMinTTL {
|
||||
return fmt.Errorf("cache_max_ttl (%d) must be ≥ cache_min_ttl (%d)", s.CacheMaxTTL, s.CacheMinTTL)
|
||||
}
|
||||
for _, raw := range strings.Split(s.UpstreamForwards, ",") {
|
||||
entry := strings.TrimSpace(raw)
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
// strip optional @port suffix (e.g. 1.1.1.1@853)
|
||||
host, _, _ := strings.Cut(entry, "@")
|
||||
if net.ParseIP(host) == nil {
|
||||
return fmt.Errorf("invalid upstream forwarder IP: %q", host)
|
||||
}
|
||||
}
|
||||
for _, raw := range strings.Split(s.AccessACL, ",") {
|
||||
entry := strings.TrimSpace(raw)
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(entry, "/") {
|
||||
if _, _, err := net.ParseCIDR(entry); err != nil {
|
||||
return fmt.Errorf("invalid access ACL CIDR: %q", entry)
|
||||
}
|
||||
} else if net.ParseIP(entry) == nil {
|
||||
return fmt.Errorf("invalid access ACL IP: %q", entry)
|
||||
}
|
||||
}
|
||||
for _, raw := range strings.Split(s.ListenAddresses, ",") {
|
||||
addr := strings.TrimSpace(raw)
|
||||
if addr == "" {
|
||||
continue
|
||||
}
|
||||
if net.ParseIP(addr) == nil {
|
||||
return fmt.Errorf("invalid listen address: %q", addr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateZone(z *models.DNSZone) error {
|
||||
if z.Name == "" {
|
||||
return errors.New("name required")
|
||||
@@ -309,6 +384,71 @@ func validateZone(z *models.DNSZone) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stats liefert Unbound-Resolver-Statistiken via `unbound-control stats_noreset`.
|
||||
// stats_noreset liest die Zähler ohne sie zurückzusetzen — safe für
|
||||
// wiederholte Aufrufe aus dem UI.
|
||||
func (h *DNSHandler) Stats(c *gin.Context) {
|
||||
out, err := exec.Command("/usr/sbin/unbound-control", "stats_noreset").Output()
|
||||
if err != nil {
|
||||
response.OK(c, gin.H{
|
||||
"error": "unbound-control nicht verfügbar: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"stats": parseUnboundStats(string(out))})
|
||||
}
|
||||
|
||||
type unboundStats struct {
|
||||
TotalQueries int64 `json:"total_queries"`
|
||||
CacheHits int64 `json:"cache_hits"`
|
||||
CacheMiss int64 `json:"cache_miss"`
|
||||
CacheHitPct float64 `json:"cache_hit_pct"`
|
||||
RecursiveReplies int64 `json:"recursive_replies"`
|
||||
Prefetch int64 `json:"prefetch"`
|
||||
RateLimited int64 `json:"rate_limited"`
|
||||
RRSetCacheBytes int64 `json:"rrset_cache_bytes"`
|
||||
MsgCacheBytes int64 `json:"msg_cache_bytes"`
|
||||
TCPUsage int64 `json:"tcp_usage"`
|
||||
Unwanted int64 `json:"unwanted"`
|
||||
}
|
||||
|
||||
func parseUnboundStats(out string) unboundStats {
|
||||
s := unboundStats{}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
k, v, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
n, _ := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
|
||||
switch strings.TrimSpace(k) {
|
||||
case "total.num.queries":
|
||||
s.TotalQueries = n
|
||||
case "total.num.cachehits":
|
||||
s.CacheHits = n
|
||||
case "total.num.cachemiss":
|
||||
s.CacheMiss = n
|
||||
case "total.num.recursivereplies":
|
||||
s.RecursiveReplies = n
|
||||
case "total.num.prefetch":
|
||||
s.Prefetch = n
|
||||
case "total.num.queries_ip_ratelimited":
|
||||
s.RateLimited = n
|
||||
case "mem.cache.rrset":
|
||||
s.RRSetCacheBytes = n
|
||||
case "mem.cache.message":
|
||||
s.MsgCacheBytes = n
|
||||
case "total.tcpusage":
|
||||
s.TCPUsage = n
|
||||
case "unwanted.queries":
|
||||
s.Unwanted = n
|
||||
}
|
||||
}
|
||||
if s.TotalQueries > 0 {
|
||||
s.CacheHitPct = float64(s.CacheHits) / float64(s.TotalQueries) * 100
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func validateRecord(r *models.DNSRecord) error {
|
||||
if r.Name == "" {
|
||||
return errors.New("name required")
|
||||
|
||||
@@ -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
|
||||
@@ -138,6 +138,7 @@ func (h *FirewallHandler) Register(rg *gin.RouterGroup) {
|
||||
rl.POST("", h.CreateRule)
|
||||
rl.GET("/:id", h.GetRule)
|
||||
rl.PUT("/:id", h.UpdateRule)
|
||||
rl.PATCH("/:id", h.PatchRule)
|
||||
rl.DELETE("/:id", h.DeleteRule)
|
||||
|
||||
nat := g.Group("/nat-rules")
|
||||
@@ -145,6 +146,7 @@ func (h *FirewallHandler) Register(rg *gin.RouterGroup) {
|
||||
nat.POST("", h.CreateNAT)
|
||||
nat.GET("/:id", h.GetNAT)
|
||||
nat.PUT("/:id", h.UpdateNAT)
|
||||
nat.PATCH("/:id", h.PatchNAT)
|
||||
nat.DELETE("/:id", h.DeleteNAT)
|
||||
}
|
||||
|
||||
@@ -269,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++ {
|
||||
@@ -350,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) {
|
||||
@@ -377,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) {
|
||||
@@ -395,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 ─────────────────────────────────────────────────────
|
||||
@@ -438,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) {
|
||||
@@ -461,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) {
|
||||
@@ -479,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 ───────────────────────────────────────────────────────────
|
||||
@@ -522,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) {
|
||||
@@ -545,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) {
|
||||
@@ -563,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 ─────────────────────────────────────────────────────
|
||||
@@ -606,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) {
|
||||
@@ -629,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) {
|
||||
@@ -647,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 ──────────────────────────────────────────────────────────────
|
||||
@@ -702,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) {
|
||||
@@ -737,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) {
|
||||
@@ -755,7 +771,50 @@ 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) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Note *string `json:"note"`
|
||||
Labels []string `json:"labels"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
if body.Note != nil {
|
||||
if err := h.Rules.PatchNote(ctx, id, *body.Note); err != nil {
|
||||
if errors.Is(err, firewall.ErrRuleNotFound) {
|
||||
response.NotFound(c, err)
|
||||
return
|
||||
}
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if body.Labels != nil {
|
||||
if err := h.Rules.PatchLabels(ctx, id, body.Labels); err != nil {
|
||||
if errors.Is(err, firewall.ErrRuleNotFound) {
|
||||
response.NotFound(c, err)
|
||||
return
|
||||
}
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
out, err := h.Rules.Get(ctx, id)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, out)
|
||||
}
|
||||
|
||||
// ── NAT Rules ──────────────────────────────────────────────────────────
|
||||
@@ -806,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) {
|
||||
@@ -837,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) {
|
||||
@@ -855,7 +916,50 @@ 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) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Note *string `json:"note"`
|
||||
Labels []string `json:"labels"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
if body.Note != nil {
|
||||
if err := h.NATRules.PatchNote(ctx, id, *body.Note); err != nil {
|
||||
if errors.Is(err, firewall.ErrNATRuleNotFound) {
|
||||
response.NotFound(c, err)
|
||||
return
|
||||
}
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if body.Labels != nil {
|
||||
if err := h.NATRules.PatchLabels(ctx, id, body.Labels); err != nil {
|
||||
if errors.Is(err, firewall.ErrNATRuleNotFound) {
|
||||
response.NotFound(c, err)
|
||||
return
|
||||
}
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
out, err := h.NATRules.Get(ctx, id)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, out)
|
||||
}
|
||||
|
||||
// ── Validators ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -35,7 +37,12 @@ func (h *ForwardProxyHandler) reload(ctx context.Context, op string) {
|
||||
}
|
||||
|
||||
func (h *ForwardProxyHandler) Register(rg *gin.RouterGroup) {
|
||||
g := rg.Group("/forward-proxy/acls")
|
||||
base := rg.Group("/forward-proxy")
|
||||
base.GET("/stats", h.Stats)
|
||||
base.GET("/settings", h.GetSettings)
|
||||
base.PUT("/settings", h.UpdateSettings)
|
||||
|
||||
g := base.Group("/acls")
|
||||
g.GET("", h.List)
|
||||
g.POST("", h.Create)
|
||||
g.GET("/:id", h.Get)
|
||||
@@ -43,6 +50,34 @@ func (h *ForwardProxyHandler) Register(rg *gin.RouterGroup) {
|
||||
g.DELETE("/:id", h.Delete)
|
||||
}
|
||||
|
||||
func (h *ForwardProxyHandler) GetSettings(c *gin.Context) {
|
||||
s, err := h.Repo.GetSettings(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, s)
|
||||
}
|
||||
|
||||
func (h *ForwardProxyHandler) UpdateSettings(c *gin.Context) {
|
||||
var req models.ForwardProxySettings
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if req.ListenPort <= 0 || req.ListenPort > 65535 {
|
||||
req.ListenPort = 3128
|
||||
}
|
||||
out, err := h.Repo.UpdateSettings(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "forward_proxy.settings.update", "settings", out, h.NodeID)
|
||||
response.OK(c, out)
|
||||
h.reload(c.Request.Context(), "settings.update")
|
||||
}
|
||||
|
||||
func (h *ForwardProxyHandler) List(c *gin.Context) {
|
||||
out, err := h.Repo.List(c.Request.Context())
|
||||
if err != nil {
|
||||
@@ -135,6 +170,68 @@ func (h *ForwardProxyHandler) Delete(c *gin.Context) {
|
||||
h.reload(c.Request.Context(), "delete")
|
||||
}
|
||||
|
||||
// Stats liefert Squid-Cache-Statistiken via `squidclient mgr:counters`.
|
||||
// Die Ausgabe enthält HTTP-Header gefolgt von key = value Zeilen.
|
||||
func (h *ForwardProxyHandler) Stats(c *gin.Context) {
|
||||
out, err := exec.Command("squidclient", "-h", "127.0.0.1", "-p", "3128", "mgr:counters").Output()
|
||||
if err != nil {
|
||||
response.OK(c, gin.H{
|
||||
"error": "squidclient nicht verfügbar: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"stats": parseSquidCounters(string(out))})
|
||||
}
|
||||
|
||||
type squidStats struct {
|
||||
ClientRequests int64 `json:"client_requests"`
|
||||
CacheHits int64 `json:"cache_hits"`
|
||||
CacheHitPct float64 `json:"cache_hit_pct"`
|
||||
ClientErrors int64 `json:"client_errors"`
|
||||
BytesIn int64 `json:"bytes_in"`
|
||||
BytesOut int64 `json:"bytes_out"`
|
||||
ServerRequests int64 `json:"server_requests"`
|
||||
ServerErrors int64 `json:"server_errors"`
|
||||
}
|
||||
|
||||
func parseSquidCounters(out string) squidStats {
|
||||
// squidclient prefixes an HTTP response header block — skip it.
|
||||
body := out
|
||||
if idx := strings.Index(out, "\r\n\r\n"); idx >= 0 {
|
||||
body = out[idx+4:]
|
||||
} else if idx := strings.Index(out, "\n\n"); idx >= 0 {
|
||||
body = out[idx+2:]
|
||||
}
|
||||
s := squidStats{}
|
||||
for _, line := range strings.Split(body, "\n") {
|
||||
k, v, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
n, _ := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
|
||||
switch strings.TrimSpace(k) {
|
||||
case "client_http.requests":
|
||||
s.ClientRequests = n
|
||||
case "client_http.hits":
|
||||
s.CacheHits = n
|
||||
case "client_http.errors":
|
||||
s.ClientErrors = n
|
||||
case "client_http.kbytes_in":
|
||||
s.BytesIn = n * 1024
|
||||
case "client_http.kbytes_out":
|
||||
s.BytesOut = n * 1024
|
||||
case "server.all.requests":
|
||||
s.ServerRequests = n
|
||||
case "server.all.errors":
|
||||
s.ServerErrors = n
|
||||
}
|
||||
}
|
||||
if s.ClientRequests > 0 {
|
||||
s.CacheHitPct = float64(s.CacheHits) / float64(s.ClientRequests) * 100
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// validateACL prüft Name (squid-konform), action, acl_type. Squid
|
||||
// nimmt viele Typen — wir whitelisten die, die in einem Forward-
|
||||
// Proxy-Setup üblich sind, damit Tippfehler nicht beim reload
|
||||
|
||||
@@ -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()})
|
||||
@@ -98,8 +99,10 @@ func (h *HAProxyStatsHandler) Stats(c *gin.Context) {
|
||||
svname := safeAt(fields, colIdx["svname"])
|
||||
pxname := safeAt(fields, colIdx["pxname"])
|
||||
|
||||
// Skip our internal stats listener and the BACKEND summary row.
|
||||
if pxname == "internal_stats" || svname == "BACKEND" || svname == "" {
|
||||
// Skip internal infrastructure rows and the BACKEND summary row.
|
||||
// api_backend = management API; rl_* = rate-limit stick-tables (no servers).
|
||||
if pxname == "internal_stats" || pxname == "api_backend" ||
|
||||
strings.HasPrefix(pxname, "rl_") || svname == "BACKEND" || svname == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -135,7 +138,7 @@ func (h *HAProxyStatsHandler) Stats(c *gin.Context) {
|
||||
}
|
||||
|
||||
func safeAt(fields []string, i int) string {
|
||||
if i <= 0 || i >= len(fields) {
|
||||
if i < 0 || i >= len(fields) {
|
||||
return ""
|
||||
}
|
||||
return fields[i]
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -13,13 +15,27 @@ import (
|
||||
)
|
||||
|
||||
type IPAddressesHandler struct {
|
||||
Repo *ipaddresses.Repo
|
||||
Audit *audit.Repo
|
||||
NodeID string
|
||||
Repo *ipaddresses.Repo
|
||||
Generator *ipaddresses.Generator
|
||||
Audit *audit.Repo
|
||||
NodeID string
|
||||
}
|
||||
|
||||
func NewIPAddressesHandler(repo *ipaddresses.Repo, a *audit.Repo, nodeID string) *IPAddressesHandler {
|
||||
return &IPAddressesHandler{Repo: repo, Audit: a, NodeID: nodeID}
|
||||
return &IPAddressesHandler{
|
||||
Repo: repo,
|
||||
Generator: ipaddresses.NewGenerator(repo),
|
||||
Audit: a,
|
||||
NodeID: nodeID,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *IPAddressesHandler) applyAsync() {
|
||||
go func() {
|
||||
if err := h.Generator.Render(context.Background()); err != nil {
|
||||
slog.Warn("ip-addresses: apply failed", "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (h *IPAddressesHandler) Register(rg *gin.RouterGroup) {
|
||||
@@ -70,6 +86,7 @@ func (h *IPAddressesHandler) Create(c *gin.Context) {
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "ip_address.create",
|
||||
req.Address, out, h.NodeID)
|
||||
h.applyAsync()
|
||||
response.Created(c, out)
|
||||
}
|
||||
|
||||
@@ -94,6 +111,7 @@ func (h *IPAddressesHandler) Update(c *gin.Context) {
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "ip_address.update",
|
||||
out.Address, out, h.NodeID)
|
||||
h.applyAsync()
|
||||
response.OK(c, out)
|
||||
}
|
||||
|
||||
@@ -112,5 +130,6 @@ func (h *IPAddressesHandler) Delete(c *gin.Context) {
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "ip_address.delete",
|
||||
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
|
||||
h.applyAsync()
|
||||
response.NoContent(c)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -15,18 +17,34 @@ import (
|
||||
)
|
||||
|
||||
type NetworksHandler struct {
|
||||
Repo *networkifs.Repo
|
||||
IPs *ipaddresses.Repo
|
||||
Zones *firewall.ZonesRepo
|
||||
Audit *audit.Repo
|
||||
NodeID string
|
||||
Repo *networkifs.Repo
|
||||
Generator *networkifs.Generator
|
||||
IPs *ipaddresses.Repo
|
||||
Zones *firewall.ZonesRepo
|
||||
Audit *audit.Repo
|
||||
NodeID string
|
||||
}
|
||||
|
||||
func NewNetworksHandler(
|
||||
repo *networkifs.Repo, ips *ipaddresses.Repo,
|
||||
zones *firewall.ZonesRepo, a *audit.Repo, nodeID string,
|
||||
) *NetworksHandler {
|
||||
return &NetworksHandler{Repo: repo, IPs: ips, Zones: zones, Audit: a, NodeID: nodeID}
|
||||
return &NetworksHandler{
|
||||
Repo: repo,
|
||||
Generator: networkifs.NewGenerator(repo),
|
||||
IPs: ips,
|
||||
Zones: zones,
|
||||
Audit: a,
|
||||
NodeID: nodeID,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *NetworksHandler) applyAsync() {
|
||||
go func() {
|
||||
if err := h.Generator.Render(context.Background()); err != nil {
|
||||
slog.Warn("network-interfaces: apply failed", "error", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (h *NetworksHandler) Register(rg *gin.RouterGroup) {
|
||||
@@ -88,6 +106,7 @@ func (h *NetworksHandler) Create(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "network_interface.create", req.Name, out, h.NodeID)
|
||||
h.applyAsync()
|
||||
response.Created(c, out)
|
||||
}
|
||||
|
||||
@@ -122,6 +141,7 @@ func (h *NetworksHandler) Update(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "network_interface.update", out.Name, out, h.NodeID)
|
||||
h.applyAsync()
|
||||
response.OK(c, out)
|
||||
}
|
||||
|
||||
@@ -140,6 +160,7 @@ func (h *NetworksHandler) Delete(c *gin.Context) {
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "network_interface.delete",
|
||||
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
|
||||
h.applyAsync()
|
||||
response.NoContent(c)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -41,6 +42,8 @@ func (h *NTPHandler) Register(rg *gin.RouterGroup) {
|
||||
g.GET("/settings", h.GetSettings)
|
||||
g.PUT("/settings", h.UpdateSettings)
|
||||
g.GET("/status", h.Status)
|
||||
g.GET("/sources", h.Sources)
|
||||
g.POST("/force-sync", h.ForceSync)
|
||||
|
||||
p := g.Group("/pools")
|
||||
p.GET("", h.ListPools)
|
||||
@@ -97,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
|
||||
}
|
||||
}
|
||||
@@ -230,10 +233,95 @@ func (h *NTPHandler) DeletePool(c *gin.Context) {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "ntp.pool.delete",
|
||||
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
|
||||
response.NoContent(c)
|
||||
h.reload(c.Request.Context(), "pool.delete")
|
||||
}
|
||||
|
||||
// ForceSync runs `chronyc makestep` which immediately adjusts the
|
||||
// system clock to the current NTP reference. Useful after a long
|
||||
// outage or VM migration where the clock has drifted by more than
|
||||
// the 1ms default slew threshold.
|
||||
func (h *NTPHandler) ForceSync(c *gin.Context) {
|
||||
out, err := exec.Command("chronyc", "makestep").CombinedOutput()
|
||||
if err != nil {
|
||||
slog.Error("ntp force-sync failed", "err", err, "out", string(out))
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "ntp.force-sync", "chrony", nil, h.NodeID)
|
||||
response.OK(c, gin.H{"message": "clock stepped", "output": string(out)})
|
||||
}
|
||||
|
||||
// Sources liefert die aktuellen NTP-Quellen via `chronyc sources`.
|
||||
// Jede Zeile wird in ein NTPSource-Objekt geparst und als Array zurückgegeben.
|
||||
func (h *NTPHandler) Sources(c *gin.Context) {
|
||||
out, err := exec.Command("chronyc", "sources").Output()
|
||||
if err != nil {
|
||||
response.OK(c, gin.H{
|
||||
"sources": []any{},
|
||||
"error": "chronyc nicht verfügbar: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"sources": parseChronymSources(string(out))})
|
||||
}
|
||||
|
||||
type ntpSource struct {
|
||||
Mode string `json:"mode"`
|
||||
State string `json:"state"`
|
||||
Active bool `json:"active"`
|
||||
Name string `json:"name"`
|
||||
Stratum int `json:"stratum"`
|
||||
Poll int `json:"poll"`
|
||||
Reach string `json:"reach"`
|
||||
LastRx string `json:"last_rx"`
|
||||
Sample string `json:"sample"`
|
||||
}
|
||||
|
||||
func parseChronymSources(out string) []ntpSource {
|
||||
modeMap := map[byte]string{'^': "server", '=': "peer", '#': "local"}
|
||||
stateMap := map[byte]string{
|
||||
'*': "synced", '+': "combined", '-': "not_combined",
|
||||
'?': "unreachable", 'x': "error", '~': "variable",
|
||||
}
|
||||
var srcs []ntpSource
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if len(line) < 2 {
|
||||
continue
|
||||
}
|
||||
mode, ok := modeMap[line[0]]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
stateChar := line[1]
|
||||
stateStr, ok := stateMap[stateChar]
|
||||
if !ok {
|
||||
stateStr = string(stateChar)
|
||||
}
|
||||
fields := strings.Fields(strings.TrimSpace(line[2:]))
|
||||
if len(fields) < 5 {
|
||||
continue
|
||||
}
|
||||
src := ntpSource{
|
||||
Mode: mode,
|
||||
State: stateStr,
|
||||
Active: stateChar == '*' || stateChar == '+',
|
||||
Name: fields[0],
|
||||
Reach: fields[3],
|
||||
LastRx: fields[4],
|
||||
}
|
||||
_, _ = fmt.Sscanf(fields[1], "%d", &src.Stratum)
|
||||
_, _ = fmt.Sscanf(fields[2], "%d", &src.Poll)
|
||||
if len(fields) >= 6 {
|
||||
src.Sample = strings.Join(fields[5:], " ")
|
||||
}
|
||||
srcs = append(srcs, src)
|
||||
}
|
||||
return srcs
|
||||
}
|
||||
|
||||
func validateNTPPool(p *models.NTPPool) error {
|
||||
if p.Address == "" {
|
||||
return errors.New("address required")
|
||||
|
||||
349
internal/handlers/oidc.go
Normal file
349
internal/handlers/oidc.go
Normal file
@@ -0,0 +1,349 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
|
||||
oidcsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/oidc"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/session"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
||||
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
|
||||
)
|
||||
|
||||
// OIDC / Keycloak SSO. Additiv zum lokalen Passwort-Login. Regeln:
|
||||
// - kein Auto-Provisioning (E-Mail muss als User existieren),
|
||||
// - Rolle kommt aus der DB-Row (nie aus dem Token),
|
||||
// - lokaler Login + TOTP bleiben unangetastet.
|
||||
//
|
||||
// Flow-State (state/PKCE-verifier/nonce) liegt stateless in einem 5-min
|
||||
// signierten HttpOnly-Cookie (SameSite=Lax, da der IdP-Redirect ein
|
||||
// top-level cross-site GET ist). Nach Erfolg wird dieselbe Session wie
|
||||
// beim lokalen Login ausgestellt (setSessionCookie + Signer).
|
||||
|
||||
const (
|
||||
oidcFlowCookie = "edgeguard_oidc_flow"
|
||||
oidcFlowTTL = 5 * time.Minute
|
||||
)
|
||||
|
||||
type OIDCHandler struct {
|
||||
Repo *oidcsvc.Repo
|
||||
Auth oidcsvc.Authenticator
|
||||
Users *usersvc.Repo
|
||||
Signer *session.Signer
|
||||
Setup *setup.Store
|
||||
Audit *audit.Repo
|
||||
NodeID string
|
||||
}
|
||||
|
||||
func NewOIDCHandler(repo *oidcsvc.Repo, auth oidcsvc.Authenticator, users *usersvc.Repo, signer *session.Signer, setupStore *setup.Store) *OIDCHandler {
|
||||
return &OIDCHandler{Repo: repo, Auth: auth, Users: users, Signer: signer, Setup: setupStore}
|
||||
}
|
||||
|
||||
func (h *OIDCHandler) WithAudit(a *audit.Repo, nodeID string) *OIDCHandler {
|
||||
h.Audit = a
|
||||
h.NodeID = nodeID
|
||||
return h
|
||||
}
|
||||
|
||||
// RegisterPublic mountet die unauth. Endpoints (auf v1, hinter SetupGate).
|
||||
func (h *OIDCHandler) RegisterPublic(rg *gin.RouterGroup) {
|
||||
g := rg.Group("/auth/oidc")
|
||||
g.GET("/settings", h.PublicSettings)
|
||||
g.GET("/login", h.Login)
|
||||
g.GET("/callback", h.Callback)
|
||||
}
|
||||
|
||||
// RegisterAdmin mountet die Admin-Endpoints (auf authed: requireAuth +
|
||||
// RequireAdminForMutations → GET für alle, PUT nur admin).
|
||||
func (h *OIDCHandler) RegisterAdmin(rg *gin.RouterGroup) {
|
||||
g := rg.Group("/oidc")
|
||||
g.GET("/settings", h.GetSettings)
|
||||
g.PUT("/settings", h.UpdateSettings)
|
||||
}
|
||||
|
||||
// PublicSettings: nur, was die Login-Seite braucht.
|
||||
func (h *OIDCHandler) PublicSettings(c *gin.Context) {
|
||||
s, err := h.Repo.Get(c.Request.Context())
|
||||
if err != nil {
|
||||
// Kein Datensatz/kein DB → SSO einfach „aus".
|
||||
response.OK(c, gin.H{"enabled": false, "button_label": ""})
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"enabled": s.Enabled, "button_label": s.ButtonLabel})
|
||||
}
|
||||
|
||||
// GetSettings: Admin-Sicht ohne Secret, mit secret_configured + redirect_uri.
|
||||
func (h *OIDCHandler) GetSettings(c *gin.Context) {
|
||||
s, err := h.Repo.Get(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
hasSecret, _ := h.Repo.HasSecret(c.Request.Context())
|
||||
response.OK(c, gin.H{
|
||||
"enabled": s.Enabled,
|
||||
"issuer_url": s.IssuerURL,
|
||||
"client_id": s.ClientID,
|
||||
"scopes": s.Scopes,
|
||||
"email_claim": s.EmailClaim,
|
||||
"button_label": s.ButtonLabel,
|
||||
"secret_configured": hasSecret,
|
||||
"redirect_uri": h.redirectURI(c),
|
||||
})
|
||||
}
|
||||
|
||||
type oidcUpdateBody struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
IssuerURL string `json:"issuer_url"`
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret *string `json:"client_secret"` // nil = unverändert, "" = löschen
|
||||
Scopes string `json:"scopes"`
|
||||
EmailClaim string `json:"email_claim"`
|
||||
ButtonLabel string `json:"button_label"`
|
||||
}
|
||||
|
||||
// UpdateSettings: PUT (admin via RequireAdminForMutations).
|
||||
func (h *OIDCHandler) UpdateSettings(c *gin.Context) {
|
||||
var body oidcUpdateBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
body.IssuerURL = strings.TrimSpace(body.IssuerURL)
|
||||
body.ClientID = strings.TrimSpace(body.ClientID)
|
||||
if body.Scopes == "" {
|
||||
body.Scopes = "openid email profile"
|
||||
}
|
||||
if body.EmailClaim == "" {
|
||||
body.EmailClaim = "email"
|
||||
}
|
||||
if body.ButtonLabel == "" {
|
||||
body.ButtonLabel = "Sign in with SSO"
|
||||
}
|
||||
|
||||
if body.Enabled {
|
||||
if body.IssuerURL == "" || body.ClientID == "" {
|
||||
response.BadRequest(c, errors.New("issuer_url und client_id sind erforderlich, wenn OIDC aktiviert ist"))
|
||||
return
|
||||
}
|
||||
if u, err := url.Parse(body.IssuerURL); err != nil || u.Scheme != "https" || u.Host == "" {
|
||||
response.BadRequest(c, errors.New("issuer_url muss eine gültige https-URL sein"))
|
||||
return
|
||||
}
|
||||
hasSecret, _ := h.Repo.HasSecret(c.Request.Context())
|
||||
providing := body.ClientSecret != nil && *body.ClientSecret != ""
|
||||
if !hasSecret && !providing {
|
||||
response.BadRequest(c, errors.New("client_secret ist erforderlich (noch keins gespeichert)"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.Repo.Update(c.Request.Context(), oidcsvc.UpdateInput{
|
||||
Enabled: body.Enabled,
|
||||
IssuerURL: body.IssuerURL,
|
||||
ClientID: body.ClientID,
|
||||
Scopes: body.Scopes,
|
||||
EmailClaim: body.EmailClaim,
|
||||
ButtonLabel: body.ButtonLabel,
|
||||
ClientSecret: body.ClientSecret,
|
||||
}); err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
h.audit(c, actorOf(c), "oidc.settings.updated", actorOf(c),
|
||||
gin.H{"enabled": body.Enabled, "issuer": body.IssuerURL})
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// Login: 302 zum IdP. Setzt das signierte Flow-Cookie.
|
||||
func (h *OIDCHandler) Login(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
s, err := h.Repo.Get(ctx)
|
||||
if err != nil || !s.Enabled {
|
||||
h.fail(c, "disabled")
|
||||
return
|
||||
}
|
||||
state, err1 := randToken(24)
|
||||
nonce, err2 := randToken(24)
|
||||
if err1 != nil || err2 != nil {
|
||||
h.fail(c, "server")
|
||||
return
|
||||
}
|
||||
verifier := oauth2.GenerateVerifier()
|
||||
redirectURI := h.redirectURI(c)
|
||||
|
||||
authURL, err := h.Auth.AuthCodeURL(ctx, redirectURI, state, nonce, verifier)
|
||||
if err != nil {
|
||||
h.fail(c, "config")
|
||||
return
|
||||
}
|
||||
|
||||
blob, _ := json.Marshal(oidcFlow{State: state, Verifier: verifier, Nonce: nonce})
|
||||
signed, err := h.Signer.SignBlob(blob, oidcFlowTTL)
|
||||
if err != nil {
|
||||
h.fail(c, "server")
|
||||
return
|
||||
}
|
||||
h.setFlowCookie(c, signed)
|
||||
c.Redirect(http.StatusFound, authURL)
|
||||
}
|
||||
|
||||
// Callback: verifiziert Flow + Token, mappt auf DB-User, stellt Session aus.
|
||||
func (h *OIDCHandler) Callback(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// Flow-Cookie lesen + sofort entwerten (single-use).
|
||||
rawFlow, _ := c.Cookie(oidcFlowCookie)
|
||||
h.clearFlowCookie(c)
|
||||
if rawFlow == "" {
|
||||
h.fail(c, "expired")
|
||||
return
|
||||
}
|
||||
payload, err := h.Signer.VerifyBlob(rawFlow)
|
||||
if err != nil {
|
||||
h.fail(c, "expired")
|
||||
return
|
||||
}
|
||||
var flow oidcFlow
|
||||
if json.Unmarshal(payload, &flow) != nil {
|
||||
h.fail(c, "expired")
|
||||
return
|
||||
}
|
||||
|
||||
if c.Query("error") != "" {
|
||||
h.fail(c, "denied")
|
||||
return
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(c.Query("state")), []byte(flow.State)) != 1 {
|
||||
h.fail(c, "state")
|
||||
return
|
||||
}
|
||||
code := c.Query("code")
|
||||
if code == "" {
|
||||
h.fail(c, "exchange")
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := h.Auth.Exchange(ctx, h.redirectURI(c), code, flow.Verifier)
|
||||
if err != nil {
|
||||
h.fail(c, "token")
|
||||
return
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(claims.Nonce), []byte(flow.Nonce)) != 1 {
|
||||
h.fail(c, "nonce")
|
||||
return
|
||||
}
|
||||
if !claims.EmailVerified || claims.Email == "" {
|
||||
h.audit(c, claims.Email, "auth.login.failed", claims.Email,
|
||||
gin.H{"via": "oidc", "reason": "email_unverified", "remote": c.ClientIP()})
|
||||
h.fail(c, "unverified")
|
||||
return
|
||||
}
|
||||
|
||||
u, _, err := h.Users.FindByEmail(ctx, claims.Email)
|
||||
if err != nil {
|
||||
reason := "oidc_no_account"
|
||||
if !errors.Is(err, usersvc.ErrNotFound) {
|
||||
reason = "server"
|
||||
}
|
||||
h.audit(c, claims.Email, "auth.login.failed", claims.Email,
|
||||
gin.H{"via": "oidc", "reason": reason, "remote": c.ClientIP()})
|
||||
h.fail(c, map[bool]string{true: "no_account", false: "server"}[reason == "oidc_no_account"])
|
||||
return
|
||||
}
|
||||
if !u.Active {
|
||||
h.audit(c, u.Email, "auth.login.failed", u.Email,
|
||||
gin.H{"via": "oidc", "reason": "account_disabled", "remote": c.ClientIP()})
|
||||
h.fail(c, "disabled")
|
||||
return
|
||||
}
|
||||
|
||||
// Opportunistisches sub-Linking + Schutz gegen E-Mail-Reassignment.
|
||||
if stored, err := h.Users.GetOIDCSubject(ctx, u.ID); err == nil {
|
||||
if stored != "" && stored != claims.Subject {
|
||||
h.audit(c, u.Email, "auth.login.failed", u.Email,
|
||||
gin.H{"via": "oidc", "reason": "subject_mismatch", "remote": c.ClientIP()})
|
||||
h.fail(c, "subject_mismatch")
|
||||
return
|
||||
}
|
||||
if stored == "" {
|
||||
_ = h.Users.SetOIDCSubject(ctx, u.ID, claims.Subject)
|
||||
}
|
||||
}
|
||||
|
||||
h.Users.RecordLogin(ctx, u.ID)
|
||||
|
||||
// Rolle STRIKT aus der DB-Row (nie aus Claims).
|
||||
raw, tok, err := h.Signer.IssueWithRole(u.Email, u.Role)
|
||||
if err != nil {
|
||||
h.fail(c, "server")
|
||||
return
|
||||
}
|
||||
setSessionCookie(c, raw, tok.Exp)
|
||||
h.audit(c, u.Email, "auth.login.success", u.Email,
|
||||
gin.H{"via": "oidc", "role": u.Role, "remote": c.ClientIP()})
|
||||
c.Redirect(http.StatusFound, "/dashboard")
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
type oidcFlow struct {
|
||||
State string `json:"s"`
|
||||
Verifier string `json:"v"`
|
||||
Nonce string `json:"n"`
|
||||
}
|
||||
|
||||
// redirectURI = https://<FQDN>/api/v1/auth/oidc/callback (FQDN aus setup.json,
|
||||
// Fallback Request-Host). Muss im IdP als Redirect-URI registriert sein.
|
||||
func (h *OIDCHandler) redirectURI(c *gin.Context) string {
|
||||
host := ""
|
||||
if h.Setup != nil {
|
||||
if st, err := h.Setup.Load(); err == nil && st != nil {
|
||||
host = strings.TrimSpace(st.FQDN)
|
||||
}
|
||||
}
|
||||
if host == "" {
|
||||
host = c.Request.Host
|
||||
}
|
||||
return "https://" + host + "/api/v1/auth/oidc/callback"
|
||||
}
|
||||
|
||||
func (h *OIDCHandler) fail(c *gin.Context, reason string) {
|
||||
c.Redirect(http.StatusFound, "/login?sso_error="+url.QueryEscape(reason))
|
||||
}
|
||||
|
||||
func (h *OIDCHandler) audit(c *gin.Context, actor, action, subject string, detail any) {
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), actor, action, subject, detail, h.NodeID)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *OIDCHandler) setFlowCookie(c *gin.Context, raw string) {
|
||||
c.SetSameSite(http.SameSiteLaxMode)
|
||||
c.SetCookie(oidcFlowCookie, raw, int(oidcFlowTTL.Seconds()), "/", "", true, true)
|
||||
}
|
||||
|
||||
func (h *OIDCHandler) clearFlowCookie(c *gin.Context) {
|
||||
c.SetSameSite(http.SameSiteLaxMode)
|
||||
c.SetCookie(oidcFlowCookie, "", -1, "/", "", true, true)
|
||||
}
|
||||
|
||||
func randToken(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
202
internal/handlers/oidc_test.go
Normal file
202
internal/handlers/oidc_test.go
Normal file
@@ -0,0 +1,202 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
|
||||
oidcsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/oidc"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/session"
|
||||
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
|
||||
)
|
||||
|
||||
// mockAuth erfüllt oidcsvc.Authenticator und liefert vorgegebene Claims —
|
||||
// kein echter IdP nötig.
|
||||
type mockAuth struct {
|
||||
claims *oidcsvc.Claims
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockAuth) AuthCodeURL(_ context.Context, _, state, _, _ string) (string, error) {
|
||||
return "https://idp.example/authorize?state=" + state, nil
|
||||
}
|
||||
func (m *mockAuth) Exchange(_ context.Context, _, _, _ string) (*oidcsvc.Claims, error) {
|
||||
return m.claims, m.err
|
||||
}
|
||||
|
||||
func oidcTestSetup(t *testing.T) (*usersvc.Repo, *pgxpool.Pool, *session.Signer) {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("EG_FWTEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set EG_FWTEST_DSN to run the oidc handler test")
|
||||
}
|
||||
ctx := context.Background()
|
||||
// Retry: goose-Erst-Apply ist nicht concurrency-safe, wenn mehrere
|
||||
// guarded Test-Pakete dieselbe frische DB parallel migrieren.
|
||||
var mErr error
|
||||
for i := 0; i < 3; i++ {
|
||||
if mErr = database.Migrate(ctx, dsn); mErr == nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(700 * time.Millisecond)
|
||||
}
|
||||
if mErr != nil {
|
||||
t.Fatalf("migrate: %v", mErr)
|
||||
}
|
||||
pool, err := database.Open(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
return usersvc.New(pool), pool, session.NewSigner([]byte("0123456789abcdef0123456789abcdef"), nil, 0)
|
||||
}
|
||||
|
||||
func seedUser(t *testing.T, repo *usersvc.Repo, pool *pgxpool.Pool, email, role string, active bool) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
_, _ = pool.Exec(ctx, `DELETE FROM users WHERE email=$1`, email)
|
||||
if _, err := repo.Create(ctx, email, "Sup3rSecret-pw-123", role, active); err != nil {
|
||||
t.Fatalf("seed user: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func runCallback(t *testing.T, h *OIDCHandler, flow oidcFlow, queryState, code string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
blob, _ := json.Marshal(flow)
|
||||
signed, err := h.Signer.SignBlob(blob, oidcFlowTTL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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}) //nolint:gosec // Test-Cookie — Secure/HttpOnly-Flags für httptest irrelevant
|
||||
c.Request = req
|
||||
h.Callback(c)
|
||||
return rec
|
||||
}
|
||||
|
||||
func sessionCookie(rec *httptest.ResponseRecorder) string {
|
||||
for _, ck := range rec.Result().Cookies() {
|
||||
if ck.Name == cookieName && ck.Value != "" && ck.MaxAge >= 0 {
|
||||
return ck.Value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestCallback_KnownActiveUser_RoleFromDB(t *testing.T) {
|
||||
users, pool, signer := oidcTestSetup(t)
|
||||
seedUser(t, users, pool, "sso-viewer@test.local", "viewer", true)
|
||||
|
||||
h := NewOIDCHandler(nil, &mockAuth{claims: &oidcsvc.Claims{
|
||||
Email: "sso-viewer@test.local", EmailVerified: true, Subject: "sub-1", Nonce: "N",
|
||||
}}, users, signer, nil)
|
||||
|
||||
rec := runCallback(t, h, oidcFlow{State: "S", Verifier: "v", Nonce: "N"}, "S", "code")
|
||||
|
||||
if loc := rec.Header().Get("Location"); loc != "/dashboard" {
|
||||
t.Fatalf("expected redirect to /dashboard, got %q (body proves failure path)", loc)
|
||||
}
|
||||
raw := sessionCookie(rec)
|
||||
if raw == "" {
|
||||
t.Fatal("expected a session cookie to be set")
|
||||
}
|
||||
tok, err := signer.Verify(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("session token invalid: %v", err)
|
||||
}
|
||||
// Kernbeweis: Rolle kommt aus der DB-Row (viewer), nicht aus Claims.
|
||||
if tok.Role != "viewer" {
|
||||
t.Errorf("token role = %q, want viewer (role must come from DB)", tok.Role)
|
||||
}
|
||||
if tok.Actor != "sso-viewer@test.local" {
|
||||
t.Errorf("token actor = %q", tok.Actor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallback_UnknownEmail_Rejected(t *testing.T) {
|
||||
users, pool, signer := oidcTestSetup(t)
|
||||
_, _ = pool.Exec(context.Background(), `DELETE FROM users WHERE email=$1`, "ghost@test.local")
|
||||
|
||||
h := NewOIDCHandler(nil, &mockAuth{claims: &oidcsvc.Claims{
|
||||
Email: "ghost@test.local", EmailVerified: true, Subject: "x", Nonce: "N",
|
||||
}}, users, signer, nil)
|
||||
|
||||
rec := runCallback(t, h, oidcFlow{State: "S", Nonce: "N"}, "S", "code")
|
||||
if !strings.Contains(rec.Header().Get("Location"), "sso_error=no_account") {
|
||||
t.Fatalf("expected sso_error=no_account, got %q", rec.Header().Get("Location"))
|
||||
}
|
||||
if sessionCookie(rec) != "" {
|
||||
t.Fatal("no session cookie expected for unknown user")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallback_InactiveUser_Rejected(t *testing.T) {
|
||||
users, pool, signer := oidcTestSetup(t)
|
||||
seedUser(t, users, pool, "sso-disabled@test.local", "admin", false)
|
||||
|
||||
h := NewOIDCHandler(nil, &mockAuth{claims: &oidcsvc.Claims{
|
||||
Email: "sso-disabled@test.local", EmailVerified: true, Subject: "x", Nonce: "N",
|
||||
}}, users, signer, nil)
|
||||
|
||||
rec := runCallback(t, h, oidcFlow{State: "S", Nonce: "N"}, "S", "code")
|
||||
if !strings.Contains(rec.Header().Get("Location"), "sso_error=disabled") {
|
||||
t.Fatalf("expected sso_error=disabled, got %q", rec.Header().Get("Location"))
|
||||
}
|
||||
if sessionCookie(rec) != "" {
|
||||
t.Fatal("no session cookie expected for inactive user")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallback_EmailUnverified_Rejected(t *testing.T) {
|
||||
users, pool, signer := oidcTestSetup(t)
|
||||
seedUser(t, users, pool, "sso-unverified@test.local", "admin", true)
|
||||
|
||||
h := NewOIDCHandler(nil, &mockAuth{claims: &oidcsvc.Claims{
|
||||
Email: "sso-unverified@test.local", EmailVerified: false, Subject: "x", Nonce: "N",
|
||||
}}, users, signer, nil)
|
||||
|
||||
rec := runCallback(t, h, oidcFlow{State: "S", Nonce: "N"}, "S", "code")
|
||||
if !strings.Contains(rec.Header().Get("Location"), "sso_error=unverified") {
|
||||
t.Fatalf("expected sso_error=unverified, got %q", rec.Header().Get("Location"))
|
||||
}
|
||||
if sessionCookie(rec) != "" {
|
||||
t.Fatal("no session cookie expected for unverified email")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallback_NonceMismatch_Rejected(t *testing.T) {
|
||||
users, pool, signer := oidcTestSetup(t)
|
||||
seedUser(t, users, pool, "sso-nonce@test.local", "admin", true)
|
||||
|
||||
h := NewOIDCHandler(nil, &mockAuth{claims: &oidcsvc.Claims{
|
||||
Email: "sso-nonce@test.local", EmailVerified: true, Subject: "x", Nonce: "WRONG",
|
||||
}}, users, signer, nil)
|
||||
|
||||
rec := runCallback(t, h, oidcFlow{State: "S", Nonce: "N"}, "S", "code")
|
||||
if !strings.Contains(rec.Header().Get("Location"), "sso_error=nonce") {
|
||||
t.Fatalf("expected sso_error=nonce, got %q", rec.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallback_StateMismatch_Rejected(t *testing.T) {
|
||||
users, _, signer := oidcTestSetup(t)
|
||||
h := NewOIDCHandler(nil, &mockAuth{claims: &oidcsvc.Claims{}}, users, signer, nil)
|
||||
|
||||
rec := runCallback(t, h, oidcFlow{State: "S", Nonce: "N"}, "WRONG", "code")
|
||||
if !strings.Contains(rec.Header().Get("Location"), "sso_error=state") {
|
||||
t.Fatalf("expected sso_error=state, got %q", rec.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
359
internal/handlers/radius.go
Normal file
359
internal/handlers/radius.go
Normal file
@@ -0,0 +1,359 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
|
||||
radiussvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/radius"
|
||||
)
|
||||
|
||||
// RADIUSHandler exposes /api/v1/radius/{settings,clients,users} for the
|
||||
// FreeRADIUS server (files-based PAP/CHAP).
|
||||
type RADIUSHandler struct {
|
||||
Repo *radiussvc.Repo
|
||||
Audit *audit.Repo
|
||||
NodeID string
|
||||
Reloader func(ctx context.Context) error
|
||||
}
|
||||
|
||||
func NewRADIUSHandler(repo *radiussvc.Repo, a *audit.Repo, nodeID string, reloader func(context.Context) error) *RADIUSHandler {
|
||||
return &RADIUSHandler{Repo: repo, Audit: a, NodeID: nodeID, Reloader: reloader}
|
||||
}
|
||||
|
||||
func (h *RADIUSHandler) reload(ctx context.Context, op string) {
|
||||
if h.Reloader == nil {
|
||||
return
|
||||
}
|
||||
if err := h.Reloader(ctx); err != nil {
|
||||
slog.Warn("freeradius: reload after mutation failed", "op", op, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *RADIUSHandler) Register(rg *gin.RouterGroup) {
|
||||
g := rg.Group("/radius")
|
||||
g.GET("/settings", h.GetSettings)
|
||||
g.PUT("/settings", h.UpdateSettings)
|
||||
|
||||
c := g.Group("/clients")
|
||||
c.GET("", h.ListClients)
|
||||
c.POST("", h.CreateClient)
|
||||
c.GET("/:id", h.GetClient)
|
||||
c.PUT("/:id", h.UpdateClient)
|
||||
c.DELETE("/:id", h.DeleteClient)
|
||||
|
||||
u := g.Group("/users")
|
||||
u.GET("", h.ListUsers)
|
||||
u.POST("", h.CreateUser)
|
||||
u.GET("/:id", h.GetUser)
|
||||
u.PUT("/:id", h.UpdateUser)
|
||||
u.DELETE("/:id", h.DeleteUser)
|
||||
}
|
||||
|
||||
var validClientName = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`)
|
||||
|
||||
// ── Settings ─────────────────────────────────────────────────────────
|
||||
|
||||
func (h *RADIUSHandler) GetSettings(c *gin.Context) {
|
||||
s, err := h.Repo.GetSettings(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, s)
|
||||
}
|
||||
|
||||
func (h *RADIUSHandler) UpdateSettings(c *gin.Context) {
|
||||
var req models.RADIUSSettings
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if err := validateIPList(req.ListenAddresses); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
out, err := h.Repo.UpdateSettings(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "radius.settings.update", "",
|
||||
gin.H{"enabled": out.Enabled}, h.NodeID)
|
||||
response.OK(c, out)
|
||||
h.reload(c.Request.Context(), "settings.update")
|
||||
}
|
||||
|
||||
// ── Clients ──────────────────────────────────────────────────────────
|
||||
|
||||
type clientView struct {
|
||||
models.RADIUSClient
|
||||
SecretConfigured bool `json:"secret_configured"`
|
||||
}
|
||||
|
||||
func clientToView(c models.RADIUSClient) clientView {
|
||||
return clientView{RADIUSClient: c, SecretConfigured: len(c.SecretEnc) > 0}
|
||||
}
|
||||
|
||||
func (h *RADIUSHandler) ListClients(c *gin.Context) {
|
||||
list, err := h.Repo.ListClients(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
out := make([]clientView, 0, len(list))
|
||||
for _, cl := range list {
|
||||
out = append(out, clientToView(cl))
|
||||
}
|
||||
response.OK(c, gin.H{"clients": out})
|
||||
}
|
||||
|
||||
func (h *RADIUSHandler) GetClient(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
cl, err := h.Repo.GetClient(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
h.clientErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, clientToView(*cl))
|
||||
}
|
||||
|
||||
type clientBody struct {
|
||||
Name string `json:"name"`
|
||||
IPAddr string `json:"ipaddr"`
|
||||
Secret *string `json:"secret"` // create: required; update: nil=unchanged
|
||||
Active bool `json:"active"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func (b *clientBody) validate(creating bool) error {
|
||||
b.Name = strings.TrimSpace(b.Name)
|
||||
b.IPAddr = strings.TrimSpace(b.IPAddr)
|
||||
if !validClientName.MatchString(b.Name) {
|
||||
return errors.New("name darf nur Buchstaben/Ziffern/._- enthalten")
|
||||
}
|
||||
if net.ParseIP(b.IPAddr) == nil {
|
||||
if _, _, err := net.ParseCIDR(b.IPAddr); err != nil {
|
||||
return errors.New("ipaddr ist keine gültige IP/CIDR: " + b.IPAddr)
|
||||
}
|
||||
}
|
||||
if creating && b.Secret == nil {
|
||||
return errors.New("secret ist erforderlich")
|
||||
}
|
||||
if b.Secret != nil {
|
||||
if len(*b.Secret) < 6 {
|
||||
return errors.New("secret muss mind. 6 Zeichen haben (leer löscht es nicht)")
|
||||
}
|
||||
if strings.ContainsAny(*b.Secret, "\r\n") {
|
||||
return errors.New("secret darf keine Zeilenumbrüche enthalten")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *RADIUSHandler) CreateClient(c *gin.Context) {
|
||||
var b clientBody
|
||||
if err := c.ShouldBindJSON(&b); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if err := b.validate(true); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
out, err := h.Repo.CreateClient(c.Request.Context(), b.Name, b.IPAddr, *b.Secret, b.Active, b.Description)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "radius.client.create", out.Name, gin.H{"ipaddr": out.IPAddr}, h.NodeID)
|
||||
response.Created(c, clientToView(*out))
|
||||
h.reload(c.Request.Context(), "client.create")
|
||||
}
|
||||
|
||||
func (h *RADIUSHandler) UpdateClient(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var b clientBody
|
||||
if err := c.ShouldBindJSON(&b); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if err := b.validate(false); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
out, err := h.Repo.UpdateClient(c.Request.Context(), id, b.Name, b.IPAddr, b.Secret, b.Active, b.Description)
|
||||
if err != nil {
|
||||
h.clientErr(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "radius.client.update", out.Name, gin.H{"ipaddr": out.IPAddr}, h.NodeID)
|
||||
response.OK(c, clientToView(*out))
|
||||
h.reload(c.Request.Context(), "client.update")
|
||||
}
|
||||
|
||||
func (h *RADIUSHandler) DeleteClient(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.Repo.DeleteClient(c.Request.Context(), id); err != nil {
|
||||
h.clientErr(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "radius.client.delete", "", gin.H{"id": id}, h.NodeID)
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
h.reload(c.Request.Context(), "client.delete")
|
||||
}
|
||||
|
||||
// ── Users ────────────────────────────────────────────────────────────
|
||||
|
||||
type userView struct {
|
||||
models.RADIUSUser
|
||||
PasswordConfigured bool `json:"password_configured"`
|
||||
}
|
||||
|
||||
func userToView(u models.RADIUSUser) userView {
|
||||
return userView{RADIUSUser: u, PasswordConfigured: len(u.PasswordEnc) > 0}
|
||||
}
|
||||
|
||||
func (h *RADIUSHandler) ListUsers(c *gin.Context) {
|
||||
list, err := h.Repo.ListUsers(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
out := make([]userView, 0, len(list))
|
||||
for _, u := range list {
|
||||
out = append(out, userToView(u))
|
||||
}
|
||||
response.OK(c, gin.H{"users": out})
|
||||
}
|
||||
|
||||
func (h *RADIUSHandler) GetUser(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
u, err := h.Repo.GetUser(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
h.userErr(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, userToView(*u))
|
||||
}
|
||||
|
||||
type userBody struct {
|
||||
Username string `json:"username"`
|
||||
Password *string `json:"password"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
func (b *userBody) validate(creating bool) error {
|
||||
b.Username = strings.TrimSpace(b.Username)
|
||||
if b.Username == "" || strings.ContainsAny(b.Username, "\r\n") {
|
||||
return errors.New("username ist erforderlich (ohne Zeilenumbrüche)")
|
||||
}
|
||||
if creating && (b.Password == nil || *b.Password == "") {
|
||||
return errors.New("password ist erforderlich")
|
||||
}
|
||||
if b.Password != nil {
|
||||
if *b.Password == "" {
|
||||
return errors.New("password darf nicht leer sein (löscht es nicht)")
|
||||
}
|
||||
if strings.ContainsAny(*b.Password, "\r\n") {
|
||||
return errors.New("password darf keine Zeilenumbrüche enthalten")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *RADIUSHandler) CreateUser(c *gin.Context) {
|
||||
var b userBody
|
||||
if err := c.ShouldBindJSON(&b); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if err := b.validate(true); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
out, err := h.Repo.CreateUser(c.Request.Context(), b.Username, *b.Password, b.Active)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "radius.user.create", out.Username, nil, h.NodeID)
|
||||
response.Created(c, userToView(*out))
|
||||
h.reload(c.Request.Context(), "user.create")
|
||||
}
|
||||
|
||||
func (h *RADIUSHandler) UpdateUser(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var b userBody
|
||||
if err := c.ShouldBindJSON(&b); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if err := b.validate(false); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
out, err := h.Repo.UpdateUser(c.Request.Context(), id, b.Username, b.Password, b.Active)
|
||||
if err != nil {
|
||||
h.userErr(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "radius.user.update", out.Username, nil, h.NodeID)
|
||||
response.OK(c, userToView(*out))
|
||||
h.reload(c.Request.Context(), "user.update")
|
||||
}
|
||||
|
||||
func (h *RADIUSHandler) DeleteUser(c *gin.Context) {
|
||||
id, ok := parseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.Repo.DeleteUser(c.Request.Context(), id); err != nil {
|
||||
h.userErr(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "radius.user.delete", "", gin.H{"id": id}, h.NodeID)
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
h.reload(c.Request.Context(), "user.delete")
|
||||
}
|
||||
|
||||
// ── error mapping ────────────────────────────────────────────────────
|
||||
|
||||
func (h *RADIUSHandler) clientErr(c *gin.Context, err error) {
|
||||
if errors.Is(err, radiussvc.ErrClientNotFound) {
|
||||
response.NotFound(c, err)
|
||||
return
|
||||
}
|
||||
response.Internal(c, err)
|
||||
}
|
||||
|
||||
func (h *RADIUSHandler) userErr(c *gin.Context, err error) {
|
||||
if errors.Is(err, radiussvc.ErrUserNotFound) {
|
||||
response.NotFound(c, err)
|
||||
return
|
||||
}
|
||||
response.Internal(c, err)
|
||||
}
|
||||
@@ -82,6 +82,10 @@ func Forbidden(c *gin.Context, err error) {
|
||||
Err(c, http.StatusForbidden, err)
|
||||
}
|
||||
|
||||
func Conflict(c *gin.Context, err error) {
|
||||
Err(c, http.StatusConflict, err)
|
||||
}
|
||||
|
||||
func Internal(c *gin.Context, err error) {
|
||||
Err(c, http.StatusInternalServerError, err)
|
||||
}
|
||||
|
||||
@@ -1,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
|
||||
}
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/clusterjoin"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
||||
)
|
||||
|
||||
@@ -12,9 +22,12 @@ import (
|
||||
// are mounted before SetupGate so they remain reachable while the API
|
||||
// is in setup mode.
|
||||
type SetupHandler struct {
|
||||
Store *setup.Store
|
||||
Audit *audit.Repo
|
||||
NodeID string
|
||||
Store *setup.Store
|
||||
Audit *audit.Repo
|
||||
NodeID string
|
||||
Version string
|
||||
ClusterStore *cluster.Store
|
||||
PeerReloader PeerReloader
|
||||
}
|
||||
|
||||
func NewSetupHandler(store *setup.Store) *SetupHandler {
|
||||
@@ -30,10 +43,28 @@ func (h *SetupHandler) WithAudit(a *audit.Repo, nodeID string) *SetupHandler {
|
||||
return h
|
||||
}
|
||||
|
||||
// WithVersion macht die laufende Version für auto-register verfügbar.
|
||||
func (h *SetupHandler) WithVersion(v string) *SetupHandler {
|
||||
h.Version = v
|
||||
return h
|
||||
}
|
||||
|
||||
// WithClusterSupport erlaubt dem JoinCluster-Handler nach dem Join den
|
||||
// Primary in der lokalen ha_nodes zu registrieren + nftables neu zu laden,
|
||||
// damit Port 8443 bidirektional offen ist.
|
||||
func (h *SetupHandler) WithClusterSupport(store *cluster.Store, reloader PeerReloader) *SetupHandler {
|
||||
h.ClusterStore = store
|
||||
h.PeerReloader = reloader
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *SetupHandler) Register(rg *gin.RouterGroup) {
|
||||
g := rg.Group("/setup")
|
||||
g.GET("/status", h.Status)
|
||||
g.POST("/complete", h.Complete)
|
||||
g.POST("/complete-node", h.CompleteAsNode)
|
||||
g.POST("/join-cluster", h.JoinCluster)
|
||||
g.GET("/replication-status", h.ReplicationStatus)
|
||||
}
|
||||
|
||||
// RegisterAuthed mountet die Endpoints die nach abgeschlossenem Setup
|
||||
@@ -111,3 +142,137 @@ func (h *SetupHandler) Complete(c *gin.Context) {
|
||||
"fqdn": st.FQDN,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *SetupHandler) CompleteAsNode(c *gin.Context) {
|
||||
var req setup.NodeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
st, err := h.Store.CompleteAsNode(req)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{
|
||||
"completed": st.Completed,
|
||||
"is_cluster_node": st.IsClusterNode,
|
||||
"fqdn": st.FQDN,
|
||||
})
|
||||
}
|
||||
|
||||
// JoinCluster performs the full cluster-join flow from the setup wizard:
|
||||
// fetches certs from the primary, writes them to disk, then marks setup
|
||||
// as completed. No CLI required.
|
||||
func (h *SetupHandler) JoinCluster(c *gin.Context) {
|
||||
var body struct {
|
||||
FQDN string `json:"fqdn" binding:"required"`
|
||||
ACMEEmail string `json:"acme_email" binding:"required,email"`
|
||||
PrimaryFQDN string `json:"primary_fqdn" binding:"required"`
|
||||
Token string `json:"token" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
fqdn := strings.ToLower(strings.TrimSpace(body.FQDN))
|
||||
|
||||
if err := clusterjoin.Join(clusterjoin.Request{
|
||||
PrimaryFQDN: body.PrimaryFQDN,
|
||||
Token: strings.TrimSpace(body.Token),
|
||||
CommonName: fqdn,
|
||||
Insecure: true, // security comes from the HMAC token, not TLS cert trust
|
||||
Force: true, // bootstrap self-signed cert must be replaced
|
||||
Version: h.Version,
|
||||
NodeID: h.NodeID,
|
||||
}); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
st, err := h.Store.CompleteAsNode(setup.NodeRequest{
|
||||
FQDN: fqdn,
|
||||
ACMEEmail: body.ACMEEmail,
|
||||
PrimaryFQDN: strings.ToLower(strings.TrimSpace(body.PrimaryFQDN)),
|
||||
})
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Pre-register the primary in our local ha_nodes so its IP lands in
|
||||
// @peer_ipv4 and port 8443 is open bidirectionally.
|
||||
if h.ClusterStore != nil && h.PeerReloader != nil {
|
||||
go h.preRegisterPrimary(body.PrimaryFQDN)
|
||||
}
|
||||
|
||||
// Logical Replication automatisch einrichten. Ohne diesen Schritt waere
|
||||
// der Node zwar im Cluster registriert, wuerde aber keinerlei geteilte
|
||||
// Config (Domains, Backends, Firewall-Rules, WireGuard, …) bekommen —
|
||||
// was frueher erst beim Failover auffiel. Laeuft detached, der Wizard
|
||||
// pollt /setup/replication-status.
|
||||
h.startReplicationSetup(body.PrimaryFQDN)
|
||||
|
||||
response.OK(c, gin.H{
|
||||
"completed": st.Completed,
|
||||
"is_cluster_node": st.IsClusterNode,
|
||||
"fqdn": st.FQDN,
|
||||
})
|
||||
}
|
||||
|
||||
// StartupPeerSync is called once after the DB pool and ClusterStore are
|
||||
// ready. On cluster nodes it re-registers the primary in the local ha_nodes
|
||||
// and reloads nftables so @peer_ipv4 is correct after a package update or
|
||||
// reboot — without requiring a new join.
|
||||
func (h *SetupHandler) StartupPeerSync() {
|
||||
if h.ClusterStore == nil || h.PeerReloader == nil {
|
||||
return
|
||||
}
|
||||
st, err := h.Store.Load()
|
||||
if err != nil || st == nil || !st.IsClusterNode || st.PrimaryFQDN == "" {
|
||||
return
|
||||
}
|
||||
h.preRegisterPrimary(st.PrimaryFQDN)
|
||||
}
|
||||
|
||||
// preRegisterPrimary inserts the primary node into the local ha_nodes with
|
||||
// its resolved IP so nftables @peer_ipv4 allows port 8443 from the primary.
|
||||
// Uses a stable ID derived from the FQDN so repeated calls are idempotent.
|
||||
func (h *SetupHandler) preRegisterPrimary(primaryFQDN string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
primaryFQDN = strings.ToLower(strings.TrimSpace(primaryFQDN))
|
||||
|
||||
addrs, err := net.DefaultResolver.LookupHost(ctx, primaryFQDN)
|
||||
if err != nil || len(addrs) == 0 {
|
||||
slog.Warn("setup: could not resolve primary FQDN for pre-registration",
|
||||
"fqdn", primaryFQDN, "error", err)
|
||||
return
|
||||
}
|
||||
ip := addrs[0]
|
||||
|
||||
// Stable ID so repeated calls (join + startup) don't accumulate rows.
|
||||
nodeID := fmt.Sprintf("prenode-%s", strings.ReplaceAll(primaryFQDN, ".", "-"))
|
||||
n := models.HANode{
|
||||
ID: nodeID,
|
||||
Name: primaryFQDN,
|
||||
FQDN: primaryFQDN,
|
||||
APIURL: "https://" + primaryFQDN + ":3443",
|
||||
Role: "primary",
|
||||
Status: "online",
|
||||
}
|
||||
n.PublicIP = &ip
|
||||
|
||||
if _, err := h.ClusterStore.UpsertSelf(ctx, n); err != nil {
|
||||
slog.Warn("setup: pre-register primary in ha_nodes failed", "fqdn", primaryFQDN, "error", err)
|
||||
return
|
||||
}
|
||||
if err := h.PeerReloader(ctx); err != nil {
|
||||
slog.Warn("setup: PeerReloader failed after primary pre-register", "error", err)
|
||||
return
|
||||
}
|
||||
slog.Info("setup: primary pre-registered locally, firewall updated",
|
||||
"fqdn", primaryFQDN, "ip", ip)
|
||||
}
|
||||
|
||||
195
internal/handlers/setup_replication.go
Normal file
195
internal/handlers/setup_replication.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||
)
|
||||
|
||||
// Automatische Logical-Replication-Einrichtung beim Cluster-Join.
|
||||
//
|
||||
// Früher war das ein manueller Schritt: nach dem Join musste der Operator
|
||||
// auf dem neuen Node `edgeguard-ctl cluster-setup-standby <primary>`
|
||||
// ausführen. Wer das übersah, hatte einen Node, der im Cluster sichtbar
|
||||
// war, aber KEINE geteilte Config replizierte — und merkte es erst beim
|
||||
// Failover. Deshalb läuft es jetzt direkt aus dem Join heraus.
|
||||
//
|
||||
// Der eigentliche Ablauf bleibt im CLI (`cluster-setup-standby`): er
|
||||
// braucht root (psql als postgres-User, pg_hba, render-config), die API
|
||||
// läuft als unprivilegierter `edgeguard`. Aufruf daher via sudo mit
|
||||
// gepinnter Regel — gleiches Muster wie bei apt-get/systemctl/tee.
|
||||
//
|
||||
// Weil die Initialkopie der geteilten Tabellen Minuten dauern kann, läuft
|
||||
// das detached; der Setup-Wizard pollt GET /setup/replication-status.
|
||||
|
||||
const replicationStateFile = "/var/lib/edgeguard/replication-setup-state.json"
|
||||
|
||||
const (
|
||||
replPhaseIdle = "idle"
|
||||
replPhaseRunning = "running"
|
||||
replPhaseDone = "done"
|
||||
replPhaseFailed = "failed"
|
||||
)
|
||||
|
||||
// replStateMu serialisiert Lesen/Schreiben der State-Datei (HTTP-Handler
|
||||
// + Hintergrund-Goroutine greifen gleichzeitig zu).
|
||||
var replStateMu sync.Mutex
|
||||
|
||||
// ReplicationSetupState hält den Fortschritt der Standby-Einrichtung.
|
||||
// Persistiert, damit der Status einen API-Neustart übersteht — der ist
|
||||
// der letzte Schritt des Setups und würde den Zustand sonst verlieren.
|
||||
type ReplicationSetupState struct {
|
||||
Phase string `json:"phase"`
|
||||
Primary string `json:"primary,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Log string `json:"log,omitempty"`
|
||||
StartedAt time.Time `json:"started_at,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
func readReplicationState() ReplicationSetupState {
|
||||
replStateMu.Lock()
|
||||
defer replStateMu.Unlock()
|
||||
raw, err := os.ReadFile(replicationStateFile)
|
||||
if err != nil {
|
||||
return ReplicationSetupState{Phase: replPhaseIdle}
|
||||
}
|
||||
var st ReplicationSetupState
|
||||
if err := json.Unmarshal(raw, &st); err != nil {
|
||||
return ReplicationSetupState{Phase: replPhaseIdle}
|
||||
}
|
||||
if st.Phase == "" {
|
||||
st.Phase = replPhaseIdle
|
||||
}
|
||||
// Ein "running", das älter als das CLI-Timeout ist, kann nur von einem
|
||||
// gestorbenen Prozess stammen (z. B. OOM-Kill). Sonst haengt der Wizard
|
||||
// ewig im Spinner.
|
||||
if st.Phase == replPhaseRunning && !st.StartedAt.IsZero() &&
|
||||
time.Since(st.StartedAt) > 15*time.Minute {
|
||||
st.Phase = replPhaseFailed
|
||||
st.Error = "Zeitüberschreitung — Einrichtung lief länger als 15 Minuten. " +
|
||||
"Manuell nachholen: sudo edgeguard-ctl cluster-setup-standby " + st.Primary
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func writeReplicationState(st ReplicationSetupState) {
|
||||
st.UpdatedAt = time.Now()
|
||||
raw, err := json.Marshal(st)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
replStateMu.Lock()
|
||||
defer replStateMu.Unlock()
|
||||
if err := configgen.AtomicWrite(replicationStateFile, raw, 0o640); err != nil {
|
||||
slog.Warn("setup: replication state write failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// validPrimaryHost laesst nur das durch, was ein Hostname oder eine IP
|
||||
// sein kann. exec.Command startet keine Shell, Metazeichen koennen also
|
||||
// ohnehin nichts ausloesen — die Pruefung haelt aber Unsinn von der
|
||||
// sudo-Regel fern und liefert dem Operator einen klaren Fehler statt
|
||||
// eines kryptischen CLI-Abbruchs.
|
||||
func validPrimaryHost(h string) bool {
|
||||
h = strings.TrimSpace(h)
|
||||
if h == "" || len(h) > 253 {
|
||||
return false
|
||||
}
|
||||
if net.ParseIP(h) != nil {
|
||||
return true
|
||||
}
|
||||
for _, label := range strings.Split(h, ".") {
|
||||
if label == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range label {
|
||||
isAlnum := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')
|
||||
if !isAlnum && r != '-' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// startReplicationSetup richtet diesen Node im Hintergrund als Logical-
|
||||
// Replication-Subscriber ein. Nicht-blockierend: der Join-Request
|
||||
// antwortet sofort, der Wizard pollt den Status.
|
||||
func (h *SetupHandler) startReplicationSetup(primary string) {
|
||||
primary = strings.ToLower(strings.TrimSpace(primary))
|
||||
if !validPrimaryHost(primary) {
|
||||
writeReplicationState(ReplicationSetupState{
|
||||
Phase: replPhaseFailed,
|
||||
Error: "ungültiger Primary-Host: " + primary,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
writeReplicationState(ReplicationSetupState{
|
||||
Phase: replPhaseRunning,
|
||||
Primary: primary,
|
||||
StartedAt: time.Now(),
|
||||
})
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
slog.Error("setup: replication setup panic", "panic", r)
|
||||
writeReplicationState(ReplicationSetupState{
|
||||
Phase: replPhaseFailed, Primary: primary,
|
||||
Error: "interner Fehler bei der Replikations-Einrichtung",
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
||||
slog.Info("setup: starting logical replication setup", "primary", primary)
|
||||
// Kein Request-Context: der Join-Request ist längst beantwortet,
|
||||
// und ein Abbruch mitten im CREATE SUBSCRIPTION wäre schlimmer
|
||||
// als ein Weiterlaufen.
|
||||
cmd := exec.Command("sudo", "-n", "/usr/bin/edgeguard-ctl", //nolint:noctx // detached by design — darf nicht am Request haengen
|
||||
"cluster-setup-standby", primary)
|
||||
out, err := cmd.CombinedOutput()
|
||||
logTail := tailString(string(out), 4000)
|
||||
|
||||
if err != nil {
|
||||
slog.Warn("setup: logical replication setup failed",
|
||||
"primary", primary, "error", err, "output", logTail)
|
||||
writeReplicationState(ReplicationSetupState{
|
||||
Phase: replPhaseFailed, Primary: primary,
|
||||
Error: err.Error(), Log: logTail,
|
||||
})
|
||||
return
|
||||
}
|
||||
slog.Info("setup: logical replication setup finished", "primary", primary)
|
||||
writeReplicationState(ReplicationSetupState{
|
||||
Phase: replPhaseDone, Primary: primary, Log: logTail,
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
// tailString kuerzt lange CLI-Ausgaben auf die letzten n Bytes — der
|
||||
// interessante Teil (Fehler, Abschlussmeldung) steht am Ende.
|
||||
func tailString(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return "…" + s[len(s)-n:]
|
||||
}
|
||||
|
||||
// ReplicationStatus liefert den Fortschritt der automatischen Standby-
|
||||
// Einrichtung. Liegt bewusst auf der Setup-Gruppe (pre-auth): der Wizard
|
||||
// pollt es, bevor auf dem neuen Node ueberhaupt ein Login moeglich ist.
|
||||
func (h *SetupHandler) ReplicationStatus(c *gin.Context) {
|
||||
response.OK(c, readReplicationState())
|
||||
}
|
||||
53
internal/handlers/setup_replication_test.go
Normal file
53
internal/handlers/setup_replication_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package handlers
|
||||
|
||||
import "testing"
|
||||
|
||||
// validPrimaryHost bewacht das einzige variable Argument einer sudo-Regel
|
||||
// (`edgeguard-ctl cluster-setup-standby *`). Der Aufruf laeuft zwar ohne
|
||||
// Shell, aber die Pruefung soll trotzdem halten was sie verspricht.
|
||||
func TestValidPrimaryHost(t *testing.T) {
|
||||
valid := []string{
|
||||
"utm-1.netcell-it.de",
|
||||
"primary",
|
||||
"10.0.5.1",
|
||||
"89.163.205.6",
|
||||
"2001:db8::1",
|
||||
"a-b-c.example.com",
|
||||
}
|
||||
for _, h := range valid {
|
||||
if !validPrimaryHost(h) {
|
||||
t.Errorf("validPrimaryHost(%q) = false, erwartet true", h)
|
||||
}
|
||||
}
|
||||
|
||||
invalid := []string{
|
||||
"",
|
||||
" ",
|
||||
"host; rm -rf /",
|
||||
"host && reboot",
|
||||
"host|tee",
|
||||
"host$(id)",
|
||||
"host`id`",
|
||||
"--tls-dir=/tmp/evil",
|
||||
"host with space",
|
||||
"host\nsecond-line",
|
||||
"..",
|
||||
"host..example.com",
|
||||
"/etc/passwd",
|
||||
}
|
||||
for _, h := range invalid {
|
||||
if validPrimaryHost(h) {
|
||||
t.Errorf("validPrimaryHost(%q) = true, erwartet false", h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidPrimaryHostRejectsOverlongName(t *testing.T) {
|
||||
long := make([]byte, 254)
|
||||
for i := range long {
|
||||
long[i] = 'a'
|
||||
}
|
||||
if validPrimaryHost(string(long)) {
|
||||
t.Error("Hostname > 253 Zeichen muss abgelehnt werden")
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,13 @@ package handlers
|
||||
import (
|
||||
"bufio"
|
||||
stdcontext "context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
@@ -21,6 +23,7 @@ import (
|
||||
aptsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/apt"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
||||
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
|
||||
)
|
||||
|
||||
// SystemHandler covers /system/health, /system/package-versions,
|
||||
@@ -40,6 +43,12 @@ type SystemHandler struct {
|
||||
// in main.go after generators are constructed. Read-only access —
|
||||
// no writes, no reloads.
|
||||
ConfigPreviewers map[string]func(stdcontext.Context) (string, error)
|
||||
// ExtraReloaders: additional service renderers triggered by
|
||||
// RenderConfigs. Keyed by service name (nftables, wireguard, etc.).
|
||||
ExtraReloaders map[string]func(stdcontext.Context) error
|
||||
// Users: optional — wired after DB pool opens. Used by AgentAuthCheck
|
||||
// so cluster peers can verify credentials against this node's DB.
|
||||
Users *usersvc.Repo
|
||||
}
|
||||
|
||||
func NewSystemHandler(version string) *SystemHandler {
|
||||
@@ -75,6 +84,20 @@ func (h *SystemHandler) WithConfigPreviewers(previewers map[string]func(stdconte
|
||||
return h
|
||||
}
|
||||
|
||||
// WithAllReloaders injectet alle Service-Reloader für RenderConfigs.
|
||||
// Reihenfolge: haproxy ist bereits in HAProxyReloader; extras sind die
|
||||
// restlichen Dienste (nftables, wireguard, squid, unbound, chrony).
|
||||
func (h *SystemHandler) WithAllReloaders(extras map[string]func(stdcontext.Context) error) *SystemHandler {
|
||||
h.ExtraReloaders = extras
|
||||
return h
|
||||
}
|
||||
|
||||
// WithUsers injectet das Users-Repo für AgentAuthCheck.
|
||||
func (h *SystemHandler) WithUsers(u *usersvc.Repo) *SystemHandler {
|
||||
h.Users = u
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *SystemHandler) Register(rg *gin.RouterGroup) {
|
||||
g := rg.Group("/system")
|
||||
g.GET("/health", h.Health)
|
||||
@@ -95,10 +118,12 @@ func (h *SystemHandler) Register(rg *gin.RouterGroup) {
|
||||
g.POST("/haproxy-reload", h.HAProxyReload)
|
||||
g.POST("/render-configs", h.RenderConfigs)
|
||||
g.POST("/service-restart", h.ServiceRestart)
|
||||
g.POST("/service-toggle", h.ServiceToggle)
|
||||
g.GET("/upgrade-status", h.UpgradeStatus)
|
||||
g.GET("/ipv6", h.IPv6)
|
||||
g.POST("/ipv6", h.SetIPv6)
|
||||
g.GET("/config-preview", h.ConfigPreview)
|
||||
g.GET("/vip-status", h.VIPStatus)
|
||||
}
|
||||
|
||||
// RegisterAgent mountet die read-only System-Endpoints auf der mTLS-
|
||||
@@ -107,13 +132,55 @@ 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)
|
||||
g.GET("/resources", h.Resources)
|
||||
// Auth-Federation: Cluster-Nodes verifizieren Credentials gegen diesen
|
||||
// Node via mTLS. Nur über den Agent-Listener (:8443) erreichbar.
|
||||
rg.POST("/agent/auth/check", h.AgentAuthCheck)
|
||||
}
|
||||
|
||||
// AgentAuthCheck verifies email+password against the local users table
|
||||
// and setup.json. Called by cluster nodes over mTLS when local auth fails
|
||||
// so users can log in on any cluster node with the primary's credentials.
|
||||
func (h *SystemHandler) AgentAuthCheck(c *gin.Context) {
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
email := strings.TrimSpace(strings.ToLower(req.Email))
|
||||
if email == "" || req.Password == "" {
|
||||
response.Unauthorized(c, errInvalidCreds)
|
||||
return
|
||||
}
|
||||
|
||||
// Check local users table first.
|
||||
if h.Users != nil {
|
||||
u, hash, err := h.Users.FindByEmail(c.Request.Context(), email)
|
||||
if err == nil && u.Active && usersvc.VerifyPassword(hash, req.Password) {
|
||||
response.OK(c, gin.H{"actor": u.Email, "role": u.Role})
|
||||
return
|
||||
}
|
||||
}
|
||||
// Fallback: setup.json admin.
|
||||
if h.Setup != nil {
|
||||
st, _ := h.Setup.Load()
|
||||
if st != nil && strings.EqualFold(st.AdminEmail, email) && st.VerifyAdminPassword(req.Password) {
|
||||
response.OK(c, gin.H{"actor": st.AdminEmail, "role": "admin"})
|
||||
return
|
||||
}
|
||||
}
|
||||
response.Unauthorized(c, errInvalidCreds)
|
||||
}
|
||||
|
||||
var errInvalidCreds = errors.New("invalid_credentials")
|
||||
|
||||
// servicesToCheck is the curated list shown on the dashboard
|
||||
// service-health-grid. Order matters (UI renders in this sequence).
|
||||
// Each entry is a (label, systemd-unit) pair — label is what the
|
||||
@@ -123,17 +190,23 @@ var servicesToCheck = []struct{ Label, Unit string }{
|
||||
{"edgeguard-scheduler", "edgeguard-scheduler"},
|
||||
{"haproxy", "haproxy"},
|
||||
{"nftables", "nftables"},
|
||||
{"keepalived", "keepalived"},
|
||||
{"unbound", "unbound"},
|
||||
{"chrony", "chrony"},
|
||||
{"squid", "squid"},
|
||||
{"kea-dhcp4", "kea-dhcp4-server"},
|
||||
{"freeradius", "freeradius"},
|
||||
{"postgresql", "postgresql"},
|
||||
{"crowdsec", "crowdsec"},
|
||||
{"crowdsec-firewall-bouncer", "crowdsec-firewall-bouncer"},
|
||||
{"edgeguard-waf", "edgeguard-waf"},
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -200,6 +273,7 @@ type resources struct {
|
||||
LoadAvg1 float64 `json:"load_avg_1"`
|
||||
LoadAvg5 float64 `json:"load_avg_5"`
|
||||
LoadAvg15 float64 `json:"load_avg_15"`
|
||||
NumCPUs int `json:"num_cpus"`
|
||||
MemTotalKB int64 `json:"mem_total_kb"`
|
||||
MemAvailKB int64 `json:"mem_avail_kb"`
|
||||
MemUsedPct float64 `json:"mem_used_pct"`
|
||||
@@ -223,7 +297,7 @@ func (h *SystemHandler) Resources(c *gin.Context) {
|
||||
// gin.Context erstellen kann. Best-effort: fehlende Quellen lassen
|
||||
// die jeweiligen Felder einfach auf 0.
|
||||
func computeLocalSystemResources() resources {
|
||||
r := resources{}
|
||||
r := resources{NumCPUs: runtime.NumCPU()}
|
||||
if data, err := os.ReadFile("/proc/loadavg"); err == nil {
|
||||
f := strings.Fields(string(data))
|
||||
if len(f) >= 3 {
|
||||
@@ -304,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 {
|
||||
@@ -356,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 {
|
||||
@@ -392,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 {
|
||||
@@ -439,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 {
|
||||
@@ -508,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 {
|
||||
@@ -526,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,
|
||||
}
|
||||
|
||||
@@ -564,31 +638,94 @@ func (h *SystemHandler) ServiceRestart(c *gin.Context) {
|
||||
response.OK(c, gin.H{"ok": true, "service": svc})
|
||||
}
|
||||
|
||||
// RenderConfigs erzwingt ein Re-Render aller Service-Configs aus dem
|
||||
// aktuellen DB-State. Wenn der HAProxyReloader gesetzt ist, läuft der
|
||||
// (rendert haproxy.cfg + reload). Praktisch wenn ein Operator denkt
|
||||
// dass die generierte Config nicht mehr mit der DB übereinstimmt
|
||||
// (Drift, Manual-Edit, etc.).
|
||||
//
|
||||
// v1 macht NUR haproxy — weitere Renderer (firewall, dns, ntp, wg,
|
||||
// squid) sind per Handler an die jeweiligen Mutations-Endpoints
|
||||
// gekoppelt; für die fehlt aktuell ein generisches "render all".
|
||||
func (h *SystemHandler) RenderConfigs(c *gin.Context) {
|
||||
if h.HAProxyReloader == nil {
|
||||
response.Err(c, http.StatusServiceUnavailable, simpleErr("renderer not wired"))
|
||||
// toggleAllowlist defines which services may be started/stopped via the UI.
|
||||
var toggleAllowlist = map[string]bool{
|
||||
"crowdsec": true,
|
||||
"crowdsec-firewall-bouncer": true,
|
||||
"edgeguard-waf": true,
|
||||
"squid": true,
|
||||
"unbound": true,
|
||||
}
|
||||
|
||||
// ServiceToggle starts or stops (and enables/disables) a service.
|
||||
// Body: {"service": "crowdsec", "enabled": true}
|
||||
func (h *SystemHandler) ServiceToggle(c *gin.Context) {
|
||||
var req struct {
|
||||
Service string `json:"service" binding:"required"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.Err(c, http.StatusBadRequest, simpleErr("service and enabled required"))
|
||||
return
|
||||
}
|
||||
ctx, cancel := stdcontext.WithTimeout(c.Request.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := h.HAProxyReloader(ctx); err != nil {
|
||||
response.Internal(c, err)
|
||||
svc := strings.TrimSpace(req.Service)
|
||||
if !toggleAllowlist[svc] {
|
||||
response.Err(c, http.StatusBadRequest, simpleErr("service not in toggle allowlist: "+svc))
|
||||
return
|
||||
}
|
||||
unit := svc + ".service"
|
||||
action := "stop"
|
||||
sysdAction := "disable"
|
||||
if req.Enabled {
|
||||
action = "start"
|
||||
sysdAction = "enable"
|
||||
}
|
||||
if out, err := exec.Command("sudo", "-n", "/usr/bin/systemctl", sysdAction, unit).CombinedOutput(); err != nil {
|
||||
response.Err(c, http.StatusInternalServerError, simpleErr(strings.TrimSpace(string(out))+": "+err.Error()))
|
||||
return
|
||||
}
|
||||
if out, err := exec.Command("sudo", "-n", "/usr/bin/systemctl", action, unit).CombinedOutput(); err != nil {
|
||||
response.Err(c, http.StatusInternalServerError, simpleErr(strings.TrimSpace(string(out))+": "+err.Error()))
|
||||
return
|
||||
}
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "system.render_configs",
|
||||
"", gin.H{}, h.NodeID)
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "system.service_toggle",
|
||||
svc, gin.H{"service": svc, "enabled": req.Enabled}, h.NodeID)
|
||||
}
|
||||
response.OK(c, gin.H{"ok": true, "rendered": []string{"haproxy"}})
|
||||
response.OK(c, gin.H{"ok": true, "service": svc, "enabled": req.Enabled})
|
||||
}
|
||||
|
||||
// RenderConfigs erzwingt ein Re-Render aller Service-Configs aus dem
|
||||
// aktuellen DB-State. Läuft haproxy + alle ExtraReloaders (nftables,
|
||||
// wireguard, squid, unbound, chrony) durch. Fehler werden gesammelt
|
||||
// und als partial-Antwort zurückgegeben — erfolgreich gerenderte
|
||||
// Dienste stehen in "rendered", fehlgeschlagene in "errors".
|
||||
func (h *SystemHandler) RenderConfigs(c *gin.Context) {
|
||||
if h.HAProxyReloader == nil && len(h.ExtraReloaders) == 0 {
|
||||
response.Err(c, http.StatusServiceUnavailable, simpleErr("renderer not wired"))
|
||||
return
|
||||
}
|
||||
ctx, cancel := stdcontext.WithTimeout(c.Request.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
rendered := []string{}
|
||||
errs := map[string]string{}
|
||||
|
||||
if h.HAProxyReloader != nil {
|
||||
if err := h.HAProxyReloader(ctx); err != nil {
|
||||
errs["haproxy"] = err.Error()
|
||||
} else {
|
||||
rendered = append(rendered, "haproxy")
|
||||
}
|
||||
}
|
||||
// Defined order so the audit log is deterministic.
|
||||
order := []string{"nftables", "wireguard", "squid", "unbound", "chrony"}
|
||||
for _, name := range order {
|
||||
fn, ok := h.ExtraReloaders[name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if err := fn(ctx); err != nil {
|
||||
errs[name] = err.Error()
|
||||
} else {
|
||||
rendered = append(rendered, name)
|
||||
}
|
||||
}
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "system.render_configs",
|
||||
"", gin.H{"rendered": rendered, "errors": errs}, h.NodeID)
|
||||
}
|
||||
response.OK(c, gin.H{"ok": len(errs) == 0, "rendered": rendered, "errors": errs})
|
||||
}
|
||||
|
||||
// UpgradeStatus liefert den Status des letzten Self-Upgrade-Versuchs.
|
||||
@@ -653,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
|
||||
}
|
||||
}
|
||||
@@ -725,10 +862,31 @@ func (h *SystemHandler) ConfigPreview(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *SystemHandler) Health(c *gin.Context) {
|
||||
response.OK(c, gin.H{
|
||||
resp := gin.H{
|
||||
"status": "ok",
|
||||
"version": h.Version,
|
||||
})
|
||||
}
|
||||
if hn, err := os.Hostname(); err == nil {
|
||||
resp["hostname"] = hn
|
||||
}
|
||||
if data, err := os.ReadFile("/proc/version"); err == nil {
|
||||
// /proc/version: "Linux version 6.x.y (...)" — Kurzform: alles
|
||||
// bis zum ersten '(' kürzen.
|
||||
line := strings.TrimSpace(string(data))
|
||||
if idx := strings.Index(line, " ("); idx > 0 {
|
||||
line = strings.TrimSpace(line[:idx])
|
||||
}
|
||||
resp["kernel"] = line
|
||||
}
|
||||
if data, err := os.ReadFile("/etc/os-release"); err == nil {
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if k, v, ok := strings.Cut(line, "="); ok && k == "PRETTY_NAME" {
|
||||
resp["os"] = strings.Trim(v, `"`)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
response.OK(c, resp)
|
||||
}
|
||||
|
||||
// PackageVersions reports installed and available versions for the
|
||||
@@ -762,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
|
||||
@@ -782,7 +940,10 @@ retry_apt() {
|
||||
attempt=$((attempt + 1))
|
||||
echo "[upgrade] attempt $attempt/$max: apt-get update + install"
|
||||
apt-get update -qq || true
|
||||
if apt-get install -y -qq -o Dpkg::Options::=--force-confold \
|
||||
# --allow-downgrades: nur relevant nach testing→stable-Kanalwechsel
|
||||
# (Testing-Versionen sortieren datumsbasiert höher als Stable-Semver).
|
||||
# No-Op im Normalfall, da die Candidate sonst immer >= installed ist.
|
||||
if apt-get install -y -qq --allow-downgrades -o Dpkg::Options::=--force-confold \
|
||||
edgeguard-api edgeguard-ui edgeguard; then
|
||||
return 0
|
||||
fi
|
||||
@@ -831,6 +992,10 @@ rm -f /var/lib/edgeguard/upgrade.sh
|
||||
_ = fallback.Process.Release()
|
||||
}
|
||||
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "system.upgrade",
|
||||
"", gin.H{"unit": unitName}, h.NodeID)
|
||||
}
|
||||
c.JSON(http.StatusAccepted, response.Envelope{
|
||||
Data: gin.H{"status": "upgrading", "unit": unitName},
|
||||
Error: nil,
|
||||
@@ -970,6 +1135,87 @@ func classifyLinkType(ifc net.Interface) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// VIPStatus returns the VRRP state and active VIPs for this node.
|
||||
// Uses net.Interfaces() (no shell-out) to check which VIPs from
|
||||
// ip_addresses WHERE is_vip=true are currently assigned locally.
|
||||
// MASTER = at least one VIP is locally present; BACKUP = none present.
|
||||
func (h *SystemHandler) VIPStatus(c *gin.Context) {
|
||||
type vipEntry struct {
|
||||
Address string `json:"address"`
|
||||
Prefix int `json:"prefix"`
|
||||
Device string `json:"device"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
type vipStatus struct {
|
||||
VRRPState string `json:"vrrp_state"`
|
||||
KeepalivedActive bool `json:"keepalived_active"`
|
||||
VIPs []vipEntry `json:"vips"`
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// keepalived service active?
|
||||
kaOut, _ := exec.CommandContext(ctx, "systemctl", "is-active", "keepalived").Output()
|
||||
kaActive := strings.TrimSpace(string(kaOut)) == "active"
|
||||
|
||||
// query VIPs from DB
|
||||
var dbVIPs []vipEntry
|
||||
if h.Pool != nil {
|
||||
rows, err := h.Pool.Query(ctx,
|
||||
`SELECT a.address, a.prefix, COALESCE(i.name,'') AS device
|
||||
FROM ip_addresses a
|
||||
LEFT JOIN network_interfaces i ON i.id = a.interface_id
|
||||
WHERE a.is_vip = true AND a.active = true
|
||||
ORDER BY a.address`)
|
||||
if err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var e vipEntry
|
||||
if err2 := rows.Scan(&e.Address, &e.Prefix, &e.Device); err2 == nil {
|
||||
dbVIPs = append(dbVIPs, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// build set of locally assigned IPs
|
||||
localIPs := make(map[string]bool)
|
||||
if ifaces, err := net.Interfaces(); err == nil {
|
||||
for _, ifc := range ifaces {
|
||||
if addrs, err2 := ifc.Addrs(); err2 == nil {
|
||||
for _, a := range addrs {
|
||||
if ipnet, ok := a.(*net.IPNet); ok {
|
||||
localIPs[ipnet.IP.String()] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
anyActive := false
|
||||
for i := range dbVIPs {
|
||||
dbVIPs[i].Active = localIPs[dbVIPs[i].Address]
|
||||
if dbVIPs[i].Active {
|
||||
anyActive = true
|
||||
}
|
||||
}
|
||||
|
||||
state := "UNKNOWN"
|
||||
if kaActive {
|
||||
if anyActive {
|
||||
state = "MASTER"
|
||||
} else {
|
||||
state = "BACKUP"
|
||||
}
|
||||
}
|
||||
|
||||
response.OK(c, vipStatus{
|
||||
VRRPState: state,
|
||||
KeepalivedActive: kaActive,
|
||||
VIPs: dbVIPs,
|
||||
})
|
||||
}
|
||||
|
||||
func flagsToList(f net.Flags) []string {
|
||||
var out []string
|
||||
if f&net.FlagUp != 0 {
|
||||
@@ -992,4 +1238,3 @@ func flagsToList(f net.Flags) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user