Compare commits
178 Commits
3178e25e78
...
v1.2.96
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
2d027b3044 | ||
|
|
9708e4441b | ||
|
|
1aaca2c056 | ||
|
|
49e3e39ea6 | ||
|
|
f6852256b1 | ||
|
|
bb14d2ac6c | ||
|
|
6e52dc0620 | ||
|
|
ca1ee64829 | ||
|
|
92eece936d | ||
|
|
519522b8ee | ||
|
|
e04bddd574 | ||
|
|
e37e472960 | ||
|
|
8c1963cac5 | ||
|
|
f33c1bfe4f | ||
|
|
c9caf35596 | ||
|
|
e18037375f | ||
|
|
3c95bc58d9 | ||
|
|
b06bb76c20 | ||
|
|
661c810cde | ||
|
|
dc8841b407 | ||
|
|
1913a69bb5 | ||
|
|
5476e84bb8 | ||
|
|
dd3e001312 | ||
|
|
ba907f5403 | ||
|
|
e419eceab8 | ||
|
|
784533eb99 | ||
|
|
b93f4e6361 | ||
|
|
ee192a1c9c | ||
|
|
99982ef9d1 | ||
|
|
386972366b | ||
|
|
4e01b4569c | ||
|
|
906c2e17a6 | ||
|
|
a490576420 | ||
|
|
8293783fe6 | ||
|
|
6445e162a6 | ||
|
|
bc5d81d966 | ||
|
|
1f0d05019e | ||
|
|
a0403b8d00 | ||
|
|
e7fc6b989b | ||
|
|
711e9446e6 | ||
|
|
f4d178cb38 | ||
|
|
9a05e8bca8 | ||
|
|
95583e9657 | ||
|
|
7fab17c231 | ||
|
|
bd6e67f059 | ||
|
|
e4b66cfcac | ||
|
|
20f3a4fe26 | ||
|
|
35b7308ce2 |
37
CLAUDE.md
37
CLAUDE.md
@@ -27,25 +27,36 @@ Vor jeder Entscheidung über Feldwerte, API-Shapes, Dateinamen, Funktions-Signat
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Arbeitsweise — Senior Engineer + MCP (PFLICHT)
|
## MCP-Tools (Architect Center)
|
||||||
|
|
||||||
### Session-Bindung
|
**project_id: 8** · Session: `$ARCHITECT_SESSION` · MCP-Server: `architect`
|
||||||
Sessions: `EdgeGuardNative-1` · project_id **8** · `$ARCHITECT_SESSION` im Env · MCP-Server `architect` verfügbar
|
|
||||||
|
|
||||||
### Code-Suche — AUTOMATISCH nutzen
|
|
||||||
|
|
||||||
Bei JEDER Code-bezogenen Frage **zuerst** `ac_search_code` aufrufen:
|
|
||||||
|
|
||||||
|
### Beim Session-Start (PFLICHT)
|
||||||
```
|
```
|
||||||
ac_search_code(query="<stichworte>", project_id=8, session_name=$(printenv ARCHITECT_SESSION), limit=6)
|
ac_recall(query="<aktuelle Aufgabe>", project_id=8, session_name="$(printenv ARCHITECT_SESSION)")
|
||||||
|
ac_search_code(query="<Stichworte>", project_id=8, session_name="$(printenv ARCHITECT_SESSION)", limit=6)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Referenzen:**
|
### Alle Tools
|
||||||
- Backend-Pattern → `project_id=6` (mail-gateway)
|
|
||||||
- UI/Bootstrap-Pattern → `project_id=5` (netcell-webpanel)
|
|
||||||
- Feature-Scope (was alt-EG konnte) → `project_id=3` (proxy-lb-waf)
|
|
||||||
|
|
||||||
**Verboten:** direkt `grep` oder `read` ohne vorheriges `ac_search_code` bei Code-Fragen.
|
| Tool | Wann verwenden |
|
||||||
|
|------|----------------|
|
||||||
|
| `ac_recall(query, project_id=8, session_name)` | Session-Start — relevante Memories laden |
|
||||||
|
| `ac_search_code(query, project_id=8, session_name, limit?)` | Vor JEDEM neuen Code schreiben |
|
||||||
|
| `ac_search_code_global(query, session_name, limit?)` | Suche über alle Projekte hinweg |
|
||||||
|
| `ac_remember(text, category, project_id=8, session_name)` | Jede neue Erkenntnis / Entscheidung |
|
||||||
|
| `ac_create_bug(title, description?, priority?, session_name?)` | Bug gefunden |
|
||||||
|
| `ac_create_feature(title, description?, priority?, session_name?)` | Feature-Idee |
|
||||||
|
| `ac_create_task(title, description?, priority?, status?, project_id?)` | Follow-up Arbeit |
|
||||||
|
| `ac_update_task(id, status?, priority?, title?)` | Task-Status ändern |
|
||||||
|
| `ac_add_decision(title, content, category?, project_id?)` | Architekturentscheidung |
|
||||||
|
| `ac_notify(message, title?, type?)` | Status-Update senden |
|
||||||
|
| `ac_get_project(session_name)` | project_id für Session ermitteln |
|
||||||
|
| `ac_research(query, context?)` | Doku / CVE / Libraries recherchieren |
|
||||||
|
| `ac_review(code, language?, focus?)` | Code-Review nach Fertigstellung |
|
||||||
|
| `ac_analyze(text, question?)` | Logs / Outputs analysieren |
|
||||||
|
|
||||||
|
**`ac_remember` Kategorien:** `decision` · `pattern` · `bugfix` · `convention` · `architecture` · `general`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
21
Makefile
21
Makefile
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
GO ?= $(shell which go || echo /usr/local/go/bin/go)
|
GO ?= $(shell which go || echo /usr/local/go/bin/go)
|
||||||
MODULE := git.netcell-it.de/projekte/edgeguard-native
|
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)
|
VERSION := $(shell cat VERSION 2>/dev/null || echo 0.0.1-dev)
|
||||||
LDFLAGS := -s -w -X main.version=$(VERSION)
|
LDFLAGS := -s -w -X main.version=$(VERSION)
|
||||||
GOFLAGS := -trimpath -mod=readonly
|
GOFLAGS := -trimpath -mod=readonly
|
||||||
@@ -85,21 +85,14 @@ deb: deb-amd64 deb-arm64
|
|||||||
GITEA_DEB_URL := https://git.netcell-it.de/api/packages/projekte/debian/pool/trixie/main/upload
|
GITEA_DEB_URL := https://git.netcell-it.de/api/packages/projekte/debian/pool/trixie/main/upload
|
||||||
|
|
||||||
publish-amd64: deb-amd64
|
publish-amd64: deb-amd64
|
||||||
@TOK="$$(cat $$HOME/.gitea-token | tr -d '\n')"; \
|
@./scripts/apt-repo/publish.sh $(VERSION) amd64
|
||||||
if [ -z "$$TOK" ]; then echo "publish: ~/.gitea-token is empty"; exit 1; fi; \
|
@echo " -> cleanup-old (keep last $${KEEP:-10})"
|
||||||
for f in edgeguard-api_$(VERSION)_amd64.deb edgeguard-ui_$(VERSION)_all.deb edgeguard_$(VERSION)_all.deb; do \
|
@./scripts/apt-repo/cleanup-old.sh
|
||||||
echo " -> publish $$f"; \
|
|
||||||
curl -sS -H "Authorization: token $$TOK" --upload-file build/deb/$$f $(GITEA_DEB_URL); \
|
|
||||||
echo ""; \
|
|
||||||
done
|
|
||||||
|
|
||||||
publish-arm64: deb-arm64
|
publish-arm64: deb-arm64
|
||||||
@TOK="$$(cat $$HOME/.gitea-token | tr -d '\n')"; \
|
@./scripts/apt-repo/publish.sh $(VERSION) arm64
|
||||||
if [ -z "$$TOK" ]; then echo "publish: ~/.gitea-token is empty"; exit 1; fi; \
|
@echo " -> cleanup-old (keep last $${KEEP:-10})"
|
||||||
echo " -> publish edgeguard-api_$(VERSION)_arm64.deb"; \
|
@./scripts/apt-repo/cleanup-old.sh
|
||||||
curl -sS -H "Authorization: token $$TOK" \
|
|
||||||
--upload-file build/deb/edgeguard-api_$(VERSION)_arm64.deb \
|
|
||||||
$(GITEA_DEB_URL)
|
|
||||||
|
|
||||||
publish: publish-amd64 publish-arm64
|
publish: publish-amd64 publish-arm64
|
||||||
|
|
||||||
|
|||||||
2
agent.md
2
agent.md
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
Der Architect Center Orchestrator dispatcht mehrere Claude Code Agenten, jeder spezialisiert auf eine Schicht des Projekts. Alle Agenten haben Zugriff auf:
|
Der Architect Center Orchestrator dispatcht mehrere Claude Code Agenten, jeder spezialisiert auf eine Schicht des Projekts. Alle Agenten haben Zugriff auf:
|
||||||
- **RAG (Qdrant):** Code-Index von mail-gateway (project_id=6) und netcell-webpanel (project_id=5) als Referenz
|
- **RAG (Qdrant):** Code-Index von mail-gateway (project_id=6) und netcell-webpanel (project_id=5) als Referenz
|
||||||
- **MCP-Server `architect`:** ac_search_code, ac_send_instruction, ac_read_file
|
- **MCP-Server `architect`:** ac_recall, ac_search_code, ac_search_code_global, ac_remember, ac_create_bug, ac_create_feature, ac_create_task, ac_update_task, ac_add_decision, ac_notify, ac_get_project, ac_research, ac_review, ac_analyze
|
||||||
- **Session:** eigene tmux-Session pro Agent (EdgeGuardNative-1 … N)
|
- **Session:** eigene tmux-Session pro Agent (EdgeGuardNative-1 … N)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import (
|
|||||||
squidrender "git.netcell-it.de/projekte/edgeguard-native/internal/squid"
|
squidrender "git.netcell-it.de/projekte/edgeguard-native/internal/squid"
|
||||||
unboundrender "git.netcell-it.de/projekte/edgeguard-native/internal/unbound"
|
unboundrender "git.netcell-it.de/projekte/edgeguard-native/internal/unbound"
|
||||||
wgrender "git.netcell-it.de/projekte/edgeguard-native/internal/wireguard"
|
wgrender "git.netcell-it.de/projekte/edgeguard-native/internal/wireguard"
|
||||||
|
kearender "git.netcell-it.de/projekte/edgeguard-native/internal/kea"
|
||||||
|
radiusrender "git.netcell-it.de/projekte/edgeguard-native/internal/freeradius"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/acme"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/acme"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/alerts"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/alerts"
|
||||||
@@ -37,6 +39,12 @@ import (
|
|||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/backup"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/backup"
|
||||||
backupremote "git.netcell-it.de/projekte/edgeguard-native/internal/services/backup/remote"
|
backupremote "git.netcell-it.de/projekte/edgeguard-native/internal/services/backup/remote"
|
||||||
dnssvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/dns"
|
dnssvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/dns"
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/aggregator"
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/clustertls"
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/jointoken"
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/clusterjoin"
|
||||||
|
aptsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/apt"
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/domainheaders"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/domains"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/domains"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/firewall"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/firewall"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/firewalllog"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/firewalllog"
|
||||||
@@ -52,9 +60,14 @@ import (
|
|||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
|
||||||
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
|
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
|
||||||
|
dhcpsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/dhcp"
|
||||||
|
oidcsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/oidc"
|
||||||
|
radiussvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/radius"
|
||||||
|
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
|
||||||
|
wafsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/waf"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "1.0.78"
|
var version = "1.2.35"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
addr := os.Getenv("EDGEGUARD_API_ADDR")
|
addr := os.Getenv("EDGEGUARD_API_ADDR")
|
||||||
@@ -105,9 +118,27 @@ func main() {
|
|||||||
|
|
||||||
requireAuth := handlers.RequireAuth(signer)
|
requireAuth := handlers.RequireAuth(signer)
|
||||||
|
|
||||||
handlers.NewSetupHandler(setupStore).Register(v1)
|
setupHdl := handlers.NewSetupHandler(setupStore).WithVersion(version)
|
||||||
handlers.NewSystemHandler(version).Register(v1)
|
setupHdl.Register(v1)
|
||||||
handlers.NewAuthHandler(setupStore, signer).Register(v1, requireAuth)
|
|
||||||
|
// systemHdl exists früh damit sowohl der frühe (DB-pool nicht
|
||||||
|
// nötige) Pfad als auch der späte WithMaintenance-Hookup gehen.
|
||||||
|
systemHdl := handlers.NewSystemHandler(version)
|
||||||
|
systemHdl.Register(v1)
|
||||||
|
authHdl := handlers.NewAuthHandler(setupStore, signer)
|
||||||
|
authHdl.Register(v1, requireAuth)
|
||||||
|
|
||||||
|
// Background-Refresh für apt-cache: hält die Apt-Lists alle 5 min
|
||||||
|
// frisch, damit der UI-Update-Banner kurz nach `make publish` ein
|
||||||
|
// verfügbares Update sieht — ohne den Background-Timer wäre der
|
||||||
|
// Cache nur nach UI-Polls aktuell und der Throttle würde
|
||||||
|
// Aktualisierungen zwischen den Polls schlucken.
|
||||||
|
aptsvc.StartBackgroundRefresh(context.Background())
|
||||||
|
|
||||||
|
// agentHdl wird vom Agent-Listener mit-gemountet (Phase 3.5).
|
||||||
|
// Nil-safe — wenn DB nicht offen ist, läuft der Agent-Listener
|
||||||
|
// nur mit den read-only System-Endpoints.
|
||||||
|
var agentHdl *handlers.ClusterHandler
|
||||||
|
|
||||||
// Open the DB pool best-effort. Without a reachable PG, CRUD
|
// Open the DB pool best-effort. Without a reachable PG, CRUD
|
||||||
// handlers stay unregistered and only Auth/Setup/System answer —
|
// handlers stay unregistered and only Auth/Setup/System answer —
|
||||||
@@ -141,8 +172,83 @@ func main() {
|
|||||||
}
|
}
|
||||||
cancel()
|
cancel()
|
||||||
|
|
||||||
|
// Phase 3.2: alle 30s Heartbeat (last_seen, status, version,
|
||||||
|
// config_hash) für die eigene Row. Goroutine läuft so lange wie
|
||||||
|
// die API — beim graceful Shutdown stoppt sie via ctx.Done().
|
||||||
|
// Hält den eigenen Node-Status auch dann frisch wenn der
|
||||||
|
// Scheduler gerade down ist; ein crashender API stoppt den
|
||||||
|
// Heartbeat → Peer-Sweeper markiert binnen 2 min "offline".
|
||||||
|
if nodeID != "" {
|
||||||
|
go runClusterHeartbeat(context.Background(), pool, nodeID, version)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Secondary: push config_hash to primary every 5 min so the primary's
|
||||||
|
// ha_nodes reflects actual state. Without this, the primary retains the
|
||||||
|
// stale hash written at join-time and the drift banner never clears.
|
||||||
|
// st.IsClusterNode + PrimaryFQDN are only set on joined secondary nodes.
|
||||||
|
if nodeID != "" && st != nil && st.IsClusterNode && st.PrimaryFQDN != "" {
|
||||||
|
if primaryURL, normErr := clusterjoin.NormalizePrimaryURL(st.PrimaryFQDN); normErr == nil {
|
||||||
|
go runPrimaryPush(context.Background(), pool, nodeID, st.FQDN, version, primaryURL)
|
||||||
|
} else {
|
||||||
|
slog.Warn("cluster: cannot normalize primary URL for push", "primary", st.PrimaryFQDN, "error", normErr)
|
||||||
|
}
|
||||||
|
// runSecondaryConfigRender wird weiter unten gestartet sobald
|
||||||
|
// clusterAggregator verfügbar ist (braucht mTLS-Client für Cert-Sync).
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 3.3: Cluster-CA + Peer-Cert. Founder-Pfad — auf einem
|
||||||
|
// frisch installierten Single-Node generieren wir die CA und
|
||||||
|
// signieren uns selbst, damit der Agent-Listener auf :8443
|
||||||
|
// gleich hochfahren kann. Joining-Nodes (Phase 3.4) werden den
|
||||||
|
// Pfad nicht durchlaufen: sie kriegen CA+Peer-Cert vom Primary
|
||||||
|
// gepusht und finden die Files bereits vor.
|
||||||
|
clusterTLSStore := clustertls.New("")
|
||||||
|
if !clusterTLSStore.HasCA() {
|
||||||
|
org := "edgeguard.local"
|
||||||
|
if st != nil && st.FQDN != "" {
|
||||||
|
org = st.FQDN
|
||||||
|
}
|
||||||
|
if err := clusterTLSStore.InitCA(org, nil); err != nil {
|
||||||
|
slog.Warn("cluster-tls: InitCA failed", "error", err)
|
||||||
|
} else {
|
||||||
|
slog.Info("cluster-tls: CA generated", "dir", clusterTLSStore.Dir, "org", org)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if clusterTLSStore.HasCA() && !clusterTLSStore.HasPeer() {
|
||||||
|
cn := "edgeguard-node"
|
||||||
|
var dnsNames []string
|
||||||
|
if st != nil && st.FQDN != "" {
|
||||||
|
cn = st.FQDN
|
||||||
|
dnsNames = []string{st.FQDN}
|
||||||
|
}
|
||||||
|
if err := clusterTLSStore.EnsureSelfSigned(cn, dnsNames, nil, nil); err != nil {
|
||||||
|
slog.Warn("cluster-tls: EnsureSelfSigned failed", "error", err)
|
||||||
|
} else {
|
||||||
|
slog.Info("cluster-tls: peer cert generated", "cn", cn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregator nur aufsetzen wenn Cert-Material da ist — sonst
|
||||||
|
// fan-out scheitert sowieso am Handshake.
|
||||||
|
var clusterAggregator *aggregator.Aggregator
|
||||||
|
if clusterTLSStore.HasPeer() {
|
||||||
|
if clientTLS, err := clusterTLSStore.ClientTLSConfig(); err == nil {
|
||||||
|
clusterAggregator = aggregator.New(clientTLS)
|
||||||
|
slog.Info("cluster: aggregator ready", "agent_port", aggregator.DefaultAgentPort)
|
||||||
|
} else {
|
||||||
|
slog.Warn("cluster: ClientTLSConfig failed", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
auditRepo := audit.New(pool)
|
||||||
domainsRepo := domains.New(pool)
|
domainsRepo := domains.New(pool)
|
||||||
|
domainHeadersRepo := domainheaders.New(pool)
|
||||||
backendsRepo := backends.New(pool)
|
backendsRepo := backends.New(pool)
|
||||||
backendServersRepo := backendservers.New(pool)
|
backendServersRepo := backendservers.New(pool)
|
||||||
routingRepo := routingrules.New(pool)
|
routingRepo := routingrules.New(pool)
|
||||||
@@ -176,13 +282,54 @@ func main() {
|
|||||||
// injiziert, damit jede Änderung ohne expliziten render-config-
|
// injiziert, damit jede Änderung ohne expliziten render-config-
|
||||||
// Aufruf live geht. Errors werden geloggt, nicht failed
|
// Aufruf live geht. Errors werden geloggt, nicht failed
|
||||||
// (Row schon committed, Operator kann manuell re-triggern).
|
// (Row schon committed, Operator kann manuell re-triggern).
|
||||||
|
// Maintenance-Endpoints brauchen den Reloader — späte Wiring
|
||||||
|
// nachdem haproxyReloader-closure existiert.
|
||||||
|
haproxyReloaderForLater := func(ctx context.Context) error {
|
||||||
|
return haproxy.New(pool).Render(ctx)
|
||||||
|
}
|
||||||
|
systemHdl.WithMaintenance(setupStore, haproxyReloaderForLater)
|
||||||
|
|
||||||
|
// Audit-Wiring (Phase Polish): Settings + Auth-Mutationen
|
||||||
|
// landen jetzt im audit_log. Nodes-id ist die persistente
|
||||||
|
// /var/lib/edgeguard/node-id.
|
||||||
|
systemHdl.WithAudit(auditRepo, nodeID)
|
||||||
|
systemHdl.WithDB(pool)
|
||||||
|
systemHdl.WithConfigPreviewers(map[string]func(context.Context) (string, error){
|
||||||
|
"haproxy": haproxy.New(pool).RenderToString,
|
||||||
|
"nftables": firewallrender.New(pool).RenderToString,
|
||||||
|
"squid": squidrender.New(pool).RenderToString,
|
||||||
|
"unbound": unboundrender.New(pool).RenderToString,
|
||||||
|
"chrony": chronyrender.New(pool).RenderToString,
|
||||||
|
"wireguard": wgrender.New(pool, secretsBox).RenderToString,
|
||||||
|
})
|
||||||
|
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).WithClusterTLS(clusterTLSStore)
|
||||||
|
systemHdl.WithUsers(usersRepo)
|
||||||
|
|
||||||
haproxyReloader := func(ctx context.Context) error {
|
haproxyReloader := func(ctx context.Context) error {
|
||||||
return haproxy.New(pool).Render(ctx)
|
return haproxy.New(pool).Render(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
authed := v1.Group("")
|
authed := v1.Group("")
|
||||||
authed.Use(requireAuth)
|
authed.Use(requireAuth, handlers.RequireAdminForMutations())
|
||||||
handlers.NewDomainsHandler(domainsRepo, routingRepo, auditRepo, nodeID, haproxyReloader).Register(authed)
|
setupHdl.RegisterAuthed(authed)
|
||||||
|
handlers.NewUsersHandler(usersRepo, auditRepo, nodeID).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, haproxyReloader).Register(authed)
|
||||||
handlers.NewBackendsHandler(backendsRepo, auditRepo, nodeID, haproxyReloader).Register(authed)
|
handlers.NewBackendsHandler(backendsRepo, auditRepo, nodeID, haproxyReloader).Register(authed)
|
||||||
handlers.NewBackendServersHandler(backendServersRepo, auditRepo, nodeID, haproxyReloader).Register(authed)
|
handlers.NewBackendServersHandler(backendServersRepo, auditRepo, nodeID, haproxyReloader).Register(authed)
|
||||||
handlers.NewRoutingRulesHandler(routingRepo, auditRepo, nodeID, haproxyReloader).Register(authed)
|
handlers.NewRoutingRulesHandler(routingRepo, auditRepo, nodeID, haproxyReloader).Register(authed)
|
||||||
@@ -190,7 +337,39 @@ func main() {
|
|||||||
handlers.NewIPAddressesHandler(ipsRepo, auditRepo, nodeID).Register(authed)
|
handlers.NewIPAddressesHandler(ipsRepo, auditRepo, nodeID).Register(authed)
|
||||||
handlers.NewRoutesHandler(staticroutes.New(pool), staticroutes.NewGenerator(pool),
|
handlers.NewRoutesHandler(staticroutes.New(pool), staticroutes.NewGenerator(pool),
|
||||||
auditRepo, nodeID).Register(authed)
|
auditRepo, nodeID).Register(authed)
|
||||||
handlers.NewClusterHandler(clusterStore, nodeID).Register(authed)
|
// Phase 3.4 — Join-Token-Service. CA-Fingerprint kommt aus
|
||||||
|
// dem clustertls.Store; ohne CA = nil Tokens, GenerateToken
|
||||||
|
// scheitert, IssueCert wird gar nicht erst gemountet.
|
||||||
|
var joinTokens *jointoken.Service
|
||||||
|
if clusterTLSStore.HasCA() {
|
||||||
|
joinTokens = jointoken.New(pool, func() (string, error) {
|
||||||
|
caCert, _, err := clusterTLSStore.LoadCA()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return jointoken.CAFingerprint16(caCert.Raw), nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// PeerReloader: nach Auto-Register triggert das den firewall-
|
||||||
|
// Render damit peer_ipv4 frisch ist und der mTLS-Listener für
|
||||||
|
// den neuen Peer erreichbar wird. Best-effort.
|
||||||
|
peerReloader := func(ctx context.Context) error {
|
||||||
|
return firewallrender.New(pool).Render(ctx)
|
||||||
|
}
|
||||||
|
clusterHdl := handlers.NewClusterHandler(clusterStore, nodeID).
|
||||||
|
WithAggregator(clusterAggregator).
|
||||||
|
WithJoinFlow(clusterTLSStore, joinTokens).
|
||||||
|
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
|
||||||
|
// Auth-Stufe.
|
||||||
|
clusterHdl.RegisterPublic(v1)
|
||||||
|
// Agent-Listener (mTLS) bekommt clusterHdl mit, damit Joiner
|
||||||
|
// sich via /agent/cluster/peers eintragen können.
|
||||||
|
agentHdl = clusterHdl
|
||||||
handlers.NewAuditHandler(auditRepo).Register(authed)
|
handlers.NewAuditHandler(auditRepo).Register(authed)
|
||||||
handlers.NewHAProxyStatsHandler().Register(authed)
|
handlers.NewHAProxyStatsHandler().Register(authed)
|
||||||
|
|
||||||
@@ -218,6 +397,8 @@ func main() {
|
|||||||
return firewallrender.New(pool).Render(ctx)
|
return firewallrender.New(pool).Render(ctx)
|
||||||
}
|
}
|
||||||
handlers.NewFirewallHandler(fwZones, fwAddrObj, fwAddrGrp, fwSvc, fwSvcGrp, fwRules, fwNAT, auditRepo, nodeID, fwReloader, pool).Register(authed)
|
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
|
// withFW wraps a service-reloader so that AFTER the service is
|
||||||
// reloaded, the firewall is also re-rendered. Necessary for
|
// reloaded, the firewall is also re-rendered. Necessary for
|
||||||
@@ -268,6 +449,30 @@ func main() {
|
|||||||
}
|
}
|
||||||
handlers.NewNTPHandler(ntpRepo, auditRepo, nodeID, withFW(chronyReloader)).Register(authed)
|
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
|
// License — node-local key store + DB-mirror of last verify
|
||||||
// result. Real verify runs against license.netcell-it.com via
|
// result. Real verify runs against license.netcell-it.com via
|
||||||
// internal/license; the scheduler triggers daily re-verify.
|
// internal/license; the scheduler triggers daily re-verify.
|
||||||
@@ -280,10 +485,35 @@ func main() {
|
|||||||
// scheduler. StartPeriodicVerification is a no-op when the key
|
// scheduler. StartPeriodicVerification is a no-op when the key
|
||||||
// is empty.
|
// is empty.
|
||||||
licClient.StartPeriodicVerification(licKeyStore.Get())
|
licClient.StartPeriodicVerification(licKeyStore.Get())
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// idempotent und atomar; kein Dienst wird neu gestartet.
|
||||||
|
go func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := firewallrender.New(pool).Render(ctx); err != nil {
|
||||||
|
slog.Warn("startup: nftables render failed", "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
mountUI(r)
|
mountUI(r)
|
||||||
|
|
||||||
|
// Phase 3.3: zweiter Listener auf :8443 mit mTLS für Cluster-Peer-
|
||||||
|
// Reads. RequireAndVerifyClientCert gegen unsere Cluster-CA — wer
|
||||||
|
// keinen CA-signierten Cert hat, kommt nicht durch den Handshake.
|
||||||
|
// 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, 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)
|
log.Printf("edgeguard-api %s listening on %s", version, addr)
|
||||||
srv := &http.Server{Addr: addr, Handler: r}
|
srv := &http.Server{Addr: addr, Handler: r}
|
||||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
@@ -291,6 +521,57 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// startAgentListener startet den mTLS-Agent-Listener auf :8443 als
|
||||||
|
// Goroutine. Mountet nur read-only Endpoints (siehe SystemHandler.
|
||||||
|
// 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, sysHdl *handlers.SystemHandler) {
|
||||||
|
store := clustertls.New("")
|
||||||
|
serverTLS, err := store.ServerTLSConfig()
|
||||||
|
if err != nil {
|
||||||
|
slog.Info("cluster: agent listener disabled (no cert material)", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
addr := os.Getenv("EDGEGUARD_AGENT_LISTEN")
|
||||||
|
if addr == "" {
|
||||||
|
// 0.0.0.0:8443 — Auth via mTLS, also unbedenklich auf Public-IP.
|
||||||
|
// nft anti-lockout-Regel + Peer-IP-Set bestimmen wer überhaupt
|
||||||
|
// connecten darf. Loopback-Tests gehen direkt.
|
||||||
|
addr = "0.0.0.0:8443"
|
||||||
|
}
|
||||||
|
r := gin.New()
|
||||||
|
r.Use(gin.Recovery())
|
||||||
|
// Kein /api/v1-Prefix auf dem Agent-Listener: das Versioning kommt
|
||||||
|
// hier implizit aus dem Binary (Peer-Roundtrip ist immer same-major).
|
||||||
|
// Aggregator-Aufrufer sehen /agent/... direkt.
|
||||||
|
root := r.Group("")
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: addr,
|
||||||
|
Handler: r,
|
||||||
|
TLSConfig: serverTLS,
|
||||||
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
slog.Info("cluster: agent (mTLS) listener starting", "addr", addr)
|
||||||
|
if err := srv.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {
|
||||||
|
slog.Error("cluster: agent listener", "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
// mountUI serves the management UI — Vite-built static assets under
|
// mountUI serves the management UI — Vite-built static assets under
|
||||||
// /usr/share/edgeguard/ui/ — with SPA fallback (any path that isn't
|
// /usr/share/edgeguard/ui/ — with SPA fallback (any path that isn't
|
||||||
// /api/* or /healthz and isn't a real file → index.html). When the
|
// /api/* or /healthz and isn't a real file → index.html). When the
|
||||||
@@ -336,10 +617,18 @@ func mountUI(r *gin.Engine) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if info, err := os.Stat(full); err == nil && !info.IsDir() {
|
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)
|
c.File(full)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// SPA fallback — React Router renders the right page.
|
// SPA fallback — React Router renders the right page.
|
||||||
|
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||||
c.File(indexPath)
|
c.File(indexPath)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -428,6 +717,152 @@ func (a backupRemoteAdapter) UploadAll(ctx context.Context, localPath string) ([
|
|||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runClusterHeartbeat tickt alle 30s und bumpt die eigene ha_nodes-Row
|
||||||
|
// (last_seen, status, version, config_hash) via cluster.Heartbeat.
|
||||||
|
// Fehler werden geloggt aber nicht zurückgegeben — der nächste Tick
|
||||||
|
// versucht es erneut. Beendet beim ctx.Done() (graceful API shutdown).
|
||||||
|
func runClusterHeartbeat(ctx context.Context, pool *pgxpoolPool, localID, version string) {
|
||||||
|
const tick = 30 * time.Second
|
||||||
|
t := time.NewTicker(tick)
|
||||||
|
defer t.Stop()
|
||||||
|
// Erster Schlag direkt nach Start damit die UI nicht 30s wartet.
|
||||||
|
if err := cluster.Heartbeat(ctx, pool, localID, version); err != nil {
|
||||||
|
slog.Warn("cluster: initial heartbeat failed", "error", err)
|
||||||
|
}
|
||||||
|
slog.Info("cluster: heartbeat goroutine started", "tick", tick.String(), "node_id", localID)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
slog.Info("cluster: heartbeat goroutine stopping")
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
hbCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
if err := cluster.Heartbeat(hbCtx, pool, localID, version); err != nil {
|
||||||
|
slog.Warn("cluster: heartbeat failed", "error", err)
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
slog.Warn("cluster: push-to-primary failed", "error", err)
|
||||||
|
} else {
|
||||||
|
slog.Debug("cluster: config_hash pushed to primary", "hash", hash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
push() // immediate push on API startup
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
push()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func randomEphemeralSecret() []byte {
|
func randomEphemeralSecret() []byte {
|
||||||
b := make([]byte, 32)
|
b := make([]byte, 32)
|
||||||
if _, err := rand.Read(b); err != nil {
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
|||||||
59
cmd/edgeguard-ctl/cluster_join.go
Normal file
59
cmd/edgeguard-ctl/cluster_join.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/clustertls"
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/clusterjoin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func cmdClusterJoin(args []string) int {
|
||||||
|
fs := flag.NewFlagSet("cluster-join", flag.ContinueOnError)
|
||||||
|
tokenFlag := fs.String("token", "", "cluster join token (eg-join-v1.…)")
|
||||||
|
insecure := fs.Bool("insecure", false, "skip TLS verification on the primary (bootstrap mode)")
|
||||||
|
cn := fs.String("cn", "", "subject common name (default: hostname)")
|
||||||
|
clusterTLSDir := fs.String("tls-dir", clustertls.DefaultDir, "where to write 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-join <primary-fqdn-or-url> --token <…>")
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
if *tokenFlag == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "edgeguard-ctl cluster-join: --token required")
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
commonName := *cn
|
||||||
|
if commonName == "" {
|
||||||
|
h, _ := os.Hostname()
|
||||||
|
commonName = h
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
primary, _ := clusterjoin.NormalizePrimaryURL(fs.Arg(0))
|
||||||
|
fmt.Printf("Cluster-Join erfolgreich.\n")
|
||||||
|
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")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
62
cmd/edgeguard-ctl/cluster_renew.go
Normal file
62
cmd/edgeguard-ctl/cluster_renew.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/clustertls"
|
||||||
|
)
|
||||||
|
|
||||||
|
// cmdClusterRenewSelf: re-issued das eigene peer.{crt,key} mit der
|
||||||
|
// lokalen Cluster-CA. Nur sinnvoll auf der Founder/Primary-Box wo die
|
||||||
|
// CA-Key noch vorhanden ist; auf Joinern fehlt die ca.key (sie haben
|
||||||
|
// nur die CA-Cert zum Verify) und der Aufruf scheitert.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// edgeguard-ctl cluster-renew-self [--cn <fqdn>] [--tls-dir <path>]
|
||||||
|
//
|
||||||
|
// Output: schreibt peer.crt + peer.key über das vorhandene peer.{crt,
|
||||||
|
// key} hinweg. Restart-Hinweis am Ende.
|
||||||
|
func cmdClusterRenewSelf(args []string) int {
|
||||||
|
fs := flag.NewFlagSet("cluster-renew-self", flag.ContinueOnError)
|
||||||
|
cn := fs.String("cn", "", "subject common name (default: aus dem aktuellen peer.crt geerbt)")
|
||||||
|
tlsDir := fs.String("tls-dir", clustertls.DefaultDir, "where peer.{crt,key} live")
|
||||||
|
fs.SetOutput(os.Stderr)
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
store := clustertls.New(*tlsDir)
|
||||||
|
if !store.HasCA() {
|
||||||
|
fmt.Fprintln(os.Stderr,
|
||||||
|
"edgeguard-ctl cluster-renew-self: no local CA found — this command works only on a founder/primary node.")
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
commonName := *cn
|
||||||
|
if commonName == "" {
|
||||||
|
if info, err := store.PeerCertInfo(); err == nil && info.CommonName != "" {
|
||||||
|
commonName = info.CommonName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if commonName == "" {
|
||||||
|
h, _ := os.Hostname()
|
||||||
|
commonName = h
|
||||||
|
}
|
||||||
|
if commonName == "" {
|
||||||
|
commonName = "edgeguard-node"
|
||||||
|
}
|
||||||
|
if err := store.RenewSelfSigned(commonName, []string{commonName}, nil, nil); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "edgeguard-ctl cluster-renew-self: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
info, _ := store.PeerCertInfo()
|
||||||
|
fmt.Printf("Cluster-Peer-Cert erneuert.\n")
|
||||||
|
fmt.Printf(" CN: %s\n", commonName)
|
||||||
|
fmt.Printf(" Files: %s/peer.{crt,key}\n", *tlsDir)
|
||||||
|
if info != nil {
|
||||||
|
fmt.Printf(" Gültig bis: %s (%d Tage)\n", info.NotAfter, info.DaysRemaining)
|
||||||
|
}
|
||||||
|
fmt.Printf("\nDamit der Agent-Listener das neue Cert lädt:\n")
|
||||||
|
fmt.Printf(" sudo systemctl restart edgeguard-api\n")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
589
cmd/edgeguard-ctl/cluster_replication.go
Normal file
589
cmd/edgeguard-ctl/cluster_replication.go
Normal file
@@ -0,0 +1,589 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"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
|
||||||
|
"backups", // Backup-Historie ist per-Node
|
||||||
|
"goose_db_version", // Migration-Tracking, internes Tool-State
|
||||||
|
}
|
||||||
|
|
||||||
|
// cmdClusterInitReplication richtet PG auf dieser Node als Logical-Replication-
|
||||||
|
// Primary ein. Idempotent — kann gefahrlos mehrfach laufen.
|
||||||
|
//
|
||||||
|
// Ablauf:
|
||||||
|
// 1. edgeguard_replicator-Rolle anlegen/aktualisieren
|
||||||
|
// 2. Passwort → /var/lib/edgeguard/pg-replication-secret
|
||||||
|
// 3. conf.d/edgeguard-replication.conf mit wal_level=logical schreiben
|
||||||
|
// 4. pg_hba.conf für Replikations-Verbindungen aktualisieren
|
||||||
|
// 5. SELECT-Grants auf alle geteilten Tabellen
|
||||||
|
// 6. PUBLICATION erstellen (alle Tabellen außer localOnlyTables)
|
||||||
|
// 7. PG reload
|
||||||
|
func cmdClusterInitReplication(args []string) int {
|
||||||
|
fs := flag.NewFlagSet("cluster-init-replication", flag.ContinueOnError)
|
||||||
|
fs.SetOutput(os.Stderr)
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
pg, err := detectPGConfig()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "cluster-init-replication: PG-Erkennung:", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
fmt.Printf("→ PostgreSQL %s/%s erkannt\n", pg.Version, pg.Cluster)
|
||||||
|
|
||||||
|
// 1. Passwort generieren
|
||||||
|
pass, err := generatePassword(32)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "cluster-init-replication: generate password:", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. edgeguard_replicator-Rolle anlegen/updaten
|
||||||
|
roleSQL := fmt.Sprintf(`DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '%s') THEN
|
||||||
|
CREATE ROLE %s REPLICATION LOGIN PASSWORD '%s';
|
||||||
|
ELSE
|
||||||
|
ALTER ROLE %s PASSWORD '%s';
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$`, egReplUser, egReplUser, pass, egReplUser, pass)
|
||||||
|
if err := psqlExec(roleSQL); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "cluster-init-replication: create replication role:", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
fmt.Printf("✓ Replication-Rolle %q angelegt/aktualisiert\n", egReplUser)
|
||||||
|
|
||||||
|
// 3. Passwort speichern
|
||||||
|
if err := os.MkdirAll(filepath.Dir(egReplSecret), 0o750); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "cluster-init-replication: mkdir:", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(egReplSecret, []byte(pass), 0o600); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "cluster-init-replication: write secret:", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
// Ownership an edgeguard-api-User übergeben damit die API lesen kann
|
||||||
|
if u, err := user.Lookup("edgeguard"); err == nil {
|
||||||
|
uid, _ := strconv.Atoi(u.Uid)
|
||||||
|
gid, _ := strconv.Atoi(u.Gid)
|
||||||
|
_ = os.Chown(egReplSecret, uid, gid)
|
||||||
|
}
|
||||||
|
fmt.Printf("✓ Replication-Secret gespeichert: %s\n", egReplSecret)
|
||||||
|
|
||||||
|
// 4. conf.d/edgeguard-replication.conf schreiben
|
||||||
|
// wal_level=logical ist eine Obermenge von replica — unterstützt
|
||||||
|
// sowohl Logical Replication als auch ggfs. physisches WAL-Archiving.
|
||||||
|
if err := os.MkdirAll(pg.ConfD, 0o755); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "cluster-init-replication: conf.d mkdir:", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
replConf := `# EdgeGuard Logical Replication — automatisch generiert
|
||||||
|
# Nicht manuell bearbeiten; wird von edgeguard-ctl cluster-init-replication verwaltet.
|
||||||
|
wal_level = logical
|
||||||
|
max_wal_senders = 10
|
||||||
|
max_replication_slots = 20
|
||||||
|
max_logical_replication_workers = 4
|
||||||
|
wal_keep_size = 512MB
|
||||||
|
# Lausche auf localhost + alle konfigurierten Interfaces damit Cluster-Peers
|
||||||
|
# sich verbinden können. '*' ist sicher weil pg_hba.conf den Zugriff auf
|
||||||
|
# bekannte Replikations-User beschränkt.
|
||||||
|
listen_addresses = '*'
|
||||||
|
`
|
||||||
|
confPath := filepath.Join(pg.ConfD, "edgeguard-replication.conf")
|
||||||
|
if err := os.WriteFile(confPath, []byte(replConf), 0o644); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "cluster-init-replication: write postgresql conf:", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
fmt.Printf("✓ %s geschrieben (wal_level=logical)\n", confPath)
|
||||||
|
|
||||||
|
// 5. pg_hba.conf aktualisieren
|
||||||
|
if err := ensureHBAReplication(pg.HBAPath); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "cluster-init-replication: pg_hba.conf:", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
fmt.Printf("✓ %s aktualisiert\n", pg.HBAPath)
|
||||||
|
|
||||||
|
// 6. PG reload (damit wal_level + pg_hba aktiv werden)
|
||||||
|
if out, err := exec.Command("pg_ctlcluster", pg.Version, pg.Cluster, "reload").CombinedOutput(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "cluster-init-replication: pg reload failed: %v\n%s\n", err, out)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
fmt.Printf("✓ PostgreSQL %s/%s neu geladen\n", pg.Version, pg.Cluster)
|
||||||
|
|
||||||
|
// 7. SELECT-Grants: edgeguard_replicator muss alle zu replizierenden
|
||||||
|
// Tabellen lesen können. DEFAULT PRIVILEGES sichert zukünftige Tabellen.
|
||||||
|
grantSQL := fmt.Sprintf(`
|
||||||
|
GRANT SELECT ON ALL TABLES IN SCHEMA public TO %s;
|
||||||
|
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO %s;
|
||||||
|
`, egReplUser, egReplUser)
|
||||||
|
if err := psqlDBExec("edgeguard", grantSQL); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "cluster-init-replication: grant SELECT:", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
fmt.Printf("✓ SELECT auf alle Tabellen für %q gewährt\n", egReplUser)
|
||||||
|
|
||||||
|
// 8. PUBLICATION erstellen — alle public-Tabellen außer localOnlyTables.
|
||||||
|
// Idempotent: DROP IF EXISTS + CREATE.
|
||||||
|
if err := createPublication(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "cluster-init-replication: create publication:", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
fmt.Printf("✓ PUBLICATION %q erstellt\n", egPubName)
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Nächste Schritte:")
|
||||||
|
fmt.Println(" 1) Auf dem Secondary: edgeguard-ctl cluster-setup-standby <primary-ip>")
|
||||||
|
fmt.Println(" 2) Cluster-Settings (VIP) auf BEIDEN Nodes separat konfigurieren")
|
||||||
|
fmt.Println(" → Settings → Cluster → VIP/Keepalived")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// createPublication baut die PUBLICATION dynamisch aus allen Tabellen
|
||||||
|
// im public-Schema minus localOnlyTables. Idempotent: löscht eine
|
||||||
|
// bestehende Publication gleichen Namens zuerst.
|
||||||
|
func createPublication() error {
|
||||||
|
// Alle Tabellen im public-Schema ermitteln
|
||||||
|
listSQL := `SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename`
|
||||||
|
out, err := psqlDBRun("edgeguard", []string{"-tA", "-c", listSQL})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list tables: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
excluded := make(map[string]bool)
|
||||||
|
for _, t := range localOnlyTables {
|
||||||
|
excluded[t] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
var tables []string
|
||||||
|
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||||
|
t := strings.TrimSpace(line)
|
||||||
|
if t == "" || excluded[t] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tables = append(tables, t)
|
||||||
|
}
|
||||||
|
if len(tables) == 0 {
|
||||||
|
return fmt.Errorf("keine Tabellen für Publication gefunden")
|
||||||
|
}
|
||||||
|
|
||||||
|
dropSQL := fmt.Sprintf("DROP PUBLICATION IF EXISTS %s;", egPubName)
|
||||||
|
if err := psqlDBExec("edgeguard", dropSQL); err != nil {
|
||||||
|
return fmt.Errorf("drop old publication: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
createSQL := fmt.Sprintf("CREATE PUBLICATION %s FOR TABLE %s;",
|
||||||
|
egPubName, strings.Join(tables, ", "))
|
||||||
|
if err := psqlDBExec("edgeguard", createSQL); err != nil {
|
||||||
|
return fmt.Errorf("create publication: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureHBAReplication fügt Einträge für die Replikations-Verbindung
|
||||||
|
// in pg_hba.conf ein. Für Logical Replication brauchen wir einen
|
||||||
|
// normalen "host edgeguard"-Eintrag (nicht "host replication").
|
||||||
|
// Idempotent via Marker-Kommentar.
|
||||||
|
func ensureHBAReplication(hbaPath string) error {
|
||||||
|
data, err := os.ReadFile(hbaPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read: %w", err)
|
||||||
|
}
|
||||||
|
const marker = "# EdgeGuard replication"
|
||||||
|
if strings.Contains(string(data), marker) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
entry := fmt.Sprintf(`
|
||||||
|
%s
|
||||||
|
host edgeguard %s 0.0.0.0/0 scram-sha-256
|
||||||
|
host edgeguard %s ::/0 scram-sha-256
|
||||||
|
host replication %s 0.0.0.0/0 scram-sha-256
|
||||||
|
host replication %s ::/0 scram-sha-256
|
||||||
|
`, marker, egReplUser, egReplUser, egReplUser, egReplUser)
|
||||||
|
f, err := os.OpenFile(hbaPath, os.O_APPEND|os.O_WRONLY, 0o640)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
_, err = f.WriteString(entry)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// cmdClusterSetupStandby richtet diesen Node als Logical-Replication-
|
||||||
|
// Subscriber ein. Der Secondary behält seine eigene beschreibbare PG-
|
||||||
|
// Instanz — nur die geteilten Tabellen werden vom Primary repliziert.
|
||||||
|
// Node-spezifische Tabellen (Interfaces, IPs, Routen, VIP-Settings …)
|
||||||
|
// bleiben lokal und werden NICHT überschrieben. Analog zu OPNsense's
|
||||||
|
// HA-Sync: Interface-IPs und Hostname bleiben immer per-Node konfiguriert.
|
||||||
|
//
|
||||||
|
// Voraussetzungen:
|
||||||
|
// - cluster-join erfolgreich (TLS-Certs in /var/lib/edgeguard/cluster-tls/)
|
||||||
|
// - Primary hat cluster-init-replication ausgeführt
|
||||||
|
// - Dieser Node hat edgeguard-api schon gelaufen (Migrations ausgeführt)
|
||||||
|
//
|
||||||
|
// Ablauf:
|
||||||
|
// 1. Replication-Credentials via mTLS vom Primary holen
|
||||||
|
// 2. Bestehende Subscription löschen (idempotent)
|
||||||
|
// 3. SUBSCRIPTION auf Primary erstellen (copy_data=true → Initialkopiierung)
|
||||||
|
// 4. Warten bis Initialkopiierung abgeschlossen
|
||||||
|
// 5. render-config ausführen damit Service-Configs den neuen Stand reflektieren
|
||||||
|
func cmdClusterSetupStandby(args []string) int {
|
||||||
|
fs := flag.NewFlagSet("cluster-setup-standby", flag.ContinueOnError)
|
||||||
|
agentPort := fs.Int("agent-port", 8443, "mTLS agent port on primary")
|
||||||
|
tlsDir := fs.String("tls-dir", clustertls.DefaultDir, "Verzeichnis mit ca.crt + peer.{crt,key}")
|
||||||
|
fs.SetOutput(os.Stderr)
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
if fs.NArg() < 1 {
|
||||||
|
fmt.Fprintln(os.Stderr, "usage: edgeguard-ctl cluster-setup-standby <primary-ip-or-host>")
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
primaryHost := fs.Arg(0)
|
||||||
|
|
||||||
|
// 1. Replication-Credentials vom Primary holen
|
||||||
|
creds, err := fetchReplicationCreds(primaryHost, *agentPort, *tlsDir)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "cluster-setup-standby: replication-creds: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
fmt.Printf("✓ Replication-Credentials von %s:%d erhalten\n", primaryHost, *agentPort)
|
||||||
|
|
||||||
|
// 2. Bestehende Subscription löschen (idempotent)
|
||||||
|
dropSQL := fmt.Sprintf(`
|
||||||
|
DO $$ BEGIN
|
||||||
|
IF EXISTS (SELECT FROM pg_subscription WHERE subname = '%s') THEN
|
||||||
|
ALTER SUBSCRIPTION %s DISABLE;
|
||||||
|
ALTER SUBSCRIPTION %s SET (slot_name = NONE);
|
||||||
|
DROP SUBSCRIPTION %s;
|
||||||
|
END IF;
|
||||||
|
END $$;`, egSubName, egSubName, egSubName, egSubName)
|
||||||
|
if err := psqlDBExec("edgeguard", dropSQL); err != nil {
|
||||||
|
// Nicht fatal — wenn PG noch keine Subscription kennt ist das OK
|
||||||
|
fmt.Printf(" → keine bestehende Subscription gefunden (ok)\n")
|
||||||
|
} else {
|
||||||
|
fmt.Println("✓ Bestehende Subscription entfernt")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. SUBSCRIPTION erstellen
|
||||||
|
// sslmode=require: Verbindung zwischen Cluster-Nodes soll immer verschlüsselt sein.
|
||||||
|
// copy_data=true: Initialkopiierung aller geteilten Tabellen vom Primary.
|
||||||
|
connStr := fmt.Sprintf(
|
||||||
|
"host=%s port=%d user=%s password=%s dbname=edgeguard sslmode=require",
|
||||||
|
creds.Host, creds.Port, creds.User, creds.Password,
|
||||||
|
)
|
||||||
|
createSQL := fmt.Sprintf(
|
||||||
|
"CREATE SUBSCRIPTION %s CONNECTION '%s' PUBLICATION %s WITH (copy_data = true, enabled = true);",
|
||||||
|
egSubName, connStr, egPubName,
|
||||||
|
)
|
||||||
|
// 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"
|
||||||
|
resp, err := client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("GET %s: %w", url, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("GET %s: HTTP %d", url, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Data pgReplicationCreds `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode response: %w", err)
|
||||||
|
}
|
||||||
|
return &result.Data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncMasterKey holt den Secrets-Master-Key vom Primary via mTLS und schreibt
|
||||||
|
// ihn nach /var/lib/edgeguard/.master_key. Dadurch können replizierte
|
||||||
|
// verschlüsselte WireGuard-Keys und PSKs auf dem Secondary entschlüsselt werden.
|
||||||
|
func syncMasterKey(host string, agentPort int, tlsDir string) error {
|
||||||
|
caPath := filepath.Join(tlsDir, "ca.crt")
|
||||||
|
certPath := filepath.Join(tlsDir, "peer.crt")
|
||||||
|
keyPath := filepath.Join(tlsDir, "peer.key")
|
||||||
|
|
||||||
|
caCert, err := os.ReadFile(caPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read ca.crt: %w", err)
|
||||||
|
}
|
||||||
|
rootPool := x509.NewCertPool()
|
||||||
|
rootPool.AppendCertsFromPEM(caCert)
|
||||||
|
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("load peer cert: %w", err)
|
||||||
|
}
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: 15 * time.Second,
|
||||||
|
Transport: &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{
|
||||||
|
RootCAs: rootPool,
|
||||||
|
Certificates: []tls.Certificate{cert},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
url := "https://" + net.JoinHostPort(host, strconv.Itoa(agentPort)) + "/agent/cluster/master-key"
|
||||||
|
resp, err := client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("GET %s: %w", url, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("GET %s: HTTP %d", url, resp.StatusCode)
|
||||||
|
}
|
||||||
|
var result struct {
|
||||||
|
Data struct {
|
||||||
|
KeyHex string `json:"key_hex"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return fmt.Errorf("decode response: %w", err)
|
||||||
|
}
|
||||||
|
key := make([]byte, 32)
|
||||||
|
if _, err := fmt.Sscanf(result.Data.KeyHex, "%x", &key); err != nil {
|
||||||
|
return fmt.Errorf("decode key_hex: %w", err)
|
||||||
|
}
|
||||||
|
const masterKeyPath = "/var/lib/edgeguard/.master_key"
|
||||||
|
if err := os.WriteFile(masterKeyPath, key, 0o600); err != nil {
|
||||||
|
return fmt.Errorf("write master key: %w", err)
|
||||||
|
}
|
||||||
|
if u, err := user.Lookup("edgeguard"); err == nil {
|
||||||
|
uid, _ := strconv.Atoi(u.Uid)
|
||||||
|
gid, _ := strconv.Atoi(u.Gid)
|
||||||
|
_ = os.Chown(masterKeyPath, uid, gid)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generatePassword erzeugt ein kryptographisch sicheres Passwort.
|
||||||
|
func generatePassword(n int) (string, error) {
|
||||||
|
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||||
|
buf := make([]byte, n)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
for i, b := range buf {
|
||||||
|
buf[i] = charset[int(b)%len(charset)]
|
||||||
|
}
|
||||||
|
return string(buf), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// psqlDBExec führt SQL in der angegebenen Datenbank als postgres-Superuser aus.
|
||||||
|
func psqlDBExec(db, sql string) error {
|
||||||
|
_, err := psqlDBRun(db, []string{"-v", "ON_ERROR_STOP=1", "-c", sql})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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...))
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
// Command edgeguard-ctl is the admin CLI for setup, migrations and
|
// Command edgeguard-ctl is the admin CLI for setup, migrations and
|
||||||
// (later) cluster ops. v1 wires migrate + initdb so postinst can
|
// cluster ops. v1.2 implements PG streaming replication setup,
|
||||||
// initialise a fresh node; cluster-* and promote remain stubs until
|
// VIP/Keepalived config and manual failover (promote).
|
||||||
// Phase 3.
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -11,7 +10,7 @@ import (
|
|||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "1.0.78"
|
var version = "1.2.15"
|
||||||
|
|
||||||
const usage = `edgeguard-ctl — EdgeGuard CLI
|
const usage = `edgeguard-ctl — EdgeGuard CLI
|
||||||
|
|
||||||
@@ -25,11 +24,25 @@ Commands:
|
|||||||
migrate check Validate embedded migrations (no DB connect)
|
migrate check Validate embedded migrations (no DB connect)
|
||||||
migrate dump [dir] Write embedded SQL files to dir (default: ./migrations)
|
migrate dump [dir] Write embedded SQL files to dir (default: ./migrations)
|
||||||
initdb Create PostgreSQL role + database (idempotent)
|
initdb Create PostgreSQL role + database (idempotent)
|
||||||
render-config Regenerate haproxy / nftables configs from PG (--no-reload, --only=)
|
render-config Regenerate all configs from PG (--no-reload, --only=svc)
|
||||||
wg-import [--path <dir>] Import existing /etc/wireguard/*.conf files into the DB
|
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
|
reset-password Generate a one-time token for the /reset-password UI flow
|
||||||
cluster-join Join an existing cluster (Phase 3, not yet implemented)
|
cluster-join <primary> --token <…>
|
||||||
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)
|
dump-config Print effective config (Phase 3, not yet implemented)
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -53,7 +66,17 @@ func main() {
|
|||||||
os.Exit(cmdWGImport(os.Args[2:]))
|
os.Exit(cmdWGImport(os.Args[2:]))
|
||||||
case "reset-password":
|
case "reset-password":
|
||||||
os.Exit(cmdResetPassword())
|
os.Exit(cmdResetPassword())
|
||||||
case "cluster-join", "cluster-leave", "promote", "dump-config":
|
case "cluster-join":
|
||||||
|
os.Exit(cmdClusterJoin(os.Args[2:]))
|
||||||
|
case "cluster-renew-self":
|
||||||
|
os.Exit(cmdClusterRenewSelf(os.Args[2:]))
|
||||||
|
case "cluster-init-replication":
|
||||||
|
os.Exit(cmdClusterInitReplication(os.Args[2:]))
|
||||||
|
case "cluster-setup-standby":
|
||||||
|
os.Exit(cmdClusterSetupStandby(os.Args[2:]))
|
||||||
|
case "promote":
|
||||||
|
os.Exit(cmdPromote(os.Args[2:]))
|
||||||
|
case "cluster-leave", "dump-config":
|
||||||
fmt.Fprintf(os.Stderr, "edgeguard-ctl: %q is a Phase-3 stub — not yet implemented\n", os.Args[1])
|
fmt.Fprintf(os.Stderr, "edgeguard-ctl: %q is a Phase-3 stub — not yet implemented\n", os.Args[1])
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
default:
|
default:
|
||||||
|
|||||||
160
cmd/edgeguard-ctl/promote.go
Normal file
160
cmd/edgeguard-ctl/promote.go
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster"
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/keepalived"
|
||||||
|
)
|
||||||
|
|
||||||
|
// cmdPromote promotes this node's PostgreSQL instance from Hot-Standby
|
||||||
|
// to Primary. Manual failover — keine automatische Promotion, um Split-Brain
|
||||||
|
// in 2-Node-Clustern ohne externen Quorum zu verhindern.
|
||||||
|
//
|
||||||
|
// Ablauf:
|
||||||
|
// 1. Prüfen ob standby.signal vorhanden (wir sind wirklich Standby)
|
||||||
|
// 2. pg_ctlcluster promote → PG wird Primary
|
||||||
|
// 3. Warten bis pg_is_in_recovery() = false
|
||||||
|
// 4. ha_nodes.pg_role auf 'primary' setzen
|
||||||
|
// 5. KeyDB cluster:pg-primary-url auf lokal setzen
|
||||||
|
// 6. keepalived.conf neu rendern (Primary bekommt Priorität 200)
|
||||||
|
// 7. keepalived reload
|
||||||
|
func cmdPromote(args []string) int {
|
||||||
|
pg, err := detectPGConfig()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "promote: PG-Erkennung:", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Standby-Signal prüfen
|
||||||
|
signalPath := filepath.Join(pg.DataDir, "standby.signal")
|
||||||
|
if _, err := os.Stat(signalPath); os.IsNotExist(err) {
|
||||||
|
fmt.Fprintf(os.Stderr,
|
||||||
|
"promote: %s nicht gefunden — diese Node ist kein PG-Standby oder wurde bereits promoted.\n",
|
||||||
|
signalPath)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("→ Promoting PostgreSQL %s/%s zu Primary...\n", pg.Version, pg.Cluster)
|
||||||
|
if out, err := exec.Command("pg_ctlcluster", pg.Version, pg.Cluster, "promote").
|
||||||
|
CombinedOutput(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "promote: pg_ctlcluster promote: %v\n%s\n", err, out)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
fmt.Println("✓ pg_ctlcluster promote gesendet")
|
||||||
|
|
||||||
|
// 2. Warten bis PG wirklich Primary ist (pg_is_in_recovery = false)
|
||||||
|
fmt.Print("→ Warte auf PG Primary-Mode")
|
||||||
|
deadline := time.Now().Add(60 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
out, err := psqlRun([]string{"-tA", "-c", "SELECT pg_is_in_recovery();"})
|
||||||
|
if err == nil && strings.TrimSpace(string(out)) == "f" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
fmt.Print(".")
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
// Nochmal prüfen
|
||||||
|
out, err := psqlRun([]string{"-tA", "-c", "SELECT pg_is_in_recovery();"})
|
||||||
|
if err != nil || strings.TrimSpace(string(out)) != "f" {
|
||||||
|
fmt.Fprintln(os.Stderr, "promote: PG ist nach 60s noch in recovery — prüfe PG-Logs")
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
fmt.Println("✓ PostgreSQL ist jetzt Primary")
|
||||||
|
|
||||||
|
// 3. ha_nodes.pg_role + role aktualisieren
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
pool, err := database.Open(ctx, database.ConnStringFromEnv())
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "promote: db connect:", err)
|
||||||
|
fmt.Println(" → ha_nodes manuell updaten: UPDATE ha_nodes SET pg_role='primary', role='primary' WHERE id='<local-id>';")
|
||||||
|
} else {
|
||||||
|
defer pool.Close()
|
||||||
|
localID, err := loadLocalID()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "promote: local node ID:", err)
|
||||||
|
} else {
|
||||||
|
_, err = pool.Exec(ctx, `UPDATE ha_nodes SET pg_role='primary', role='primary', status='online', updated_at=NOW() WHERE id=$1`, localID)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "promote: update ha_nodes:", err)
|
||||||
|
} else {
|
||||||
|
fmt.Println("✓ ha_nodes.pg_role = 'primary' gesetzt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. KeyDB cluster:pg-primary-url updaten
|
||||||
|
if err := updateKeyDBPrimaryURL(); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "promote: KeyDB update: %v\n", err)
|
||||||
|
fmt.Println(" → Manuell: redis-cli SET cluster:pg-primary-url 'postgres://edgeguard@/edgeguard'")
|
||||||
|
} else {
|
||||||
|
fmt.Println("✓ KeyDB cluster:pg-primary-url aktualisiert")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Keepalived.conf neu rendern (Primary = Priorität 200)
|
||||||
|
if pool != nil {
|
||||||
|
localID, _ := loadLocalID()
|
||||||
|
kg := keepalived.New(pool, localID)
|
||||||
|
renderCtx, renderCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer renderCancel()
|
||||||
|
if err := kg.Render(renderCtx); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "promote: keepalived render: %v\n", err)
|
||||||
|
fmt.Println(" → Manuell: edgeguard-ctl render-config --only=keepalived")
|
||||||
|
} else {
|
||||||
|
fmt.Println("✓ keepalived.conf neu gerendert (Priority 200)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("✓ Promotion abgeschlossen. Diese Node ist jetzt der primäre EdgeGuard-Knoten.")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("Empfohlene Nachschritte:")
|
||||||
|
fmt.Println(" 1) sudo systemctl restart edgeguard-api (falls noch nicht laufend)")
|
||||||
|
fmt.Println(" 2) Alte Primary-Node nach Recovery als neuen Standby einrichten:")
|
||||||
|
fmt.Println(" edgeguard-ctl cluster-setup-standby <diese-node-ip>")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadLocalID liest die Node-ID aus /var/lib/edgeguard/node.conf.
|
||||||
|
func loadLocalID() (string, error) {
|
||||||
|
c, err := cluster.LoadLocalConfig("")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if c.NodeID == "" {
|
||||||
|
return "", fmt.Errorf("NODE_ID in node.conf ist leer")
|
||||||
|
}
|
||||||
|
return c.NodeID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateKeyDBPrimaryURL schreibt den lokalen PG-DSN als cluster:pg-primary-url
|
||||||
|
// in KeyDB, damit alle Nodes im Cluster Writes an diese Node schicken.
|
||||||
|
func updateKeyDBPrimaryURL() error {
|
||||||
|
// edgeguard-api nutzt Unix-Socket-Auth, der DSN ist immer lokal.
|
||||||
|
const localDSN = "postgres://edgeguard@/edgeguard?host=/var/run/postgresql"
|
||||||
|
out, err := exec.Command("redis-cli",
|
||||||
|
"-s", "/var/run/keydb/keydb.sock",
|
||||||
|
"SET", "cluster:pg-primary-url", localDSN,
|
||||||
|
).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
// Fallback: Standard-Port
|
||||||
|
out2, err2 := exec.Command("redis-cli",
|
||||||
|
"-p", "6379",
|
||||||
|
"SET", "cluster:pg-primary-url", localDSN,
|
||||||
|
).CombinedOutput()
|
||||||
|
if err2 != nil {
|
||||||
|
return fmt.Errorf("%v: %s / %v: %s", err, out, err2, out2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -8,10 +8,14 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/chrony"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/chrony"
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/freeradius"
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/kea"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/firewall"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/firewall"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/haproxy"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/haproxy"
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster"
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/keepalived"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/configorch"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/configorch"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/squid"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/squid"
|
||||||
@@ -59,12 +63,29 @@ func cmdRenderConfig(args []string) int {
|
|||||||
wg := wireguard.New(pool, secrets.New(""))
|
wg := wireguard.New(pool, secrets.New(""))
|
||||||
ub := unbound.New(pool)
|
ub := unbound.New(pool)
|
||||||
cn := chrony.New(pool)
|
cn := chrony.New(pool)
|
||||||
|
ke := kea.New(pool)
|
||||||
|
fr := freeradius.New(pool, secrets.New(""))
|
||||||
if skipReload {
|
if skipReload {
|
||||||
hap.SkipReload = true
|
hap.SkipReload = true
|
||||||
fw.SkipReload = true
|
fw.SkipReload = true
|
||||||
|
sq.SkipReload = true
|
||||||
|
wg.SkipReload = true
|
||||||
|
ub.SkipReload = true
|
||||||
|
cn.SkipReload = true
|
||||||
|
ke.SkipReload = true
|
||||||
|
fr.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}
|
||||||
|
if ka != nil {
|
||||||
|
gens = append(gens, ka)
|
||||||
|
}
|
||||||
|
|
||||||
results, runErr := configorch.Run(ctx, gens, only)
|
results, runErr := configorch.Run(ctx, gens, only)
|
||||||
fmt.Print(configorch.Summarise(results))
|
fmt.Print(configorch.Summarise(results))
|
||||||
|
|||||||
@@ -46,7 +46,9 @@ func cmdWGImport(args []string) int {
|
|||||||
wireguard.NewPeersRepo(pool),
|
wireguard.NewPeersRepo(pool),
|
||||||
box,
|
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 {
|
if err != nil {
|
||||||
fmt.Fprintln(os.Stderr, "wg-import:", err)
|
fmt.Fprintln(os.Stderr, "wg-import:", err)
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
@@ -9,21 +9,30 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster"
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/clustertls"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/license"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/license"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/acme"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/acme"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/alerts"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/alerts"
|
||||||
|
"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"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/backup"
|
||||||
backupremote "git.netcell-it.de/projekte/edgeguard-native/internal/services/backup/remote"
|
backupremote "git.netcell-it.de/projekte/edgeguard-native/internal/services/backup/remote"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/certrenewer"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/certrenewer"
|
||||||
@@ -32,7 +41,7 @@ import (
|
|||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "1.0.78"
|
var version = "1.2.35"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// renewTickInterval — how often we re-evaluate expiring certs.
|
// renewTickInterval — how often we re-evaluate expiring certs.
|
||||||
@@ -53,10 +62,85 @@ const (
|
|||||||
// Retention: 14 erfolgreiche Backups (default in backup.Service).
|
// Retention: 14 erfolgreiche Backups (default in backup.Service).
|
||||||
backupTickInterval = 24 * time.Hour
|
backupTickInterval = 24 * time.Hour
|
||||||
|
|
||||||
// configHashTickInterval — alle 5 min config_hash neu berechnen
|
// staleSweepTickInterval — Phase 3.2: alle 30s prüfen ob Peers
|
||||||
// und in ha_nodes der eigenen Row schreiben. Cluster-UI nutzt
|
// last_seen länger als staleThreshold nicht gemeldet haben →
|
||||||
// das fürs Drift-Banner — pro-Mutation-Refresh wäre teuer.
|
// status='offline'. Symmetrisch zum 30s-API-Heartbeat.
|
||||||
configHashTickInterval = 5 * time.Minute
|
staleSweepTickInterval = 30 * time.Second
|
||||||
|
|
||||||
|
// staleThreshold — Peer gilt als offline wenn last_seen älter als
|
||||||
|
// das ist. 4× Heartbeat-Intervall lässt einen verpassten Tick
|
||||||
|
// (Restart, GC-Pause, kurzer Network-Glitch) zu ohne false positive.
|
||||||
|
staleThreshold = 2 * time.Minute
|
||||||
|
|
||||||
|
// clusterCertCheckInterval — täglicher Check ob CA + peer-cert
|
||||||
|
// in den nächsten clusterCertWarnDays ablaufen. Bei Hit feuert
|
||||||
|
// ein Alert (dedupe 12h damit der Operator nicht alle 24h dieselbe
|
||||||
|
// Warnung sieht).
|
||||||
|
clusterCertCheckInterval = 24 * time.Hour
|
||||||
|
|
||||||
|
// clusterCertWarnDays — Schwelle für die Cert-Expiry-Warnung.
|
||||||
|
// Operator hat damit min. 30 Tage Vorlauf für `edgeguard-ctl
|
||||||
|
// cluster-renew-self` oder einen manuellen Re-Join.
|
||||||
|
clusterCertWarnDays = 30
|
||||||
|
|
||||||
|
// clusterCertAutoRenewDays — Schwelle ab der wir automatisch
|
||||||
|
// neu signieren (nur Founder mit lokaler CA). Wir liegen 2× vor
|
||||||
|
// der Warn-Schwelle damit ein verpasster Tick + ein verpasster
|
||||||
|
// Restart-Window noch passen.
|
||||||
|
clusterCertAutoRenewDays = 60
|
||||||
|
|
||||||
|
// diskCheckInterval — stündliche Disk-Usage-Prüfung. Fire-Schwellen
|
||||||
|
// in runDiskCheck (warning 80%, error 90%). Stündlich ist schnell
|
||||||
|
// genug damit der Operator vor /var = 100% noch Zeit zum Aufräumen
|
||||||
|
// hat, ohne Log-Spam zu produzieren (Dedupe 12h pro Severity).
|
||||||
|
diskCheckInterval = 1 * time.Hour
|
||||||
|
|
||||||
|
diskWarnPct = 80.0
|
||||||
|
diskCriticalPct = 90.0
|
||||||
|
|
||||||
|
// auditCleanupInterval — täglicher Cleanup. Audit-Rows älter als
|
||||||
|
// auditRetentionDays werden gelöscht. Idempotent — wenn nichts da
|
||||||
|
// ist passiert nichts.
|
||||||
|
auditCleanupInterval = 24 * time.Hour
|
||||||
|
auditRetentionDays = 90
|
||||||
|
|
||||||
|
// backendDownCheckInterval — alle 2 Minuten HAProxy-Stats lesen und
|
||||||
|
// prüfen ob ein Backend komplett ausgefallen ist (alle Server DOWN).
|
||||||
|
// Dedupe 12h pro Backend → kein Alert-Spam. Frischer Alert wenn das
|
||||||
|
// Backend nach 12h immer noch unten ist.
|
||||||
|
backendDownCheckInterval = 2 * time.Minute
|
||||||
|
|
||||||
|
// memCheckInterval — alle 5 Minuten /proc/meminfo lesen. Schwellen
|
||||||
|
// warning 85%, critical 95%. Dedupe 1h pro Severity damit bei einem
|
||||||
|
// kurzfristigen Spike nicht jeder Tick feuert.
|
||||||
|
memCheckInterval = 5 * time.Minute
|
||||||
|
memWarnPct = 85.0
|
||||||
|
memCriticalPct = 95.0
|
||||||
|
|
||||||
|
// conntrackCheckInterval — alle 2 Minuten /proc/sys/net/netfilter/
|
||||||
|
// nf_conntrack_count+max lesen. Eine volle conntrack-Tabelle verwirft
|
||||||
|
// alle neuen Verbindungen ohne jegliche Rückmeldung. 2-Minuten-Takt
|
||||||
|
// erlaubt früh zu warnen bevor die Tabelle überläuft.
|
||||||
|
// Schwellen analog Disk: 80% Warning, 90% Critical. Dedupe 1h.
|
||||||
|
conntrackCheckInterval = 2 * time.Minute
|
||||||
|
conntrackWarnPct = 80.0
|
||||||
|
conntrackCriticalPct = 90.0
|
||||||
|
|
||||||
|
// ntpSyncCheckInterval — alle 10 Minuten chronyc tracking aufrufen.
|
||||||
|
// Keine Sync bedeutet: Uhr driftet → TLS-Cert-Prüfung schlägt fehl
|
||||||
|
// wenn die Abweichung > Toleranz des Gegenstücks (i.d.R. ±1 min),
|
||||||
|
// JWT-Ablauf inkonsistent, Cluster-Split-Brain möglich. Dedupe 1h
|
||||||
|
// damit ein kurzer Upstream-Ausfall (Reboot, DHCP-Pause) keinen
|
||||||
|
// Alert-Regen produziert.
|
||||||
|
ntpSyncCheckInterval = 10 * time.Minute
|
||||||
|
|
||||||
|
// wgTunnelCheckInterval — alle 5 Minuten WireGuard-Client-Tunnels
|
||||||
|
// auf Aktualität prüfen. Client-Tunnels (mode='client') haben genau
|
||||||
|
// einen Peer; wenn dessen letzter Handshake älter als wgStaleSec ist,
|
||||||
|
// ist der Tunnel effektiv tot — Traffic droht lautlos. Dedupe 30min
|
||||||
|
// pro Tunnel damit schnell wiederhergestellte Tunnels nur einmal feuern.
|
||||||
|
wgTunnelCheckInterval = 5 * time.Minute
|
||||||
|
wgStaleSec = int64(5 * 60) // 5 Minuten ohne Handshake = tot
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -98,6 +182,7 @@ func main() {
|
|||||||
"dir", backupSvc.BackupDir, "keep_n", backup.DefaultKeepN)
|
"dir", backupSvc.BackupDir, "keep_n", backup.DefaultKeepN)
|
||||||
|
|
||||||
alertSvc := alerts.New(pool)
|
alertSvc := alerts.New(pool)
|
||||||
|
auditRepo := audit.New(pool)
|
||||||
alertDedupe := newDedupe(12 * time.Hour)
|
alertDedupe := newDedupe(12 * time.Hour)
|
||||||
|
|
||||||
if renewer != nil {
|
if renewer != nil {
|
||||||
@@ -105,13 +190,15 @@ func main() {
|
|||||||
}
|
}
|
||||||
runLicenseVerify(ctx, licClient, licKeyStore, licRepo, nodeID, alertSvc, alertDedupe)
|
runLicenseVerify(ctx, licClient, licKeyStore, licRepo, nodeID, alertSvc, alertDedupe)
|
||||||
|
|
||||||
// Lokale Node-ID für config-hash-refresh. EnsureNodeID liefert
|
// Lokale Node-ID für Heartbeat. EnsureNodeID liefert dieselbe ID
|
||||||
// dieselbe ID die die API hat (gleiches /var/lib/edgeguard/node-id).
|
// die die API hat (gleiches /var/lib/edgeguard/node-id).
|
||||||
localID, _ := cluster.EnsureNodeID("")
|
localID, _ := cluster.EnsureNodeID("")
|
||||||
slog.Info("scheduler: config-hash refresh enabled", "tick", configHashTickInterval, "node_id", localID)
|
slog.Info("scheduler: stale-sweeper enabled",
|
||||||
// Initial-Refresh damit /cluster/status nach API+Scheduler-Boot
|
"tick", staleSweepTickInterval, "threshold", staleThreshold, "node_id", localID)
|
||||||
// nicht 5min auf den ersten Wert wartet.
|
// Initial-Sweep + initial-Heartbeat damit /cluster/status nach
|
||||||
runConfigHash(ctx, pool, localID)
|
// Scheduler-Boot direkt einen frischen Zustand sieht.
|
||||||
|
runHeartbeat(ctx, pool, localID, version)
|
||||||
|
runStaleSweep(ctx, pool)
|
||||||
|
|
||||||
renewTick := time.NewTicker(renewTickInterval)
|
renewTick := time.NewTicker(renewTickInterval)
|
||||||
defer renewTick.Stop()
|
defer renewTick.Stop()
|
||||||
@@ -119,8 +206,48 @@ func main() {
|
|||||||
defer licTick.Stop()
|
defer licTick.Stop()
|
||||||
backupTick := time.NewTicker(backupTickInterval)
|
backupTick := time.NewTicker(backupTickInterval)
|
||||||
defer backupTick.Stop()
|
defer backupTick.Stop()
|
||||||
hashTick := time.NewTicker(configHashTickInterval)
|
sweepTick := time.NewTicker(staleSweepTickInterval)
|
||||||
defer hashTick.Stop()
|
defer sweepTick.Stop()
|
||||||
|
clusterCertTick := time.NewTicker(clusterCertCheckInterval)
|
||||||
|
defer clusterCertTick.Stop()
|
||||||
|
// Initial-Cert-Check direkt beim Start, sonst sieht der Operator
|
||||||
|
// einen Warning erst nach 24h ab Boot.
|
||||||
|
runClusterCertExpiryCheck(ctx, alertSvc, alertDedupe)
|
||||||
|
|
||||||
|
diskTick := time.NewTicker(diskCheckInterval)
|
||||||
|
defer diskTick.Stop()
|
||||||
|
// Initial-Disk-Check: wenn die Box schon bei 95% steht beim
|
||||||
|
// Scheduler-Boot, wollen wir keine Stunde auf den ersten Alert
|
||||||
|
// warten.
|
||||||
|
runDiskCheck(ctx, alertSvc, alertDedupe)
|
||||||
|
|
||||||
|
auditTick := time.NewTicker(auditCleanupInterval)
|
||||||
|
defer auditTick.Stop()
|
||||||
|
|
||||||
|
backendDownTick := time.NewTicker(backendDownCheckInterval)
|
||||||
|
defer backendDownTick.Stop()
|
||||||
|
// Initial-Check direkt beim Start — wenn ein Backend seit dem letzten
|
||||||
|
// Scheduler-Restart down ist, brauchen wir nicht 2 Minuten zu warten.
|
||||||
|
runBackendDownCheck(ctx, pool, alertSvc, alertDedupe)
|
||||||
|
|
||||||
|
memTick := time.NewTicker(memCheckInterval)
|
||||||
|
defer memTick.Stop()
|
||||||
|
runMemoryCheck(ctx, alertSvc, alertDedupe)
|
||||||
|
|
||||||
|
conntrackTick := time.NewTicker(conntrackCheckInterval)
|
||||||
|
defer conntrackTick.Stop()
|
||||||
|
runConntrackCheck(ctx, alertSvc, alertDedupe)
|
||||||
|
|
||||||
|
ntpSyncTick := time.NewTicker(ntpSyncCheckInterval)
|
||||||
|
defer ntpSyncTick.Stop()
|
||||||
|
// Kein Initial-Check bei Boot: chrony braucht nach dem Start
|
||||||
|
// einige Sekunden bis zur ersten Synchronisation — ein
|
||||||
|
// sofortiger Check würde immer feuern.
|
||||||
|
|
||||||
|
wgTunnelTick := time.NewTicker(wgTunnelCheckInterval)
|
||||||
|
defer wgTunnelTick.Stop()
|
||||||
|
// Kein Initial-Check bei Boot: Tunnels brauchen nach dem Start
|
||||||
|
// des wg-quick-Dienstes einen Moment für den ersten Handshake.
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -132,13 +259,626 @@ func main() {
|
|||||||
case <-licTick.C:
|
case <-licTick.C:
|
||||||
runLicenseVerify(ctx, licClient, licKeyStore, licRepo, nodeID, alertSvc, alertDedupe)
|
runLicenseVerify(ctx, licClient, licKeyStore, licRepo, nodeID, alertSvc, alertDedupe)
|
||||||
case <-backupTick.C:
|
case <-backupTick.C:
|
||||||
runBackup(ctx, backupSvc, version, alertSvc)
|
runBackup(ctx, backupSvc, version, alertSvc, setupStore)
|
||||||
case <-hashTick.C:
|
case <-sweepTick.C:
|
||||||
runConfigHash(ctx, pool, localID)
|
// Symmetrisches Heartbeat aus dem Scheduler — falls die API
|
||||||
|
// pausiert/hängt, hält der Scheduler die eigene Row warm.
|
||||||
|
// Idempotent zur API-Heartbeat-Goroutine.
|
||||||
|
runHeartbeat(ctx, pool, localID, version)
|
||||||
|
runStaleSweep(ctx, pool)
|
||||||
|
case <-clusterCertTick.C:
|
||||||
|
runClusterCertExpiryCheck(ctx, alertSvc, alertDedupe)
|
||||||
|
case <-diskTick.C:
|
||||||
|
runDiskCheck(ctx, alertSvc, alertDedupe)
|
||||||
|
case <-auditTick.C:
|
||||||
|
runAuditCleanup(ctx, auditRepo, setupStore)
|
||||||
|
case <-backendDownTick.C:
|
||||||
|
runBackendDownCheck(ctx, pool, alertSvc, alertDedupe)
|
||||||
|
case <-memTick.C:
|
||||||
|
runMemoryCheck(ctx, alertSvc, alertDedupe)
|
||||||
|
case <-conntrackTick.C:
|
||||||
|
runConntrackCheck(ctx, alertSvc, alertDedupe)
|
||||||
|
case <-ntpSyncTick.C:
|
||||||
|
runNTPSyncCheck(ctx, alertSvc, alertDedupe)
|
||||||
|
case <-wgTunnelTick.C:
|
||||||
|
runWGClientTunnelCheck(ctx, pool, alertSvc, alertDedupe)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runAuditCleanup löscht audit_log-Rows älter als die konfigurierte
|
||||||
|
// Retention. Operator kann den Wert in den Settings übersteuern; ohne
|
||||||
|
// Setup-Custom fällt's auf auditRetentionDays-Default zurück.
|
||||||
|
// Schutz vor unbounded growth — auf einer aktiven Box wird das Log
|
||||||
|
// nach 1-2 Jahren mehrere GB groß und macht die /logs-Page langsam.
|
||||||
|
// Best-effort: Fehler werden nur geloggt, der Tick läuft beim nächsten
|
||||||
|
// Zyklus wieder.
|
||||||
|
func runAuditCleanup(ctx context.Context, r *audit.Repo, setupStore *setup.Store) {
|
||||||
|
if r == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
keepDays := auditRetentionDays
|
||||||
|
if setupStore != nil {
|
||||||
|
if st, err := setupStore.Load(); err == nil && st != nil && st.AuditRetentionDays > 0 {
|
||||||
|
keepDays = st.AuditRetentionDays
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
n, err := r.Cleanup(cctx, keepDays)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("scheduler: audit cleanup failed",
|
||||||
|
"keep_days", keepDays, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
slog.Info("scheduler: audit cleanup",
|
||||||
|
"deleted", n, "keep_days", keepDays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// left" und damit failed services.
|
||||||
|
// - >= 80% → Warning. Operator hat noch Luft aber sollte aufräumen.
|
||||||
|
// - < 80% → kein Alert.
|
||||||
|
//
|
||||||
|
// Dedupe-Keys pro Severity, damit ein lang-belegtes Filesystem nicht
|
||||||
|
// jede Stunde feuert (12h pro Stufe). Bei Übergang warning→critical
|
||||||
|
// gibt's einen frischen Alert weil die Keys verschieden sind.
|
||||||
|
//
|
||||||
|
// Fix-Hint im Body: was der Operator als Erstes prüfen soll
|
||||||
|
// (/var/backups/edgeguard, /var/log/edgeguard, /var/cache/apt).
|
||||||
|
func runDiskCheck(ctx context.Context, a *alerts.Service, d *dedupe) {
|
||||||
|
if a == nil || d == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var fs syscall.Statfs_t
|
||||||
|
if err := syscall.Statfs("/", &fs); err != nil {
|
||||||
|
slog.Warn("scheduler: disk-check statfs failed", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
total := float64(fs.Blocks) * float64(fs.Bsize)
|
||||||
|
free := float64(fs.Bavail) * float64(fs.Bsize)
|
||||||
|
if total <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
usedPct := (total - free) * 100 / total
|
||||||
|
freeGB := free / (1024 * 1024 * 1024)
|
||||||
|
totalGB := total / (1024 * 1024 * 1024)
|
||||||
|
|
||||||
|
var key, title string
|
||||||
|
var sev alerts.Severity
|
||||||
|
switch {
|
||||||
|
case usedPct >= diskCriticalPct:
|
||||||
|
key = "disk.full.critical"
|
||||||
|
sev = alerts.SeverityError
|
||||||
|
title = fmt.Sprintf("Disk kritisch voll: %.0f%%", usedPct)
|
||||||
|
case usedPct >= diskWarnPct:
|
||||||
|
key = "disk.full.warning"
|
||||||
|
sev = alerts.SeverityWarning
|
||||||
|
title = fmt.Sprintf("Disk-Belegung hoch: %.0f%%", usedPct)
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !d.shouldFire(key) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
desc := fmt.Sprintf(
|
||||||
|
"Wurzel-Filesystem (/) ist zu %.1f%% belegt — noch %.2f GB von %.2f GB frei.\n\n"+
|
||||||
|
"Häufige Verursacher checken:\n"+
|
||||||
|
" sudo du -hs /var/backups/edgeguard /var/log/edgeguard /var/cache/apt /var/lib/postgresql\n\n"+
|
||||||
|
"Backup-Retention ist 14 (default). Manuell aufräumen:\n"+
|
||||||
|
" ls -lhS /var/backups/edgeguard | head\n"+
|
||||||
|
" sudo apt-get clean # /var/cache/apt leeren",
|
||||||
|
usedPct, freeGB, totalGB)
|
||||||
|
if _, err := a.Fire(ctx, "disk.full", sev, title, desc); err != nil {
|
||||||
|
slog.Warn("scheduler: disk-check alert fire failed", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runClusterCertExpiryCheck warnt wenn CA oder peer.crt in <
|
||||||
|
// clusterCertWarnDays Tagen ablaufen (oder schon abgelaufen sind).
|
||||||
|
// Dedupe pro Cert-Typ + 12h.
|
||||||
|
//
|
||||||
|
// Zusätzlich (Phase 1.1.1): wenn das peer.crt < clusterCertAutoRenewDays
|
||||||
|
// remaining hat UND eine lokale CA existiert, wird automatisch neu
|
||||||
|
// signiert. Restart-Hinweis als Info-Alert — wir starten edgeguard-api
|
||||||
|
// nicht selbst neu, das passiert beim nächsten geplanten Update/Reboot.
|
||||||
|
// runMemoryCheck liest /proc/meminfo und feuert bei hoher RAM-Belegung.
|
||||||
|
// Schwellen: warning >= 85%, critical >= 95%. Dedupe 1h pro Severity
|
||||||
|
// damit kurze Spikes (Backup, apt-Upgrade) keine Alert-Flut erzeugen.
|
||||||
|
func runMemoryCheck(ctx context.Context, a *alerts.Service, d *dedupe) {
|
||||||
|
if a == nil || d == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile("/proc/meminfo")
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var memTotal, memAvail int64
|
||||||
|
for _, line := range strings.Split(string(data), "\n") {
|
||||||
|
var key string
|
||||||
|
var val int64
|
||||||
|
if _, err := fmt.Sscanf(line, "%s %d", &key, &val); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch key {
|
||||||
|
case "MemTotal:":
|
||||||
|
memTotal = val
|
||||||
|
case "MemAvailable:":
|
||||||
|
memAvail = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if memTotal <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
usedPct := float64(memTotal-memAvail) * 100 / float64(memTotal)
|
||||||
|
usedGB := float64(memTotal-memAvail) / 1024 / 1024
|
||||||
|
totalGB := float64(memTotal) / 1024 / 1024
|
||||||
|
|
||||||
|
var key, title string
|
||||||
|
var sev alerts.Severity
|
||||||
|
switch {
|
||||||
|
case usedPct >= memCriticalPct:
|
||||||
|
key = "mem.high.critical"
|
||||||
|
sev = alerts.SeverityError
|
||||||
|
title = fmt.Sprintf("RAM kritisch hoch: %.0f%%", usedPct)
|
||||||
|
case usedPct >= memWarnPct:
|
||||||
|
key = "mem.high.warning"
|
||||||
|
sev = alerts.SeverityWarning
|
||||||
|
title = fmt.Sprintf("RAM-Belegung hoch: %.0f%%", usedPct)
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !d.shouldFire(key) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
desc := fmt.Sprintf(
|
||||||
|
"RAM-Auslastung: %.1f%% — %.1f von %.1f GB belegt.\n\n"+
|
||||||
|
"Häufige Ursachen:\n"+
|
||||||
|
" • Unbound-Cache zu groß (rrset-cache-size in /etc/edgeguard/unbound/unbound.conf)\n"+
|
||||||
|
" • Squid cache_mem zu groß (64 MB default)\n"+
|
||||||
|
" • PostgreSQL shared_buffers (default ~128 MB)\n"+
|
||||||
|
" • Prozesse prüfen: ps aux --sort=-%%mem | head -10",
|
||||||
|
usedPct, usedGB, totalGB)
|
||||||
|
if _, err := a.Fire(ctx, "mem.high", sev, title, desc); err != nil {
|
||||||
|
slog.Warn("scheduler: memory-check alert fire failed", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runConntrackCheck liest die conntrack-Tabellen-Belegung aus /proc und
|
||||||
|
// feuert bei hoher Auslastung. Eine volle conntrack-Tabelle (100%)
|
||||||
|
// verwirft alle neuen TCP/UDP-Verbindungen ohne ICMP-Rückmeldung —
|
||||||
|
// der Operator sieht auf der Gegenstelle nur Timeouts.
|
||||||
|
//
|
||||||
|
// Schwellen: 80% Warning, 90% Critical (wie Disk, niedriger als RAM weil
|
||||||
|
// der Impact sofortig ist). Dedupe 1h pro Severity.
|
||||||
|
func runConntrackCheck(ctx context.Context, a *alerts.Service, d *dedupe) {
|
||||||
|
if a == nil || d == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
readInt := func(path string) int64 {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
v, _ := strconv.ParseInt(strings.TrimSpace(string(b)), 10, 64)
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
count := readInt("/proc/sys/net/netfilter/nf_conntrack_count")
|
||||||
|
max := readInt("/proc/sys/net/netfilter/nf_conntrack_max")
|
||||||
|
if max <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
usedPct := float64(count) * 100 / float64(max)
|
||||||
|
|
||||||
|
var key, title string
|
||||||
|
var sev alerts.Severity
|
||||||
|
switch {
|
||||||
|
case usedPct >= conntrackCriticalPct:
|
||||||
|
key = "conntrack.high.critical"
|
||||||
|
sev = alerts.SeverityError
|
||||||
|
title = fmt.Sprintf("Conntrack-Tabelle kritisch voll: %.0f%%", usedPct)
|
||||||
|
case usedPct >= conntrackWarnPct:
|
||||||
|
key = "conntrack.high.warning"
|
||||||
|
sev = alerts.SeverityWarning
|
||||||
|
title = fmt.Sprintf("Conntrack-Tabelle fast voll: %.0f%%", usedPct)
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !d.shouldFire(key) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
desc := fmt.Sprintf(
|
||||||
|
"Conntrack-Auslastung: %.1f%% — %d von %d Einträgen belegt.\n\n"+
|
||||||
|
"Wenn die Tabelle auf 100%% steigt, werden alle neuen Verbindungen\n"+
|
||||||
|
"ohne Fehlermeldung verworfen (Silent Drop).\n\n"+
|
||||||
|
"Maßnahmen:\n"+
|
||||||
|
" • Zeitweilige Spikes: nf_conntrack_max erhöhen\n"+
|
||||||
|
" (sysctl net.netfilter.nf_conntrack_max)\n"+
|
||||||
|
" • Leaks: conntrack -L | sort | head zeigt häufige Quellen\n"+
|
||||||
|
" • Timeouts reduzieren (z.B. nf_conntrack_tcp_timeout_established)",
|
||||||
|
usedPct, count, max)
|
||||||
|
if _, err := a.Fire(ctx, "conntrack.high", sev, title, desc); err != nil {
|
||||||
|
slog.Warn("scheduler: conntrack-check alert fire failed", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runNTPSyncCheck ruft chronyc tracking auf und feuert einen Alert wenn
|
||||||
|
// chrony keine synchronisierte Zeitquelle hat (Stratum 0 oder ≥ 16).
|
||||||
|
// Zeitdrift > ~1 Minute führt zu TLS-Handshake-Fehlern, JWT-Ablauf-
|
||||||
|
// Inkonsistenzen und möglichen Cluster-Problemen. Dedupe 1h.
|
||||||
|
func runNTPSyncCheck(ctx context.Context, a *alerts.Service, d *dedupe) {
|
||||||
|
if a == nil || d == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := exec.Command("chronyc", "tracking").Output()
|
||||||
|
if err != nil {
|
||||||
|
// chrony nicht installiert oder nicht gestartet — kein Alert,
|
||||||
|
// weil wir nicht wissen ob chrony hier überhaupt erwartet wird.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
synced, stratum, ref := parseChronyTrackingForAlert(string(out))
|
||||||
|
if synced {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const key = "ntp.unsync"
|
||||||
|
if !d.shouldFire(key) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
refStr := ref
|
||||||
|
if refStr == "" {
|
||||||
|
refStr = "(keine Referenz)"
|
||||||
|
}
|
||||||
|
title := fmt.Sprintf("NTP nicht synchronisiert (Stratum %d)", stratum)
|
||||||
|
desc := fmt.Sprintf(
|
||||||
|
"chrony hat keine synchronisierte Zeitquelle.\n"+
|
||||||
|
"Referenz: %s Stratum: %d\n\n"+
|
||||||
|
"Mögliche Ursachen:\n"+
|
||||||
|
" • Upstream-NTP-Server nicht erreichbar (UDP/123 blockiert?)\n"+
|
||||||
|
" • Pool-DNS-Einträge lösen nicht auf\n"+
|
||||||
|
" • chrony läuft, braucht aber noch Zeit nach Boot (warten)\n\n"+
|
||||||
|
"Prüfen: chronyc sources -v — chronyc tracking",
|
||||||
|
refStr, stratum)
|
||||||
|
if _, err := a.Fire(ctx, "ntp.unsync", alerts.SeverityWarning, title, desc); err != nil {
|
||||||
|
slog.Warn("scheduler: ntp-sync-check alert fire failed", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseChronyTrackingForAlert ist eine schlanke Variante des NTP-Handler-
|
||||||
|
// Parsers: liefert nur synced/stratum/reference ohne die vollen Felder.
|
||||||
|
func parseChronyTrackingForAlert(out string) (synced bool, stratum int, reference string) {
|
||||||
|
for _, line := range strings.Split(out, "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
key, val, ok := strings.Cut(line, ":")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
val = strings.TrimSpace(val)
|
||||||
|
switch key {
|
||||||
|
case "Reference ID":
|
||||||
|
if i := strings.Index(val, "("); i >= 0 {
|
||||||
|
reference = strings.Trim(val[i:], "()")
|
||||||
|
}
|
||||||
|
if val != "00000000 ()" {
|
||||||
|
synced = true
|
||||||
|
}
|
||||||
|
case "Stratum":
|
||||||
|
fmt.Sscanf(val, "%d", &stratum)
|
||||||
|
if stratum > 0 && stratum < 16 {
|
||||||
|
synced = true
|
||||||
|
} else if stratum == 0 || stratum >= 16 {
|
||||||
|
synced = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// runWGClientTunnelCheck prüft alle aktiven WireGuard-Client-Tunnels
|
||||||
|
// (mode='client') auf Handshake-Aktualität. Ein Client-Tunnel hat genau
|
||||||
|
// einen Peer; wenn dessen letzter Handshake älter als wgStaleSec oder
|
||||||
|
// noch nie stattgefunden hat, ist der Tunnel tot — Traffic wird lautlos
|
||||||
|
// verworfen (kein ICMP Unreachable). Dedupe 30min pro Tunnel damit
|
||||||
|
// nach einer Selbstheilung nicht alle paar Minuten neu gefeuert wird.
|
||||||
|
func runWGClientTunnelCheck(ctx context.Context, pool *pgxpool.Pool, a *alerts.Service, d *dedupe) {
|
||||||
|
if a == nil || d == nil || pool == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alle aktiven Client-Interfaces aus DB laden.
|
||||||
|
type wgIface struct{ name string }
|
||||||
|
rows, err := pool.Query(ctx,
|
||||||
|
`SELECT name FROM wg_interfaces WHERE mode = 'client' AND active = true ORDER BY name`)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var ifaces []wgIface
|
||||||
|
for rows.Next() {
|
||||||
|
var n string
|
||||||
|
if err := rows.Scan(&n); err == nil {
|
||||||
|
ifaces = append(ifaces, wgIface{n})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if len(ifaces) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
for _, ifc := range ifaces {
|
||||||
|
out, err := exec.Command("wg", "show", ifc.name, "dump").Output()
|
||||||
|
if err != nil {
|
||||||
|
// Interface existiert nicht mehr im Kernel (wg-quick down) —
|
||||||
|
// das ist selbst schon ein Problem; kein separater Alert hier,
|
||||||
|
// da systemd-Restart-Policy das abdeckt.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||||
|
// Zeile 0 ist die Interface-Zeile (own key / pubkey / port / fwmark).
|
||||||
|
// Zeile 1 ist die Peer-Zeile: pubkey psk endpoint allowed-ips last-hs rx tx keepalive
|
||||||
|
if len(lines) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields := strings.Fields(lines[1])
|
||||||
|
if len(fields) < 5 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lastHS, _ := strconv.ParseInt(fields[4], 10, 64)
|
||||||
|
|
||||||
|
stale := lastHS == 0 || (now-lastHS) > wgStaleSec
|
||||||
|
if !stale {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := "wg.tunnel.down." + ifc.name
|
||||||
|
if !d.shouldFire(key) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var detail string
|
||||||
|
if lastHS == 0 {
|
||||||
|
detail = "Noch kein Handshake — Tunnel wurde nie erfolgreich aufgebaut."
|
||||||
|
} else {
|
||||||
|
ageMin := (now - lastHS) / 60
|
||||||
|
detail = fmt.Sprintf("Letzter Handshake: vor %d Minuten.", ageMin)
|
||||||
|
}
|
||||||
|
title := fmt.Sprintf("WireGuard-Tunnel %s ausgefallen", ifc.name)
|
||||||
|
desc := fmt.Sprintf(
|
||||||
|
"Client-Tunnel %s hat seit >5 Minuten keinen Handshake.\n%s\n\n"+
|
||||||
|
"Traffic zu den RemoteAllowed-Netzen wird lautlos verworfen.\n\n"+
|
||||||
|
"Mögliche Ursachen:\n"+
|
||||||
|
" • Remote-Peer nicht erreichbar (Firewall, Routing)\n"+
|
||||||
|
" • Remote-Server-Keypair geändert (Public-Key stimmt nicht mehr)\n"+
|
||||||
|
" • UDP-Port des Peers geblockt\n"+
|
||||||
|
" • wg-quick-Dienst auf dieser Box gestoppt: systemctl status wg-quick@%s",
|
||||||
|
ifc.name, detail, ifc.name)
|
||||||
|
if _, err := a.Fire(ctx, "wg.tunnel.down", alerts.SeverityError, title, desc); err != nil {
|
||||||
|
slog.Warn("scheduler: wg-tunnel-check alert fire failed", "iface", ifc.name, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var egBackendRE = regexp.MustCompile(`^eg_backend_(\d+)$`)
|
||||||
|
|
||||||
|
// runBackendDownCheck liest HAProxy-Stats via Admin-Socket und feuert
|
||||||
|
// einen Error-Alert für jedes Backend bei dem alle Server DOWN sind
|
||||||
|
// (und mind. einer einen echten Health-Check hat). Dedupe 12h pro Backend.
|
||||||
|
func runBackendDownCheck(ctx context.Context, pool *pgxpool.Pool, a *alerts.Service, d *dedupe) {
|
||||||
|
if a == nil || d == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conn, err := net.DialTimeout("unix", "/run/haproxy/admin.sock", 2*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
// HAProxy läuft nicht oder Socket nicht erreichbar — kein Alert,
|
||||||
|
// das ist der Dienst selbst nicht der Scheduler.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
_ = conn.SetDeadline(time.Now().Add(3 * time.Second))
|
||||||
|
if _, err := conn.Write([]byte("show stat\n")); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type srvEntry struct{ status string; hasCheck bool }
|
||||||
|
byBackend := map[string][]srvEntry{}
|
||||||
|
colIdx := map[string]int{}
|
||||||
|
scanner := bufio.NewScanner(conn)
|
||||||
|
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields := strings.Split(line, ",")
|
||||||
|
if strings.HasPrefix(line, "# ") {
|
||||||
|
fields[0] = strings.TrimPrefix(fields[0], "# ")
|
||||||
|
for i, name := range fields {
|
||||||
|
colIdx[name] = i
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
px := fieldAt(fields, colIdx["pxname"])
|
||||||
|
sv := fieldAt(fields, colIdx["svname"])
|
||||||
|
if !strings.HasPrefix(px, "eg_backend_") || sv == "BACKEND" || sv == "FRONTEND" || sv == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
status := fieldAt(fields, colIdx["status"])
|
||||||
|
byBackend[px] = append(byBackend[px], srvEntry{
|
||||||
|
status: status,
|
||||||
|
hasCheck: status != "no check",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(byBackend) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Friendly Backend-Namen aus DB — best-effort, Fehler = anonyme ID.
|
||||||
|
bkRepo := backends.New(pool)
|
||||||
|
bklist, _ := bkRepo.List(ctx)
|
||||||
|
nameOf := func(id int64) string {
|
||||||
|
for _, b := range bklist {
|
||||||
|
if b.ID == id {
|
||||||
|
return b.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("#%d", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
for haName, servers := range byBackend {
|
||||||
|
hasRealCheck, allDown := false, true
|
||||||
|
for _, s := range servers {
|
||||||
|
if s.hasCheck {
|
||||||
|
hasRealCheck = true
|
||||||
|
}
|
||||||
|
if s.status == "UP" {
|
||||||
|
allDown = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasRealCheck || !allDown {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m := egBackendRE.FindStringSubmatch(haName)
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id, _ := strconv.ParseInt(m[1], 10, 64)
|
||||||
|
name := nameOf(id)
|
||||||
|
|
||||||
|
key := "backend.down." + haName
|
||||||
|
if !d.shouldFire(key) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
msg := fmt.Sprintf(
|
||||||
|
"Alle Server in Backend \"%s\" sind DOWN — HAProxy liefert 503 für alle Requests zu diesem Backend.\n\n"+
|
||||||
|
"HAProxy-Backend-Name: %s\n\n"+
|
||||||
|
"Nächste Schritte:\n"+
|
||||||
|
" • Dienst auf Backend-Host prüfen (systemctl status / docker ps)\n"+
|
||||||
|
" • Health-Check-Pfad erreichbar? (curl http://<server>:<port><path>)\n"+
|
||||||
|
" • Firewall-Regeln zwischen EdgeGuard und Backend-Host prüfen",
|
||||||
|
name, haName)
|
||||||
|
if _, err := a.Fire(ctx, "backend.down", alerts.SeverityError,
|
||||||
|
fmt.Sprintf("Backend DOWN: %s", name), msg); err != nil {
|
||||||
|
slog.Warn("scheduler: backend-down alert fire failed",
|
||||||
|
"backend", name, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fieldAt(fields []string, i int) string {
|
||||||
|
if i < 0 || i >= len(fields) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fields[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
func runClusterCertExpiryCheck(ctx context.Context, a *alerts.Service, d *dedupe) {
|
||||||
|
if a == nil || d == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
store := clustertls.New("")
|
||||||
|
|
||||||
|
// Auto-Renew zuerst — danach lesen wir die (eventuell frischen)
|
||||||
|
// Cert-Infos für den Alert-Check ab.
|
||||||
|
tryAutoRenew(ctx, store, a, d)
|
||||||
|
|
||||||
|
check := func(kind, key string, info *clustertls.CertInfo, err error) {
|
||||||
|
if err != nil {
|
||||||
|
return // Cert nicht vorhanden / unleserlich — keine Warnung.
|
||||||
|
}
|
||||||
|
if info.DaysRemaining > clusterCertWarnDays {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !d.shouldFire(key) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sev := alerts.SeverityWarning
|
||||||
|
title := "Cluster-" + kind + " läuft bald ab"
|
||||||
|
desc := fmt.Sprintf("%s (CN=%s) läuft in %d Tagen ab (NotAfter=%s).",
|
||||||
|
kind, info.CommonName, info.DaysRemaining, info.NotAfter.Format(time.RFC3339))
|
||||||
|
if info.DaysRemaining < 0 {
|
||||||
|
sev = alerts.SeverityError
|
||||||
|
title = "Cluster-" + kind + " ist abgelaufen"
|
||||||
|
desc = fmt.Sprintf("%s (CN=%s) ist seit %d Tagen abgelaufen (NotAfter=%s).",
|
||||||
|
kind, info.CommonName, -info.DaysRemaining, info.NotAfter.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
desc += "\n\nFix: sudo edgeguard-ctl cluster-renew-self (founder/single-node)\n sudo systemctl restart edgeguard-api"
|
||||||
|
if _, err := a.Fire(ctx, "cluster.cert.expiring", sev, title, desc); err != nil {
|
||||||
|
slog.Warn("scheduler: cluster cert alert fire failed", "kind", kind, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if store.HasCA() {
|
||||||
|
info, err := store.CACertInfo()
|
||||||
|
check("CA", "cluster.cert.expiring:ca", info, err)
|
||||||
|
}
|
||||||
|
if store.HasPeer() {
|
||||||
|
info, err := store.PeerCertInfo()
|
||||||
|
check("peer-Cert", "cluster.cert.expiring:peer", info, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tryAutoRenew: wenn das peer.crt < clusterCertAutoRenewDays Tage
|
||||||
|
// remaining hat UND wir eine lokale CA haben (= Founder-Node), wird
|
||||||
|
// automatisch ein frisches peer.{crt,key} signiert. Edgeguard-api
|
||||||
|
// muss anschließend manuell restartet werden damit der Listener das
|
||||||
|
// neue Material lädt — wir alarmieren das, restarten aber nicht
|
||||||
|
// selbst (würde laufende Requests + die Heartbeat-Goroutine killen).
|
||||||
|
//
|
||||||
|
// Joiner-Nodes (keine eigene CA) ignorieren wir hier; sie laufen über
|
||||||
|
// einen anderen Renewal-Pfad (Phase 3.6, Renewal-Token via mTLS).
|
||||||
|
func tryAutoRenew(ctx context.Context, store *clustertls.Store, a *alerts.Service, d *dedupe) {
|
||||||
|
if !store.HasPeer() || !store.HasCA() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
info, err := store.PeerCertInfo()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if info.DaysRemaining > clusterCertAutoRenewDays {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// CN aus dem alten Cert übernehmen — sonst würde ein Hostname-
|
||||||
|
// Wechsel mitten in der Renewal unbemerkt durchgehen.
|
||||||
|
cn := info.CommonName
|
||||||
|
if cn == "" {
|
||||||
|
cn = "edgeguard-node"
|
||||||
|
}
|
||||||
|
if err := store.RenewSelfSigned(cn, []string{cn}, nil, nil); err != nil {
|
||||||
|
slog.Warn("scheduler: cluster cert auto-renew failed", "error", err)
|
||||||
|
// Failure-Alert dedupe 12h — Operator soll daran erinnert werden.
|
||||||
|
if d.shouldFire("cluster.cert.auto_renew.failed") {
|
||||||
|
_, _ = a.Fire(ctx, "cluster.cert.auto_renew.failed",
|
||||||
|
alerts.SeverityError,
|
||||||
|
"Cluster-Peer-Cert Auto-Renew fehlgeschlagen",
|
||||||
|
"clustertls.RenewSelfSigned: "+err.Error()+
|
||||||
|
"\n\nFix: sudo edgeguard-ctl cluster-renew-self")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Success — neuer Cert auf Disk, alter Cert noch im API-Speicher.
|
||||||
|
// Info-Alert mit Restart-Hinweis. Dedupe 24h damit nicht
|
||||||
|
// gefloodet wird wenn der Operator nicht restartet.
|
||||||
|
if d.shouldFire("cluster.cert.auto_renew.ok") {
|
||||||
|
fresh, _ := store.PeerCertInfo()
|
||||||
|
until := info.NotAfter.Format(time.RFC3339)
|
||||||
|
if fresh != nil {
|
||||||
|
until = fresh.NotAfter.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
_, _ = a.Fire(ctx, "cluster.cert.auto_renewed",
|
||||||
|
alerts.SeverityInfo,
|
||||||
|
"Cluster-Peer-Cert automatisch erneuert",
|
||||||
|
fmt.Sprintf("Neues Peer-Cert auf Disk (CN=%s, gültig bis %s). "+
|
||||||
|
"Damit edgeguard-api das neue Cert in den mTLS-Listener lädt:\n\n"+
|
||||||
|
" sudo systemctl restart edgeguard-api\n\n"+
|
||||||
|
"Bis dahin nutzt der laufende Prozess das alte Cert.", cn, until))
|
||||||
|
}
|
||||||
|
slog.Info("scheduler: cluster peer cert auto-renewed", "cn", cn,
|
||||||
|
"old_days_remaining", info.DaysRemaining)
|
||||||
|
}
|
||||||
|
|
||||||
// dedupe verhindert dass derselbe Alert-Key (z.B. "cert.expiring:utm-1.netcell-it.de")
|
// dedupe verhindert dass derselbe Alert-Key (z.B. "cert.expiring:utm-1.netcell-it.de")
|
||||||
// öfter als alle 12h gefeuert wird. In-memory — Scheduler-Restart
|
// öfter als alle 12h gefeuert wird. In-memory — Scheduler-Restart
|
||||||
// resettet, was OK ist (Operator soll bei restart wieder einen kennen-
|
// resettet, was OK ist (Operator soll bei restart wieder einen kennen-
|
||||||
@@ -200,18 +940,38 @@ func runCertExpiryCheck(ctx context.Context, repo *tlscerts.Repo,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// runConfigHash berechnet den Hash und schreibt ihn in ha_nodes.
|
// runHeartbeat schreibt last_seen + status=online + version + config_hash
|
||||||
// Pool kann nil sein (scheduler-pool-fail beim boot) — dann no-op.
|
// auf die eigene ha_nodes-Row. Pool kann nil sein (scheduler-pool-fail
|
||||||
func runConfigHash(ctx context.Context, pool *pgxpoolPool, localID string) {
|
// beim Boot) — dann no-op. Errors landen im WARN, kein Abort der Schleife.
|
||||||
|
func runHeartbeat(ctx context.Context, pool *pgxpoolPool, localID, version string) {
|
||||||
if pool == nil || localID == "" {
|
if pool == nil || localID == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
hash, err := cluster.RefreshLocalHash(ctx, pool, localID)
|
hbCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
if err != nil {
|
defer cancel()
|
||||||
slog.Warn("scheduler: config-hash refresh failed", "error", err)
|
if err := cluster.Heartbeat(hbCtx, pool, localID, version); err != nil {
|
||||||
|
slog.Warn("scheduler: heartbeat failed", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runStaleSweep markiert Peers mit last_seen < NOW()-staleThreshold als
|
||||||
|
// offline. Logged nur wenn Rows betroffen sind (sonst floodet das Log
|
||||||
|
// mit "0 rows" alle 30s).
|
||||||
|
func runStaleSweep(ctx context.Context, pool *pgxpoolPool) {
|
||||||
|
if pool == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
slog.Debug("scheduler: config-hash refreshed", "hash", hash)
|
swCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
flipped, err := cluster.SweepStaleNodes(swCtx, pool, staleThreshold)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("scheduler: stale-sweep failed", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if flipped > 0 {
|
||||||
|
slog.Info("scheduler: marked stale peers offline",
|
||||||
|
"count", flipped, "threshold", staleThreshold)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// pgxpoolPool ist ein lokaler Alias damit die Signatur stabil bleibt
|
// pgxpoolPool ist ein lokaler Alias damit die Signatur stabil bleibt
|
||||||
@@ -220,7 +980,7 @@ type pgxpoolPool = pgxpool.Pool
|
|||||||
|
|
||||||
// runBackup führt einen scheduled Backup aus + prunet alte. Failures
|
// runBackup führt einen scheduled Backup aus + prunet alte. Failures
|
||||||
// loggen wir + alarmieren — verlorene Backups sind kritisch.
|
// loggen wir + alarmieren — verlorene Backups sind kritisch.
|
||||||
func runBackup(ctx context.Context, svc *backup.Service, version string, a *alerts.Service) {
|
func runBackup(ctx context.Context, svc *backup.Service, version string, a *alerts.Service, setupStore *setup.Store) {
|
||||||
res, err := svc.Run(ctx, backup.KindScheduled, version)
|
res, err := svc.Run(ctx, backup.KindScheduled, version)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("scheduler: backup failed", "error", err, "file", res.File)
|
slog.Warn("scheduler: backup failed", "error", err, "file", res.File)
|
||||||
@@ -235,7 +995,13 @@ func runBackup(ctx context.Context, svc *backup.Service, version string, a *aler
|
|||||||
"file", res.File, "size", res.SizeBytes,
|
"file", res.File, "size", res.SizeBytes,
|
||||||
"db_bytes", res.DBDumpBytes, "files_bytes", res.FilesBytes,
|
"db_bytes", res.DBDumpBytes, "files_bytes", res.FilesBytes,
|
||||||
"sha256", res.SHA256)
|
"sha256", res.SHA256)
|
||||||
if err := svc.Prune(ctx, backup.DefaultKeepN); err != nil {
|
keepN := backup.DefaultKeepN
|
||||||
|
if setupStore != nil {
|
||||||
|
if st, err := setupStore.Load(); err == nil && st != nil && st.BackupRetentionKeep > 0 {
|
||||||
|
keepN = st.BackupRetentionKeep
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := svc.Prune(ctx, keepN); err != nil {
|
||||||
slog.Warn("scheduler: backup prune failed", "error", err)
|
slog.Warn("scheduler: backup prune failed", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -296,11 +1062,18 @@ func runRenewer(ctx context.Context, r *certrenewer.Service, a *alerts.Service,
|
|||||||
slog.Info("scheduler: renewer pass complete",
|
slog.Info("scheduler: renewer pass complete",
|
||||||
"checked", res.Checked, "renewed", res.Renewed,
|
"checked", res.Checked, "renewed", res.Renewed,
|
||||||
"failed", res.Failed, "skipped", res.Skipped)
|
"failed", res.Failed, "skipped", res.Skipped)
|
||||||
if a != nil && res.Failed > 0 && d != nil && d.shouldFire("cert.renew_failed") {
|
if a != nil && d != nil {
|
||||||
_, _ = a.Fire(ctx, "cert.renew_failed", alerts.SeverityError,
|
for _, domain := range res.FailedDomains {
|
||||||
"Cert-Renewal teilweise fehlgeschlagen",
|
key := "cert.renew_failed:" + domain
|
||||||
fmt.Sprintf("Renewer-Cycle: %d checked, %d renewed, %d failed, %d skipped",
|
if !d.shouldFire(key) {
|
||||||
res.Checked, res.Renewed, res.Failed, res.Skipped))
|
continue
|
||||||
|
}
|
||||||
|
_, _ = a.Fire(ctx, "cert.renew_failed", alerts.SeverityError,
|
||||||
|
"Cert-Renewal fehlgeschlagen: "+domain,
|
||||||
|
"Let's Encrypt Erneuerung für "+domain+" ist fehlgeschlagen. "+
|
||||||
|
"Prüfe ACME-Konfiguration und DNS-Erreichbarkeit. "+
|
||||||
|
"Nächster Versuch beim nächsten Renewer-Tick (alle 6h).")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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 cancelled): 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"
|
||||||
|
}
|
||||||
@@ -25,7 +25,23 @@ PrivateTmp=true
|
|||||||
PrivateDevices=true
|
PrivateDevices=true
|
||||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
|
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
|
||||||
SystemCallFilter=@system-service
|
SystemCallFilter=@system-service
|
||||||
ReadWritePaths=/etc/edgeguard /var/lib/edgeguard /var/log/edgeguard
|
# /var/lib/apt /var/cache/apt /etc/apt/apt.conf.d: damit `sudo -n apt-get
|
||||||
|
# update -qq` aus dem Update-Banner-Handler überhaupt in die Apt-Lists
|
||||||
|
# schreiben darf. Ohne diese Pfade läuft das Kommando still durch
|
||||||
|
# (exit 0) und der Cache bleibt veraltet — Banner zeigt nie ein Update.
|
||||||
|
# Befund 2026-05-14: trotz 30s-Poll blieb Candidate=Installed. mail-
|
||||||
|
# gateway hat dasselbe Set unter nmg-api.service.
|
||||||
|
#
|
||||||
|
# /var/backups/edgeguard: Backup-Service schreibt tar.gz Snapshots
|
||||||
|
# hierhin. Ohne Pfad in ReadWritePaths: read-only filesystem → EROFS.
|
||||||
|
# Befund 2026-05-15: "create eg-…tar.gz: read-only file system".
|
||||||
|
#
|
||||||
|
# /etc/chrony/conf.d /etc/unbound/unbound.conf.d /etc/wireguard:
|
||||||
|
# chrony/unbound/wg-Renderer schreiben edgeguard.conf bzw. <iface>.conf
|
||||||
|
# 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 /var/lib/crowdsec
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
|
|||||||
@@ -22,7 +22,10 @@ PrivateTmp=true
|
|||||||
PrivateDevices=true
|
PrivateDevices=true
|
||||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||||
SystemCallFilter=@system-service
|
SystemCallFilter=@system-service
|
||||||
ReadWritePaths=/etc/edgeguard /var/lib/edgeguard /var/log/edgeguard
|
# Scheduler ruft `backup.Service.Run` für scheduled backups + braucht
|
||||||
|
# apt-Pfade für die background-apt-update-Goroutine (parität zur API,
|
||||||
|
# falls die API down ist). /var/backups/edgeguard für tar.gz-Output.
|
||||||
|
ReadWritePaths=/etc/edgeguard /var/lib/edgeguard /var/log/edgeguard /var/backups/edgeguard /var/lib/apt /var/cache/apt /etc/apt/apt.conf.d
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
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
|
||||||
27
go.mod
27
go.mod
@@ -3,23 +3,32 @@ module git.netcell-it.de/projekte/edgeguard-native
|
|||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/corazawaf/coraza/v3 v3.7.0
|
||||||
|
github.com/coreos/go-oidc/v3 v3.18.0
|
||||||
|
github.com/dropmorepackets/haproxy-go v0.0.8
|
||||||
github.com/fsnotify/fsnotify v1.10.1
|
github.com/fsnotify/fsnotify v1.10.1
|
||||||
github.com/gin-gonic/gin v1.10.0
|
github.com/gin-gonic/gin v1.10.0
|
||||||
github.com/go-acme/lego/v4 v4.35.2
|
github.com/go-acme/lego/v4 v4.35.2
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/jackc/pgx/v5 v5.9.2
|
github.com/jackc/pgx/v5 v5.9.2
|
||||||
|
github.com/minio/minio-go/v7 v7.1.0
|
||||||
|
github.com/pkg/sftp v1.13.10
|
||||||
|
github.com/pquerna/otp v1.5.0
|
||||||
github.com/pressly/goose/v3 v3.27.1
|
github.com/pressly/goose/v3 v3.27.1
|
||||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||||
golang.org/x/crypto v0.51.0
|
golang.org/x/crypto v0.51.0
|
||||||
|
golang.org/x/oauth2 v0.36.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/boombuler/barcode v1.0.1 // indirect
|
||||||
github.com/bytedance/sonic v1.11.6 // indirect
|
github.com/bytedance/sonic v1.11.6 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||||
|
github.com/corazawaf/libinjection-go v0.3.2 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||||
@@ -28,34 +37,43 @@ require (
|
|||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.23.0 // indirect
|
github.com/go-playground/validator/v10 v10.23.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/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/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect
|
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect
|
||||||
|
github.com/kaptinlin/go-i18n v0.1.4 // indirect
|
||||||
|
github.com/kaptinlin/jsonschema v0.4.6 // indirect
|
||||||
github.com/klauspost/compress v1.18.5 // indirect
|
github.com/klauspost/compress v1.18.5 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
|
||||||
github.com/klauspost/crc32 v1.3.0 // indirect
|
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||||
github.com/kr/fs v0.1.0 // indirect
|
github.com/kr/fs v0.1.0 // indirect
|
||||||
github.com/kr/pretty v0.3.1 // indirect
|
github.com/kr/pretty v0.3.1 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/magefile/mage v1.17.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.21 // indirect
|
github.com/mattn/go-isatty v0.0.21 // indirect
|
||||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||||
github.com/miekg/dns v1.1.72 // indirect
|
github.com/miekg/dns v1.1.72 // indirect
|
||||||
github.com/minio/crc64nvme v1.1.1 // indirect
|
github.com/minio/crc64nvme v1.1.1 // indirect
|
||||||
github.com/minio/md5-simd v1.1.2 // 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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
|
github.com/petar-dambovaliev/aho-corasick v0.0.0-20250424160509-463d218d4745 // indirect
|
||||||
github.com/philhofer/fwd v1.2.0 // indirect
|
github.com/philhofer/fwd v1.2.0 // indirect
|
||||||
github.com/pkg/sftp v1.13.10 // indirect
|
|
||||||
github.com/rs/xid v1.6.0 // indirect
|
github.com/rs/xid v1.6.0 // indirect
|
||||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||||
|
github.com/tidwall/gjson v1.18.0 // indirect
|
||||||
|
github.com/tidwall/match v1.1.1 // indirect
|
||||||
|
github.com/tidwall/pretty v1.2.1 // indirect
|
||||||
github.com/tinylib/msgp v1.6.1 // indirect
|
github.com/tinylib/msgp v1.6.1 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // 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.2.12 // indirect
|
||||||
|
github.com/valllabh/ocsf-schema-golang v1.0.3 // indirect
|
||||||
github.com/zeebo/xxh3 v1.1.0 // indirect
|
github.com/zeebo/xxh3 v1.1.0 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
@@ -68,4 +86,5 @@ require (
|
|||||||
golang.org/x/tools v0.44.0 // indirect
|
golang.org/x/tools v0.44.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
rsc.io/binaryregexp v0.2.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
64
go.sum
64
go.sum
@@ -1,3 +1,6 @@
|
|||||||
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||||
|
github.com/boombuler/barcode v1.0.1 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyXcs=
|
||||||
|
github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
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 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||||
@@ -10,13 +13,25 @@ github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/
|
|||||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
github.com/cloudwego/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 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||||
|
github.com/corazawaf/coraza-coreruleset v0.0.0-20240226094324-415b1017abdc h1:OlJhrgI3I+FLUCTI3JJW8MoqyM78WbqJjecqMnqG+wc=
|
||||||
|
github.com/corazawaf/coraza-coreruleset v0.0.0-20240226094324-415b1017abdc/go.mod h1:7rsocqNDkTCira5T0M7buoKR2ehh7YZiPkzxRuAgvVU=
|
||||||
|
github.com/corazawaf/coraza/v3 v3.7.0 h1:LIQqu1r+l6e/U/gyiZeykWaNNBY1TzRLz+aaI+QYEEM=
|
||||||
|
github.com/corazawaf/coraza/v3 v3.7.0/go.mod h1:dOSt5evqC7EstouEv6ghhui01+oVUwp9X1vybWwqTlo=
|
||||||
|
github.com/corazawaf/libinjection-go v0.3.2 h1:9rrKt0lpg4WvUXt+lwS06GywfqRXXsa/7JcOw5cQLwI=
|
||||||
|
github.com/corazawaf/libinjection-go v0.3.2/go.mod h1:Ik/+w3UmTWH9yn366RgS9D95K3y7Atb5m/H/gXzzPCk=
|
||||||
|
github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A=
|
||||||
|
github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.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.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 h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dropmorepackets/haproxy-go v0.0.8 h1:kS2Wa8+ZDcnJdRSTiuBsaPun5hpdUPIuLQ+Drp9ZxYs=
|
||||||
|
github.com/dropmorepackets/haproxy-go v0.0.8/go.mod h1:4a2AmmVjvg2zPNdizGZrMN8ZSUpj90U43VlcdbOIBnU=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
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 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
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 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||||
@@ -39,8 +54,10 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
|||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
github.com/go-playground/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 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o=
|
||||||
github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
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.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
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=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
@@ -48,6 +65,10 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/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 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
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 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
@@ -56,14 +77,18 @@ github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
|||||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
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 h1:9Nu54bhS/H/Kgo2/7xNSUuC5G28VR8ljfrLKU2G4IjU=
|
||||||
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12/go.mod h1:TBzl5BIHNXfS9+C35ZyJaklL7mLDbgUkcgXzSLa8Tk0=
|
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12/go.mod h1:TBzl5BIHNXfS9+C35ZyJaklL7mLDbgUkcgXzSLa8Tk0=
|
||||||
|
github.com/kaptinlin/go-i18n v0.1.4 h1:wCiwAn1LOcvymvWIVAM4m5dUAMiHunTdEubLDk4hTGs=
|
||||||
|
github.com/kaptinlin/go-i18n v0.1.4/go.mod h1:g1fn1GvTgT4CiLE8/fFE1hboHWJ6erivrDpiDtCcFKg=
|
||||||
|
github.com/kaptinlin/jsonschema v0.4.6 h1:vOSFg5tjmfkOdKg+D6Oo4fVOM/pActWu/ntkPsI1T64=
|
||||||
|
github.com/kaptinlin/jsonschema v0.4.6/go.mod h1:1DUd7r5SdyB2ZnMtyB7uLv64dE3zTFTiYytDCd+AEL0=
|
||||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
github.com/klauspost/cpuid/v2 v2.0.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.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 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
|
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
|
||||||
@@ -77,6 +102,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
|||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/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 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
github.com/magefile/mage v1.17.0 h1:dS4tkq997Ism03akafC8509iqDjeE7TNTexI25Y7sXM=
|
||||||
|
github.com/magefile/mage v1.17.0/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA=
|
||||||
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
|
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
|
||||||
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||||
@@ -97,8 +124,10 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd
|
|||||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
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.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
|
github.com/petar-dambovaliev/aho-corasick v0.0.0-20250424160509-463d218d4745 h1:Vpr4VgAizEgEZsaMohpw6JYDP+i9Of9dmdY4ufNP6HI=
|
||||||
|
github.com/petar-dambovaliev/aho-corasick v0.0.0-20250424160509-463d218d4745/go.mod h1:EHPiTAKtiFmrMldLUNswFwfZ2eJIYBHktdaUTZxYWRw=
|
||||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
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/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||||
@@ -107,6 +136,8 @@ github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1Hbe
|
|||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.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 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||||
|
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||||
github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
|
github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4=
|
||||||
github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
|
github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
@@ -122,22 +153,30 @@ github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDq
|
|||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.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.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.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.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||||
|
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||||
|
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||||
|
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||||
|
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
|
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||||
|
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
|
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
|
||||||
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
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 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||||
|
github.com/valllabh/ocsf-schema-golang v1.0.3 h1:eR8k/3jP/OOqB8LRCtdJ4U+vlgd/gk5y3KMXoodrsrw=
|
||||||
|
github.com/valllabh/ocsf-schema-golang v1.0.3/go.mod h1:sZ3as9xqm1SSK5feFWIR2CuGeGRhsM7TR1MbpBctzPk=
|
||||||
|
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||||
|
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
@@ -153,11 +192,14 @@ golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
|||||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||||
|
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
|
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.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 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||||
|
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
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 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||||
@@ -179,4 +221,6 @@ modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw
|
|||||||
modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U=
|
modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U=
|
||||||
modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
|
modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
|
||||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||||
|
rsc.io/binaryregexp v0.2.0 h1:HfqmD5MEmC0zvwBuF187nq9mdnXjXsSivRiXN7SmRkE=
|
||||||
|
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
|
||||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||||
|
|||||||
273
internal/aggregator/aggregator.go
Normal file
273
internal/aggregator/aggregator.go
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
// Package aggregator führt parallele Cluster-Reads gegen alle Peer-Nodes
|
||||||
|
// via mTLS aus.
|
||||||
|
//
|
||||||
|
// Pattern: ein Aggregator-Endpoint auf der Main-API (z.B.
|
||||||
|
// /api/v1/cluster/system/load) ruft Aggregator.FanOut() — das verteilt
|
||||||
|
// die Request parallel an alle Peers' Agent-Listener (:8443 mTLS) und
|
||||||
|
// sammelt die Antworten in einer Map[node_id]→Ergebnis. Timeouts pro
|
||||||
|
// Peer (3s default) verhindern dass ein hängender Peer die ganze Antwort
|
||||||
|
// blockt; partielle Ergebnisse + per-Peer-Fehler werden zurückgegeben.
|
||||||
|
//
|
||||||
|
// mTLS-Auth: ClientTLSConfig aus clustertls.Store. CA muss auf beiden
|
||||||
|
// Seiten dieselbe sein — sonst RequireAndVerifyClientCert lehnt ab.
|
||||||
|
package aggregator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster"
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultAgentPort: alle Peers exposen ihren mTLS-Listener auf diesem
|
||||||
|
// Port. api_url in ha_nodes zeigt typischerweise auf den Public-3443-
|
||||||
|
// Port — wir derive'n den Agent-Port daraus, statt eine zweite Spalte
|
||||||
|
// in ha_nodes zu führen.
|
||||||
|
const DefaultAgentPort = 8443
|
||||||
|
|
||||||
|
// DefaultPeerTimeout: pro-Peer-Timeout. Aggregat-Caller sollten eine
|
||||||
|
// Obergrenze von max(N×PeerTimeout/parallel) im Kopf haben; in der
|
||||||
|
// Praxis ist alles parallel, also bestimmt der langsamste Peer die
|
||||||
|
// Latenz.
|
||||||
|
const DefaultPeerTimeout = 3 * time.Second
|
||||||
|
|
||||||
|
// Aggregator: dünner Wrapper mit ClientTLSConfig + http.Client.
|
||||||
|
type Aggregator struct {
|
||||||
|
HTTPClient *http.Client
|
||||||
|
AgentPort int
|
||||||
|
}
|
||||||
|
|
||||||
|
// New: liefert einen Aggregator der ClientTLSConfig verwendet. Wenn
|
||||||
|
// clientTLS == nil, geht der Client auf normales TLS-Verify zurück —
|
||||||
|
// für Tests nützlich, in Prod aber unsicher (würde Cert-Verify gegen
|
||||||
|
// System-Trust laufen, das den Cluster-CA nicht kennt).
|
||||||
|
func New(clientTLS *tls.Config) *Aggregator {
|
||||||
|
tr := &http.Transport{
|
||||||
|
TLSClientConfig: clientTLS,
|
||||||
|
MaxIdleConns: 16,
|
||||||
|
MaxIdleConnsPerHost: 2,
|
||||||
|
IdleConnTimeout: 90 * time.Second,
|
||||||
|
TLSHandshakeTimeout: 3 * time.Second,
|
||||||
|
ExpectContinueTimeout: 1 * time.Second,
|
||||||
|
ResponseHeaderTimeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
return &Aggregator{
|
||||||
|
HTTPClient: &http.Client{
|
||||||
|
Transport: tr,
|
||||||
|
Timeout: DefaultPeerTimeout,
|
||||||
|
},
|
||||||
|
AgentPort: DefaultAgentPort,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PeerResult kapselt das Ergebnis eines parallelen Fan-Out-Calls.
|
||||||
|
// Wenn Err != nil ist Data leer; sonst enthält Data den raw-JSON-Body
|
||||||
|
// (Aufrufer entscheidet ob es per-Peer typed-unmarshalled oder als
|
||||||
|
// map[string]any belassen wird).
|
||||||
|
type PeerResult struct {
|
||||||
|
NodeID string `json:"node_id"`
|
||||||
|
FQDN string `json:"fqdn"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Data json.RawMessage `json:"data,omitempty"`
|
||||||
|
Err string `json:"error,omitempty"`
|
||||||
|
Duration int64 `json:"duration_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FanOut: ruft GET <agent-url>/<path> für jeden Peer in `peers` parallel
|
||||||
|
// und sammelt die Ergebnisse in einer slice (stabile Sortierung nach
|
||||||
|
// Peer-FQDN für deterministisches UI-Rendering).
|
||||||
|
//
|
||||||
|
// `path` ist relativ, z.B. "/agent/system/load". `localID` wird als
|
||||||
|
// Marker übergeben damit der Aufrufer den eigenen Node von der Map
|
||||||
|
// ausschließen kann.
|
||||||
|
func (a *Aggregator) FanOut(ctx context.Context, peers []models.HANode, path, localID string) []PeerResult {
|
||||||
|
if !strings.HasPrefix(path, "/") {
|
||||||
|
path = "/" + path
|
||||||
|
}
|
||||||
|
results := make([]PeerResult, len(peers))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i, p := range peers {
|
||||||
|
if p.ID == localID {
|
||||||
|
// Eigener Node nicht über mTLS dial'n — wäre teuer + im
|
||||||
|
// Aufrufer wahrscheinlich der lokale Path
|
||||||
|
results[i] = PeerResult{NodeID: p.ID, FQDN: p.FQDN, OK: false, Err: "skipped: local node"}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
wg.Add(1)
|
||||||
|
i := i
|
||||||
|
p := p
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
results[i] = a.callPeer(ctx, p, path)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// callPeer macht den Einzel-Call. Wandelt p.APIURL in https://host:8443/
|
||||||
|
// um (Port übersteuert, Pfad ersetzt). Bei Connection-Fehler / Timeout
|
||||||
|
// liefert ein PeerResult mit OK=false zurück.
|
||||||
|
func (a *Aggregator) callPeer(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()
|
||||||
|
res.Duration = time.Since(start).Milliseconds()
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
reqCtx, cancel := context.WithTimeout(ctx, DefaultPeerTimeout)
|
||||||
|
defer cancel()
|
||||||
|
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, target, nil)
|
||||||
|
if err != nil {
|
||||||
|
res.Err = err.Error()
|
||||||
|
res.Duration = time.Since(start).Milliseconds()
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
resp, err := a.HTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
res.Err = err.Error()
|
||||||
|
res.Duration = time.Since(start).Milliseconds()
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1 MiB cap
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
res.Err = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
res.Duration = time.Since(start).Milliseconds()
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
res.OK = true
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// agentURL: nimmt z.B. "https://node1.example.com:3443" + port=8443 +
|
||||||
|
// path="/agent/system/load" und liefert "https://node1.example.com:8443/agent/system/load".
|
||||||
|
// Wir tauschen den Port aus, behalten Schema + Host (nur).
|
||||||
|
func agentURL(apiURL string, agentPort int, path string) (string, error) {
|
||||||
|
if apiURL == "" {
|
||||||
|
return "", errors.New("empty api_url")
|
||||||
|
}
|
||||||
|
u, err := url.Parse(apiURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if u.Scheme == "" {
|
||||||
|
u.Scheme = "https"
|
||||||
|
}
|
||||||
|
host := u.Hostname()
|
||||||
|
if host == "" {
|
||||||
|
return "", errors.New("api_url has no host")
|
||||||
|
}
|
||||||
|
u.Host = net.JoinHostPort(host, fmt.Sprint(agentPort))
|
||||||
|
u.Path = path
|
||||||
|
u.RawQuery = ""
|
||||||
|
return u.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostPeer sendet einen POST-Request an einen einzelnen Peer.
|
||||||
|
// Wird vom Rolling-Update-Orchestrator genutzt um /agent/cluster/trigger-update
|
||||||
|
// auf dem Secondary auszulösen.
|
||||||
|
func (a *Aggregator) PostPeer(ctx context.Context, p models.HANode, path string) PeerResult {
|
||||||
|
start := time.Now()
|
||||||
|
res := PeerResult{NodeID: p.ID, FQDN: p.FQDN}
|
||||||
|
target, err := agentURL(p.APIURL, a.AgentPort, path)
|
||||||
|
if err != nil {
|
||||||
|
res.Err = "bad api_url: " + err.Error()
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
reqCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, target, nil)
|
||||||
|
if err != nil {
|
||||||
|
res.Err = err.Error()
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := a.HTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
res.Err = err.Error()
|
||||||
|
res.Duration = time.Since(start).Milliseconds()
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted {
|
||||||
|
res.Err = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
res.Duration = time.Since(start).Milliseconds()
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
res.OK = true
|
||||||
|
res.Duration = time.Since(start).Milliseconds()
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostPeerWithBody sendet einen POST-Request mit JSON-Body an einen Peer.
|
||||||
|
// Wird für VIP-Schwenk-Tests genutzt (/agent/cluster/vip-cmd).
|
||||||
|
func (a *Aggregator) PostPeerWithBody(ctx context.Context, p models.HANode, path string, body []byte) PeerResult {
|
||||||
|
start := time.Now()
|
||||||
|
res := PeerResult{NodeID: p.ID, FQDN: p.FQDN}
|
||||||
|
target, err := agentURL(p.APIURL, a.AgentPort, path)
|
||||||
|
if err != nil {
|
||||||
|
res.Err = "bad api_url: " + err.Error()
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
reqCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, target, strings.NewReader(string(body)))
|
||||||
|
if err != nil {
|
||||||
|
res.Err = err.Error()
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := a.HTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
res.Err = err.Error()
|
||||||
|
res.Duration = time.Since(start).Milliseconds()
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted && resp.StatusCode != http.StatusNoContent {
|
||||||
|
res.Err = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
|
||||||
|
res.Duration = time.Since(start).Milliseconds()
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
res.OK = true
|
||||||
|
res.Data = respBody
|
||||||
|
res.Duration = time.Since(start).Milliseconds()
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compile-time check dass cluster importiert wird (für Drift-Detection
|
||||||
|
// vom hashSpec — die Aggregator-Resultate werden parallel im Drift-
|
||||||
|
// Banner mitverarbeitet). Nicht runtime-essentiell, aber dokumentiert
|
||||||
|
// die Abhängigkeit.
|
||||||
|
var _ = cluster.ComputeConfigHash
|
||||||
@@ -49,6 +49,30 @@ func New(pool *pgxpool.Pool) *Generator {
|
|||||||
|
|
||||||
func (g *Generator) Name() string { return "chrony" }
|
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 {
|
func (g *Generator) Render(ctx context.Context) error {
|
||||||
settings, err := g.Repo.GetSettings(ctx)
|
settings, err := g.Repo.GetSettings(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
438
internal/cluster/clustertls/clustertls.go
Normal file
438
internal/cluster/clustertls/clustertls.go
Normal file
@@ -0,0 +1,438 @@
|
|||||||
|
// Package clustertls verwaltet die per-Cluster Certificate Authority
|
||||||
|
// 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)
|
||||||
|
//
|
||||||
|
// Workflow:
|
||||||
|
// * 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
|
||||||
|
// 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
|
||||||
|
// idempotent gerufen; Listener auf :8443 kann sofort hochfahren.
|
||||||
|
//
|
||||||
|
// Pattern 1:1 aus mail-gateway/internal/services/clustertls/clustertls.go,
|
||||||
|
// nur Dir + Filenamen angepasst (kein /etc/nmg → /var/lib/edgeguard).
|
||||||
|
package clustertls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultDir = "/var/lib/edgeguard/cluster-tls"
|
||||||
|
|
||||||
|
caCertFile = "ca.crt"
|
||||||
|
caKeyFile = "ca.key"
|
||||||
|
peerCertFile = "peer.crt"
|
||||||
|
peerKeyFile = "peer.key"
|
||||||
|
|
||||||
|
caValidity = 10 * 365 * 24 * time.Hour // 10 Jahre — Cluster-Lifetime
|
||||||
|
peerValidity = 365 * 24 * time.Hour // 1 Jahr — Renewal künftig automatisch
|
||||||
|
)
|
||||||
|
|
||||||
|
// Store kapselt die CA + Peer-Cert-Persistierung unter Dir.
|
||||||
|
type Store struct{ Dir string }
|
||||||
|
|
||||||
|
func New(dir string) *Store {
|
||||||
|
if dir == "" {
|
||||||
|
dir = DefaultDir
|
||||||
|
}
|
||||||
|
return &Store{Dir: dir}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasCA prüft ob ca.crt + ca.key existieren.
|
||||||
|
func (s *Store) HasCA() bool {
|
||||||
|
_, e1 := os.Stat(filepath.Join(s.Dir, caCertFile))
|
||||||
|
_, e2 := os.Stat(filepath.Join(s.Dir, caKeyFile))
|
||||||
|
return e1 == nil && e2 == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasPeer prüft ob peer.crt + peer.key existieren.
|
||||||
|
func (s *Store) HasPeer() bool {
|
||||||
|
_, e1 := os.Stat(filepath.Join(s.Dir, peerCertFile))
|
||||||
|
_, e2 := os.Stat(filepath.Join(s.Dir, peerKeyFile))
|
||||||
|
return e1 == nil && e2 == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
if s.HasCA() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if now == nil {
|
||||||
|
now = time.Now
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(s.Dir, 0o700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||||
|
tpl := &x509.Certificate{
|
||||||
|
SerialNumber: serial,
|
||||||
|
Subject: pkix.Name{
|
||||||
|
CommonName: "EdgeGuard Cluster CA",
|
||||||
|
Organization: []string{organisation},
|
||||||
|
},
|
||||||
|
NotBefore: now().UTC(),
|
||||||
|
NotAfter: now().Add(caValidity).UTC(),
|
||||||
|
IsCA: true,
|
||||||
|
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||||
|
BasicConstraintsValid: true,
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, pub, priv)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writePEM(filepath.Join(s.Dir, caCertFile), 0o644, "CERTIFICATE", der); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
keyDER, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writePEM(filepath.Join(s.Dir, caKeyFile), 0o600, "PRIVATE KEY", keyDER)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureSelfSigned: erstellt peer.crt + peer.key signiert mit der eigenen
|
||||||
|
// CA, falls noch nicht vorhanden. Verwendet für den "Cluster-Founder"-Pfad
|
||||||
|
// (erste Node generiert CA + ihren eigenen Cert). commonName ist meist
|
||||||
|
// die FQDN; dnsNames + ips landen in SubjectAlternativeName, damit der
|
||||||
|
// TLS-Handshake gegen IP-Adressen + Hostnamen funktioniert.
|
||||||
|
//
|
||||||
|
// Idempotent — vorhandenes peer.crt wird NICHT überschrieben (sonst
|
||||||
|
// würden andere Peers ihre Vertrauenskette verlieren nach jedem Boot).
|
||||||
|
func (s *Store) EnsureSelfSigned(commonName string, dnsNames []string, ips []net.IP, now func() time.Time) error {
|
||||||
|
if s.HasPeer() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !s.HasCA() {
|
||||||
|
return errors.New("clustertls: peer cert requested but no CA in place — run InitCA first")
|
||||||
|
}
|
||||||
|
if now == nil {
|
||||||
|
now = time.Now
|
||||||
|
}
|
||||||
|
caCert, caKey, err := s.LoadCA()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||||
|
tpl := &x509.Certificate{
|
||||||
|
SerialNumber: serial,
|
||||||
|
Subject: pkix.Name{CommonName: commonName},
|
||||||
|
DNSNames: dnsNames,
|
||||||
|
IPAddresses: ips,
|
||||||
|
NotBefore: now().UTC(),
|
||||||
|
NotAfter: now().Add(peerValidity).UTC(),
|
||||||
|
// Server-auth + Client-auth — ein Cert nutzbar für Listener und Outbound.
|
||||||
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, tpl, caCert, pub, caKey)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writePEM(filepath.Join(s.Dir, peerCertFile), 0o644, "CERTIFICATE", der); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
keyDER, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writePEM(filepath.Join(s.Dir, peerKeyFile), 0o600, "PRIVATE KEY", keyDER)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadCA parst CA-Cert + Ed25519 Private Key vom Disk.
|
||||||
|
func (s *Store) LoadCA() (*x509.Certificate, ed25519.PrivateKey, error) {
|
||||||
|
certPEM, err := os.ReadFile(filepath.Join(s.Dir, caCertFile))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("read CA cert: %w", err)
|
||||||
|
}
|
||||||
|
block, _ := pem.Decode(certPEM)
|
||||||
|
if block == nil {
|
||||||
|
return nil, nil, errors.New("ca cert: invalid PEM")
|
||||||
|
}
|
||||||
|
cert, err := x509.ParseCertificate(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("parse CA cert: %w", err)
|
||||||
|
}
|
||||||
|
keyPEM, err := os.ReadFile(filepath.Join(s.Dir, caKeyFile))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("read CA key: %w", err)
|
||||||
|
}
|
||||||
|
keyBlock, _ := pem.Decode(keyPEM)
|
||||||
|
if keyBlock == nil {
|
||||||
|
return nil, nil, errors.New("ca key: invalid PEM")
|
||||||
|
}
|
||||||
|
raw, err := x509.ParsePKCS8PrivateKey(keyBlock.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("parse CA key: %w", err)
|
||||||
|
}
|
||||||
|
priv, ok := raw.(ed25519.PrivateKey)
|
||||||
|
if !ok {
|
||||||
|
return nil, nil, fmt.Errorf("CA key is not ed25519 (got %T)", raw)
|
||||||
|
}
|
||||||
|
return cert, priv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignCSR signiert einen joining-peer CSR. Caller hat den one-shot
|
||||||
|
// cluster-join-Token bereits geprüft (Phase 3.4 — Aufrufer in handlers).
|
||||||
|
func (s *Store) SignCSR(csrPEM string, now func() time.Time) (string, error) {
|
||||||
|
if now == nil {
|
||||||
|
now = time.Now
|
||||||
|
}
|
||||||
|
block, _ := pem.Decode([]byte(csrPEM))
|
||||||
|
if block == nil || block.Type != "CERTIFICATE REQUEST" {
|
||||||
|
return "", errors.New("invalid CSR PEM")
|
||||||
|
}
|
||||||
|
csr, err := x509.ParseCertificateRequest(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("parse CSR: %w", err)
|
||||||
|
}
|
||||||
|
if err := csr.CheckSignature(); err != nil {
|
||||||
|
return "", fmt.Errorf("bad CSR signature: %w", err)
|
||||||
|
}
|
||||||
|
caCert, caKey, err := s.LoadCA()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||||
|
tpl := &x509.Certificate{
|
||||||
|
SerialNumber: serial,
|
||||||
|
Subject: csr.Subject,
|
||||||
|
DNSNames: csr.DNSNames,
|
||||||
|
IPAddresses: csr.IPAddresses,
|
||||||
|
NotBefore: now().UTC(),
|
||||||
|
NotAfter: now().Add(peerValidity).UTC(),
|
||||||
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, tpl, caCert, csr.PublicKey, caKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return pemString("CERTIFICATE", der), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPeerKeyAndCSR generiert einen frischen Ed25519 Key + CSR. Verwendet
|
||||||
|
// vom joining peer bevor er an /cluster/issue-cert POSTet.
|
||||||
|
func NewPeerKeyAndCSR(commonName string, dnsNames []string, ips []net.IP) (keyPEM, csrPEM string, err error) {
|
||||||
|
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
tpl := &x509.CertificateRequest{
|
||||||
|
Subject: pkix.Name{CommonName: commonName},
|
||||||
|
DNSNames: dnsNames,
|
||||||
|
IPAddresses: ips,
|
||||||
|
}
|
||||||
|
csrDER, err := x509.CreateCertificateRequest(rand.Reader, tpl, priv)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
keyDER, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return pemString("PRIVATE KEY", keyDER), pemString("CERTIFICATE REQUEST", csrDER), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteOwnPeerMaterial persistiert peer.key + peer.crt. Wird vom
|
||||||
|
// cluster-join-Flow gerufen wenn der Primary das Cert signiert hat.
|
||||||
|
func (s *Store) WriteOwnPeerMaterial(keyPEM, certPEM string) error {
|
||||||
|
if err := os.MkdirAll(s.Dir, 0o700); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(s.Dir, peerKeyFile), []byte(keyPEM), 0o600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(filepath.Join(s.Dir, peerCertFile), []byte(certPEM), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerTLSConfig: tls.Config für den mTLS-Listener (z.B. :8443).
|
||||||
|
// peer.crt/peer.key als Identity, ca.crt als einzige ClientCA.
|
||||||
|
// ClientAuth=RequireAndVerifyClientCert.
|
||||||
|
func (s *Store) ServerTLSConfig() (*tls.Config, error) {
|
||||||
|
pair, err := tls.LoadX509KeyPair(filepath.Join(s.Dir, peerCertFile), filepath.Join(s.Dir, peerKeyFile))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pool, err := s.caPool()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &tls.Config{
|
||||||
|
Certificates: []tls.Certificate{pair},
|
||||||
|
ClientCAs: pool,
|
||||||
|
RootCAs: pool,
|
||||||
|
ClientAuth: tls.RequireAndVerifyClientCert,
|
||||||
|
MinVersion: tls.VersionTLS13,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientTLSConfig: tls.Config für outbound peer-to-peer Calls.
|
||||||
|
// Präsentiert peer.crt, verifiziert Server gegen ca.crt.
|
||||||
|
func (s *Store) ClientTLSConfig() (*tls.Config, error) {
|
||||||
|
pair, err := tls.LoadX509KeyPair(filepath.Join(s.Dir, peerCertFile), filepath.Join(s.Dir, peerKeyFile))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pool, err := s.caPool()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &tls.Config{
|
||||||
|
Certificates: []tls.Certificate{pair},
|
||||||
|
RootCAs: pool,
|
||||||
|
MinVersion: tls.VersionTLS13,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CACertPEM gibt die CA-Cert als PEM-String zurück (für Join-Token-Export).
|
||||||
|
func (s *Store) CACertPEM() (string, error) {
|
||||||
|
b, err := os.ReadFile(filepath.Join(s.Dir, caCertFile))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PeerCertInfo liefert die Metadata des eigenen peer.crt. Wenn keiner
|
||||||
|
// existiert: (nil, error).
|
||||||
|
func (s *Store) PeerCertInfo() (*CertInfo, error) {
|
||||||
|
return parseCertInfo(filepath.Join(s.Dir, peerCertFile))
|
||||||
|
}
|
||||||
|
|
||||||
|
// CACertInfo liefert die Metadata der Cluster-CA. Wenn keiner
|
||||||
|
// existiert: (nil, error).
|
||||||
|
func (s *Store) CACertInfo() (*CertInfo, error) {
|
||||||
|
return parseCertInfo(filepath.Join(s.Dir, caCertFile))
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCertInfo(path string) (*CertInfo, error) {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
block, _ := pem.Decode(raw)
|
||||||
|
if block == nil {
|
||||||
|
return nil, errors.New("invalid PEM")
|
||||||
|
}
|
||||||
|
cert, err := x509.ParseCertificate(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
days := int(time.Until(cert.NotAfter) / (24 * time.Hour))
|
||||||
|
return &CertInfo{
|
||||||
|
CommonName: cert.Subject.CommonName,
|
||||||
|
NotBefore: cert.NotBefore.UTC(),
|
||||||
|
NotAfter: cert.NotAfter.UTC(),
|
||||||
|
DaysRemaining: days,
|
||||||
|
IsCA: cert.IsCA,
|
||||||
|
SerialHex: cert.SerialNumber.Text(16),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenewSelfSigned überschreibt peer.crt + peer.key mit einem frisch
|
||||||
|
// erzeugten Paar, signiert mit der eigenen CA. Nur sinnvoll auf dem
|
||||||
|
// Founder/Primary — Joiner sollten ihren Cert über den /cluster/
|
||||||
|
// issue-cert-Flow ablösen, sobald wir Renewal-Tokens bauen.
|
||||||
|
//
|
||||||
|
// Anders als EnsureSelfSigned wird hier ÜBERSCHRIEBEN. Caller (Handler
|
||||||
|
// + CLI) ist verantwortlich für vorherigen Restart-Hinweis bzw.
|
||||||
|
// Service-Restart nach dem Call.
|
||||||
|
func (s *Store) RenewSelfSigned(commonName string, dnsNames []string, ips []net.IP, now func() time.Time) error {
|
||||||
|
if !s.HasCA() {
|
||||||
|
return errors.New("clustertls: RenewSelfSigned requires own CA")
|
||||||
|
}
|
||||||
|
if now == nil {
|
||||||
|
now = time.Now
|
||||||
|
}
|
||||||
|
caCert, caKey, err := s.LoadCA()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||||
|
tpl := &x509.Certificate{
|
||||||
|
SerialNumber: serial,
|
||||||
|
Subject: pkix.Name{CommonName: commonName},
|
||||||
|
DNSNames: dnsNames,
|
||||||
|
IPAddresses: ips,
|
||||||
|
NotBefore: now().UTC(),
|
||||||
|
NotAfter: now().Add(peerValidity).UTC(),
|
||||||
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, tpl, caCert, pub, caKey)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writePEM(filepath.Join(s.Dir, peerCertFile), 0o644, "CERTIFICATE", der); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
keyDER, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writePEM(filepath.Join(s.Dir, peerKeyFile), 0o600, "PRIVATE KEY", keyDER)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) caPool() (*x509.CertPool, error) {
|
||||||
|
caPEM, err := os.ReadFile(filepath.Join(s.Dir, caCertFile))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pool := x509.NewCertPool()
|
||||||
|
if !pool.AppendCertsFromPEM(caPEM) {
|
||||||
|
return nil, errors.New("failed to add CA to pool")
|
||||||
|
}
|
||||||
|
return pool, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writePEM(path string, mode os.FileMode, kind string, der []byte) error {
|
||||||
|
return os.WriteFile(path, []byte(pemString(kind, der)), mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pemString(kind string, der []byte) string {
|
||||||
|
return string(pem.EncodeToMemory(&pem.Block{Type: kind, Bytes: der}))
|
||||||
|
}
|
||||||
@@ -35,10 +35,13 @@ import (
|
|||||||
|
|
||||||
// hashTable beschreibt eine Tabelle die in den config-hash einfließt.
|
// hashTable beschreibt eine Tabelle die in den config-hash einfließt.
|
||||||
type hashTable struct {
|
type hashTable struct {
|
||||||
Name string
|
Name string
|
||||||
Singleton bool // dns_settings, ntp_settings → eine row, id=1
|
Singleton bool // dns_settings, ntp_settings → eine row, id=1
|
||||||
ExtraExclude []string // Spalten die zusätzlich aus to_jsonb gefiltert werden
|
ExtraExclude []string // Spalten die zusätzlich aus to_jsonb gefiltert werden
|
||||||
SkipUpdatedAt bool // setze true wenn updated_at semantisch relevant ist
|
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
|
// hashSpec ist die Reihenfolge-stabile Liste. NEUE Tabellen hier
|
||||||
@@ -49,15 +52,13 @@ var hashSpec = []hashTable{
|
|||||||
{Name: "backends"},
|
{Name: "backends"},
|
||||||
{Name: "backend_servers"},
|
{Name: "backend_servers"},
|
||||||
{Name: "routing_rules"},
|
{Name: "routing_rules"},
|
||||||
{Name: "network_interfaces"},
|
|
||||||
{Name: "ip_addresses"},
|
|
||||||
{Name: "tls_certs", ExtraExclude: []string{"last_renewed_at", "last_error"}},
|
{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_objects"},
|
||||||
{Name: "firewall_address_groups"},
|
{Name: "firewall_address_groups"},
|
||||||
{Name: "firewall_services"},
|
{Name: "firewall_services", MigrationDefault: true},
|
||||||
{Name: "firewall_service_groups"},
|
{Name: "firewall_service_groups", MigrationDefault: true},
|
||||||
{Name: "firewall_rules"},
|
{Name: "firewall_rules"},
|
||||||
{Name: "firewall_nat_rules"},
|
{Name: "firewall_nat_rules"},
|
||||||
|
|
||||||
@@ -67,12 +68,29 @@ var hashSpec = []hashTable{
|
|||||||
|
|
||||||
{Name: "dns_zones"},
|
{Name: "dns_zones"},
|
||||||
{Name: "dns_records"},
|
{Name: "dns_records"},
|
||||||
{Name: "dns_settings", Singleton: true},
|
|
||||||
|
|
||||||
{Name: "ntp_pools"},
|
{Name: "ntp_pools", MigrationDefault: true},
|
||||||
{Name: "ntp_settings", Singleton: 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.
|
// 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
|
// ComputeConfigHash gibt den 16-hex-char-Hash über alle Spec-Tabellen
|
||||||
// zurück. Fehlende Tabellen (transienter schema-flux) werden als
|
// zurück. Fehlende Tabellen (transienter schema-flux) werden als
|
||||||
// leerer Per-Table-Hash behandelt — kein Abbruch.
|
// 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) {
|
func ComputeConfigHash(ctx context.Context, pool *pgxpool.Pool) (string, error) {
|
||||||
if pool == nil {
|
if pool == nil {
|
||||||
return "", fmt.Errorf("nil pool")
|
return "", fmt.Errorf("nil pool")
|
||||||
}
|
}
|
||||||
h := sha256.New()
|
h := sha256.New()
|
||||||
|
hasUserConfig := false
|
||||||
for _, t := range hashSpec {
|
for _, t := range hashSpec {
|
||||||
var s string
|
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.
|
// Migration fehlt o.ä. → leeren string nehmen, weiter.
|
||||||
s = ""
|
s = ""
|
||||||
}
|
}
|
||||||
|
if s != "" && !t.Singleton && !t.MigrationDefault {
|
||||||
|
hasUserConfig = true
|
||||||
|
}
|
||||||
h.Write([]byte(t.Name))
|
h.Write([]byte(t.Name))
|
||||||
h.Write([]byte{0})
|
h.Write([]byte{0})
|
||||||
h.Write([]byte(s))
|
h.Write([]byte(s))
|
||||||
h.Write([]byte{0})
|
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
|
return hex.EncodeToString(h.Sum(nil))[:16], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
72
internal/cluster/heartbeat.go
Normal file
72
internal/cluster/heartbeat.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package cluster
|
||||||
|
|
||||||
|
// Phase-3.2: periodischer Heartbeat + Stale-Sweeper.
|
||||||
|
//
|
||||||
|
// Hintergrund: EnsureSelfRegistered schreibt last_seen einmal beim
|
||||||
|
// API-Boot. Ohne periodisches Re-Schreiben friert last_seen auf den
|
||||||
|
// Boot-Zeitpunkt ein — Peers (im Multi-Node-Setup) hätten keinen Weg
|
||||||
|
// zu erkennen ob dieser Node noch lebt. Die Heartbeat-Goroutine in der
|
||||||
|
// API bumpt das alle 30s; der Scheduler räumt mit SweepStaleNodes Peers
|
||||||
|
// die länger als <threshold> nicht gemeldet haben auf status='offline'.
|
||||||
|
//
|
||||||
|
// Single-Node-Effekt: Cluster-UI zeigt korrekt "last seen 12s" statt
|
||||||
|
// "last seen 3h" weil last_seen frisch ist. UI-Drift-Banner-Logik im
|
||||||
|
// ClusterHandler.Status nutzt die selben Felder.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Heartbeat bumpt last_seen + status='online' für die eigene Node-Row
|
||||||
|
// und aktualisiert version + config_hash. Idempotent. UPDATE-only — die
|
||||||
|
// Row muss existieren (wird via EnsureSelfRegistered beim Boot angelegt).
|
||||||
|
//
|
||||||
|
// Hash-Berechnung läuft synchron — typisch <50ms auf einer realistischen
|
||||||
|
// DB-Größe; falls die compute-SQL fehlt (Migration im Flux) wird der
|
||||||
|
// vorhandene config_hash via COALESCE behalten.
|
||||||
|
func Heartbeat(ctx context.Context, pool *pgxpool.Pool, localID, version string) error {
|
||||||
|
if pool == nil || localID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
hash, _ := ComputeConfigHash(ctx, pool)
|
||||||
|
_, err := pool.Exec(ctx, `
|
||||||
|
UPDATE ha_nodes SET
|
||||||
|
last_seen = NOW(),
|
||||||
|
status = 'online',
|
||||||
|
version = COALESCE(NULLIF($1, ''), version),
|
||||||
|
config_hash = COALESCE(NULLIF($2, ''), config_hash),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $3`, version, hash, localID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SweepStaleNodes flippt status='online' → 'offline' für Peers deren
|
||||||
|
// last_seen älter als threshold ist. Liefert die Anzahl gefliptpter
|
||||||
|
// Rows zurück (für Logging). Idempotent — markiert keine Rows die
|
||||||
|
// schon offline sind.
|
||||||
|
//
|
||||||
|
// Threshold-Konvention: 4× Heartbeat-Intervall = 2 min bei 30s-Tick.
|
||||||
|
// Lässt Platz für eine verpasste API-Tick (Restart, GC-Pause, kurzer
|
||||||
|
// Network-Glitch) ohne false-positive Offline.
|
||||||
|
func SweepStaleNodes(ctx context.Context, pool *pgxpool.Pool, threshold time.Duration) (int64, error) {
|
||||||
|
if pool == nil || threshold <= 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
// Wir bauen das Interval als String — pgx kann time.Duration nicht
|
||||||
|
// direkt als INTERVAL serialisieren.
|
||||||
|
interval := fmt.Sprintf("%d seconds", int(threshold.Seconds()))
|
||||||
|
tag, err := pool.Exec(ctx, `
|
||||||
|
UPDATE ha_nodes SET
|
||||||
|
status = 'offline',
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE last_seen < NOW() - $1::interval
|
||||||
|
AND status = 'online'`, interval)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return tag.RowsAffected(), nil
|
||||||
|
}
|
||||||
244
internal/cluster/jointoken/jointoken.go
Normal file
244
internal/cluster/jointoken/jointoken.go
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
// Package jointoken implementiert one-shot Cluster-Join-Token für
|
||||||
|
// Phase 3.4.
|
||||||
|
//
|
||||||
|
// Format: "eg-join-v1.<base64url(payload)>.<base64url(hmac)>"
|
||||||
|
//
|
||||||
|
// Payload (CBOR-ähnlich, kompakt JSON): {n: nonce(16B base64),
|
||||||
|
// e: expires_at_unix, f: ca_fingerprint_hex_16chars}
|
||||||
|
//
|
||||||
|
// HMAC: SHA-256 über payload-bytes, Key aus /var/lib/edgeguard/cluster-join-secret
|
||||||
|
// (auto-generiert beim ersten Generate-Call, 32B random, 0600).
|
||||||
|
//
|
||||||
|
// Lifecycle:
|
||||||
|
// 1. Admin POSTet /cluster/join-tokens → Server generiert Token, schickt
|
||||||
|
// ihn als Klartext-String (einmalig). Server speichert NICHTS
|
||||||
|
// (token ist self-contained).
|
||||||
|
// 2. Joiner POSTet /cluster/issue-cert mit {token, csr}.
|
||||||
|
// 3. Server Verify(token): HMAC ok? Nicht abgelaufen? CA-Fingerprint
|
||||||
|
// passt zum eigenen? Nonce nicht schon in join_tokens_used?
|
||||||
|
// 4. Bei Erfolg: nonce in join_tokens_used inserten (PK-Conflict =
|
||||||
|
// Re-Use → reject). Dann CSR signieren + zurückgeben.
|
||||||
|
//
|
||||||
|
// One-Shot-Semantik: PG-Insert mit ON CONFLICT DO NOTHING; wenn keine
|
||||||
|
// Row inserted wurde → schon konsumiert. Das ersetzt einen sonst
|
||||||
|
// nötigen Distributed-Lock.
|
||||||
|
package jointoken
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// SecretPath: HMAC-Key. Auto-Generated beim ersten Generate.
|
||||||
|
DefaultSecretPath = "/var/lib/edgeguard/cluster-join-secret"
|
||||||
|
|
||||||
|
tokenPrefix = "eg-join-v1."
|
||||||
|
|
||||||
|
// DefaultTTL — wie lange ein generierter Token gültig bleibt.
|
||||||
|
DefaultTTL = 24 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
// Service kapselt Token-Gen + -Verify mit DB-One-Shot-Tracking.
|
||||||
|
type Service struct {
|
||||||
|
Pool *pgxpool.Pool
|
||||||
|
SecretPath string
|
||||||
|
GetCAFinger func() (string, error) // liefert 16-hex-fingerprint der eigenen CA
|
||||||
|
TTL time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(pool *pgxpool.Pool, getCAFinger func() (string, error)) *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.
|
||||||
|
type payload struct {
|
||||||
|
Nonce string `json:"n"`
|
||||||
|
Expires int64 `json:"e"`
|
||||||
|
CAFinger16 string `json:"f"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate erzeugt einen frischen Token. Caller (Handler) muss bereits
|
||||||
|
// Admin-Auth geprüft haben.
|
||||||
|
func (s *Service) Generate() (string, time.Time, error) {
|
||||||
|
if s.GetCAFinger == nil {
|
||||||
|
return "", time.Time{}, errors.New("jointoken: GetCAFinger unset")
|
||||||
|
}
|
||||||
|
finger, err := s.GetCAFinger()
|
||||||
|
if err != nil {
|
||||||
|
return "", time.Time{}, fmt.Errorf("ca fingerprint: %w", err)
|
||||||
|
}
|
||||||
|
secret, err := s.ensureSecret()
|
||||||
|
if err != nil {
|
||||||
|
return "", time.Time{}, fmt.Errorf("load secret: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonceBytes := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(nonceBytes); err != nil {
|
||||||
|
return "", time.Time{}, err
|
||||||
|
}
|
||||||
|
ttl := s.TTL
|
||||||
|
if ttl <= 0 {
|
||||||
|
ttl = DefaultTTL
|
||||||
|
}
|
||||||
|
exp := time.Now().Add(ttl).UTC()
|
||||||
|
p := payload{
|
||||||
|
Nonce: base64.RawURLEncoding.EncodeToString(nonceBytes),
|
||||||
|
Expires: exp.Unix(),
|
||||||
|
CAFinger16: finger,
|
||||||
|
}
|
||||||
|
pj, err := json.Marshal(p)
|
||||||
|
if err != nil {
|
||||||
|
return "", time.Time{}, err
|
||||||
|
}
|
||||||
|
mac := hmac.New(sha256.New, secret)
|
||||||
|
mac.Write(pj)
|
||||||
|
sig := mac.Sum(nil)
|
||||||
|
|
||||||
|
return tokenPrefix +
|
||||||
|
base64.RawURLEncoding.EncodeToString(pj) + "." +
|
||||||
|
base64.RawURLEncoding.EncodeToString(sig), exp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consume verifiziert den Token UND markiert die nonce als verbraucht
|
||||||
|
// — wenn die nonce schon in join_tokens_used steht (oder das INSERT
|
||||||
|
// nichts geändert hat), lehnen wir ab. Erfolgreicher Consume liefert
|
||||||
|
// die CA-Fingerprint aus dem Payload (Caller kann gegen die eigene
|
||||||
|
// CA vergleichen).
|
||||||
|
//
|
||||||
|
// `consumedBy` ist freier String der zum Audit ins DB-Row landet
|
||||||
|
// (typisch: peer.fqdn oder peer.api_url).
|
||||||
|
func (s *Service) Consume(ctx context.Context, token, consumedBy string) (payload, error) {
|
||||||
|
var zero payload
|
||||||
|
if !strings.HasPrefix(token, tokenPrefix) {
|
||||||
|
return zero, errors.New("invalid token format")
|
||||||
|
}
|
||||||
|
rest := strings.TrimPrefix(token, tokenPrefix)
|
||||||
|
parts := strings.SplitN(rest, ".", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return zero, errors.New("invalid token format")
|
||||||
|
}
|
||||||
|
pj, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||||
|
if err != nil {
|
||||||
|
return zero, fmt.Errorf("decode payload: %w", err)
|
||||||
|
}
|
||||||
|
sig, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return zero, fmt.Errorf("decode sig: %w", err)
|
||||||
|
}
|
||||||
|
secret, err := s.loadSecret()
|
||||||
|
if err != nil {
|
||||||
|
return zero, fmt.Errorf("load secret: %w", err)
|
||||||
|
}
|
||||||
|
mac := hmac.New(sha256.New, secret)
|
||||||
|
mac.Write(pj)
|
||||||
|
want := mac.Sum(nil)
|
||||||
|
if !hmac.Equal(want, sig) {
|
||||||
|
return zero, errors.New("bad signature")
|
||||||
|
}
|
||||||
|
var p payload
|
||||||
|
if err := json.Unmarshal(pj, &p); err != nil {
|
||||||
|
return zero, fmt.Errorf("unmarshal payload: %w", err)
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if now.Unix() > p.Expires {
|
||||||
|
return zero, errors.New("token expired")
|
||||||
|
}
|
||||||
|
if s.GetCAFinger != nil {
|
||||||
|
ownFinger, err := s.GetCAFinger()
|
||||||
|
if err == nil && ownFinger != p.CAFinger16 {
|
||||||
|
return zero, errors.New("ca fingerprint mismatch (wrong cluster?)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// One-shot-INSERT — wenn die nonce schon da ist (Re-Use), schlägt
|
||||||
|
// das fehl und wir lehnen ab.
|
||||||
|
exp := time.Unix(p.Expires, 0).UTC()
|
||||||
|
tag, err := s.Pool.Exec(ctx, `
|
||||||
|
INSERT INTO join_tokens_used (nonce, expires_at, consumed_by)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (nonce) DO NOTHING`, p.Nonce, exp, consumedBy)
|
||||||
|
if err != nil {
|
||||||
|
return zero, fmt.Errorf("track nonce: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return zero, errors.New("token already used")
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanupExpired löscht abgelaufene Nonce-Rows. Idempotent; Aufruf vom
|
||||||
|
// Scheduler einmal pro Stunde reicht.
|
||||||
|
func (s *Service) CleanupExpired(ctx context.Context) (int64, error) {
|
||||||
|
tag, err := s.Pool.Exec(ctx,
|
||||||
|
`DELETE FROM join_tokens_used WHERE expires_at < NOW() - INTERVAL '7 days'`)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return tag.RowsAffected(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── secret handling ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (s *Service) ensureSecret() ([]byte, error) {
|
||||||
|
if b, err := s.loadSecret(); err == nil && len(b) >= 32 {
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
// Generate fresh secret. Dir wird in postinst erstellt (0700,
|
||||||
|
// edgeguard-owned), wir schreiben direkt rein.
|
||||||
|
dir := filepath.Dir(s.path())
|
||||||
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
key := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(key); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(s.path(), key, 0o600); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) loadSecret() ([]byte, error) {
|
||||||
|
return os.ReadFile(s.path())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) path() string {
|
||||||
|
if s.SecretPath != "" {
|
||||||
|
return s.SecretPath
|
||||||
|
}
|
||||||
|
return DefaultSecretPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// CAFingerprint16 liefert die ersten 16 Hex-Chars vom SHA-256 der CA-
|
||||||
|
// Certificate DER-Bytes. Eindeutig genug für den Cluster-Trust-Check
|
||||||
|
// (64 bit Entropie); kürzer als 64 Hex damit Token nicht aufbläst.
|
||||||
|
func CAFingerprint16(caCertDER []byte) string {
|
||||||
|
sum := sha256.Sum256(caCertDER)
|
||||||
|
return hex.EncodeToString(sum[:])[:16]
|
||||||
|
}
|
||||||
@@ -100,6 +100,31 @@ RETURNING id, name, fqdn, api_url, public_ip, internal_ip, mgmt_ip,
|
|||||||
return scanNode(row)
|
return scanNode(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete entfernt einen Peer aus ha_nodes. Caller (Handler) verhindert
|
||||||
|
// dass die lokale Node sich selbst löscht — sonst geht der nächste
|
||||||
|
// Heartbeat-Tick die Row wieder anlegen UND der Cluster-Status zeigt
|
||||||
|
// für 2 min "weg" obwohl der Node noch läuft.
|
||||||
|
func (s *Store) Delete(ctx context.Context, id string) error {
|
||||||
|
tag, err := s.Pool.Exec(ctx, `DELETE FROM ha_nodes WHERE id = $1`, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
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
|
// EnsureSelfRegistered mints the node-id if needed, builds the row
|
||||||
// from setup.json + os.Hostname + node.conf, and upserts it. Called
|
// from setup.json + os.Hostname + node.conf, and upserts it. Called
|
||||||
// on edgeguard-api boot AFTER the DB pool is reachable.
|
// on edgeguard-api boot AFTER the DB pool is reachable.
|
||||||
|
|||||||
@@ -99,6 +99,33 @@ func RestartService(name string) error {
|
|||||||
return nil
|
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
|
// EtcEdgeguard is the on-target config root. Templated path used by
|
||||||
// all renderers — never let renderers hard-code their own.
|
// all renderers — never let renderers hard-code their own.
|
||||||
const EtcEdgeguard = "/etc/edgeguard"
|
const EtcEdgeguard = "/etc/edgeguard"
|
||||||
|
|||||||
432
internal/crowdsec/service.go
Normal file
432
internal/crowdsec/service.go
Normal file
@@ -0,0 +1,432 @@
|
|||||||
|
// Package crowdsec wraps sudo /usr/bin/cscli calls for the edgeguard
|
||||||
|
// management API. All list operations use -o json. Mutation operations
|
||||||
|
// (add/delete) use the appropriate cscli sub-commands.
|
||||||
|
//
|
||||||
|
// edgeguard runs as a non-root system user; every cscli call goes
|
||||||
|
// through sudo (allowed entries are in /etc/sudoers.d/edgeguard).
|
||||||
|
package crowdsec
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrNotInstalled is returned when /usr/bin/cscli is not found.
|
||||||
|
var ErrNotInstalled = errors.New("crowdsec not installed")
|
||||||
|
|
||||||
|
// IsInstalled checks whether /usr/bin/cscli exists on this host.
|
||||||
|
func IsInstalled() bool {
|
||||||
|
_, err := os.Stat("/usr/bin/cscli")
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Types -----------------------------------------------------------
|
||||||
|
|
||||||
|
// Decision represents a single IP decision (ban/captcha/etc.) in CrowdSec.
|
||||||
|
type Decision struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Origin string `json:"origin"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
Duration string `json:"duration"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Country string `json:"country,omitempty"`
|
||||||
|
AS string `json:"as,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alert represents a CrowdSec alert with associated decisions.
|
||||||
|
type Alert struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Scenario string `json:"scenario"`
|
||||||
|
EventsCount int `json:"events_count"`
|
||||||
|
Source AlertSource `json:"source"`
|
||||||
|
StartAt string `json:"start_at"`
|
||||||
|
StopAt string `json:"stop_at"`
|
||||||
|
Decisions []Decision `json:"decisions,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlertSource holds the source IP/range info for an alert.
|
||||||
|
type AlertSource struct {
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Country string `json:"cn,omitempty"`
|
||||||
|
ASName string `json:"as_name,omitempty"`
|
||||||
|
Range string `json:"range,omitempty"`
|
||||||
|
Scope string `json:"scope,omitempty"`
|
||||||
|
Value string `json:"value,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bouncer represents a registered CrowdSec bouncer.
|
||||||
|
type Bouncer struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
IPAddress string `json:"ip_address,omitempty"`
|
||||||
|
Revoked bool `json:"revoked"`
|
||||||
|
LastPull string `json:"last_pull,omitempty"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
Version string `json:"version,omitempty"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
AuthType string `json:"auth_type,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Machine represents a registered CrowdSec agent/machine.
|
||||||
|
type Machine struct {
|
||||||
|
MachineID string `json:"machineId"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
LastPush string `json:"last_push,omitempty"`
|
||||||
|
IsValidated bool `json:"isValidated"`
|
||||||
|
Version string `json:"version,omitempty"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HubItem represents a CrowdSec hub item (collection, parser, scenario, etc.).
|
||||||
|
type HubItem struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
LocalVersion string `json:"local_version,omitempty"`
|
||||||
|
LocalPath string `json:"local_path,omitempty"`
|
||||||
|
Author string `json:"author,omitempty"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status summarises the runtime state of the CrowdSec stack on this node.
|
||||||
|
type Status struct {
|
||||||
|
Installed bool `json:"installed"`
|
||||||
|
AgentRunning bool `json:"agent_running"`
|
||||||
|
BouncerRunning bool `json:"bouncer_running"`
|
||||||
|
Version string `json:"version,omitempty"`
|
||||||
|
DecisionCount int `json:"decision_count"`
|
||||||
|
AlertCount int `json:"alert_count"`
|
||||||
|
BouncerCount int `json:"bouncer_count"`
|
||||||
|
MachineCount int `json:"machine_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Helpers ---------------------------------------------------------
|
||||||
|
|
||||||
|
// sudoCscli executes `sudo -n /usr/bin/cscli <args...>` and returns stdout.
|
||||||
|
func sudoCscli(ctx context.Context, args ...string) ([]byte, error) {
|
||||||
|
full := append([]string{"-n", "/usr/bin/cscli"}, args...)
|
||||||
|
cmd := exec.CommandContext(ctx, "sudo", full...)
|
||||||
|
var out, errBuf bytes.Buffer
|
||||||
|
cmd.Stdout = &out
|
||||||
|
cmd.Stderr = &errBuf
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
slog.Error("crowdsec: sudoCscli failed", "args", args, "error", err, "stderr", errBuf.String())
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if errBuf.Len() > 0 {
|
||||||
|
slog.Warn("crowdsec: sudoCscli stderr", "args", args, "stderr", errBuf.String())
|
||||||
|
}
|
||||||
|
slog.Debug("crowdsec: sudoCscli ok", "args", args[0], "bytes", out.Len())
|
||||||
|
return out.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// systemctlActive returns true when the named unit is "active".
|
||||||
|
func systemctlActive(ctx context.Context, unit string) bool {
|
||||||
|
cmd := exec.CommandContext(ctx, "systemctl", "is-active", "--quiet", unit)
|
||||||
|
return cmd.Run() == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// unmarshalSlice unmarshals JSON that may be "null" (cscli returns null
|
||||||
|
// instead of [] when no items exist). Returns an empty slice in that case.
|
||||||
|
func unmarshalSlice[T any](data []byte) ([]T, error) {
|
||||||
|
data = bytes.TrimSpace(data)
|
||||||
|
if bytes.Equal(data, []byte("null")) || len(data) == 0 {
|
||||||
|
return []T{}, nil
|
||||||
|
}
|
||||||
|
var result []T
|
||||||
|
if err := json.Unmarshal(data, &result); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- ServiceStatus ---------------------------------------------------
|
||||||
|
|
||||||
|
// ServiceStatus returns a Status struct describing the current state of the
|
||||||
|
// CrowdSec agent and bouncer on this node. Does NOT need cscli installed —
|
||||||
|
// it uses systemctl for the running-state checks. Version is extracted via
|
||||||
|
// `cscli version` when available.
|
||||||
|
func ServiceStatus(ctx context.Context) Status {
|
||||||
|
st := Status{
|
||||||
|
Installed: IsInstalled(),
|
||||||
|
AgentRunning: systemctlActive(ctx, "crowdsec"),
|
||||||
|
BouncerRunning: systemctlActive(ctx, "crowdsec-firewall-bouncer"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if st.Installed {
|
||||||
|
// Grab version from `sudo -n /usr/bin/cscli version` — first line only.
|
||||||
|
// Output is not JSON; it looks like "version: v1.6.3-..."
|
||||||
|
if out, err := sudoCscli(ctx, "version"); err == nil {
|
||||||
|
scanner := bufio.NewScanner(bytes.NewReader(out))
|
||||||
|
if scanner.Scan() {
|
||||||
|
st.Version = strings.TrimSpace(scanner.Text())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only query cscli data endpoints when the agent is running — cscli
|
||||||
|
// hangs on its local socket when the agent is stopped, which would
|
||||||
|
// block the entire status response and leave the UI with no data.
|
||||||
|
if st.AgentRunning {
|
||||||
|
if decisions, err := Decisions(ctx); err == nil {
|
||||||
|
st.DecisionCount = len(decisions)
|
||||||
|
}
|
||||||
|
if alerts, err := Alerts(ctx, 500); err == nil {
|
||||||
|
st.AlertCount = len(alerts)
|
||||||
|
}
|
||||||
|
if bouncers, err := Bouncers(ctx); err == nil {
|
||||||
|
st.BouncerCount = len(bouncers)
|
||||||
|
}
|
||||||
|
if machines, err := Machines(ctx); err == nil {
|
||||||
|
st.MachineCount = len(machines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Decisions -------------------------------------------------------
|
||||||
|
|
||||||
|
// cscli decisions list -o json returns alert-level objects with nested
|
||||||
|
// decisions[] arrays. These intermediate types are used only for parsing.
|
||||||
|
type cscliDecisionRaw struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Duration string `json:"duration"`
|
||||||
|
Origin string `json:"origin"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type cscliAlertRaw struct {
|
||||||
|
Scenario string `json:"scenario"`
|
||||||
|
Decisions []cscliDecisionRaw `json:"decisions"`
|
||||||
|
Source struct {
|
||||||
|
IP string `json:"ip"`
|
||||||
|
CN string `json:"cn"`
|
||||||
|
ASName string `json:"as_name"`
|
||||||
|
} `json:"source"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decisions lists all active decisions by flattening the alert-level JSON
|
||||||
|
// that cscli emits (each alert contains a nested decisions[] array).
|
||||||
|
func Decisions(ctx context.Context) ([]Decision, error) {
|
||||||
|
if !IsInstalled() {
|
||||||
|
return nil, ErrNotInstalled
|
||||||
|
}
|
||||||
|
out, err := sudoCscli(ctx, "decisions", "list", "-o", "json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
alerts, err := unmarshalSlice[cscliAlertRaw](out)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var result []Decision
|
||||||
|
for _, a := range alerts {
|
||||||
|
for _, d := range a.Decisions {
|
||||||
|
result = append(result, Decision{
|
||||||
|
ID: d.ID,
|
||||||
|
Origin: d.Origin,
|
||||||
|
Type: d.Type,
|
||||||
|
Scope: d.Scope,
|
||||||
|
Value: d.Value,
|
||||||
|
Duration: d.Duration,
|
||||||
|
Reason: a.Scenario,
|
||||||
|
Country: a.Source.CN,
|
||||||
|
AS: a.Source.ASName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
result = []Decision{}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddDecision creates a new ban/captcha decision for the given IP.
|
||||||
|
func AddDecision(ctx context.Context, ip, duration, reason, typ string) error {
|
||||||
|
if !IsInstalled() {
|
||||||
|
return ErrNotInstalled
|
||||||
|
}
|
||||||
|
_, err := sudoCscli(ctx, "decisions", "add",
|
||||||
|
"--ip", ip,
|
||||||
|
"--duration", duration,
|
||||||
|
"--reason", reason,
|
||||||
|
"--type", typ,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteDecisionByIP removes all decisions for a given IP address.
|
||||||
|
func DeleteDecisionByIP(ctx context.Context, ip string) error {
|
||||||
|
if !IsInstalled() {
|
||||||
|
return ErrNotInstalled
|
||||||
|
}
|
||||||
|
_, err := sudoCscli(ctx, "decisions", "delete", "--ip", ip)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteDecisionByID removes a single decision by its numeric ID.
|
||||||
|
func DeleteDecisionByID(ctx context.Context, id string) error {
|
||||||
|
if !IsInstalled() {
|
||||||
|
return ErrNotInstalled
|
||||||
|
}
|
||||||
|
_, err := sudoCscli(ctx, "decisions", "delete", "--id", id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Alerts ----------------------------------------------------------
|
||||||
|
|
||||||
|
// Alerts lists recent alerts (up to limit).
|
||||||
|
func Alerts(ctx context.Context, limit int) ([]Alert, error) {
|
||||||
|
if !IsInstalled() {
|
||||||
|
return nil, ErrNotInstalled
|
||||||
|
}
|
||||||
|
out, err := sudoCscli(ctx, "alerts", "list", "-o", "json",
|
||||||
|
"-l", fmt.Sprintf("%d", limit))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return unmarshalSlice[Alert](out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAlert discards (deletes) a single alert by its ID.
|
||||||
|
func DeleteAlert(ctx context.Context, id string) error {
|
||||||
|
if !IsInstalled() {
|
||||||
|
return ErrNotInstalled
|
||||||
|
}
|
||||||
|
_, err := sudoCscli(ctx, "alerts", "delete", "--id", id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Bouncers --------------------------------------------------------
|
||||||
|
|
||||||
|
// Bouncers lists all registered bouncers.
|
||||||
|
func Bouncers(ctx context.Context) ([]Bouncer, error) {
|
||||||
|
if !IsInstalled() {
|
||||||
|
return nil, ErrNotInstalled
|
||||||
|
}
|
||||||
|
out, err := sudoCscli(ctx, "bouncers", "list", "-o", "json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return unmarshalSlice[Bouncer](out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteBouncer removes a bouncer by name.
|
||||||
|
func DeleteBouncer(ctx context.Context, name string) error {
|
||||||
|
if !IsInstalled() {
|
||||||
|
return ErrNotInstalled
|
||||||
|
}
|
||||||
|
_, err := sudoCscli(ctx, "bouncers", "delete", name)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Machines --------------------------------------------------------
|
||||||
|
|
||||||
|
// cscliMachineRaw mirrors the actual cscli JSON with its mixed camelCase /
|
||||||
|
// snake_case field names. Only used inside Machines().
|
||||||
|
type cscliMachineRaw struct {
|
||||||
|
MachineID string `json:"machineId"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
LastPush string `json:"last_push"`
|
||||||
|
IsValidated bool `json:"isValidated"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Machines lists all registered machines/agents.
|
||||||
|
func Machines(ctx context.Context) ([]Machine, error) {
|
||||||
|
if !IsInstalled() {
|
||||||
|
return nil, ErrNotInstalled
|
||||||
|
}
|
||||||
|
out, err := sudoCscli(ctx, "machines", "list", "-o", "json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
raw, err := unmarshalSlice[cscliMachineRaw](out)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make([]Machine, len(raw))
|
||||||
|
for i, r := range raw {
|
||||||
|
result[i] = Machine{
|
||||||
|
MachineID: r.MachineID,
|
||||||
|
CreatedAt: r.CreatedAt,
|
||||||
|
UpdatedAt: r.UpdatedAt,
|
||||||
|
LastPush: r.LastPush,
|
||||||
|
IsValidated: r.IsValidated,
|
||||||
|
Version: r.Version,
|
||||||
|
Status: r.Status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteMachine removes a machine by its machine ID.
|
||||||
|
func DeleteMachine(ctx context.Context, id string) error {
|
||||||
|
if !IsInstalled() {
|
||||||
|
return ErrNotInstalled
|
||||||
|
}
|
||||||
|
_, err := sudoCscli(ctx, "machines", "delete", "--machine-id", id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Collections -----------------------------------------------------
|
||||||
|
|
||||||
|
// Collections lists installed/available hub collections.
|
||||||
|
// cscli returns {"collections": [...]} (not a flat array) — we unwrap the key.
|
||||||
|
func Collections(ctx context.Context) ([]HubItem, error) {
|
||||||
|
if !IsInstalled() {
|
||||||
|
return nil, ErrNotInstalled
|
||||||
|
}
|
||||||
|
out, err := sudoCscli(ctx, "collections", "list", "-o", "json")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = bytes.TrimSpace(out)
|
||||||
|
if bytes.Equal(out, []byte("null")) || len(out) == 0 {
|
||||||
|
return []HubItem{}, nil
|
||||||
|
}
|
||||||
|
// cscli wraps collections in {"collections": [...]}
|
||||||
|
var wrapper struct {
|
||||||
|
Collections []HubItem `json:"collections"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(out, &wrapper); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if wrapper.Collections == nil {
|
||||||
|
return []HubItem{}, nil
|
||||||
|
}
|
||||||
|
return wrapper.Collections, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstallCollection installs a hub collection by name (--force to upgrade).
|
||||||
|
func InstallCollection(ctx context.Context, name string) error {
|
||||||
|
if !IsInstalled() {
|
||||||
|
return ErrNotInstalled
|
||||||
|
}
|
||||||
|
_, err := sudoCscli(ctx, "collections", "install", name, "--force")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveCollection removes a hub collection by name.
|
||||||
|
func RemoveCollection(ctx context.Context, name string) error {
|
||||||
|
if !IsInstalled() {
|
||||||
|
return ErrNotInstalled
|
||||||
|
}
|
||||||
|
_, err := sudoCscli(ctx, "collections", "remove", name)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -15,8 +15,8 @@ CREATE TABLE IF NOT EXISTS ha_nodes (
|
|||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
fqdn TEXT NOT NULL,
|
fqdn TEXT NOT NULL,
|
||||||
api_url TEXT NOT NULL,
|
api_url TEXT NOT NULL,
|
||||||
public_ip INET,
|
public_ip TEXT,
|
||||||
internal_ip INET,
|
internal_ip TEXT,
|
||||||
role TEXT NOT NULL DEFAULT 'peer',
|
role TEXT NOT NULL DEFAULT 'peer',
|
||||||
last_seen TIMESTAMPTZ,
|
last_seen TIMESTAMPTZ,
|
||||||
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
ALTER TABLE ha_nodes
|
ALTER TABLE ha_nodes
|
||||||
ADD COLUMN IF NOT EXISTS version TEXT,
|
ADD COLUMN IF NOT EXISTS version TEXT,
|
||||||
ADD COLUMN IF NOT EXISTS config_hash 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';
|
ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'unknown';
|
||||||
|
|
||||||
ALTER TABLE ha_nodes
|
ALTER TABLE ha_nodes
|
||||||
@@ -33,7 +33,7 @@ ALTER TABLE ha_nodes
|
|||||||
DROP CONSTRAINT IF EXISTS ha_nodes_status_check;
|
DROP CONSTRAINT IF EXISTS ha_nodes_status_check;
|
||||||
ALTER TABLE ha_nodes
|
ALTER TABLE ha_nodes
|
||||||
DROP COLUMN IF EXISTS status,
|
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 config_hash,
|
||||||
DROP COLUMN IF EXISTS version;
|
DROP COLUMN IF EXISTS version;
|
||||||
-- +goose StatementEnd
|
-- +goose StatementEnd
|
||||||
|
|||||||
39
internal/database/migrations/0023_domains_settings.sql
Normal file
39
internal/database/migrations/0023_domains_settings.sql
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- +goose StatementBegin
|
||||||
|
|
||||||
|
-- Domain-Settings die HAProxy pro Domain rendern kann. Bisher
|
||||||
|
-- hatten Domains nur ein binäres hsts_enabled — jetzt ist HSTS
|
||||||
|
-- granular (max-age, subdomains, preload) plus Maintenance-Mode
|
||||||
|
-- und Canonical-www-Redirect.
|
||||||
|
|
||||||
|
ALTER TABLE domains
|
||||||
|
ADD COLUMN IF NOT EXISTS hsts_max_age INTEGER NOT NULL DEFAULT 31536000,
|
||||||
|
ADD COLUMN IF NOT EXISTS hsts_subdomains BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
ADD COLUMN IF NOT EXISTS hsts_preload BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
ADD COLUMN IF NOT EXISTS maintenance_mode BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
ADD COLUMN IF NOT EXISTS maintenance_message TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS www_redirect TEXT NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
-- www_redirect-Werte:
|
||||||
|
-- '' — kein Redirect (Default; Domain wird direkt geserved)
|
||||||
|
-- 'to-naked'— www.foo.com → foo.com (canonical naked-domain)
|
||||||
|
-- 'to-www' — foo.com → www.foo.com (canonical www)
|
||||||
|
ALTER TABLE domains
|
||||||
|
DROP CONSTRAINT IF EXISTS domains_www_redirect_check;
|
||||||
|
ALTER TABLE domains
|
||||||
|
ADD CONSTRAINT domains_www_redirect_check
|
||||||
|
CHECK (www_redirect IN ('', 'to-naked', 'to-www'));
|
||||||
|
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- +goose StatementBegin
|
||||||
|
ALTER TABLE domains DROP CONSTRAINT IF EXISTS domains_www_redirect_check;
|
||||||
|
ALTER TABLE domains
|
||||||
|
DROP COLUMN IF EXISTS www_redirect,
|
||||||
|
DROP COLUMN IF EXISTS maintenance_message,
|
||||||
|
DROP COLUMN IF EXISTS maintenance_mode,
|
||||||
|
DROP COLUMN IF EXISTS hsts_preload,
|
||||||
|
DROP COLUMN IF EXISTS hsts_subdomains,
|
||||||
|
DROP COLUMN IF EXISTS hsts_max_age;
|
||||||
|
-- +goose StatementEnd
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- +goose StatementBegin
|
||||||
|
|
||||||
|
-- Phase 2 Domain-Settings:
|
||||||
|
-- * rate_limit_rps — Requests pro Sekunde Cap je Client-IP (0 = aus).
|
||||||
|
-- HAProxy implementiert das via per-Domain Stick-Table (siehe
|
||||||
|
-- internal/haproxy/haproxy.cfg.tpl).
|
||||||
|
-- * max_body_kb — max. Request-Body-Größe in KiB (0 = aus).
|
||||||
|
-- HAProxy verweigert größere Requests mit 413.
|
||||||
|
-- * domain_response_headers — beliebige Set-Header Pairs die je
|
||||||
|
-- Domain auf jede Response gesetzt werden (CORS, CSP, X-Frame-
|
||||||
|
-- Options, …). HAProxy rendert pro Eintrag einen `http-response
|
||||||
|
-- set-header` mit Host-ACL.
|
||||||
|
|
||||||
|
ALTER TABLE domains
|
||||||
|
ADD COLUMN IF NOT EXISTS rate_limit_rps INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS max_body_kb INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
ALTER TABLE domains
|
||||||
|
DROP CONSTRAINT IF EXISTS domains_rate_limit_rps_check;
|
||||||
|
ALTER TABLE domains
|
||||||
|
ADD CONSTRAINT domains_rate_limit_rps_check CHECK (rate_limit_rps >= 0);
|
||||||
|
|
||||||
|
ALTER TABLE domains
|
||||||
|
DROP CONSTRAINT IF EXISTS domains_max_body_kb_check;
|
||||||
|
ALTER TABLE domains
|
||||||
|
ADD CONSTRAINT domains_max_body_kb_check CHECK (max_body_kb >= 0);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS domain_response_headers (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
domain_id BIGINT NOT NULL REFERENCES domains(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ein Header-Name darf pro Domain nur einmal auftauchen (case-insensitive,
|
||||||
|
-- HTTP-Header sind per RFC nicht case-sensitive). Verhindert dass zwei
|
||||||
|
-- konkurrierende Werte für "X-Frame-Options" gesetzt werden.
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_drh_domain_name_ci
|
||||||
|
ON domain_response_headers (domain_id, lower(name));
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_drh_domain
|
||||||
|
ON domain_response_headers (domain_id, position);
|
||||||
|
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- +goose StatementBegin
|
||||||
|
DROP TABLE IF EXISTS domain_response_headers;
|
||||||
|
ALTER TABLE domains DROP CONSTRAINT IF EXISTS domains_max_body_kb_check;
|
||||||
|
ALTER TABLE domains DROP CONSTRAINT IF EXISTS domains_rate_limit_rps_check;
|
||||||
|
ALTER TABLE domains
|
||||||
|
DROP COLUMN IF EXISTS max_body_kb,
|
||||||
|
DROP COLUMN IF EXISTS rate_limit_rps;
|
||||||
|
-- +goose StatementEnd
|
||||||
28
internal/database/migrations/0025_join_tokens_used.sql
Normal file
28
internal/database/migrations/0025_join_tokens_used.sql
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- +goose StatementBegin
|
||||||
|
|
||||||
|
-- Phase 3.4: Cluster-Join-Token One-Shot-Consume-Tracking.
|
||||||
|
--
|
||||||
|
-- Token-Format ist HMAC-self-contained (siehe internal/cluster/jointoken):
|
||||||
|
-- nonce + expires_at + ca-fingerprint, signiert mit dem Cluster-Join-
|
||||||
|
-- Secret. Damit der Token wirklich nur EINMAL benutzbar ist, speichern
|
||||||
|
-- wir die nonce beim Consume — Re-Use → 409. expires_at hilft beim
|
||||||
|
-- Cleanup (Tokens älter als ihre eigene Expiry können gelöscht werden,
|
||||||
|
-- auch ungebraucht).
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS join_tokens_used (
|
||||||
|
nonce TEXT PRIMARY KEY,
|
||||||
|
used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
consumed_by TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_join_tokens_used_expires
|
||||||
|
ON join_tokens_used (expires_at);
|
||||||
|
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- +goose StatementBegin
|
||||||
|
DROP TABLE IF EXISTS join_tokens_used;
|
||||||
|
-- +goose StatementEnd
|
||||||
6
internal/database/migrations/0026_wg_client_routes.sql
Normal file
6
internal/database/migrations/0026_wg_client_routes.sql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
-- +goose Up
|
||||||
|
ALTER TABLE wireguard_interfaces
|
||||||
|
ADD COLUMN IF NOT EXISTS client_routes TEXT;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
ALTER TABLE wireguard_interfaces DROP COLUMN IF EXISTS client_routes;
|
||||||
22
internal/database/migrations/0027_http_protocol_flags.sql
Normal file
22
internal/database/migrations/0027_http_protocol_flags.sql
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- +goose StatementBegin
|
||||||
|
|
||||||
|
-- backends.force_http1: zwingt HAProxy, die Backend-Verbindung mit
|
||||||
|
-- HTTP/1.1 zu führen (alpn http/1.1) statt H2+H1.1 zu verhandeln.
|
||||||
|
-- Nötig für Backends die kein h2 sprechen (Legacy-Apps, manche nginx-Configs).
|
||||||
|
ALTER TABLE backends
|
||||||
|
ADD COLUMN IF NOT EXISTS force_http1 BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
-- domains.disable_h3: unterdrückt den Alt-Svc-Response-Header für diese
|
||||||
|
-- Domain. Browser erhalten keinen Hinweis auf h3/QUIC und bleiben auf
|
||||||
|
-- h2/http1.1 — sinnvoll wenn Clients Probleme mit QUIC-Verbindungen melden.
|
||||||
|
ALTER TABLE domains
|
||||||
|
ADD COLUMN IF NOT EXISTS disable_h3 BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- +goose StatementBegin
|
||||||
|
ALTER TABLE backends DROP COLUMN IF EXISTS force_http1;
|
||||||
|
ALTER TABLE domains DROP COLUMN IF EXISTS disable_h3;
|
||||||
|
-- +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
|
||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
_ "embed"
|
_ "embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -52,6 +53,18 @@ func New(pool *pgxpool.Pool) *Generator { return &Generator{Pool: pool} }
|
|||||||
|
|
||||||
func (g *Generator) Name() string { return "nftables" }
|
func (g *Generator) Name() string { return "nftables" }
|
||||||
|
|
||||||
|
func (g *Generator) RenderToString(ctx context.Context) (string, error) {
|
||||||
|
view, err := g.loadView(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("nftables: load state: %w", err)
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tpl.Execute(&buf, view); err != nil {
|
||||||
|
return "", fmt.Errorf("nftables: render template: %w", err)
|
||||||
|
}
|
||||||
|
return buf.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (g *Generator) Render(ctx context.Context) error {
|
func (g *Generator) Render(ctx context.Context) error {
|
||||||
view, err := g.loadView(ctx)
|
view, err := g.loadView(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -103,6 +116,30 @@ type View struct {
|
|||||||
// Operator never edits these — they belong to the service. If
|
// Operator never edits these — they belong to the service. If
|
||||||
// the service is removed/disabled, the rule is gone next render.
|
// the service is removed/disabled, the rule is gone next render.
|
||||||
AutoRules []AutoFWRule
|
AutoRules []AutoFWRule
|
||||||
|
|
||||||
|
// WGServerIfaces holds the interface names of all active
|
||||||
|
// WireGuard server-mode interfaces. The forward chain emits
|
||||||
|
// iifname "<iface>" accept for each — required so that peer-to-
|
||||||
|
// peer traffic (Peer A → Peer B both connected to the same wg
|
||||||
|
// interface) can be forwarded by the box. Without this, the
|
||||||
|
// forward policy=drop silently kills all inter-peer packets.
|
||||||
|
WGServerIfaces []string
|
||||||
|
|
||||||
|
// WGSiteMasq drives masquerade rules in postrouting_nat: one entry
|
||||||
|
// per active WireGuard server interface. Without masquerade, traffic
|
||||||
|
// from VPN roadwarrior clients (e.g. 192.168.99.3) forwarded to a
|
||||||
|
// site-to-site LAN (10.0.10.0/24) comes back with the client's tunnel
|
||||||
|
// IP as destination. The remote gateway (Unify Home) doesn't know
|
||||||
|
// that IP and drops the reply. Masquerade rewrites the source to the
|
||||||
|
// server's own tunnel IP so return traffic follows the same path back.
|
||||||
|
WGSiteMasq []WGSiteMasqEntry
|
||||||
|
}
|
||||||
|
|
||||||
|
// WGSiteMasqEntry is one WireGuard server interface's masquerade config.
|
||||||
|
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
|
// AutoFWRule is one auto-emitted inbound rule. Proto is "tcp" or
|
||||||
@@ -113,6 +150,8 @@ type AutoFWRule struct {
|
|||||||
Proto string
|
Proto string
|
||||||
Port int
|
Port int
|
||||||
DstIP string
|
DstIP string
|
||||||
|
L3 string // "ip"/"ip6" — gesetzt für DstIP-Rules (Familie); leer = agnostisch
|
||||||
|
Iface string // optional: scope auf ein iifname (z.B. DHCP udp/67 nur auf LAN)
|
||||||
Comment string
|
Comment string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +166,11 @@ type RuleLeg struct {
|
|||||||
DstIfaces []string
|
DstIfaces []string
|
||||||
SrcAddrs []string
|
SrcAddrs []string
|
||||||
DstAddrs []string
|
DstAddrs []string
|
||||||
Service ResolvedService // Proto="" → no service match (any)
|
// L3 ist "ip" (IPv4) oder "ip6" (IPv6) für das Adress-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
|
// ResolvedRule has all addresses + services already expanded so the
|
||||||
@@ -160,6 +203,12 @@ type ResolvedNATRule struct {
|
|||||||
DPortStart, DPortEnd int
|
DPortStart, DPortEnd int
|
||||||
TargetAddr string
|
TargetAddr string
|
||||||
TargetPortStart, TargetPortEnd int
|
TargetPortStart, TargetPortEnd int
|
||||||
|
// 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
|
Comment string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,26 +294,15 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// Expand to one Leg per (rule × service); rules without a service
|
// Expand to one Leg per (rule × service × address-family). Rules
|
||||||
// produce one leg with empty Proto.
|
// without a service produce one leg-set with empty Proto.
|
||||||
for _, r := range rules {
|
for _, r := range rules {
|
||||||
if len(r.Services) == 0 {
|
if len(r.Services) == 0 {
|
||||||
view.Legs = append(view.Legs, RuleLeg{
|
view.Legs = append(view.Legs, expandFamilyLegs(r, ResolvedService{}, false)...)
|
||||||
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,
|
|
||||||
})
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, svc := range r.Services {
|
for _, svc := range r.Services {
|
||||||
view.Legs = append(view.Legs, RuleLeg{
|
view.Legs = append(view.Legs, expandFamilyLegs(r, svc, true)...)
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,6 +316,30 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) {
|
|||||||
// ── Auto-Rules aus laufender Service-Config ──
|
// ── Auto-Rules aus laufender Service-Config ──
|
||||||
view.AutoRules = g.loadAutoRules(ctx)
|
view.AutoRules = g.loadAutoRules(ctx)
|
||||||
|
|
||||||
|
// ── WireGuard server-iface names (für forward-chain + site-to-site masquerade) ──
|
||||||
|
wgRows, err := g.Pool.Query(ctx,
|
||||||
|
`SELECT name, address_cidr FROM wireguard_interfaces WHERE active AND mode = 'server'`)
|
||||||
|
if err == nil {
|
||||||
|
defer wgRows.Close()
|
||||||
|
for wgRows.Next() {
|
||||||
|
var name, cidr string
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return view, nil
|
return view, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,11 +371,24 @@ func (g *Generator) loadAutoRules(ctx context.Context) []AutoFWRule {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Squid Forward-Proxy: wenn ≥1 aktive ACL → tcp 3128 inbound
|
// Squid Forward-Proxy: lese Port + Listen-Adressen aus
|
||||||
// (squid bindet aktuell 0.0.0.0:3128, daher kein DstIP-Filter).
|
// forward_proxy_settings. Für jede nicht-loopback IP eine
|
||||||
var aclCount int
|
// Auto-Rule; leere Liste = alle Interfaces (generische Regel).
|
||||||
if err := g.Pool.QueryRow(ctx, `SELECT count(*) FROM forward_proxy_acls WHERE active`).Scan(&aclCount); err == nil && aclCount > 0 {
|
var squidAddrs string
|
||||||
out = append(out, AutoFWRule{Proto: "tcp", Port: 3128, Comment: "Forward-Proxy (Squid)"})
|
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.
|
// WireGuard server-mode: udp <listen_port> pro aktive iface.
|
||||||
@@ -344,7 +419,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.
|
// splitCSV — wie in den Service-renderern.
|
||||||
@@ -368,6 +496,131 @@ func isLoopback(ip string) bool {
|
|||||||
return strings.HasPrefix(ip, "127.")
|
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 agnostisch.
|
||||||
|
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 Adress-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
|
// 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").
|
// 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
|
type addrObjMap map[int64]string
|
||||||
@@ -626,6 +879,19 @@ ORDER BY priority DESC, id ASC`)
|
|||||||
if outZone != nil {
|
if outZone != nil {
|
||||||
r.OutIfaces = zoneIfaces[*outZone]
|
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)
|
out = append(out, r)
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
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, agnostisch
|
||||||
|
},
|
||||||
|
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.
|
# Source: internal/firewall/firewall.go.
|
||||||
# Re-generate via `edgeguard-ctl render-config` or via API mutations.
|
# Re-generate via `edgeguard-ctl render-config` or via API mutations.
|
||||||
|
|
||||||
flush ruleset
|
add table inet edgeguard
|
||||||
|
flush table inet edgeguard
|
||||||
|
|
||||||
table inet edgeguard {
|
table inet edgeguard {
|
||||||
set peer_ipv4 {
|
set peer_ipv4 {
|
||||||
@@ -29,6 +30,9 @@ table inet edgeguard {
|
|||||||
# erreichbar.
|
# erreichbar.
|
||||||
tcp dport 22 ct state new limit rate 10/minute accept comment "anti-lockout: SSH (rate-limited)"
|
tcp dport 22 ct state new limit rate 10/minute accept comment "anti-lockout: SSH (rate-limited)"
|
||||||
tcp dport 443 accept comment "anti-lockout: HAProxy public HTTPS"
|
tcp dport 443 accept comment "anti-lockout: HAProxy public HTTPS"
|
||||||
|
# HTTP/3 läuft als QUIC über UDP/443. HAProxy bindet quic4@:443
|
||||||
|
# parallel zum TCP-Listener; ohne diese Regel droppt der policy.
|
||||||
|
udp dport 443 accept comment "anti-lockout: HAProxy HTTP/3 (QUIC)"
|
||||||
tcp dport 3443 accept comment "anti-lockout: Management-UI (HAProxy admin HTTPS)"
|
tcp dport 3443 accept comment "anti-lockout: Management-UI (HAProxy admin HTTPS)"
|
||||||
|
|
||||||
# Stateful baseline
|
# Stateful baseline
|
||||||
@@ -46,13 +50,18 @@ table inet edgeguard {
|
|||||||
# Cluster-internal: peers reach edgeguard-api over mTLS on :8443
|
# Cluster-internal: peers reach edgeguard-api over mTLS on :8443
|
||||||
tcp dport 8443 ip saddr @peer_ipv4 accept
|
tcp dport 8443 ip saddr @peer_ipv4 accept
|
||||||
tcp dport 8443 ip6 saddr @peer_ipv6 accept
|
tcp dport 8443 ip6 saddr @peer_ipv6 accept
|
||||||
|
# Cluster-internal: PG Logical Replication (:5432) + KeyDB Active-Active (:6379)
|
||||||
|
tcp dport 5432 ip saddr @peer_ipv4 accept
|
||||||
|
tcp dport 5432 ip6 saddr @peer_ipv6 accept
|
||||||
|
tcp dport 6379 ip saddr @peer_ipv4 accept
|
||||||
|
tcp dport 6379 ip6 saddr @peer_ipv6 accept
|
||||||
|
|
||||||
# ── Service-Auto-Rules (DNS/Squid/WG/...) ──
|
# ── Service-Auto-Rules (DNS/Squid/WG/...) ──
|
||||||
# Aus dem laufenden Service-State abgeleitet — Operator
|
# Aus dem laufenden Service-State abgeleitet — Operator
|
||||||
# editiert diese nicht. Wenn der Service entfernt/disabled
|
# editiert diese nicht. Wenn der Service entfernt/disabled
|
||||||
# wird, ist die Rule beim nächsten Render weg.
|
# wird, ist die Rule beim nächsten Render weg.
|
||||||
{{range .AutoRules}}
|
{{range .AutoRules}}
|
||||||
{{if .DstIP}}ip daddr {{.DstIP}} {{end}}{{.Proto}} dport {{.Port}} accept comment "auto: {{.Comment}}"
|
{{if .Iface}}iifname "{{.Iface}}" {{end}}{{if .DstIP}}{{.L3}} daddr {{.DstIP}} {{end}}{{.Proto}} dport {{.Port}} accept comment "auto: {{.Comment}}"
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
# ── Operator-defined rules ──
|
# ── Operator-defined rules ──
|
||||||
@@ -62,7 +71,7 @@ table inet edgeguard {
|
|||||||
die Comment-Zeile angehängt — sonst frisst nft die rule
|
die Comment-Zeile angehängt — sonst frisst nft die rule
|
||||||
als Teil des # Kommentars). */ -}}
|
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}}{{.Action}}
|
{{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}}
|
{{end}}
|
||||||
|
|
||||||
# ── DEFAULT-DROP LOGGING ───────────────────────────────────────
|
# ── DEFAULT-DROP LOGGING ───────────────────────────────────────
|
||||||
@@ -92,9 +101,18 @@ table inet edgeguard {
|
|||||||
# nach und erlauben new-state-Pakete von dort. Return-Pakete
|
# nach und erlauben new-state-Pakete von dort. Return-Pakete
|
||||||
# gehen via ct state established schon durch.
|
# gehen via ct state established schon durch.
|
||||||
{{range .NATRules}}{{if or (eq .Kind "snat") (eq .Kind "masquerade")}}{{if .SrcCIDR}}
|
{{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}}
|
{{end}}{{end}}{{end}}
|
||||||
|
|
||||||
|
# Auto-Forward für WireGuard-Server-Interfaces: Peer-to-Peer-
|
||||||
|
# Traffic (Peer A → Peer B, beide via dasselbe wg-Interface)
|
||||||
|
# muss durch die Box forwarded werden. Ohne diese Regel droppt
|
||||||
|
# policy=drop den Traffic, auch wenn WG ihn korrekt entschlüsselt.
|
||||||
|
# Return-Pakete gehen bereits via ct state established durch.
|
||||||
|
{{range .WGServerIfaces}}
|
||||||
|
iifname "{{.}}" accept comment "auto: WireGuard peer-forward {{.}}"
|
||||||
|
{{end}}
|
||||||
|
|
||||||
# Default-Drop-Logging (limit-rated, siehe input-chain).
|
# Default-Drop-Logging (limit-rated, siehe input-chain).
|
||||||
limit rate 10/second log prefix "edgeguard:drop-forward " group 0
|
limit rate 10/second log prefix "edgeguard:drop-forward " group 0
|
||||||
}
|
}
|
||||||
@@ -110,7 +128,7 @@ table inet edgeguard {
|
|||||||
{{""}}
|
{{""}}
|
||||||
{{/* nft-Syntax: erst L3-match (ip saddr/daddr), DANN L4 (tcp/udp dport).
|
{{/* nft-Syntax: erst L3-match (ip saddr/daddr), DANN L4 (tcp/udp dport).
|
||||||
Sonst quittiert der parser '... unexpected ip' an dieser Stelle. */}}
|
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}}
|
{{end}}{{end}}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,14 +144,24 @@ table inet edgeguard {
|
|||||||
# client-IP (für Logging / Geo-Block: später optional via
|
# client-IP (für Logging / Geo-Block: später optional via
|
||||||
# NAT-Rule-Flag preserve_client_ip).
|
# NAT-Rule-Flag preserve_client_ip).
|
||||||
ct status dnat masquerade
|
ct status dnat masquerade
|
||||||
|
|
||||||
|
# Auto-Masquerade für WireGuard site-to-site: VPN-Clients (z. B. Roadwarrior
|
||||||
|
# mit 192.168.99.3) greifen auf LANs hinter anderen Peers zu (z. B. 10.0.10.0/24).
|
||||||
|
# Das entfernte Gateway (z. B. Unify Home) sieht als Return-Destination die
|
||||||
|
# VPN-Client-IP — die es nicht in seiner Routing-Table hat → Reply wird gedroppt.
|
||||||
|
# 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}}" {{.L3}} saddr {{.VPNNet}} masquerade comment "auto: WireGuard site-to-site masquerade {{.Iface}}"
|
||||||
|
{{end}}
|
||||||
{{range .NATRules}}{{if eq .Kind "snat"}}
|
{{range .NATRules}}{{if eq .Kind "snat"}}
|
||||||
# NAT {{.ID}} (snat{{if .Comment}} — {{.Comment}}{{end}})
|
# 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"}}
|
{{end}}{{if eq .Kind "masquerade"}}
|
||||||
# NAT {{.ID}} (masquerade{{if .Comment}} — {{.Comment}}{{end}})
|
# 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}}
|
{{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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,9 +22,47 @@ func NewAuditHandler(repo *audit.Repo) *AuditHandler { return &AuditHandler{Repo
|
|||||||
func (h *AuditHandler) Register(rg *gin.RouterGroup) {
|
func (h *AuditHandler) Register(rg *gin.RouterGroup) {
|
||||||
g := rg.Group("/audit")
|
g := rg.Group("/audit")
|
||||||
g.GET("/recent", h.Recent)
|
g.GET("/recent", h.Recent)
|
||||||
|
g.GET("/search", h.Search)
|
||||||
g.GET("/live", h.Live)
|
g.GET("/live", h.Live)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Search filtert audit_log nach Actor/Action/Subject/Date-Range.
|
||||||
|
// Query-Params: actor, action, subject (alle ILIKE-Substring),
|
||||||
|
// since/until (RFC3339), limit (max 500), offset.
|
||||||
|
func (h *AuditHandler) Search(c *gin.Context) {
|
||||||
|
f := audit.SearchFilter{
|
||||||
|
Actor: c.Query("actor"),
|
||||||
|
Action: c.Query("action"),
|
||||||
|
Subject: c.Query("subject"),
|
||||||
|
}
|
||||||
|
if s := c.Query("since"); s != "" {
|
||||||
|
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||||||
|
f.Since = &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s := c.Query("until"); s != "" {
|
||||||
|
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||||||
|
f.Until = &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v := c.Query("limit"); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil {
|
||||||
|
f.Limit = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v := c.Query("offset"); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil {
|
||||||
|
f.Offset = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows, err := h.Repo.Search(c.Request.Context(), f)
|
||||||
|
if err != nil {
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"entries": rows, "limit": f.Limit, "offset": f.Offset})
|
||||||
|
}
|
||||||
|
|
||||||
// Recent returns the most recent audit_log entries — used by the
|
// Recent returns the most recent audit_log entries — used by the
|
||||||
// dashboard fallback path (z.B. wenn WebSocket nicht verbinden kann).
|
// dashboard fallback path (z.B. wenn WebSocket nicht verbinden kann).
|
||||||
// ?limit=N (1–100, default 10).
|
// ?limit=N (1–100, default 10).
|
||||||
|
|||||||
@@ -1,38 +1,81 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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/handlers/response"
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/session"
|
"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/setup"
|
||||||
|
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AuthHandler exposes login / me / logout. v1 verifies against the
|
// AuthHandler exposes login / me / logout.
|
||||||
// setup-store (single admin); admin_users-table support comes when the
|
// Login checks the DB users table first; falls back to the setup-store
|
||||||
// users repo lands.
|
// admin for backwards compatibility. On a successful setup-store login
|
||||||
|
// the account is auto-migrated into the DB (Upsert) so it shows up in
|
||||||
|
// user management from that point on.
|
||||||
type AuthHandler struct {
|
type AuthHandler struct {
|
||||||
Setup *setup.Store
|
Setup *setup.Store
|
||||||
Signer *session.Signer
|
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 {
|
func NewAuthHandler(s *setup.Store, sig *session.Signer) *AuthHandler {
|
||||||
return &AuthHandler{Setup: s, Signer: sig}
|
return &AuthHandler{Setup: s, Signer: sig}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithAudit: Audit-Repo + NodeID damit Password-Operationen (change,
|
||||||
|
// reset, login-success/fail) ins audit_log fließen.
|
||||||
|
func (h *AuthHandler) WithAudit(a *audit.Repo, nodeID string) *AuthHandler {
|
||||||
|
h.Audit = a
|
||||||
|
h.NodeID = nodeID
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithUsers injects the users repo so Login can verify against the DB.
|
||||||
|
func (h *AuthHandler) WithUsers(u *usersvc.Repo) *AuthHandler {
|
||||||
|
h.Users = u
|
||||||
|
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
|
// Register mounts /auth/login + /logout (public) and /auth/me
|
||||||
// (gated by requireAuth, passed in as a per-route middleware).
|
// (gated by requireAuth, passed in as a per-route middleware).
|
||||||
func (h *AuthHandler) Register(rg *gin.RouterGroup, requireAuth gin.HandlerFunc) {
|
func (h *AuthHandler) Register(rg *gin.RouterGroup, requireAuth gin.HandlerFunc) {
|
||||||
g := rg.Group("/auth")
|
g := rg.Group("/auth")
|
||||||
g.POST("/login", h.Login)
|
g.POST("/login", h.Login)
|
||||||
g.POST("/logout", h.Logout)
|
g.POST("/logout", h.Logout)
|
||||||
|
g.POST("/totp-verify", h.TOTPVerify)
|
||||||
g.GET("/me", requireAuth, h.Me)
|
g.GET("/me", requireAuth, h.Me)
|
||||||
g.POST("/reset-password", h.ResetPassword)
|
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 {
|
type loginRequest struct {
|
||||||
@@ -41,9 +84,10 @@ type loginRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type loginResponse struct {
|
type loginResponse struct {
|
||||||
Actor string `json:"actor"`
|
Actor string `json:"actor"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
ExpiresAt time.Time `json:"expires_at"`
|
ExpiresAt time.Time `json:"expires_at"`
|
||||||
|
TOTPRequired bool `json:"totp_required,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) Login(c *gin.Context) {
|
func (h *AuthHandler) Login(c *gin.Context) {
|
||||||
@@ -61,19 +105,109 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
|||||||
response.Err(c, http.StatusServiceUnavailable, errors.New("setup_required"))
|
response.Err(c, http.StatusServiceUnavailable, errors.New("setup_required"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !strings.EqualFold(st.AdminEmail, strings.TrimSpace(req.Email)) ||
|
|
||||||
!st.VerifyAdminPassword(req.Password) {
|
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 {
|
||||||
|
ai, dbErr := h.Users.FindForAuth(c.Request.Context(), email)
|
||||||
|
if dbErr == nil {
|
||||||
|
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(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 = 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 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"))
|
response.Unauthorized(c, errors.New("invalid_credentials"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
raw, tok, err := h.Signer.IssueWithRole(st.AdminEmail, "admin")
|
// 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
|
||||||
|
}
|
||||||
|
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)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Internal(c, err)
|
response.Internal(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setSessionCookie(c, raw, tok.Exp)
|
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{
|
response.OK(c, loginResponse{
|
||||||
Actor: tok.Actor,
|
Actor: tok.Actor,
|
||||||
Role: tok.Role,
|
Role: tok.Role,
|
||||||
@@ -81,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) {
|
func (h *AuthHandler) Logout(c *gin.Context) {
|
||||||
clearSessionCookie(c)
|
clearSessionCookie(c)
|
||||||
response.OK(c, gin.H{"logged_out": true})
|
response.OK(c, gin.H{"logged_out": true})
|
||||||
@@ -122,9 +396,134 @@ func (h *AuthHandler) ResetPassword(c *gin.Context) {
|
|||||||
response.BadRequest(c, err)
|
response.BadRequest(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if h.Audit != nil {
|
||||||
|
// ResetPassword: keine Session, deshalb "self-reset" als Actor
|
||||||
|
// damit der Audit-Trail zeigt dass es kein admin-mediated Reset war.
|
||||||
|
_ = h.Audit.Log(c.Request.Context(), "self-reset", "auth.password.reset",
|
||||||
|
"", gin.H{"remote": c.ClientIP()}, h.NodeID)
|
||||||
|
}
|
||||||
response.OK(c, gin.H{"ok": true})
|
response.OK(c, gin.H{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type changePasswordRequest struct {
|
||||||
|
CurrentPassword string `json:"current_password" binding:"required"`
|
||||||
|
NewPassword string `json:"new_password" binding:"required,min=12"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangePassword: authenticated User wechselt sein eigenes Passwort.
|
||||||
|
// Anders als ResetPassword (CLI-Token-Flow für vergessenes Passwort)
|
||||||
|
// 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)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if st == nil || !st.Completed {
|
||||||
|
response.Err(c, http.StatusServiceUnavailable, errors.New("setup_required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !st.VerifyAdminPassword(req.CurrentPassword) {
|
||||||
|
response.Unauthorized(c, errors.New("invalid_current_password"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Setup.SetAdminPassword(req.NewPassword); err != nil {
|
||||||
|
response.BadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.Audit != nil {
|
||||||
|
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "auth.password.change",
|
||||||
|
st.AdminEmail, gin.H{"actor": actorOf(c)}, h.NodeID)
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkWithPrimary verifies credentials against the primary node via mTLS.
|
||||||
|
// Returns actor+role on success, error on failure.
|
||||||
|
func (h *AuthHandler) checkWithPrimary(ctx context.Context, primaryFQDN, email, password string) (string, string, error) {
|
||||||
|
clientTLS, err := h.ClusterTLS.ClientTLSConfig()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
tr := &http.Transport{TLSClientConfig: clientTLS, TLSHandshakeTimeout: 5 * time.Second}
|
||||||
|
client := &http.Client{Transport: tr, Timeout: 8 * time.Second}
|
||||||
|
|
||||||
|
body, _ := json.Marshal(map[string]string{"email": email, "password": password})
|
||||||
|
reqURL := "https://" + primaryFQDN + ":8443/agent/auth/check"
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", "", errors.New("primary: " + strings.TrimSpace(string(raw)))
|
||||||
|
}
|
||||||
|
var env struct {
|
||||||
|
Data struct {
|
||||||
|
Actor string `json:"actor"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &env); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
if env.Data.Actor == "" {
|
||||||
|
return "", "", errors.New("primary returned empty actor")
|
||||||
|
}
|
||||||
|
return env.Data.Actor, env.Data.Role, nil
|
||||||
|
}
|
||||||
|
|
||||||
func setSessionCookie(c *gin.Context, raw string, expUnix int64) {
|
func setSessionCookie(c *gin.Context, raw string, expUnix int64) {
|
||||||
maxAge := int(time.Until(time.Unix(expUnix, 0)).Seconds())
|
maxAge := int(time.Until(time.Unix(expUnix, 0)).Seconds())
|
||||||
if maxAge < 0 {
|
if maxAge < 0 {
|
||||||
|
|||||||
@@ -117,6 +117,10 @@ func (h *BackendsHandler) Delete(c *gin.Context) {
|
|||||||
response.NotFound(c, err)
|
response.NotFound(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if errors.Is(err, backends.ErrInUse) {
|
||||||
|
response.Conflict(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
response.Internal(c, err)
|
response.Internal(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,32 +2,346 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/aggregator"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster"
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/clustertls"
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/jointoken"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ClusterHandler exposes cluster-state endpoints. v1 ist read-only;
|
// ClusterHandler exposes cluster-state endpoints. /status ist die
|
||||||
// /status liefert eine strukturierte UI-Sicht (local + peers + health),
|
// strukturierte UI-Sicht (local + peers + health), /nodes ist die
|
||||||
// /nodes bleibt als simpler list-endpoint für Tools/Scripts.
|
// simple list, /system/load fan-outet via mTLS-Aggregator zu allen
|
||||||
|
// Peers und liefert pro Node die /proc-Metriken.
|
||||||
|
//
|
||||||
|
// Aggregator kann nil sein (clustertls nicht initialisiert) — dann
|
||||||
|
// liefert /cluster/system/load nur den lokalen Wert.
|
||||||
type ClusterHandler struct {
|
type ClusterHandler struct {
|
||||||
Store *cluster.Store
|
Store *cluster.Store
|
||||||
LocalID string
|
LocalID string
|
||||||
|
Aggregator *aggregator.Aggregator
|
||||||
|
Version string // laufende Binary-Version, für Rolling-Update-Koordination
|
||||||
|
|
||||||
|
// TLSStore + Tokens: optional, gesetzt bei Phase 3.4. Erlauben das
|
||||||
|
// Generieren von Join-Tokens und das Issue-Cert für joining Peers.
|
||||||
|
TLSStore *clustertls.Store
|
||||||
|
Tokens *jointoken.Service
|
||||||
|
|
||||||
|
// PeerReloader: optional, gesetzt bei Phase 3.5. Nach Auto-Register
|
||||||
|
// triggert das den firewall-Render damit peer_ipv4 frisch ist.
|
||||||
|
PeerReloader PeerReloader
|
||||||
|
|
||||||
|
// Audit + NodeID: optional, gesetzt via WithAudit. Nötig für
|
||||||
|
// protokollierte, mutierende Aktionen wie den Replication-Repair.
|
||||||
|
Audit *audit.Repo
|
||||||
|
NodeID string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewClusterHandler(store *cluster.Store, localID string) *ClusterHandler {
|
func NewClusterHandler(store *cluster.Store, localID string) *ClusterHandler {
|
||||||
return &ClusterHandler{Store: store, LocalID: localID}
|
return &ClusterHandler{Store: store, LocalID: localID}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithAggregator: optionale Aggregator-Konfiguration. Nur wenn vorhanden
|
||||||
|
// wird /cluster/system/load die Peers via mTLS abklappern.
|
||||||
|
func (h *ClusterHandler) WithAggregator(a *aggregator.Aggregator) *ClusterHandler {
|
||||||
|
h.Aggregator = a
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithJoinFlow: Cluster-CA + Join-Token-Service. Nur wenn beide gesetzt
|
||||||
|
// sind exposen wir /cluster/join-tokens (admin) + /cluster/issue-cert (public).
|
||||||
|
func (h *ClusterHandler) WithJoinFlow(store *clustertls.Store, tokens *jointoken.Service) *ClusterHandler {
|
||||||
|
h.TLSStore = store
|
||||||
|
h.Tokens = tokens
|
||||||
|
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) {
|
func (h *ClusterHandler) Register(rg *gin.RouterGroup) {
|
||||||
g := rg.Group("/cluster")
|
g := rg.Group("/cluster")
|
||||||
g.GET("/nodes", h.ListNodes)
|
g.GET("/nodes", h.ListNodes)
|
||||||
g.GET("/status", h.Status)
|
g.GET("/status", h.Status)
|
||||||
|
g.GET("/system/load", h.SystemLoad)
|
||||||
|
g.DELETE("/nodes/:id", h.DeleteNode)
|
||||||
|
g.GET("/vip-settings", h.GetVIPSettings)
|
||||||
|
g.PUT("/vip-settings", h.UpdateVIPSettings)
|
||||||
|
g.POST("/rolling-update", h.RollingUpdate)
|
||||||
|
g.GET("/rolling-update/status", h.RollingUpdateStatus)
|
||||||
|
g.POST("/repair-replication", h.RepairReplication)
|
||||||
|
g.GET("/repair-replication/status", h.RepairReplicationStatus)
|
||||||
|
g.GET("/vip-status", h.VIPStatus)
|
||||||
|
g.POST("/vip-test", h.VIPTest)
|
||||||
|
if h.TLSStore != nil {
|
||||||
|
g.GET("/cert-status", h.CertStatus)
|
||||||
|
g.POST("/renew-self", h.RenewSelf)
|
||||||
|
}
|
||||||
|
if h.TLSStore != nil && h.Tokens != nil {
|
||||||
|
g.POST("/join-tokens", h.GenerateJoinToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
//
|
||||||
|
// Nach erfolgreichem Delete triggert der PeerReloader (falls gesetzt)
|
||||||
|
// einen Firewall-Render — peer_ipv4-Set verliert die IP, der entfernte
|
||||||
|
// Peer kann nicht mehr auf :8443/:16379 connecten.
|
||||||
|
func (h *ClusterHandler) DeleteNode(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
if id == "" {
|
||||||
|
response.BadRequest(c, simpleError("missing id"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if id == h.LocalID {
|
||||||
|
response.BadRequest(c, simpleError("cannot remove the local node — would auto-recreate on next heartbeat"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Store.Delete(c.Request.Context(), id); err != nil {
|
||||||
|
if err == cluster.ErrNotFound {
|
||||||
|
response.NotFound(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.PeerReloader != nil {
|
||||||
|
go func() {
|
||||||
|
rctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := h.PeerReloader(rctx); err != nil {
|
||||||
|
slog.Warn("cluster: firewall render after peer delete failed", "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
slog.Info("cluster: peer removed", "id", id, "actor", actorOf(c))
|
||||||
|
response.NoContent(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetVIPSettings liest die cluster_settings-Singleton-Row (VIP/VRRP-Config).
|
||||||
|
func (h *ClusterHandler) GetVIPSettings(c *gin.Context) {
|
||||||
|
if h.Store == nil {
|
||||||
|
response.NotFound(c, simpleError("cluster store not available"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var cs vipSettingsRow
|
||||||
|
row := h.Store.Pool.QueryRow(c.Request.Context(), `
|
||||||
|
SELECT vip_address, vip_interface, vip_auth_pass, vrrp_router_id,
|
||||||
|
hb_interface, hb_src_ip, hb_peer_ip, hb_router_id, gw_check_ip
|
||||||
|
FROM cluster_settings WHERE id = 1`)
|
||||||
|
if err := row.Scan(&cs.VIPAddress, &cs.VIPInterface, &cs.VIPAuthPass, &cs.VRRPRouterID,
|
||||||
|
&cs.HBInterface, &cs.HBSrcIP, &cs.HBPeerIP, &cs.HBRouterID, &cs.GWCheckIP); err != nil {
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, cs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateVIPSettings speichert die VIP/VRRP-Konfiguration und triggert
|
||||||
|
// einen Keepalived-Config-Render. Viewer-Schutz via RequireAdminForMutations-
|
||||||
|
// Middleware auf der authed-Group — kein Extra-Check nötig.
|
||||||
|
func (h *ClusterHandler) UpdateVIPSettings(c *gin.Context) {
|
||||||
|
var req vipSettingsRow
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.Store == nil {
|
||||||
|
response.NotFound(c, simpleError("cluster store not available"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err := h.Store.Pool.Exec(c.Request.Context(), `
|
||||||
|
UPDATE cluster_settings
|
||||||
|
SET vip_address=$1, vip_interface=$2, vip_auth_pass=$3, vrrp_router_id=$4,
|
||||||
|
hb_interface=$5, hb_src_ip=$6, hb_peer_ip=$7, hb_router_id=$8, gw_check_ip=$9,
|
||||||
|
updated_at=NOW()
|
||||||
|
WHERE id=1`,
|
||||||
|
nullIfEmpty(req.VIPAddress), nullIfEmpty(req.VIPInterface),
|
||||||
|
nullIfEmpty(req.VIPAuthPass), req.VRRPRouterID,
|
||||||
|
nullIfEmpty(req.HBInterface), nullIfEmpty(req.HBSrcIP),
|
||||||
|
nullIfEmpty(req.HBPeerIP), req.HBRouterID, nullIfEmpty(req.GWCheckIP))
|
||||||
|
if err != nil {
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("cluster: VIP settings updated", "vip", req.VIPAddress, "actor", actorOf(c))
|
||||||
|
// Keepalived-Config asynchron neu rendern
|
||||||
|
if h.PeerReloader != nil {
|
||||||
|
go func() {
|
||||||
|
rctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := h.PeerReloader(rctx); err != nil {
|
||||||
|
slog.Warn("cluster: keepalived render after VIP update failed", "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
response.NoContent(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
type vipSettingsRow struct {
|
||||||
|
VIPAddress *string `json:"vip_address"`
|
||||||
|
VIPInterface *string `json:"vip_interface"`
|
||||||
|
VIPAuthPass *string `json:"vip_auth_pass"`
|
||||||
|
VRRPRouterID int `json:"vrrp_router_id"`
|
||||||
|
HBInterface *string `json:"hb_interface"`
|
||||||
|
HBSrcIP *string `json:"hb_src_ip"`
|
||||||
|
HBPeerIP *string `json:"hb_peer_ip"`
|
||||||
|
HBRouterID int `json:"hb_router_id"`
|
||||||
|
GWCheckIP *string `json:"gw_check_ip"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullIfEmpty(s *string) *string {
|
||||||
|
if s == nil || *s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterPublic mountet die public (unauth) Endpoints — joining Peers
|
||||||
|
// haben noch keine Session/Cert, deshalb läuft /issue-cert vor der
|
||||||
|
// requireAuth-Middleware. Aufrufer muss diesen Group auf /api/v1 setzen
|
||||||
|
// (NICHT auf authed).
|
||||||
|
func (h *ClusterHandler) RegisterPublic(rg *gin.RouterGroup) {
|
||||||
|
if h.TLSStore == nil || h.Tokens == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
g := rg.Group("/cluster")
|
||||||
|
g.POST("/issue-cert", h.IssueCert)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterAgent mountet die Peer-only-Endpoints auf dem mTLS-Agent-
|
||||||
|
// Listener. Auth läuft über das Peer-Cert (RequireAndVerifyClientCert
|
||||||
|
// im ServerTLSConfig); der CN des Cert ist die FQDN des Peers.
|
||||||
|
//
|
||||||
|
// /agent/cluster/peers — joining Peer trägt sich nach erfolgreichem
|
||||||
|
// issue-cert hier ein, damit der Primary ihn in ha_nodes hat (mit
|
||||||
|
// status='joining') und der nächste Firewall-Render-Lauf seine IP
|
||||||
|
// ins peer_ipv4-Set aufnimmt.
|
||||||
|
func (h *ClusterHandler) RegisterAgent(rg *gin.RouterGroup) {
|
||||||
|
g := rg.Group("/agent/cluster")
|
||||||
|
g.POST("/peers", h.AgentRegisterPeer)
|
||||||
|
g.GET("/identity", h.AgentIdentity)
|
||||||
|
g.GET("/pg-replication-info", h.AgentPGReplicationInfo)
|
||||||
|
g.GET("/master-key", h.AgentMasterKey)
|
||||||
|
g.GET("/version", h.AgentVersion)
|
||||||
|
g.POST("/trigger-update", h.AgentTriggerUpdate)
|
||||||
|
g.GET("/active-ips", h.AgentActiveIPs)
|
||||||
|
g.POST("/vip-cmd", h.AgentVIPCmd)
|
||||||
|
g.GET("/tls-certs", h.AgentTLSCerts)
|
||||||
|
g.POST("/repair-replication", h.AgentRepairReplication)
|
||||||
|
g.GET("/repair-replication/status", h.AgentRepairReplicationStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentIdentity gibt die eigene ha_nodes-Row zurück. Wird vom Primary
|
||||||
|
// genutzt um joining-Peers aktiv zu reconcilen wenn autoRegister (Push)
|
||||||
|
// fehlgeschlagen ist — Pull-Fallback.
|
||||||
|
func (h *ClusterHandler) AgentIdentity(c *gin.Context) {
|
||||||
|
if h.Store == nil || h.LocalID == "" {
|
||||||
|
response.NotFound(c, simpleError("node not registered"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
node, err := h.Store.Get(c.Request.Context(), h.LocalID)
|
||||||
|
if err != nil {
|
||||||
|
if err == cluster.ErrNotFound {
|
||||||
|
response.NotFound(c, simpleError("local node not in ha_nodes yet"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, node)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentPGReplicationInfo gibt die Replication-Credentials für pg_basebackup
|
||||||
|
// zurück. Nur über den mTLS-Agent-Listener erreichbar. Liest das Passwort
|
||||||
|
// aus /var/lib/edgeguard/pg-replication-secret. Gibt 404 zurück wenn die
|
||||||
|
// Datei fehlt (cluster-init-replication noch nicht ausgeführt).
|
||||||
|
func (h *ClusterHandler) AgentPGReplicationInfo(c *gin.Context) {
|
||||||
|
const secretPath = "/var/lib/edgeguard/pg-replication-secret"
|
||||||
|
pass, err := readFileString(secretPath)
|
||||||
|
if err != nil {
|
||||||
|
response.NotFound(c, simpleError("pg-replication-secret nicht gefunden — cluster-init-replication auf dem Primary ausführen"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Host = eigene Public-IP aus ha_nodes (oder Fallback: FQDN)
|
||||||
|
host := ""
|
||||||
|
if h.Store != nil && h.LocalID != "" {
|
||||||
|
if node, err := h.Store.Get(c.Request.Context(), h.LocalID); err == nil {
|
||||||
|
if node.PublicIP != nil && *node.PublicIP != "" {
|
||||||
|
host = *node.PublicIP
|
||||||
|
}
|
||||||
|
if host == "" {
|
||||||
|
host = node.FQDN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{
|
||||||
|
"host": host,
|
||||||
|
"port": 5432,
|
||||||
|
"user": "edgeguard_replicator",
|
||||||
|
"password": strings.TrimSpace(pass),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentMasterKey gibt den Secrets-Master-Key zurück, damit cluster-setup-standby
|
||||||
|
// ihn auf dem Secondary synchronisieren kann. Nur über den mTLS-Agent-Listener
|
||||||
|
// erreichbar. Ohne gemeinsamen Master-Key können replizierte verschlüsselte
|
||||||
|
// Felder (WireGuard private keys, PSKs) auf dem Secondary nicht entschlüsselt werden.
|
||||||
|
func (h *ClusterHandler) AgentMasterKey(c *gin.Context) {
|
||||||
|
const keyPath = "/var/lib/edgeguard/.master_key"
|
||||||
|
data, err := os.ReadFile(keyPath)
|
||||||
|
if err != nil {
|
||||||
|
response.NotFound(c, simpleError("master key nicht gefunden"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"key_hex": fmt.Sprintf("%x", data)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func readFileString(path string) (string, error) {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PeerReloader: optionale Funktion die nach einem Auto-Register
|
||||||
|
// Firewall + ggfs. andere Configs regeneriert (damit peer_ipv4-Set
|
||||||
|
// frisch ist). Wird vom main.go gesetzt.
|
||||||
|
type PeerReloader func(ctx context.Context) error
|
||||||
|
|
||||||
|
// WithPeerReloader: nach jedem AgentRegisterPeer-Aufruf gefeuert.
|
||||||
|
func (h *ClusterHandler) WithPeerReloader(r PeerReloader) *ClusterHandler {
|
||||||
|
h.PeerReloader = r
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithVersion: setzt die laufende Binary-Version für Rolling-Update-Koordination.
|
||||||
|
func (h *ClusterHandler) WithVersion(v string) *ClusterHandler {
|
||||||
|
h.Version = v
|
||||||
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *ClusterHandler) ListNodes(c *gin.Context) {
|
func (h *ClusterHandler) ListNodes(c *gin.Context) {
|
||||||
@@ -91,6 +405,23 @@ func (h *ClusterHandler) Status(c *gin.Context) {
|
|||||||
if len(out.Peers) > 0 {
|
if len(out.Peers) > 0 {
|
||||||
out.Mode = "cluster"
|
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
|
// Drift-Detection: jeder peer mit anderem config_hash als unser
|
||||||
// lokaler → Banner-Trigger im UI.
|
// lokaler → Banner-Trigger im UI.
|
||||||
if localHash != nil && *localHash != "" {
|
if localHash != nil && *localHash != "" {
|
||||||
@@ -116,3 +447,526 @@ func (h *ClusterHandler) Status(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
response.OK(c, out)
|
response.OK(c, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SystemLoad aggregiert /proc-Metriken aller Peers via mTLS-Aggregator.
|
||||||
|
// Liefert ein Array { node_id, fqdn, ok, data, error, duration_ms }.
|
||||||
|
// Lokaler Node wird IMMER eingefügt (direkter Call statt mTLS-Roundtrip).
|
||||||
|
func (h *ClusterHandler) SystemLoad(c *gin.Context) {
|
||||||
|
all, err := h.Store.List(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
results := make([]aggregator.PeerResult, 0, len(all))
|
||||||
|
// Lokalen Node selbst befragen: wir rufen die Resources-Funktion
|
||||||
|
// inline statt einen mTLS-Loopback aufzubauen — auch wenn der
|
||||||
|
// Agent-Listener läuft, ist ein direkter Call billiger.
|
||||||
|
for _, n := range all {
|
||||||
|
if n.ID == h.LocalID {
|
||||||
|
local := localSystemLoad()
|
||||||
|
raw, _ := json.Marshal(local)
|
||||||
|
results = append(results, aggregator.PeerResult{
|
||||||
|
NodeID: n.ID,
|
||||||
|
FQDN: n.FQDN,
|
||||||
|
OK: true,
|
||||||
|
Data: raw,
|
||||||
|
Duration: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if h.Aggregator != nil {
|
||||||
|
peers := make([]models.HANode, 0, len(all))
|
||||||
|
for _, n := range all {
|
||||||
|
if n.ID == h.LocalID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
peers = append(peers, n)
|
||||||
|
}
|
||||||
|
// /agent/system/resources auf dem Peer-Agent-Listener (:8443 mTLS).
|
||||||
|
fan := h.Aggregator.FanOut(c.Request.Context(), peers, "/agent/system/resources", h.LocalID)
|
||||||
|
results = append(results, fan...)
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"nodes": results})
|
||||||
|
}
|
||||||
|
|
||||||
|
// localSystemLoad ruft die selben Werte wie /system/resources, aber
|
||||||
|
// als bare struct (kein gin.Context). Damit liefert SystemLoad pro Node
|
||||||
|
// dasselbe Format wie der Agent-Endpoint.
|
||||||
|
func localSystemLoad() any {
|
||||||
|
// SystemHandler.Resources nutzt ein internes `resources` struct.
|
||||||
|
// Wir duplizieren die /proc-Reads nicht — der Agent-Listener mountet
|
||||||
|
// denselben Handler und liefert die JSON-Struktur. Für den lokalen
|
||||||
|
// Path liefern wir das Snapshot über computeLocalSystemResources.
|
||||||
|
return computeLocalSystemResources()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 3.4: Cluster-Join Token Flow ────────────────────────────────
|
||||||
|
|
||||||
|
// GenerateJoinToken — Admin generiert einen one-shot Bootstrap-Token
|
||||||
|
// für einen neuen Peer. Der FQDN des neuen Nodes wird vorab übergeben
|
||||||
|
// so dass er sofort in ha_nodes vorregistriert und im UI angezeigt werden
|
||||||
|
// kann. Token wird NUR EINMAL zurückgegeben.
|
||||||
|
func (h *ClusterHandler) GenerateJoinToken(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
NodeFQDN string `json:"node_fqdn"`
|
||||||
|
}
|
||||||
|
// Body optional — wenn leer, läuft der Flow ohne Pre-Register.
|
||||||
|
_ = c.ShouldBindJSON(&req)
|
||||||
|
|
||||||
|
token, exp, err := h.Tokens.Generate()
|
||||||
|
if err != nil {
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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-
|
||||||
|
// Auth nötig (der Joiner hat noch keine).
|
||||||
|
type issueCertRequest struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
CSR string `json:"csr"`
|
||||||
|
}
|
||||||
|
type issueCertResponse struct {
|
||||||
|
CACert string `json:"ca_cert"`
|
||||||
|
PeerCert string `json:"peer_cert"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ClusterHandler) IssueCert(c *gin.Context) {
|
||||||
|
var req issueCertRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Token == "" || req.CSR == "" {
|
||||||
|
response.BadRequest(c, errInvalidJoinRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// consumedBy → Remote-IP. Audit-Trail wenn jemand Tokens stiehlt
|
||||||
|
// und vom falschen Host einlöst.
|
||||||
|
clientIP := c.ClientIP()
|
||||||
|
if _, err := h.Tokens.Consume(c.Request.Context(), req.Token, clientIP); err != nil {
|
||||||
|
response.BadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// CSR signieren.
|
||||||
|
peerCert, err := h.TLSStore.SignCSR(req.CSR, nil)
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
caPEM, err := h.TLSStore.CACertPEM()
|
||||||
|
if err != nil {
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-register the joining node SYNCHRONOUSLY before returning the
|
||||||
|
// cert so that nftables @peer_ipv4 already contains the joiner's IP
|
||||||
|
// by the time they call autoRegister on port 8443. A goroutine here
|
||||||
|
// caused a race: cert returned → joiner calls autoRegister → nftables
|
||||||
|
// not updated yet → connection refused → status stays "joining".
|
||||||
|
if h.Store != nil && h.PeerReloader != nil {
|
||||||
|
h.preRegisterJoiner(c.Request.Context(), clientIP, req.CSR)
|
||||||
|
}
|
||||||
|
|
||||||
|
response.OK(c, issueCertResponse{
|
||||||
|
CACert: caPEM,
|
||||||
|
PeerCert: peerCert,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// preRegisterJoiner inserts a minimal ha_nodes row for the joining peer
|
||||||
|
// (using the CSR CN as FQDN and the HTTP client IP as public_ip), then
|
||||||
|
// triggers a firewall reload so @peer_ipv4 contains the new IP before
|
||||||
|
// the peer tries to call /agent/cluster/peers on port 8443.
|
||||||
|
// Uses a stable deterministic ID so re-joins are idempotent.
|
||||||
|
func (h *ClusterHandler) preRegisterJoiner(parent context.Context, clientIP, csrPEM string) {
|
||||||
|
ctx, cancel := context.WithTimeout(parent, 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
fqdn := cnFromCSR(csrPEM)
|
||||||
|
if fqdn == "" {
|
||||||
|
fqdn = "joining-" + clientIP
|
||||||
|
}
|
||||||
|
nodeID := fmt.Sprintf("prenode-%s", strings.ReplaceAll(fqdn, ".", "-"))
|
||||||
|
|
||||||
|
n := models.HANode{
|
||||||
|
ID: nodeID,
|
||||||
|
Name: fqdn,
|
||||||
|
FQDN: fqdn,
|
||||||
|
APIURL: "https://" + fqdn + ":3443",
|
||||||
|
Role: "peer",
|
||||||
|
Status: "joining",
|
||||||
|
}
|
||||||
|
n.PublicIP = &clientIP
|
||||||
|
|
||||||
|
if _, err := h.Store.UpsertSelf(ctx, n); err != nil {
|
||||||
|
slog.Warn("cluster: pre-register joiner failed", "fqdn", fqdn, "ip", clientIP, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.PeerReloader(ctx); err != nil {
|
||||||
|
slog.Warn("cluster: PeerReloader failed after pre-register", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("cluster: joiner pre-registered, firewall updated", "fqdn", fqdn, "ip", clientIP)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ptrStr dereferences a *string safely for comparison; nil → "".
|
||||||
|
func ptrStr(s *string) string {
|
||||||
|
if s == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *s
|
||||||
|
}
|
||||||
|
|
||||||
|
// cnFromCSR extracts the Subject Common Name from a PEM-encoded CSR.
|
||||||
|
// Returns empty string on any parse error.
|
||||||
|
func cnFromCSR(csrPEM string) string {
|
||||||
|
block, _ := pem.Decode([]byte(csrPEM))
|
||||||
|
if block == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
csr, err := x509.ParseCertificateRequest(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return csr.Subject.CommonName
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconcileJoiningPeers versucht für jeden Peer im Status "joining" oder
|
||||||
|
// "pending" die echte Node-ID via /agent/cluster/identity zu holen und
|
||||||
|
// ihn in ha_nodes mit der richtigen ID einzutragen. Self-Healing-
|
||||||
|
// Fallback wenn autoRegister (Push von joining-Peer zu Primary) wegen
|
||||||
|
// eines temporären Netzwerkproblems fehlgeschlagen ist.
|
||||||
|
//
|
||||||
|
// Die public_ip des Placeholder-Rows wird in der neuen Row übernommen
|
||||||
|
// damit @peer_ipv4 (nftables) korrekt bleibt.
|
||||||
|
func (h *ClusterHandler) reconcileJoiningPeers(placeholders []models.HANode) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
results := h.Aggregator.FanOut(ctx, placeholders, "/agent/cluster/identity", h.LocalID)
|
||||||
|
changed := false
|
||||||
|
for i, res := range results {
|
||||||
|
if !res.OK || len(res.Data) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var identity models.HANode
|
||||||
|
if err := json.Unmarshal(res.Data, &identity); err != nil || identity.ID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
placeholder := placeholders[i]
|
||||||
|
if identity.ID == placeholder.ID {
|
||||||
|
continue // ID bereits korrekt
|
||||||
|
}
|
||||||
|
// Echte ID gefunden — row mit realer ID anlegen, public_ip aus
|
||||||
|
// dem Placeholder-Row übernehmen damit nftables korrekt bleibt.
|
||||||
|
n := identity
|
||||||
|
n.Status = "online"
|
||||||
|
if n.PublicIP == nil {
|
||||||
|
n.PublicIP = placeholder.PublicIP
|
||||||
|
}
|
||||||
|
if n.InternalIP == nil {
|
||||||
|
n.InternalIP = placeholder.InternalIP
|
||||||
|
}
|
||||||
|
// Placeholder zuerst löschen: ha_nodes hat UNIQUE(fqdn). Ohne
|
||||||
|
// dieses Delete würde UpsertSelf (ON CONFLICT(id)) mit fqdn-
|
||||||
|
// unique-Violation scheitern.
|
||||||
|
_ = h.Store.DeletePlaceholdersByFQDN(ctx, n.FQDN, n.ID)
|
||||||
|
out, err := h.Store.UpsertSelf(ctx, n)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("cluster: reconcile joining peer: upsert failed",
|
||||||
|
"fqdn", n.FQDN, "real_id", n.ID, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
changed = true
|
||||||
|
slog.Info("cluster: joining peer reconciled via identity pull",
|
||||||
|
"id", out.ID, "fqdn", out.FQDN, "placeholder_id", placeholder.ID)
|
||||||
|
}
|
||||||
|
if changed && h.PeerReloader != nil {
|
||||||
|
rctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := h.PeerReloader(rctx); err != nil {
|
||||||
|
slog.Warn("cluster: PeerReloader failed after reconcile", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentVersion gibt die laufende Binary-Version zurück. Wird vom Rolling-
|
||||||
|
// Update-Orchestrator gepollt um zu erkennen wann der Secondary die neue
|
||||||
|
// Version hat.
|
||||||
|
func (h *ClusterHandler) AgentVersion(c *gin.Context) {
|
||||||
|
response.OK(c, gin.H{"version": h.Version})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentTriggerUpdate startet den Upgrade-Prozess auf diesem Node via
|
||||||
|
// systemd-run (detached). Wird vom Primary via mTLS aufgerufen um den
|
||||||
|
// Secondary zuerst zu aktualisieren (Rolling-Update). Pattern identisch
|
||||||
|
// zu /system/upgrade — nutzt dieselbe Sudoers-Whitelist aus dem postinst.
|
||||||
|
func (h *ClusterHandler) AgentTriggerUpdate(c *gin.Context) {
|
||||||
|
const scriptPath = "/var/lib/edgeguard/upgrade.sh"
|
||||||
|
const script = `#!/bin/bash
|
||||||
|
set -e
|
||||||
|
sleep 2
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
dpkg --configure -a || true
|
||||||
|
retry_apt() {
|
||||||
|
local attempt=0 max=3 wait_for=15
|
||||||
|
while [ $attempt -lt $max ]; do
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
apt-get update -qq || true
|
||||||
|
if apt-get install -y -qq -o Dpkg::Options::=--force-confold \
|
||||||
|
edgeguard-api edgeguard-ui edgeguard; then return 0; fi
|
||||||
|
[ $attempt -lt $max ] && sleep $wait_for && wait_for=$((wait_for * 2))
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
retry_apt
|
||||||
|
echo "[upgrade] complete"
|
||||||
|
rm -f /var/lib/edgeguard/upgrade.sh
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const unitName = "edgeguard-upgrade.service"
|
||||||
|
_ = exec.Command("sudo", "-n", "/usr/bin/systemctl", "reset-failed", unitName).Run()
|
||||||
|
cmd := exec.Command("sudo", "-n", "/usr/bin/systemd-run",
|
||||||
|
"--unit="+unitName,
|
||||||
|
"--description=EdgeGuard self-upgrade",
|
||||||
|
"--collect",
|
||||||
|
"bash", scriptPath)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
slog.Warn("cluster: AgentTriggerUpdate: systemd-run failed", "error", err)
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("cluster: rolling update triggered on this node by primary mTLS call",
|
||||||
|
"client", c.ClientIP())
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"status": "upgrading"})
|
||||||
|
}
|
||||||
|
|
||||||
|
var errInvalidJoinRequest = simpleError("missing token or csr")
|
||||||
|
|
||||||
|
type simpleError string
|
||||||
|
|
||||||
|
func (e simpleError) Error() string { return string(e) }
|
||||||
|
|
||||||
|
// ── Cluster-Cert-Status + Renewal ─────────────────────────────────────
|
||||||
|
|
||||||
|
// CertStatus liefert Metadata zu CA + Peer-Cert (Common Name, Expiry,
|
||||||
|
// days_remaining). UI nutzt das für Expiry-Warnungen.
|
||||||
|
func (h *ClusterHandler) CertStatus(c *gin.Context) {
|
||||||
|
out := gin.H{
|
||||||
|
"has_ca": h.TLSStore.HasCA(),
|
||||||
|
"has_peer": h.TLSStore.HasPeer(),
|
||||||
|
}
|
||||||
|
if h.TLSStore.HasCA() {
|
||||||
|
if info, err := h.TLSStore.CACertInfo(); err == nil {
|
||||||
|
out["ca"] = info
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if h.TLSStore.HasPeer() {
|
||||||
|
if info, err := h.TLSStore.PeerCertInfo(); err == nil {
|
||||||
|
out["peer"] = info
|
||||||
|
}
|
||||||
|
}
|
||||||
|
response.OK(c, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RenewSelf re-signed das eigene peer.crt mit der eigenen CA. Nur
|
||||||
|
// sinnvoll auf einer Founder-Box; Joiner haben keine eigene CA.
|
||||||
|
//
|
||||||
|
// 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);
|
||||||
|
// stattdessen liefern wir einen Hinweis im Response.
|
||||||
|
func (h *ClusterHandler) RenewSelf(c *gin.Context) {
|
||||||
|
if !h.TLSStore.HasCA() {
|
||||||
|
response.BadRequest(c, simpleError("no local CA — joiners cannot self-renew"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Common-Name aus dem existierenden Peer-Cert übernehmen damit der
|
||||||
|
// Cert weiterhin auf die aktuelle FQDN passt.
|
||||||
|
cn := "edgeguard-node"
|
||||||
|
if info, err := h.TLSStore.PeerCertInfo(); err == nil && info.CommonName != "" {
|
||||||
|
cn = info.CommonName
|
||||||
|
}
|
||||||
|
if err := h.TLSStore.RenewSelfSigned(cn, []string{cn}, nil, nil); err != nil {
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
info, err := h.TLSStore.PeerCertInfo()
|
||||||
|
if err != nil {
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{
|
||||||
|
"peer": info,
|
||||||
|
"restart_hint": "systemctl restart edgeguard-api",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Phase 3.5: Auto-Register beim Cluster-Join ────────────────────────
|
||||||
|
|
||||||
|
// registerPeerRequest: vom Joiner via mTLS-POST an /agent/cluster/peers.
|
||||||
|
// CN des Client-Cert authentifiziert den Peer. Wir nehmen nur die Felder
|
||||||
|
// 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"`
|
||||||
|
ConfigHash *string `json:"config_hash"` // nil=absent (don't change), ""=no user config
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentRegisterPeer: vom Joiner nach issue-cert via mTLS aufgerufen.
|
||||||
|
// Validiert dass der Client-Cert-CN zur fqdn passt (verhindert Cross-
|
||||||
|
// Peer-Hijack) und upsertet die Row in ha_nodes mit status='joining'.
|
||||||
|
// Phase 3.2 Heartbeat wird die Status auf 'online' ändern sobald der
|
||||||
|
// Joiner seinen eigenen Heartbeat-Tick startet.
|
||||||
|
func (h *ClusterHandler) AgentRegisterPeer(c *gin.Context) {
|
||||||
|
var req registerPeerRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.ID == "" || req.FQDN == "" {
|
||||||
|
response.BadRequest(c, simpleError("id + fqdn required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Cert-CN-Check: TLS-Layer hat den Cert bereits gegen unsere CA
|
||||||
|
// verifiziert; jetzt prüfen wir dass der CN zur claimed FQDN passt.
|
||||||
|
// Sonst könnte ein peer1.example.com Cert nutzen um peer2.example.com
|
||||||
|
// in ha_nodes zu schreiben.
|
||||||
|
if c.Request.TLS == nil || len(c.Request.TLS.PeerCertificates) == 0 {
|
||||||
|
response.Forbidden(c, simpleError("no client cert presented"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cn := c.Request.TLS.PeerCertificates[0].Subject.CommonName
|
||||||
|
if cn != req.FQDN {
|
||||||
|
slog.Warn("cluster: peer cert CN does not match registration FQDN",
|
||||||
|
"cn", cn, "fqdn", req.FQDN)
|
||||||
|
response.Forbidden(c, simpleError("cert CN does not match fqdn"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wir bauen ein models.HANode zusammen + nutzen den existierenden
|
||||||
|
// UpsertSelf. (UpsertSelf nimmt eine HANode für einen registrierenden
|
||||||
|
// 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.)
|
||||||
|
n := models.HANode{
|
||||||
|
ID: req.ID,
|
||||||
|
Name: req.Name,
|
||||||
|
FQDN: req.FQDN,
|
||||||
|
APIURL: req.APIURL,
|
||||||
|
Role: "peer",
|
||||||
|
Status: "online", // peer IS online — it just connected via mTLS
|
||||||
|
}
|
||||||
|
if req.PublicIP != "" {
|
||||||
|
v := req.PublicIP
|
||||||
|
n.PublicIP = &v
|
||||||
|
}
|
||||||
|
if req.InternalIP != "" {
|
||||||
|
v := req.InternalIP
|
||||||
|
n.InternalIP = &v
|
||||||
|
}
|
||||||
|
if req.MgmtIP != "" {
|
||||||
|
v := req.MgmtIP
|
||||||
|
n.MgmtIP = &v
|
||||||
|
}
|
||||||
|
if req.Version != "" {
|
||||||
|
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 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()
|
||||||
|
if err := h.PeerReloader(rctx); err != nil {
|
||||||
|
slog.Warn("cluster: PeerReloader failed after AgentRegisterPeer", "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("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)
|
||||||
|
}
|
||||||
|
|||||||
118
internal/handlers/cluster_certsync.go
Normal file
118
internal/handlers/cluster_certsync.go
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/aggregator"
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
const tlsCertDir = "/etc/edgeguard/tls"
|
||||||
|
|
||||||
|
// AgentTLSCerts liefert alle .pem-Dateien aus /etc/edgeguard/tls/ als
|
||||||
|
// Base64-Map. Wird vom Secondary via mTLS aufgerufen um Zertifikate
|
||||||
|
// des Primary zu spiegeln.
|
||||||
|
func (h *ClusterHandler) AgentTLSCerts(c *gin.Context) {
|
||||||
|
entries, err := os.ReadDir(tlsCertDir)
|
||||||
|
if err != nil {
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
certs := make(map[string]string, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".pem") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(tlsCertDir, e.Name()))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
certs[e.Name()] = base64.StdEncoding.EncodeToString(data)
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"certs": certs})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncTLSCertsFromPrimary holt alle TLS-Zertifikate vom Primary via mTLS
|
||||||
|
// und schreibt geänderte Dateien nach /etc/edgeguard/tls/. Relädt HAProxy
|
||||||
|
// wenn mindestens ein Zertifikat aktualisiert wurde.
|
||||||
|
//
|
||||||
|
// Läuft auf dem Secondary bei jedem runSecondaryConfigRender-Tick —
|
||||||
|
// nicht hash-gated, da certbot-Renewals den config_hash nicht ändern.
|
||||||
|
func SyncTLSCertsFromPrimary(ctx context.Context, pool *pgxpool.Pool, agg *aggregator.Aggregator, localID string) error {
|
||||||
|
if agg == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Primary-Peer aus ha_nodes ermitteln
|
||||||
|
rows, err := pool.Query(ctx,
|
||||||
|
`SELECT id, fqdn, api_url FROM ha_nodes WHERE id != $1 LIMIT 1`, localID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var primary *models.HANode
|
||||||
|
for rows.Next() {
|
||||||
|
n := &models.HANode{}
|
||||||
|
if err := rows.Scan(&n.ID, &n.FQDN, &n.APIURL); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
primary = n
|
||||||
|
}
|
||||||
|
if primary == nil {
|
||||||
|
return nil // kein Peer → Single-Node
|
||||||
|
}
|
||||||
|
|
||||||
|
results := agg.FanOut(ctx, []models.HANode{*primary}, "/agent/cluster/tls-certs", localID)
|
||||||
|
if len(results) == 0 || !results[0].OK {
|
||||||
|
return nil // Primary nicht erreichbar — nächster Tick
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
Certs map[string]string `json:"certs"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(results[0].Data, &payload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(tlsCertDir, 0o750); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
changed := false
|
||||||
|
for name, b64 := range payload.Certs {
|
||||||
|
data, err := base64.StdEncoding.DecodeString(b64)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("cert-sync: base64 decode failed", "file", name, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path := filepath.Join(tlsCertDir, name)
|
||||||
|
existing, readErr := os.ReadFile(path)
|
||||||
|
if readErr == nil && bytes.Equal(existing, data) {
|
||||||
|
continue // unverändert
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, data, 0o640); err != nil {
|
||||||
|
slog.Warn("cert-sync: write failed", "file", name, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
changed = true
|
||||||
|
slog.Info("cert-sync: updated", "file", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if changed {
|
||||||
|
if err := exec.Command("sudo", "-n", "/usr/bin/systemctl", "reload", "haproxy.service").Run(); err != nil {
|
||||||
|
slog.Warn("cert-sync: haproxy reload failed", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
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 ""
|
||||||
|
}
|
||||||
339
internal/handlers/cluster_rollingupdate.go
Normal file
339
internal/handlers/cluster_rollingupdate.go
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
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 triggern
|
||||||
|
slog.Info("rolling-update: posting trigger-update to secondary", "fqdn", secondary.FQDN)
|
||||||
|
result := h.Aggregator.PostPeer(ctx, *secondary, "/agent/cluster/trigger-update")
|
||||||
|
if !result.OK {
|
||||||
|
writeRollingUpdateState(RollingUpdateState{
|
||||||
|
Phase: phaseFailed,
|
||||||
|
SecondaryID: secondary.ID,
|
||||||
|
SecondaryFQDN: secondary.FQDN,
|
||||||
|
Error: "trigger-update failed: " + result.Err,
|
||||||
|
})
|
||||||
|
slog.Warn("rolling-update: secondary trigger failed", "error", result.Err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Secondary-Version pollen — der Secondary restartet nach dem
|
||||||
|
// Upgrade, danach zeigt /agent/cluster/version eine neue Version.
|
||||||
|
writeRollingUpdateState(RollingUpdateState{
|
||||||
|
Phase: phaseWaitingSecondary,
|
||||||
|
SecondaryID: secondary.ID,
|
||||||
|
SecondaryFQDN: secondary.FQDN,
|
||||||
|
})
|
||||||
|
slog.Info("rolling-update: waiting for secondary version flip",
|
||||||
|
"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
|
||||||
|
if apt-get install -y -qq -o Dpkg::Options::=--force-confold \
|
||||||
|
edgeguard-api edgeguard-ui edgeguard; then return 0; fi
|
||||||
|
[ $attempt -lt $max ] && sleep $wait_for && wait_for=$((wait_for * 2))
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
retry_apt
|
||||||
|
echo "[upgrade] complete"
|
||||||
|
rm -f /var/lib/edgeguard/upgrade.sh
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
|
||||||
|
writeRollingUpdateState(RollingUpdateState{
|
||||||
|
Phase: phaseFailed,
|
||||||
|
SecondaryID: secondary.ID,
|
||||||
|
SecondaryFQDN: secondary.FQDN,
|
||||||
|
Error: "write upgrade script: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const unitName = "edgeguard-upgrade.service"
|
||||||
|
_ = exec.Command("sudo", "-n", "/usr/bin/systemctl", "reset-failed", unitName).Run()
|
||||||
|
cmd := exec.Command("sudo", "-n", "/usr/bin/systemd-run",
|
||||||
|
"--unit="+unitName,
|
||||||
|
"--description=EdgeGuard self-upgrade",
|
||||||
|
"--collect",
|
||||||
|
"bash", scriptPath)
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
writeRollingUpdateState(RollingUpdateState{
|
||||||
|
Phase: phaseFailed,
|
||||||
|
SecondaryID: secondary.ID,
|
||||||
|
SecondaryFQDN: secondary.FQDN,
|
||||||
|
Error: "systemd-run failed: " + err.Error(),
|
||||||
|
})
|
||||||
|
slog.Warn("rolling-update: primary systemd-run failed", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// State bleibt "updating-primary" — der Primary restartet gleich.
|
||||||
|
// UI erkennt Version-Flip via /system/health und schließt den Flow.
|
||||||
|
slog.Info("rolling-update: primary upgrade dispatched, process will restart")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
@@ -54,6 +59,8 @@ func (h *DNSHandler) Register(rg *gin.RouterGroup) {
|
|||||||
|
|
||||||
g.GET("/settings", h.GetSettings)
|
g.GET("/settings", h.GetSettings)
|
||||||
g.PUT("/settings", h.UpdateSettings)
|
g.PUT("/settings", h.UpdateSettings)
|
||||||
|
g.GET("/stats", h.Stats)
|
||||||
|
g.POST("/flush-cache", h.FlushCache)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Zones ──────────────────────────────────────────────────────
|
// ── Zones ──────────────────────────────────────────────────────
|
||||||
@@ -145,6 +152,8 @@ func (h *DNSHandler) DeleteZone(c *gin.Context) {
|
|||||||
response.Internal(c, err)
|
response.Internal(c, err)
|
||||||
return
|
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)
|
response.NoContent(c)
|
||||||
h.reload(c.Request.Context(), "zone.delete")
|
h.reload(c.Request.Context(), "zone.delete")
|
||||||
}
|
}
|
||||||
@@ -256,6 +265,8 @@ func (h *DNSHandler) DeleteRecord(c *gin.Context) {
|
|||||||
response.Internal(c, err)
|
response.Internal(c, err)
|
||||||
return
|
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)
|
response.NoContent(c)
|
||||||
h.reload(c.Request.Context(), "record.delete")
|
h.reload(c.Request.Context(), "record.delete")
|
||||||
}
|
}
|
||||||
@@ -277,6 +288,10 @@ func (h *DNSHandler) UpdateSettings(c *gin.Context) {
|
|||||||
response.BadRequest(c, err)
|
response.BadRequest(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := validateSettings(&req); err != nil {
|
||||||
|
response.BadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
out, err := h.Repo.UpdateSettings(c.Request.Context(), req)
|
out, err := h.Repo.UpdateSettings(c.Request.Context(), req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Internal(c, err)
|
response.Internal(c, err)
|
||||||
@@ -287,8 +302,68 @@ func (h *DNSHandler) UpdateSettings(c *gin.Context) {
|
|||||||
h.reload(c.Request.Context(), "settings.update")
|
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 ─────────────────────────────────────────────────
|
// ── 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 {
|
func validateZone(z *models.DNSZone) error {
|
||||||
if z.Name == "" {
|
if z.Name == "" {
|
||||||
return errors.New("name required")
|
return errors.New("name required")
|
||||||
@@ -309,6 +384,71 @@ func validateZone(z *models.DNSZone) error {
|
|||||||
return nil
|
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 {
|
func validateRecord(r *models.DNSRecord) error {
|
||||||
if r.Name == "" {
|
if r.Name == "" {
|
||||||
return errors.New("name required")
|
return errors.New("name required")
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/domainheaders"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/domains"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/domains"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/routingrules"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/routingrules"
|
||||||
)
|
)
|
||||||
@@ -18,6 +19,7 @@ import (
|
|||||||
type DomainsHandler struct {
|
type DomainsHandler struct {
|
||||||
Repo *domains.Repo
|
Repo *domains.Repo
|
||||||
Routing *routingrules.Repo
|
Routing *routingrules.Repo
|
||||||
|
Headers *domainheaders.Repo
|
||||||
Audit *audit.Repo
|
Audit *audit.Repo
|
||||||
NodeID string
|
NodeID string
|
||||||
|
|
||||||
@@ -29,8 +31,8 @@ type DomainsHandler struct {
|
|||||||
Reloader func(ctx context.Context) error
|
Reloader func(ctx context.Context) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDomainsHandler(repo *domains.Repo, routing *routingrules.Repo, a *audit.Repo, nodeID string, reloader func(context.Context) error) *DomainsHandler {
|
func NewDomainsHandler(repo *domains.Repo, routing *routingrules.Repo, headers *domainheaders.Repo, a *audit.Repo, nodeID string, reloader func(context.Context) error) *DomainsHandler {
|
||||||
return &DomainsHandler{Repo: repo, Routing: routing, Audit: a, NodeID: nodeID, Reloader: reloader}
|
return &DomainsHandler{Repo: repo, Routing: routing, Headers: headers, Audit: a, NodeID: nodeID, Reloader: reloader}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *DomainsHandler) reload(ctx context.Context, op string) {
|
func (h *DomainsHandler) reload(ctx context.Context, op string) {
|
||||||
@@ -50,6 +52,11 @@ func (h *DomainsHandler) Register(rg *gin.RouterGroup) {
|
|||||||
g.PUT("/:id", h.Update)
|
g.PUT("/:id", h.Update)
|
||||||
g.DELETE("/:id", h.Delete)
|
g.DELETE("/:id", h.Delete)
|
||||||
g.GET("/:id/routing-rules", h.ListRoutingRules)
|
g.GET("/:id/routing-rules", h.ListRoutingRules)
|
||||||
|
|
||||||
|
g.GET("/:id/headers", h.ListHeaders)
|
||||||
|
g.POST("/:id/headers", h.CreateHeader)
|
||||||
|
g.PUT("/:id/headers/:hid", h.UpdateHeader)
|
||||||
|
g.DELETE("/:id/headers/:hid", h.DeleteHeader)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *DomainsHandler) List(c *gin.Context) {
|
func (h *DomainsHandler) List(c *gin.Context) {
|
||||||
@@ -161,6 +168,127 @@ func parseID(c *gin.Context) (int64, bool) {
|
|||||||
return id, true
|
return id, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Response-Header CRUD ──────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Header werden über `http-response set-header` von HAProxy gesetzt
|
||||||
|
// (siehe internal/haproxy/haproxy.cfg.tpl). Name ist case-insensitive
|
||||||
|
// unique pro Domain — Doppel-Eintrag erkennt PG selbst via Unique-Index
|
||||||
|
// und liefert 409 zurück.
|
||||||
|
|
||||||
|
func (h *DomainsHandler) ListHeaders(c *gin.Context) {
|
||||||
|
id, ok := parseID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := h.Headers.ListForDomain(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"headers": out})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DomainsHandler) CreateHeader(c *gin.Context) {
|
||||||
|
id, ok := parseID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req models.DomainResponseHeader
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.DomainID = id
|
||||||
|
if !validHeaderName(req.Name) {
|
||||||
|
response.BadRequest(c, errors.New("invalid header name"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := h.Headers.Create(c.Request.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "domain.header.create", out.Name, out, h.NodeID)
|
||||||
|
response.Created(c, out); h.reload(c.Request.Context(), "header.create")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DomainsHandler) UpdateHeader(c *gin.Context) {
|
||||||
|
domainID, ok := parseID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hid, err := strconv.ParseInt(c.Param("hid"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, errors.New("invalid header id"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req models.DomainResponseHeader
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !validHeaderName(req.Name) {
|
||||||
|
response.BadRequest(c, errors.New("invalid header name"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := h.Headers.Update(c.Request.Context(), domainID, hid, req)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, domainheaders.ErrNotFound) {
|
||||||
|
response.NotFound(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "domain.header.update", out.Name, out, h.NodeID)
|
||||||
|
response.OK(c, out); h.reload(c.Request.Context(), "header.update")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DomainsHandler) DeleteHeader(c *gin.Context) {
|
||||||
|
_, ok := parseID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hid, err := strconv.ParseInt(c.Param("hid"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, errors.New("invalid header id"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Headers.Delete(c.Request.Context(), hid); err != nil {
|
||||||
|
if errors.Is(err, domainheaders.ErrNotFound) {
|
||||||
|
response.NotFound(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "domain.header.delete",
|
||||||
|
strconv.FormatInt(hid, 10), gin.H{"id": hid}, h.NodeID)
|
||||||
|
response.NoContent(c); h.reload(c.Request.Context(), "header.delete")
|
||||||
|
}
|
||||||
|
|
||||||
|
// validHeaderName: RFC 7230 token chars. Wir engen das auf eine
|
||||||
|
// vernünftige Untermenge ein (a-z A-Z 0-9 + `-`), damit niemand
|
||||||
|
// versehentlich Newlines oder Quotes in den HAProxy-Renderer schiebt
|
||||||
|
// und die Config-Datei sprengt.
|
||||||
|
func validHeaderName(name string) bool {
|
||||||
|
if name == "" || len(name) > 100 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range name {
|
||||||
|
switch {
|
||||||
|
case r >= 'a' && r <= 'z',
|
||||||
|
r >= 'A' && r <= 'Z',
|
||||||
|
r >= '0' && r <= '9',
|
||||||
|
r == '-':
|
||||||
|
// ok
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func actorOf(c *gin.Context) string {
|
func actorOf(c *gin.Context) string {
|
||||||
if t := CurrentToken(c); t != nil {
|
if t := CurrentToken(c); t != nil {
|
||||||
return t.Actor
|
return t.Actor
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ func (h *FirewallHandler) Register(rg *gin.RouterGroup) {
|
|||||||
g := rg.Group("/firewall")
|
g := rg.Group("/firewall")
|
||||||
|
|
||||||
g.GET("/auto-rules", h.AutoRules)
|
g.GET("/auto-rules", h.AutoRules)
|
||||||
|
g.GET("/counters", h.Counters)
|
||||||
|
|
||||||
zn := g.Group("/zones")
|
zn := g.Group("/zones")
|
||||||
zn.GET("", h.ListZone)
|
zn.GET("", h.ListZone)
|
||||||
@@ -137,6 +138,7 @@ func (h *FirewallHandler) Register(rg *gin.RouterGroup) {
|
|||||||
rl.POST("", h.CreateRule)
|
rl.POST("", h.CreateRule)
|
||||||
rl.GET("/:id", h.GetRule)
|
rl.GET("/:id", h.GetRule)
|
||||||
rl.PUT("/:id", h.UpdateRule)
|
rl.PUT("/:id", h.UpdateRule)
|
||||||
|
rl.PATCH("/:id", h.PatchRule)
|
||||||
rl.DELETE("/:id", h.DeleteRule)
|
rl.DELETE("/:id", h.DeleteRule)
|
||||||
|
|
||||||
nat := g.Group("/nat-rules")
|
nat := g.Group("/nat-rules")
|
||||||
@@ -144,6 +146,7 @@ func (h *FirewallHandler) Register(rg *gin.RouterGroup) {
|
|||||||
nat.POST("", h.CreateNAT)
|
nat.POST("", h.CreateNAT)
|
||||||
nat.GET("/:id", h.GetNAT)
|
nat.GET("/:id", h.GetNAT)
|
||||||
nat.PUT("/:id", h.UpdateNAT)
|
nat.PUT("/:id", h.UpdateNAT)
|
||||||
|
nat.PATCH("/:id", h.PatchNAT)
|
||||||
nat.DELETE("/:id", h.DeleteNAT)
|
nat.DELETE("/:id", h.DeleteNAT)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -757,6 +760,48 @@ func (h *FirewallHandler) DeleteRule(c *gin.Context) {
|
|||||||
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 ──────────────────────────────────────────────────────────
|
// ── NAT Rules ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (h *FirewallHandler) ListNAT(c *gin.Context) {
|
func (h *FirewallHandler) ListNAT(c *gin.Context) {
|
||||||
@@ -857,6 +902,48 @@ func (h *FirewallHandler) DeleteNAT(c *gin.Context) {
|
|||||||
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 ─────────────────────────────────────────────────────────
|
// ── Validators ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func validateAddrObjValue(kind, value string) error {
|
func validateAddrObjValue(kind, value string) error {
|
||||||
|
|||||||
63
internal/handlers/firewall_counters.go
Normal file
63
internal/handlers/firewall_counters.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ruleCounter is one operator-defined firewall rule's live packet/byte
|
||||||
|
// counter. The nft ruleset template marks each rule with
|
||||||
|
// `counter … comment "egid:<id>"` so we can correlate the counter
|
||||||
|
// values back to DB rule IDs.
|
||||||
|
type ruleCounter struct {
|
||||||
|
RuleID int64 `json:"rule_id"`
|
||||||
|
Packets int64 `json:"packets"`
|
||||||
|
Bytes int64 `json:"bytes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
reEgid = regexp.MustCompile(`comment "egid:(\d+)"`)
|
||||||
|
reCounter = regexp.MustCompile(`\bcounter packets (\d+) bytes (\d+)\b`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Counters reads live hit-counters from the running nft ruleset.
|
||||||
|
// Runs `sudo -n /usr/sbin/nft list table inet edgeguard` (already
|
||||||
|
// allowed by the postinst sudoers rule) and parses each rule line
|
||||||
|
// for the `counter packets N bytes M … comment "egid:X"` pattern
|
||||||
|
// that the ruleset template emits.
|
||||||
|
func (h *FirewallHandler) Counters(c *gin.Context) {
|
||||||
|
out, err := exec.Command("sudo", "-n", "/usr/sbin/nft", "list", "table", "inet", "edgeguard").CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
response.OK(c, gin.H{
|
||||||
|
"counters": []ruleCounter{},
|
||||||
|
"error": strings.TrimSpace(string(out)),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"counters": parseNFTCounters(string(out))})
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseNFTCounters(output string) []ruleCounter {
|
||||||
|
var out []ruleCounter
|
||||||
|
for _, line := range strings.Split(output, "\n") {
|
||||||
|
mID := reEgid.FindStringSubmatch(line)
|
||||||
|
mCnt := reCounter.FindStringSubmatch(line)
|
||||||
|
if mID == nil || mCnt == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id, err1 := strconv.ParseInt(mID[1], 10, 64)
|
||||||
|
pkts, err2 := strconv.ParseInt(mCnt[1], 10, 64)
|
||||||
|
byts, err3 := strconv.ParseInt(mCnt[2], 10, 64)
|
||||||
|
if err1 != nil || err2 != nil || err3 != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, ruleCounter{RuleID: id, Packets: pkts, Bytes: byts})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
70
internal/handlers/firewall_counters_test.go
Normal file
70
internal/handlers/firewall_counters_test.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseNFTCounters_ParsesCounterAndEgid(t *testing.T) {
|
||||||
|
// nft list output shows `counter packets N bytes M` with actual values
|
||||||
|
// (zero when unused). Template emits `counter <action> comment "egid:X"`;
|
||||||
|
// nft expands that to `counter packets N bytes M <action> comment "egid:X"`.
|
||||||
|
input := `
|
||||||
|
table inet edgeguard {
|
||||||
|
chain input {
|
||||||
|
type filter hook input priority 0; policy drop;
|
||||||
|
tcp dport 22 ct state new limit rate 10/minute accept comment "anti-lockout: SSH"
|
||||||
|
tcp dport 80 counter packets 0 bytes 0 accept comment "egid:1"
|
||||||
|
ip saddr 10.0.0.0/8 tcp dport 443 counter packets 1234 bytes 567890 accept comment "egid:2"
|
||||||
|
ip saddr 1.2.3.4 drop comment "egid:3"
|
||||||
|
tcp dport 8080 log prefix "edgeguard:4 " group 0 counter packets 7 bytes 420 reject comment "egid:4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
counters := parseNFTCounters(input)
|
||||||
|
|
||||||
|
if len(counters) != 3 {
|
||||||
|
t.Fatalf("expected 3 counters (egid:1,2,4), got %d: %+v", len(counters), counters)
|
||||||
|
}
|
||||||
|
|
||||||
|
byID := map[int64]ruleCounter{}
|
||||||
|
for _, c := range counters {
|
||||||
|
byID[c.RuleID] = c
|
||||||
|
}
|
||||||
|
|
||||||
|
// egid:1 — zero counters are valid
|
||||||
|
if c, ok := byID[1]; !ok {
|
||||||
|
t.Error("missing counter for egid:1")
|
||||||
|
} else if c.Packets != 0 || c.Bytes != 0 {
|
||||||
|
t.Errorf("egid:1 want packets=0 bytes=0, got packets=%d bytes=%d", c.Packets, c.Bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// egid:2 — non-zero counters
|
||||||
|
if c, ok := byID[2]; !ok {
|
||||||
|
t.Error("missing counter for egid:2")
|
||||||
|
} else if c.Packets != 1234 || c.Bytes != 567890 {
|
||||||
|
t.Errorf("egid:2 want packets=1234 bytes=567890, got packets=%d bytes=%d", c.Packets, c.Bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// egid:3 — line has no counter keyword → must not appear
|
||||||
|
if _, ok := byID[3]; ok {
|
||||||
|
t.Error("egid:3 has no counter statement and must not appear in output")
|
||||||
|
}
|
||||||
|
|
||||||
|
// egid:4 — counter + log + reject
|
||||||
|
if _, ok := byID[4]; !ok {
|
||||||
|
t.Error("missing counter for egid:4")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseNFTCounters_EmptyOutput(t *testing.T) {
|
||||||
|
if counters := parseNFTCounters(""); len(counters) != 0 {
|
||||||
|
t.Errorf("expected empty result, got %+v", counters)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseNFTCounters_NoRules(t *testing.T) {
|
||||||
|
input := "table inet edgeguard {\n chain input {\n }\n}\n"
|
||||||
|
if counters := parseNFTCounters(input); len(counters) != 0 {
|
||||||
|
t.Errorf("expected empty result for table with no operator rules, got %+v", counters)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"os/exec"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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) {
|
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.GET("", h.List)
|
||||||
g.POST("", h.Create)
|
g.POST("", h.Create)
|
||||||
g.GET("/:id", h.Get)
|
g.GET("/:id", h.Get)
|
||||||
@@ -43,6 +50,34 @@ func (h *ForwardProxyHandler) Register(rg *gin.RouterGroup) {
|
|||||||
g.DELETE("/:id", h.Delete)
|
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) {
|
func (h *ForwardProxyHandler) List(c *gin.Context) {
|
||||||
out, err := h.Repo.List(c.Request.Context())
|
out, err := h.Repo.List(c.Request.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -135,6 +170,68 @@ func (h *ForwardProxyHandler) Delete(c *gin.Context) {
|
|||||||
h.reload(c.Request.Context(), "delete")
|
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
|
// validateACL prüft Name (squid-konform), action, acl_type. Squid
|
||||||
// nimmt viele Typen — wir whitelisten die, die in einem Forward-
|
// nimmt viele Typen — wir whitelisten die, die in einem Forward-
|
||||||
// Proxy-Setup üblich sind, damit Tippfehler nicht beim reload
|
// Proxy-Setup üblich sind, damit Tippfehler nicht beim reload
|
||||||
|
|||||||
@@ -32,14 +32,29 @@ const haproxyAdminSock = "/run/haproxy/admin.sock"
|
|||||||
// 'show stat' CSV. We only emit the fields the dashboard cares
|
// 'show stat' CSV. We only emit the fields the dashboard cares
|
||||||
// about — full CSV is ~80 columns of which 90% are noise here.
|
// about — full CSV is ~80 columns of which 90% are noise here.
|
||||||
type backendStat struct {
|
type backendStat struct {
|
||||||
Backend string `json:"backend"` // pxname
|
Backend string `json:"backend"` // pxname
|
||||||
Server string `json:"server"` // svname
|
Server string `json:"server"` // svname
|
||||||
Status string `json:"status"` // UP|DOWN|MAINT|...
|
Status string `json:"status"` // UP|DOWN|MAINT|...
|
||||||
Sessions int64 `json:"sessions"` // current sessions (scur)
|
Sessions int64 `json:"sessions"` // current sessions (scur)
|
||||||
BIn int64 `json:"bytes_in"`
|
BIn int64 `json:"bytes_in"`
|
||||||
BOut int64 `json:"bytes_out"`
|
BOut int64 `json:"bytes_out"`
|
||||||
LastChg int64 `json:"last_change_sec"` // seconds since last status change
|
ReqTot int64 `json:"req_tot"` // total requests since start (req_tot)
|
||||||
Health string `json:"health,omitempty"` // check_status (e.g. L7OK)
|
ReqRate int64 `json:"req_rate"` // requests/s last second (req_rate, server-level)
|
||||||
|
LastChg int64 `json:"last_change_sec"` // seconds since last status change
|
||||||
|
Health string `json:"health,omitempty"` // check_status (e.g. L7OK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// frontendStat holds per-listener traffic counters from HAProxy's
|
||||||
|
// show-stat CSV. Gives the operator a global view of how much HTTP(S)
|
||||||
|
// traffic is flowing through the gateway.
|
||||||
|
type frontendStat struct {
|
||||||
|
Name string `json:"name"` // pxname (e.g. "https_in", "http_in")
|
||||||
|
Sessions int64 `json:"sessions"` // current sessions (scur)
|
||||||
|
MaxSess int64 `json:"max_sess"` // maximum concurrent sessions since start (smax)
|
||||||
|
BIn int64 `json:"bytes_in"`
|
||||||
|
BOut int64 `json:"bytes_out"`
|
||||||
|
ReqTot int64 `json:"req_tot"` // total requests since start
|
||||||
|
ReqRate int64 `json:"req_rate"` // requests/s last second
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *HAProxyStatsHandler) Stats(c *gin.Context) {
|
func (h *HAProxyStatsHandler) Stats(c *gin.Context) {
|
||||||
@@ -47,13 +62,13 @@ func (h *HAProxyStatsHandler) Stats(c *gin.Context) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
// Socket nicht erreichbar (haproxy down oder no perm) →
|
// Socket nicht erreichbar (haproxy down oder no perm) →
|
||||||
// leere Liste statt 500 damit das Dashboard nicht rot wird.
|
// leere Liste statt 500 damit das Dashboard nicht rot wird.
|
||||||
response.OK(c, gin.H{"backends": []backendStat{}, "error": err.Error()})
|
response.OK(c, gin.H{"backends": []backendStat{}, "frontends": []frontendStat{}, "error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
_ = conn.SetDeadline(time.Now().Add(3 * time.Second))
|
_ = conn.SetDeadline(time.Now().Add(3 * time.Second))
|
||||||
if _, err := conn.Write([]byte("show stat\n")); err != nil {
|
if _, err := conn.Write([]byte("show stat\n")); err != nil {
|
||||||
response.OK(c, gin.H{"backends": []backendStat{}, "error": err.Error()})
|
response.OK(c, gin.H{"backends": []backendStat{}, "frontends": []frontendStat{}, "error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +76,8 @@ func (h *HAProxyStatsHandler) Stats(c *gin.Context) {
|
|||||||
// "# pxname,svname,..." — die nutzen wir um Spalten-Indizes
|
// "# pxname,svname,..." — die nutzen wir um Spalten-Indizes
|
||||||
// zu finden, weil das Format zwischen Versionen wechseln kann.
|
// zu finden, weil das Format zwischen Versionen wechseln kann.
|
||||||
colIdx := map[string]int{}
|
colIdx := map[string]int{}
|
||||||
out := []backendStat{}
|
backends := []backendStat{}
|
||||||
|
frontends := []frontendStat{}
|
||||||
|
|
||||||
scanner := bufio.NewScanner(conn)
|
scanner := bufio.NewScanner(conn)
|
||||||
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||||
@@ -79,34 +95,49 @@ func (h *HAProxyStatsHandler) Stats(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Skip frontend rows + the "BACKEND" summary row — we want
|
|
||||||
// the per-server view ("L4OK", "L7OK", etc.).
|
|
||||||
svname := safeAt(fields, colIdx["svname"])
|
svname := safeAt(fields, colIdx["svname"])
|
||||||
pxname := safeAt(fields, colIdx["pxname"])
|
pxname := safeAt(fields, colIdx["pxname"])
|
||||||
if svname == "" || svname == "FRONTEND" || svname == "BACKEND" {
|
|
||||||
|
// 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
|
continue
|
||||||
}
|
}
|
||||||
// Skip our internal api_backend stats listener and frontends.
|
|
||||||
if pxname == "internal_stats" {
|
if svname == "FRONTEND" {
|
||||||
|
// Collect frontend (listener) counters.
|
||||||
|
frontends = append(frontends, frontendStat{
|
||||||
|
Name: pxname,
|
||||||
|
Sessions: parseInt64(safeAt(fields, colIdx["scur"])),
|
||||||
|
MaxSess: parseInt64(safeAt(fields, colIdx["smax"])),
|
||||||
|
BIn: parseInt64(safeAt(fields, colIdx["bin"])),
|
||||||
|
BOut: parseInt64(safeAt(fields, colIdx["bout"])),
|
||||||
|
ReqTot: parseInt64(safeAt(fields, colIdx["req_tot"])),
|
||||||
|
ReqRate: parseInt64(safeAt(fields, colIdx["req_rate"])),
|
||||||
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
st := backendStat{
|
|
||||||
|
// Per-server backend row.
|
||||||
|
backends = append(backends, backendStat{
|
||||||
Backend: pxname,
|
Backend: pxname,
|
||||||
Server: svname,
|
Server: svname,
|
||||||
Status: safeAt(fields, colIdx["status"]),
|
Status: safeAt(fields, colIdx["status"]),
|
||||||
Sessions: parseInt64(safeAt(fields, colIdx["scur"])),
|
Sessions: parseInt64(safeAt(fields, colIdx["scur"])),
|
||||||
BIn: parseInt64(safeAt(fields, colIdx["bin"])),
|
BIn: parseInt64(safeAt(fields, colIdx["bin"])),
|
||||||
BOut: parseInt64(safeAt(fields, colIdx["bout"])),
|
BOut: parseInt64(safeAt(fields, colIdx["bout"])),
|
||||||
|
ReqTot: parseInt64(safeAt(fields, colIdx["req_tot"])),
|
||||||
|
ReqRate: parseInt64(safeAt(fields, colIdx["req_rate"])),
|
||||||
LastChg: parseInt64(safeAt(fields, colIdx["lastchg"])),
|
LastChg: parseInt64(safeAt(fields, colIdx["lastchg"])),
|
||||||
Health: safeAt(fields, colIdx["check_status"]),
|
Health: safeAt(fields, colIdx["check_status"]),
|
||||||
}
|
})
|
||||||
out = append(out, st)
|
|
||||||
}
|
}
|
||||||
response.OK(c, gin.H{"backends": out})
|
response.OK(c, gin.H{"backends": backends, "frontends": frontends})
|
||||||
}
|
}
|
||||||
|
|
||||||
func safeAt(fields []string, i int) string {
|
func safeAt(fields []string, i int) string {
|
||||||
if i <= 0 || i >= len(fields) {
|
if i < 0 || i >= len(fields) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return fields[i]
|
return fields[i]
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"log/slog"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -13,13 +15,27 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type IPAddressesHandler struct {
|
type IPAddressesHandler struct {
|
||||||
Repo *ipaddresses.Repo
|
Repo *ipaddresses.Repo
|
||||||
Audit *audit.Repo
|
Generator *ipaddresses.Generator
|
||||||
NodeID string
|
Audit *audit.Repo
|
||||||
|
NodeID string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewIPAddressesHandler(repo *ipaddresses.Repo, a *audit.Repo, nodeID string) *IPAddressesHandler {
|
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) {
|
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",
|
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "ip_address.create",
|
||||||
req.Address, out, h.NodeID)
|
req.Address, out, h.NodeID)
|
||||||
|
h.applyAsync()
|
||||||
response.Created(c, out)
|
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",
|
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "ip_address.update",
|
||||||
out.Address, out, h.NodeID)
|
out.Address, out, h.NodeID)
|
||||||
|
h.applyAsync()
|
||||||
response.OK(c, out)
|
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",
|
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "ip_address.delete",
|
||||||
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
|
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
|
||||||
|
h.applyAsync()
|
||||||
response.NoContent(c)
|
response.NoContent(c)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,4 +117,27 @@ func tokenFromRequest(c *gin.Context) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RequireAdminForMutations blocks non-GET/HEAD requests from non-admin
|
||||||
|
// users. GET and HEAD are always allowed for authenticated users so
|
||||||
|
// read-only ("viewer") accounts can browse all data. Every state-
|
||||||
|
// changing request (POST, PUT, PATCH, DELETE) requires role="admin".
|
||||||
|
//
|
||||||
|
// Must be mounted AFTER RequireAuth so the token is already in context.
|
||||||
|
func RequireAdminForMutations() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
m := c.Request.Method
|
||||||
|
if m == http.MethodGet || m == http.MethodHead || m == http.MethodOptions {
|
||||||
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tok := CurrentToken(c)
|
||||||
|
if tok == nil || tok.Role != "admin" {
|
||||||
|
response.Err(c, http.StatusForbidden, errors.New("admin_required"))
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func ptr[T any](v T) *T { return &v }
|
func ptr[T any](v T) *T { return &v }
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"log/slog"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -15,18 +17,34 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type NetworksHandler struct {
|
type NetworksHandler struct {
|
||||||
Repo *networkifs.Repo
|
Repo *networkifs.Repo
|
||||||
IPs *ipaddresses.Repo
|
Generator *networkifs.Generator
|
||||||
Zones *firewall.ZonesRepo
|
IPs *ipaddresses.Repo
|
||||||
Audit *audit.Repo
|
Zones *firewall.ZonesRepo
|
||||||
NodeID string
|
Audit *audit.Repo
|
||||||
|
NodeID string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewNetworksHandler(
|
func NewNetworksHandler(
|
||||||
repo *networkifs.Repo, ips *ipaddresses.Repo,
|
repo *networkifs.Repo, ips *ipaddresses.Repo,
|
||||||
zones *firewall.ZonesRepo, a *audit.Repo, nodeID string,
|
zones *firewall.ZonesRepo, a *audit.Repo, nodeID string,
|
||||||
) *NetworksHandler {
|
) *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) {
|
func (h *NetworksHandler) Register(rg *gin.RouterGroup) {
|
||||||
@@ -88,6 +106,7 @@ func (h *NetworksHandler) Create(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "network_interface.create", req.Name, out, h.NodeID)
|
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "network_interface.create", req.Name, out, h.NodeID)
|
||||||
|
h.applyAsync()
|
||||||
response.Created(c, out)
|
response.Created(c, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +141,7 @@ func (h *NetworksHandler) Update(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "network_interface.update", out.Name, out, h.NodeID)
|
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "network_interface.update", out.Name, out, h.NodeID)
|
||||||
|
h.applyAsync()
|
||||||
response.OK(c, out)
|
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",
|
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "network_interface.delete",
|
||||||
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
|
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
|
||||||
|
h.applyAsync()
|
||||||
response.NoContent(c)
|
response.NoContent(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
@@ -37,6 +41,9 @@ func (h *NTPHandler) Register(rg *gin.RouterGroup) {
|
|||||||
g := rg.Group("/ntp")
|
g := rg.Group("/ntp")
|
||||||
g.GET("/settings", h.GetSettings)
|
g.GET("/settings", h.GetSettings)
|
||||||
g.PUT("/settings", h.UpdateSettings)
|
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 := g.Group("/pools")
|
||||||
p.GET("", h.ListPools)
|
p.GET("", h.ListPools)
|
||||||
@@ -46,6 +53,74 @@ func (h *NTPHandler) Register(rg *gin.RouterGroup) {
|
|||||||
p.DELETE("/:id", h.DeletePool)
|
p.DELETE("/:id", h.DeletePool)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Status liefert den aktuellen chrony-Tracking-Status via `chronyc tracking`.
|
||||||
|
// Output-Felder: synced (bool), reference (Quell-Server), stratum (int),
|
||||||
|
// offset_ms (float), freq_ppm (float), rms_offset_ms (float), error (string
|
||||||
|
// wenn chronyc nicht verfügbar oder System-Takt nicht sync'd).
|
||||||
|
func (h *NTPHandler) Status(c *gin.Context) {
|
||||||
|
out, err := exec.Command("chronyc", "tracking").Output()
|
||||||
|
if err != nil {
|
||||||
|
response.OK(c, gin.H{
|
||||||
|
"synced": false,
|
||||||
|
"error": "chronyc nicht verfügbar: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp := parseChronyTracking(string(out))
|
||||||
|
response.OK(c, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
type chronyStatus struct {
|
||||||
|
Synced bool `json:"synced"`
|
||||||
|
Reference string `json:"reference"`
|
||||||
|
Stratum int `json:"stratum"`
|
||||||
|
OffsetMs float64 `json:"offset_ms"`
|
||||||
|
FreqPPM float64 `json:"freq_ppm"`
|
||||||
|
RMSOffsetMs float64 `json:"rms_offset_ms"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseChronyTracking(out string) chronyStatus {
|
||||||
|
s := chronyStatus{}
|
||||||
|
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":
|
||||||
|
// "Reference ID : A29FC801 (time.cloudflare.com)"
|
||||||
|
if i := strings.Index(val, "("); i >= 0 {
|
||||||
|
s.Reference = strings.Trim(val[i:], "()")
|
||||||
|
} else {
|
||||||
|
s.Reference = val
|
||||||
|
}
|
||||||
|
s.Synced = val != "00000000 ()"
|
||||||
|
case "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)
|
||||||
|
s.OffsetMs = v * 1000
|
||||||
|
case "Frequency":
|
||||||
|
// "-12.345 ppm slow" or "+12.345 ppm fast"
|
||||||
|
fmt.Sscanf(val, "%f", &s.FreqPPM)
|
||||||
|
case "RMS offset":
|
||||||
|
var v float64
|
||||||
|
fmt.Sscanf(val, "%f", &v)
|
||||||
|
s.RMSOffsetMs = v * 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
func (h *NTPHandler) GetSettings(c *gin.Context) {
|
func (h *NTPHandler) GetSettings(c *gin.Context) {
|
||||||
s, err := h.Repo.GetSettings(c.Request.Context())
|
s, err := h.Repo.GetSettings(c.Request.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -158,10 +233,95 @@ func (h *NTPHandler) DeletePool(c *gin.Context) {
|
|||||||
response.Internal(c, err)
|
response.Internal(c, err)
|
||||||
return
|
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)
|
response.NoContent(c)
|
||||||
h.reload(c.Request.Context(), "pool.delete")
|
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 {
|
func validateNTPPool(p *models.NTPPool) error {
|
||||||
if p.Address == "" {
|
if p.Address == "" {
|
||||||
return errors.New("address required")
|
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.NewRequest(http.MethodGet,
|
||||||
|
"/api/v1/auth/oidc/callback?state="+queryState+"&code="+code, nil)
|
||||||
|
req.AddCookie(&http.Cookie{Name: oidcFlowCookie, Value: signed})
|
||||||
|
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)
|
Err(c, http.StatusForbidden, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Conflict(c *gin.Context, err error) {
|
||||||
|
Err(c, http.StatusConflict, err)
|
||||||
|
}
|
||||||
|
|
||||||
func Internal(c *gin.Context, err error) {
|
func Internal(c *gin.Context, err error) {
|
||||||
Err(c, http.StatusInternalServerError, err)
|
Err(c, http.StatusInternalServerError, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,20 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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/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"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -11,22 +22,60 @@ import (
|
|||||||
// are mounted before SetupGate so they remain reachable while the API
|
// are mounted before SetupGate so they remain reachable while the API
|
||||||
// is in setup mode.
|
// is in setup mode.
|
||||||
type SetupHandler struct {
|
type SetupHandler struct {
|
||||||
Store *setup.Store
|
Store *setup.Store
|
||||||
|
Audit *audit.Repo
|
||||||
|
NodeID string
|
||||||
|
Version string
|
||||||
|
ClusterStore *cluster.Store
|
||||||
|
PeerReloader PeerReloader
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSetupHandler(store *setup.Store) *SetupHandler {
|
func NewSetupHandler(store *setup.Store) *SetupHandler {
|
||||||
return &SetupHandler{Store: store}
|
return &SetupHandler{Store: store}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithAudit injiziert Audit-Repo + Node-ID damit Mutationen (contact-emails)
|
||||||
|
// in audit_log landen. Optional — wenn Audit nil bleibt, läuft die
|
||||||
|
// Mutation, aber ohne Log-Eintrag.
|
||||||
|
func (h *SetupHandler) WithAudit(a *audit.Repo, nodeID string) *SetupHandler {
|
||||||
|
h.Audit = a
|
||||||
|
h.NodeID = nodeID
|
||||||
|
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) {
|
func (h *SetupHandler) Register(rg *gin.RouterGroup) {
|
||||||
g := rg.Group("/setup")
|
g := rg.Group("/setup")
|
||||||
g.GET("/status", h.Status)
|
g.GET("/status", h.Status)
|
||||||
g.POST("/complete", h.Complete)
|
g.POST("/complete", h.Complete)
|
||||||
|
g.POST("/complete-node", h.CompleteAsNode)
|
||||||
|
g.POST("/join-cluster", h.JoinCluster)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterAuthed mountet die Endpoints die nach abgeschlossenem Setup
|
||||||
|
// den Admin-Modus brauchen — Aufrufer hat requireAuth schon dran.
|
||||||
|
func (h *SetupHandler) RegisterAuthed(rg *gin.RouterGroup) {
|
||||||
|
g := rg.Group("/setup")
|
||||||
|
g.POST("/contact-emails", h.SetContactEmails)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status returns just the public bits of the setup state: whether
|
// Status returns just the public bits of the setup state: whether
|
||||||
// it's done and (if so) the configured admin_email + fqdn. Never
|
// it's done and (if so) the configured admin_email + acme_email +
|
||||||
// exposes the password hash.
|
// fqdn. Never exposes the password hash.
|
||||||
func (h *SetupHandler) Status(c *gin.Context) {
|
func (h *SetupHandler) Status(c *gin.Context) {
|
||||||
st, err := h.Store.Load()
|
st, err := h.Store.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -36,10 +85,45 @@ func (h *SetupHandler) Status(c *gin.Context) {
|
|||||||
response.OK(c, gin.H{
|
response.OK(c, gin.H{
|
||||||
"completed": st.Completed,
|
"completed": st.Completed,
|
||||||
"admin_email": st.AdminEmail,
|
"admin_email": st.AdminEmail,
|
||||||
|
"acme_email": st.ACMEEmail,
|
||||||
"fqdn": st.FQDN,
|
"fqdn": st.FQDN,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetContactEmails: Admin-only Update der zwei E-Mail-Felder.
|
||||||
|
// Sessions bleiben aktiv (Cookie referenziert den alten Actor); auf
|
||||||
|
// nächstem Login zählt der neue Wert.
|
||||||
|
func (h *SetupHandler) SetContactEmails(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
AdminEmail string `json:"admin_email" binding:"required,email"`
|
||||||
|
ACMEEmail string `json:"acme_email" binding:"required,email"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Store.SetContactEmails(req.AdminEmail, req.ACMEEmail); err != nil {
|
||||||
|
response.BadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st, err := h.Store.Load()
|
||||||
|
if err != nil {
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.Audit != nil {
|
||||||
|
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "setup.contact_emails",
|
||||||
|
st.AdminEmail, gin.H{
|
||||||
|
"admin_email": st.AdminEmail,
|
||||||
|
"acme_email": st.ACMEEmail,
|
||||||
|
}, h.NodeID)
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{
|
||||||
|
"admin_email": st.AdminEmail,
|
||||||
|
"acme_email": st.ACMEEmail,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *SetupHandler) Complete(c *gin.Context) {
|
func (h *SetupHandler) Complete(c *gin.Context) {
|
||||||
var req setup.Request
|
var req setup.Request
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
@@ -57,3 +141,130 @@ func (h *SetupHandler) Complete(c *gin.Context) {
|
|||||||
"fqdn": st.FQDN,
|
"fqdn": st.FQDN,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *SetupHandler) CompleteAsNode(c *gin.Context) {
|
||||||
|
var req setup.NodeRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st, err := h.Store.CompleteAsNode(req)
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{
|
||||||
|
"completed": st.Completed,
|
||||||
|
"is_cluster_node": st.IsClusterNode,
|
||||||
|
"fqdn": st.FQDN,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// JoinCluster performs the full cluster-join flow from the setup wizard:
|
||||||
|
// fetches certs from the primary, writes them to disk, then marks setup
|
||||||
|
// as completed. No CLI required.
|
||||||
|
func (h *SetupHandler) JoinCluster(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
FQDN string `json:"fqdn" binding:"required"`
|
||||||
|
ACMEEmail string `json:"acme_email" binding:"required,email"`
|
||||||
|
PrimaryFQDN string `json:"primary_fqdn" binding:"required"`
|
||||||
|
Token string `json:"token" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
response.BadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fqdn := strings.ToLower(strings.TrimSpace(body.FQDN))
|
||||||
|
|
||||||
|
if err := clusterjoin.Join(clusterjoin.Request{
|
||||||
|
PrimaryFQDN: body.PrimaryFQDN,
|
||||||
|
Token: strings.TrimSpace(body.Token),
|
||||||
|
CommonName: fqdn,
|
||||||
|
Insecure: true, // security comes from the HMAC token, not TLS cert trust
|
||||||
|
Force: true, // bootstrap self-signed cert must be replaced
|
||||||
|
Version: h.Version,
|
||||||
|
NodeID: h.NodeID,
|
||||||
|
}); err != nil {
|
||||||
|
response.BadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
st, err := h.Store.CompleteAsNode(setup.NodeRequest{
|
||||||
|
FQDN: fqdn,
|
||||||
|
ACMEEmail: body.ACMEEmail,
|
||||||
|
PrimaryFQDN: strings.ToLower(strings.TrimSpace(body.PrimaryFQDN)),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-register the primary in our local ha_nodes so its IP lands in
|
||||||
|
// @peer_ipv4 and port 8443 is open bidirectionally.
|
||||||
|
if h.ClusterStore != nil && h.PeerReloader != nil {
|
||||||
|
go h.preRegisterPrimary(body.PrimaryFQDN)
|
||||||
|
}
|
||||||
|
|
||||||
|
response.OK(c, gin.H{
|
||||||
|
"completed": st.Completed,
|
||||||
|
"is_cluster_node": st.IsClusterNode,
|
||||||
|
"fqdn": st.FQDN,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartupPeerSync is called once after the DB pool and ClusterStore are
|
||||||
|
// ready. On cluster nodes it re-registers the primary in the local ha_nodes
|
||||||
|
// and reloads nftables so @peer_ipv4 is correct after a package update or
|
||||||
|
// reboot — without requiring a new join.
|
||||||
|
func (h *SetupHandler) StartupPeerSync() {
|
||||||
|
if h.ClusterStore == nil || h.PeerReloader == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
st, err := h.Store.Load()
|
||||||
|
if err != nil || st == nil || !st.IsClusterNode || st.PrimaryFQDN == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.preRegisterPrimary(st.PrimaryFQDN)
|
||||||
|
}
|
||||||
|
|
||||||
|
// preRegisterPrimary inserts the primary node into the local ha_nodes with
|
||||||
|
// its resolved IP so nftables @peer_ipv4 allows port 8443 from the primary.
|
||||||
|
// Uses a stable ID derived from the FQDN so repeated calls are idempotent.
|
||||||
|
func (h *SetupHandler) preRegisterPrimary(primaryFQDN string) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
primaryFQDN = strings.ToLower(strings.TrimSpace(primaryFQDN))
|
||||||
|
|
||||||
|
addrs, err := net.DefaultResolver.LookupHost(ctx, primaryFQDN)
|
||||||
|
if err != nil || len(addrs) == 0 {
|
||||||
|
slog.Warn("setup: could not resolve primary FQDN for pre-registration",
|
||||||
|
"fqdn", primaryFQDN, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ip := addrs[0]
|
||||||
|
|
||||||
|
// Stable ID so repeated calls (join + startup) don't accumulate rows.
|
||||||
|
nodeID := fmt.Sprintf("prenode-%s", strings.ReplaceAll(primaryFQDN, ".", "-"))
|
||||||
|
n := models.HANode{
|
||||||
|
ID: nodeID,
|
||||||
|
Name: primaryFQDN,
|
||||||
|
FQDN: primaryFQDN,
|
||||||
|
APIURL: "https://" + primaryFQDN + ":3443",
|
||||||
|
Role: "primary",
|
||||||
|
Status: "online",
|
||||||
|
}
|
||||||
|
n.PublicIP = &ip
|
||||||
|
|
||||||
|
if _, err := h.ClusterStore.UpsertSelf(ctx, n); err != nil {
|
||||||
|
slog.Warn("setup: pre-register primary in ha_nodes failed", "fqdn", primaryFQDN, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.PeerReloader(ctx); err != nil {
|
||||||
|
slog.Warn("setup: PeerReloader failed after primary pre-register", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("setup: primary pre-registered locally, firewall updated",
|
||||||
|
"fqdn", primaryFQDN, "ip", ip)
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
186
internal/handlers/users.go
Normal file
186
internal/handlers/users.go
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"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/users"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UsersHandler exposes:
|
||||||
|
//
|
||||||
|
// GET /api/v1/users — list all users
|
||||||
|
// POST /api/v1/users — create user
|
||||||
|
// PUT /api/v1/users/:id — update email/role/active (not password)
|
||||||
|
// POST /api/v1/users/:id/password — set new password
|
||||||
|
// DELETE /api/v1/users/:id — delete user
|
||||||
|
type UsersHandler struct {
|
||||||
|
Repo *users.Repo
|
||||||
|
Audit *audit.Repo
|
||||||
|
NodeID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUsersHandler(repo *users.Repo, a *audit.Repo, nodeID string) *UsersHandler {
|
||||||
|
return &UsersHandler{Repo: repo, Audit: a, NodeID: nodeID}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *UsersHandler) Register(rg *gin.RouterGroup) {
|
||||||
|
g := rg.Group("/users")
|
||||||
|
g.GET("", h.List)
|
||||||
|
g.POST("", h.Create)
|
||||||
|
g.PUT("/:id", h.Update)
|
||||||
|
g.POST("/:id/password", h.SetPassword)
|
||||||
|
g.DELETE("/:id", h.Delete)
|
||||||
|
g.DELETE("/:id/totp", h.DisableTOTP)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *UsersHandler) List(c *gin.Context) {
|
||||||
|
out, err := h.Repo.List(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
out = []users.User{}
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"users": out})
|
||||||
|
}
|
||||||
|
|
||||||
|
type createUserReq struct {
|
||||||
|
Email string `json:"email" binding:"required,email"`
|
||||||
|
Password string `json:"password" binding:"required,min=12"`
|
||||||
|
Role string `json:"role" binding:"required,oneof=admin viewer"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *UsersHandler) Create(c *gin.Context) {
|
||||||
|
var req createUserReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u, err := h.Repo.Create(c.Request.Context(), req.Email, req.Password, req.Role, req.Active)
|
||||||
|
if errors.Is(err, users.ErrEmailTaken) {
|
||||||
|
response.Err(c, http.StatusConflict, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "user.create", u.Email,
|
||||||
|
gin.H{"role": u.Role, "active": u.Active}, h.NodeID)
|
||||||
|
response.Created(c, u)
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateUserReq struct {
|
||||||
|
Email string `json:"email" binding:"required,email"`
|
||||||
|
Role string `json:"role" binding:"required,oneof=admin viewer"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *UsersHandler) Update(c *gin.Context) {
|
||||||
|
id, ok := parseID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req updateUserReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
u, err := h.Repo.Update(c.Request.Context(), id, req.Email, req.Role, req.Active)
|
||||||
|
if errors.Is(err, users.ErrNotFound) {
|
||||||
|
response.NotFound(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, users.ErrEmailTaken) {
|
||||||
|
response.Err(c, http.StatusConflict, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "user.update", u.Email,
|
||||||
|
gin.H{"role": u.Role, "active": u.Active}, h.NodeID)
|
||||||
|
response.OK(c, u)
|
||||||
|
}
|
||||||
|
|
||||||
|
type setPasswordReq struct {
|
||||||
|
Password string `json:"password" binding:"required,min=12"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *UsersHandler) SetPassword(c *gin.Context) {
|
||||||
|
id, ok := parseID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req setPasswordReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Repo.SetPassword(c.Request.Context(), id, req.Password); err != nil {
|
||||||
|
if errors.Is(err, users.ErrNotFound) {
|
||||||
|
response.NotFound(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "user.password.set",
|
||||||
|
c.Param("id"), nil, h.NodeID)
|
||||||
|
response.OK(c, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *UsersHandler) Delete(c *gin.Context) {
|
||||||
|
id, ok := parseID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Prevent deleting the own account.
|
||||||
|
tok := CurrentToken(c)
|
||||||
|
if tok != nil {
|
||||||
|
if u, _, err := h.Repo.FindByEmail(c.Request.Context(), tok.Actor); err == nil {
|
||||||
|
if u.ID == id {
|
||||||
|
response.Err(c, http.StatusBadRequest, errors.New("cannot delete own account"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := h.Repo.Delete(c.Request.Context(), id); err != nil {
|
||||||
|
if errors.Is(err, users.ErrNotFound) {
|
||||||
|
response.NotFound(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "user.delete",
|
||||||
|
c.Param("id"), nil, h.NodeID)
|
||||||
|
response.OK(c, gin.H{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DisableTOTP allows an admin to disable 2FA for any user.
|
||||||
|
func (h *UsersHandler) DisableTOTP(c *gin.Context) {
|
||||||
|
id, ok := parseID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Repo.DisableTOTP(c.Request.Context(), id); err != nil {
|
||||||
|
if errors.Is(err, users.ErrNotFound) {
|
||||||
|
response.NotFound(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "user.totp.disabled",
|
||||||
|
c.Param("id"), nil, h.NodeID)
|
||||||
|
response.OK(c, gin.H{"ok": true})
|
||||||
|
}
|
||||||
227
internal/handlers/waf.go
Normal file
227
internal/handlers/waf.go
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"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"
|
||||||
|
wafsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/waf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// wafRuleIDRe erlaubt nur einzelne CRS-Rule-IDs oder Ranges ("942100" /
|
||||||
|
// "942100-942999") als Exclusion — verhindert SecLang-Direktiven-Injection.
|
||||||
|
var wafRuleIDRe = regexp.MustCompile(`^[0-9]{1,9}(-[0-9]{1,9})?$`)
|
||||||
|
|
||||||
|
// WafHandler exposes the per-domain WAF configuration REST API:
|
||||||
|
//
|
||||||
|
// GET /waf/configs — list all configs (one per domain)
|
||||||
|
// GET /waf/configs/:domain_id — get config for a domain
|
||||||
|
// PUT /waf/configs/:domain_id — upsert config for a domain
|
||||||
|
type WafHandler struct {
|
||||||
|
Repo *wafsvc.Repo
|
||||||
|
Audit *audit.Repo
|
||||||
|
NodeID string
|
||||||
|
Reloader func(ctx context.Context) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWafHandler(repo *wafsvc.Repo, a *audit.Repo, nodeID string, reloader func(context.Context) error) *WafHandler {
|
||||||
|
return &WafHandler{Repo: repo, Audit: a, NodeID: nodeID, Reloader: reloader}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *WafHandler) Register(rg *gin.RouterGroup) {
|
||||||
|
g := rg.Group("/waf")
|
||||||
|
g.GET("/configs", h.List)
|
||||||
|
g.GET("/configs/:domain_id", h.Get)
|
||||||
|
g.PUT("/configs/:domain_id", h.Upsert)
|
||||||
|
g.GET("/alerts", h.ListAlerts)
|
||||||
|
g.DELETE("/alerts", h.PurgeAlerts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List returns all WAF configs.
|
||||||
|
func (h *WafHandler) List(c *gin.Context) {
|
||||||
|
configs, err := h.Repo.List(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"configs": configs})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the WAF config for a single domain.
|
||||||
|
// Returns a default (disabled) config when none exists yet.
|
||||||
|
func (h *WafHandler) Get(c *gin.Context) {
|
||||||
|
domainID, err := strconv.ParseInt(c.Param("domain_id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, errors.New("invalid domain_id"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg, err := h.Repo.GetByDomain(c.Request.Context(), domainID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, wafsvc.ErrNotFound) {
|
||||||
|
// Return a default config so the UI always gets a usable object.
|
||||||
|
response.OK(c, gin.H{"config": defaultConfig(domainID)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"config": cfg})
|
||||||
|
}
|
||||||
|
|
||||||
|
// upsertBody is the accepted JSON for PUT /waf/configs/:domain_id.
|
||||||
|
type upsertBody struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
ParanoiaLevel int `json:"paranoia_level"`
|
||||||
|
RuleExclusions []string `json:"rule_exclusions"`
|
||||||
|
ExclusionNotes map[string]string `json:"exclusion_notes"`
|
||||||
|
TrustedProxies []string `json:"trusted_proxies"`
|
||||||
|
CustomRules string `json:"custom_rules"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert creates or updates the WAF config for a domain.
|
||||||
|
func (h *WafHandler) Upsert(c *gin.Context) {
|
||||||
|
domainID, err := strconv.ParseInt(c.Param("domain_id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, errors.New("invalid domain_id"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body upsertBody
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
response.BadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Mode == "" {
|
||||||
|
body.Mode = "detection"
|
||||||
|
}
|
||||||
|
if body.ParanoiaLevel < 1 || body.ParanoiaLevel > 4 {
|
||||||
|
body.ParanoiaLevel = 1
|
||||||
|
}
|
||||||
|
if body.RuleExclusions == nil {
|
||||||
|
body.RuleExclusions = []string{}
|
||||||
|
}
|
||||||
|
if body.TrustedProxies == nil {
|
||||||
|
body.TrustedProxies = []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if body.ExclusionNotes == nil {
|
||||||
|
body.ExclusionNotes = map[string]string{}
|
||||||
|
}
|
||||||
|
// Exclusions müssen reine Rule-IDs/Ranges sein (sonst Direktiven-Injection
|
||||||
|
// in die SecLang-Config via Newline).
|
||||||
|
for _, ex := range body.RuleExclusions {
|
||||||
|
if !wafRuleIDRe.MatchString(strings.TrimSpace(ex)) {
|
||||||
|
response.BadRequest(c, errors.New("ungültige Rule-Exclusion (nur IDs/Ranges erlaubt): "+ex))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Trusted-Proxies müssen gültige IPs/CIDRs sein.
|
||||||
|
for _, p := range body.TrustedProxies {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if net.ParseIP(p) == nil {
|
||||||
|
if _, _, err := net.ParseCIDR(p); err != nil {
|
||||||
|
response.BadRequest(c, errors.New("ungültiger Trusted-Proxy (IP/CIDR): "+p))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cfg := models.WafConfig{
|
||||||
|
DomainID: domainID,
|
||||||
|
Enabled: body.Enabled,
|
||||||
|
Mode: body.Mode,
|
||||||
|
ParanoiaLevel: body.ParanoiaLevel,
|
||||||
|
RuleExclusions: body.RuleExclusions,
|
||||||
|
ExclusionNotes: body.ExclusionNotes,
|
||||||
|
TrustedProxies: body.TrustedProxies,
|
||||||
|
CustomRules: body.CustomRules,
|
||||||
|
}
|
||||||
|
result, err := h.Repo.Upsert(c.Request.Context(), cfg)
|
||||||
|
if err != nil {
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "waf.config.upsert",
|
||||||
|
strconv.FormatInt(domainID, 10),
|
||||||
|
gin.H{"enabled": body.Enabled, "mode": body.Mode, "paranoia_level": body.ParanoiaLevel},
|
||||||
|
h.NodeID)
|
||||||
|
// Reload HAProxy so the SPOE filter is added/removed based on
|
||||||
|
// whether any domain now has WAF enabled.
|
||||||
|
if h.Reloader != nil {
|
||||||
|
go func() {
|
||||||
|
if err := h.Reloader(context.Background()); err != nil {
|
||||||
|
slog.Warn("waf: haproxy reload after config change failed", "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"config": result})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAlerts returns recent WAF alerts. Optional: ?domain_id=X&limit=N
|
||||||
|
func (h *WafHandler) ListAlerts(c *gin.Context) {
|
||||||
|
var domainID *int64
|
||||||
|
if v := c.Query("domain_id"); v != "" {
|
||||||
|
id, err := strconv.ParseInt(v, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
response.BadRequest(c, errors.New("invalid domain_id"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
domainID = &id
|
||||||
|
}
|
||||||
|
limit := 200
|
||||||
|
if v := c.Query("limit"); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||||
|
limit = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
alerts, err := h.Repo.ListAlerts(c.Request.Context(), domainID, limit)
|
||||||
|
if err != nil {
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"alerts": alerts})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurgeAlerts deletes old WAF alerts. Optional: ?days=N (default 30)
|
||||||
|
func (h *WafHandler) PurgeAlerts(c *gin.Context) {
|
||||||
|
days := 30
|
||||||
|
if v := c.Query("days"); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||||
|
days = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := h.Repo.PurgeAlerts(c.Request.Context(), days); err != nil {
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "waf.alerts.purge",
|
||||||
|
"", gin.H{"days": days}, h.NodeID)
|
||||||
|
response.OK(c, gin.H{"ok": true, "days": days})
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultConfig returns a sensible disabled default for a domain
|
||||||
|
// that has no WAF config row yet.
|
||||||
|
func defaultConfig(domainID int64) models.WafConfig {
|
||||||
|
return models.WafConfig{
|
||||||
|
DomainID: domainID,
|
||||||
|
Enabled: false,
|
||||||
|
Mode: "detection",
|
||||||
|
ParanoiaLevel: 1,
|
||||||
|
RuleExclusions: []string{},
|
||||||
|
ExclusionNotes: map[string]string{},
|
||||||
|
TrustedProxies: []string{},
|
||||||
|
CustomRules: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,6 +83,8 @@ func (h *WireguardHandler) Register(rg *gin.RouterGroup) {
|
|||||||
// row per (iface, peer) with last_handshake + transfer counters.
|
// row per (iface, peer) with last_handshake + transfer counters.
|
||||||
// Polled by the UI every 10s; no DB write.
|
// Polled by the UI every 10s; no DB write.
|
||||||
g.GET("/status", h.Status)
|
g.GET("/status", h.Status)
|
||||||
|
g.GET("/importable", h.ListImportable)
|
||||||
|
g.POST("/import", h.Import)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Live wg-show status ─────────────────────────────────────────────
|
// ── Live wg-show status ─────────────────────────────────────────────
|
||||||
@@ -142,6 +144,45 @@ func (h *WireguardHandler) Status(c *gin.Context) {
|
|||||||
response.OK(c, gin.H{"status": rows})
|
response.OK(c, gin.H{"status": rows})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Import ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const wgImportDir = "/etc/wireguard"
|
||||||
|
|
||||||
|
// ListImportable scans /etc/wireguard for *.conf files and reports
|
||||||
|
// which are new (not yet in the DB) vs. already imported.
|
||||||
|
func (h *WireguardHandler) ListImportable(c *gin.Context) {
|
||||||
|
im := wireguard.NewImporter(h.Ifaces, h.Peers, h.Box)
|
||||||
|
entries, err := im.ListImportable(c.Request.Context(), wgImportDir)
|
||||||
|
if err != nil {
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"entries": entries})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import imports selected (or all) WireGuard .conf files from
|
||||||
|
// /etc/wireguard into the DB. Body: { "names": ["wg0","wg1"] }.
|
||||||
|
// Empty or missing names → import all.
|
||||||
|
func (h *WireguardHandler) Import(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Names []string `json:"names"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
im := wireguard.NewImporter(h.Ifaces, h.Peers, h.Box)
|
||||||
|
res, err := im.ImportSelected(c.Request.Context(), wgImportDir, req.Names)
|
||||||
|
if err != nil {
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "wireguard.import",
|
||||||
|
fmt.Sprintf("%d ifaces", res.IfacesAdded), res, h.NodeID)
|
||||||
|
h.reload(c.Request.Context(), "import")
|
||||||
|
response.OK(c, res)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Keygen ────────────────────────────────────────────────────────
|
// ── Keygen ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (h *WireguardHandler) GenerateKeypair(c *gin.Context) {
|
func (h *WireguardHandler) GenerateKeypair(c *gin.Context) {
|
||||||
@@ -173,6 +214,7 @@ type ifaceCreateReq struct {
|
|||||||
AllowedIPs *string `json:"allowed_ips,omitempty"`
|
AllowedIPs *string `json:"allowed_ips,omitempty"`
|
||||||
PersistentKeepalive *int `json:"persistent_keepalive,omitempty"`
|
PersistentKeepalive *int `json:"persistent_keepalive,omitempty"`
|
||||||
MTU *int `json:"mtu,omitempty"`
|
MTU *int `json:"mtu,omitempty"`
|
||||||
|
ClientRoutes *string `json:"client_routes,omitempty"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Active bool `json:"active"`
|
Active bool `json:"active"`
|
||||||
Description *string `json:"description,omitempty"`
|
Description *string `json:"description,omitempty"`
|
||||||
@@ -222,6 +264,7 @@ func (h *WireguardHandler) CreateIface(c *gin.Context) {
|
|||||||
PeerEndpoint: req.PeerEndpoint, PeerPublicKey: req.PeerPublicKey,
|
PeerEndpoint: req.PeerEndpoint, PeerPublicKey: req.PeerPublicKey,
|
||||||
PeerPSKEnc: encPSK, AllowedIPs: req.AllowedIPs,
|
PeerPSKEnc: encPSK, AllowedIPs: req.AllowedIPs,
|
||||||
PersistentKeepalive: req.PersistentKeepalive, MTU: req.MTU,
|
PersistentKeepalive: req.PersistentKeepalive, MTU: req.MTU,
|
||||||
|
ClientRoutes: req.ClientRoutes,
|
||||||
Role: req.Role, Active: req.Active, Description: req.Description,
|
Role: req.Role, Active: req.Active, Description: req.Description,
|
||||||
}
|
}
|
||||||
if ifc.Role == "" {
|
if ifc.Role == "" {
|
||||||
@@ -311,6 +354,7 @@ func (h *WireguardHandler) UpdateIface(c *gin.Context) {
|
|||||||
PeerEndpoint: req.PeerEndpoint, PeerPublicKey: req.PeerPublicKey,
|
PeerEndpoint: req.PeerEndpoint, PeerPublicKey: req.PeerPublicKey,
|
||||||
PeerPSKEnc: encPSK, AllowedIPs: req.AllowedIPs,
|
PeerPSKEnc: encPSK, AllowedIPs: req.AllowedIPs,
|
||||||
PersistentKeepalive: req.PersistentKeepalive, MTU: req.MTU,
|
PersistentKeepalive: req.PersistentKeepalive, MTU: req.MTU,
|
||||||
|
ClientRoutes: req.ClientRoutes,
|
||||||
Role: req.Role, Active: req.Active, Description: req.Description,
|
Role: req.Role, Active: req.Active, Description: req.Description,
|
||||||
}
|
}
|
||||||
if ifc.Role == "" {
|
if ifc.Role == "" {
|
||||||
@@ -526,7 +570,11 @@ func (h *WireguardHandler) UpdatePeer(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
} else if req.PublicKey != "" {
|
} else if req.PublicKey != "" {
|
||||||
pub = req.PublicKey
|
pub = req.PublicKey
|
||||||
encPriv = nil
|
if req.PublicKey != cur.PublicKey {
|
||||||
|
// New externally-provided public key — server has no private key for it.
|
||||||
|
encPriv = nil
|
||||||
|
}
|
||||||
|
// Same public key → keep cur.PrivateKeyEnc (peer still has generated keypair).
|
||||||
}
|
}
|
||||||
encPSK := cur.PSKEnc
|
encPSK := cur.PSKEnc
|
||||||
if req.GeneratePSK {
|
if req.GeneratePSK {
|
||||||
@@ -645,12 +693,15 @@ func (h *WireguardHandler) peerConfigText(ctx context.Context, peerID int64) (st
|
|||||||
}
|
}
|
||||||
fmt.Fprintf(&b, "PresharedKey = %s\n", string(psk))
|
fmt.Fprintf(&b, "PresharedKey = %s\n", string(psk))
|
||||||
}
|
}
|
||||||
// AllowedIPs on the client side is "everything that should go
|
// AllowedIPs on the client side: the server's tunnel subnet
|
||||||
// through the tunnel". For a server hosting an internal LAN
|
// (so peers can reach each other + the gateway) plus any
|
||||||
// this is typically 10.x/8 or the server's address range. We
|
// additional "push routes" the operator configured on the
|
||||||
// default to the iface address (so the client can at least
|
// server interface (e.g. 10.0.10.0/24 for a LAN behind the box).
|
||||||
// reach the gateway) — operator can edit downloaded conf.
|
clientAllowedIPs := ifc.AddressCIDR
|
||||||
fmt.Fprintf(&b, "AllowedIPs = %s\n", ifc.AddressCIDR)
|
if ifc.ClientRoutes != nil && strings.TrimSpace(*ifc.ClientRoutes) != "" {
|
||||||
|
clientAllowedIPs += ", " + strings.TrimSpace(*ifc.ClientRoutes)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "AllowedIPs = %s\n", clientAllowedIPs)
|
||||||
// Endpoint — the operator's public host:port that peers dial.
|
// Endpoint — the operator's public host:port that peers dial.
|
||||||
// We don't know this here (could be a CNAME or behind a load
|
// We don't know this here (could be a CNAME or behind a load
|
||||||
// balancer); leave a placeholder the operator must fill in.
|
// balancer); leave a placeholder the operator must fill in.
|
||||||
|
|||||||
@@ -10,6 +10,14 @@ global
|
|||||||
daemon
|
daemon
|
||||||
ssl-default-bind-options ssl-min-ver TLSv1.2 no-tls-tickets
|
ssl-default-bind-options ssl-min-ver TLSv1.2 no-tls-tickets
|
||||||
ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
|
ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
|
||||||
|
# HAProxy 3.0.x in Trixie ist mit USE_QUIC=1 + USE_QUIC_OPENSSL_COMPAT=1
|
||||||
|
# gebaut; die System-OpenSSL kann QUIC aber nur über den Kompat-Layer.
|
||||||
|
# Ohne dieses Flag verweigert HAProxy `bind quic4@…` mit "this SSL
|
||||||
|
# library does not support the QUIC protocol".
|
||||||
|
limited-quic
|
||||||
|
# Runtime-API-Socket für edgeguard-api (show stat, show info, …).
|
||||||
|
# mode 0660 + group haproxy → edgeguard-user (in haproxy-group) kann lesen.
|
||||||
|
stats socket /run/haproxy/admin.sock mode 0660 level admin expose-fd listeners
|
||||||
|
|
||||||
defaults
|
defaults
|
||||||
log global
|
log global
|
||||||
@@ -27,16 +35,30 @@ defaults
|
|||||||
|
|
||||||
# ── Public :80 ─────────────────────────────────────────────────────────
|
# ── Public :80 ─────────────────────────────────────────────────────────
|
||||||
# ACME-01 challenges proxy to edgeguard-api which serves the webroot.
|
# ACME-01 challenges proxy to edgeguard-api which serves the webroot.
|
||||||
# Everything else redirects to HTTPS.
|
# Domains with http_to_https=false bypass the redirect and are proxied
|
||||||
|
# directly. Everything else redirects to HTTPS.
|
||||||
frontend public_http
|
frontend public_http
|
||||||
bind :80
|
bind :80
|
||||||
|
{{- if .IPv6Enabled}}
|
||||||
|
bind [::]:80
|
||||||
|
{{- end}}
|
||||||
|
|
||||||
acl is_acme path_beg /.well-known/acme-challenge/
|
acl is_acme path_beg /.well-known/acme-challenge/
|
||||||
|
{{- range $d := .HTTPDomains}}
|
||||||
|
acl is_http_only hdr(host) -i {{$d.Name}}
|
||||||
|
{{- end}}
|
||||||
|
|
||||||
# Redirect to HTTPS first (skipped for ACME paths) — must come
|
# Redirect to HTTPS (skipped for ACME and http-only domains).
|
||||||
# before use_backend so HAProxy doesn't warn about ordering.
|
http-request redirect scheme https code 301 unless is_acme{{if .HTTPDomains}} or is_http_only{{end}}
|
||||||
http-request redirect scheme https code 301 unless is_acme
|
|
||||||
|
|
||||||
|
{{- range $d := .HTTPDomains}}
|
||||||
|
{{- range $r := $d.Routes}}
|
||||||
|
use_backend eg_backend_{{$r.BackendID}} if { hdr(host) -i {{$d.Name}} } { path_beg {{$r.PathPrefix}} }
|
||||||
|
{{- end}}
|
||||||
|
{{- if $d.PrimaryBackendID}}
|
||||||
|
use_backend eg_backend_{{$d.PrimaryBackendID}} if { hdr(host) -i {{$d.Name}} }
|
||||||
|
{{- end}}
|
||||||
|
{{- end}}
|
||||||
use_backend api_backend if is_acme
|
use_backend api_backend if is_acme
|
||||||
|
|
||||||
# ── Public :443 (Customer-Backends only) ──────────────────────────────
|
# ── Public :443 (Customer-Backends only) ──────────────────────────────
|
||||||
@@ -50,8 +72,23 @@ frontend public_http
|
|||||||
# Admin-Panel fällt. mailgateway/enconf-Pattern.
|
# Admin-Panel fällt. mailgateway/enconf-Pattern.
|
||||||
frontend public_https
|
frontend public_https
|
||||||
bind :443 ssl crt /etc/edgeguard/tls/ alpn h2,http/1.1
|
bind :443 ssl crt /etc/edgeguard/tls/ alpn h2,http/1.1
|
||||||
|
# HTTP/3 via QUIC (UDP/443). HAProxy 3.0.x ist mit USE_QUIC=1
|
||||||
|
# gebaut; Browser fallen via Alt-Svc-Header (siehe unten) für
|
||||||
|
# Folge-Requests auf h3 zurück.
|
||||||
|
bind quic4@:443 ssl crt /etc/edgeguard/tls/ alpn h3
|
||||||
|
{{- if .IPv6Enabled}}
|
||||||
|
bind [::]:443 ssl crt /etc/edgeguard/tls/ alpn h2,http/1.1
|
||||||
|
bind quic6@:443 ssl crt /etc/edgeguard/tls/ alpn h3
|
||||||
|
{{- end}}
|
||||||
|
{{- if .WAFEnabled}}
|
||||||
|
# WAF: SPOE-Filter — edgeguard-waf inspiziert jeden Request.
|
||||||
|
# filter muss vor allen http-request/http-response-Direktiven stehen.
|
||||||
|
filter spoe engine edgeguard-waf config /etc/edgeguard/haproxy/coraza-spoe.cfg
|
||||||
|
{{- end}}
|
||||||
|
|
||||||
http-response set-header Strict-Transport-Security "max-age=31536000"
|
# Alt-Svc: signalisiert dass h3 auf demselben Port verfügbar ist.
|
||||||
|
# ma=86400 = Browser darf den Hinweis 24h cachen.
|
||||||
|
http-response set-header Alt-Svc "h3=\":443\"; ma=86400"
|
||||||
|
|
||||||
# Client-IP-Weiterleitung an Backends. `option forwardfor` (defaults)
|
# Client-IP-Weiterleitung an Backends. `option forwardfor` (defaults)
|
||||||
# setzt X-Forwarded-For; wir ergänzen Proto + RealIP damit Apps
|
# setzt X-Forwarded-For; wir ergänzen Proto + RealIP damit Apps
|
||||||
@@ -59,8 +96,59 @@ frontend public_https
|
|||||||
# echte Source-IP ohne XFF-Chain-Parsing brauchen.
|
# echte Source-IP ohne XFF-Chain-Parsing brauchen.
|
||||||
http-request set-header X-Forwarded-Proto https
|
http-request set-header X-Forwarded-Proto https
|
||||||
http-request set-header X-Real-IP %[src]
|
http-request set-header X-Real-IP %[src]
|
||||||
|
{{- if .WAFEnabled}}
|
||||||
|
# WAF: Request blockieren wenn edgeguard-waf txn.waf.status gesetzt hat.
|
||||||
|
http-request deny deny_status 403 if { var(txn.waf.status) -m found }
|
||||||
|
{{- end}}
|
||||||
|
|
||||||
|
{{- if .GlobalMaintenance}}
|
||||||
|
# Whole-Box-Maintenance — Settings → Maintenance-Mode aktiv. Dieser
|
||||||
|
# Block kommt VOR allen per-Domain ACLs und blockt JEDEN Customer-
|
||||||
|
# Request. Mgmt-UI auf :3443 (mgmt_https) ist davon nicht betroffen.
|
||||||
|
http-request return status 503 content-type "text/plain; charset=utf-8" string "{{.GlobalMaintenanceMessage}}"
|
||||||
|
{{- end}}
|
||||||
|
|
||||||
{{- range $d := .Domains}}
|
{{- range $d := .Domains}}
|
||||||
|
{{- if $d.RedirectFromHost}}
|
||||||
|
# www-Redirect: {{$d.RedirectFromHost}} → {{$d.Name}}
|
||||||
|
http-request redirect prefix https://{{$d.Name}} code 301 if { hdr(host) -i {{$d.RedirectFromHost}} }
|
||||||
|
{{- end}}
|
||||||
|
{{- if $d.MaintenanceMode}}
|
||||||
|
# Wartungs-Modus für {{$d.Name}} — alle Requests werden mit 503 beantwortet.
|
||||||
|
http-request return status 503 content-type "text/plain; charset=utf-8" string "{{$d.MaintMessage}}" if { hdr(host) -i {{$d.Name}} }
|
||||||
|
{{- end}}
|
||||||
|
{{- if $d.MaxBodyBytes}}
|
||||||
|
# Body-Size-Cap für {{$d.Name}}: {{$d.MaxBodyBytes}} Bytes.
|
||||||
|
# Nur Content-Length wird geprüft — Chunked-Bodies wären erst nach
|
||||||
|
# http-buffer-request abgreifbar, das wollen wir auf Frontend-Level
|
||||||
|
# nicht aktivieren (verbraucht RAM pro Connection).
|
||||||
|
http-request deny deny_status 413 if { hdr(host) -i {{$d.Name}} } { req.hdr_val(content-length) -m int gt {{$d.MaxBodyBytes}} }
|
||||||
|
{{- end}}
|
||||||
|
{{- if $d.RateLimitThreshold}}
|
||||||
|
# Rate-Limit für {{$d.Name}}: {{$d.RateLimitRPS}} req/s pro Client-IP
|
||||||
|
# (Schwelle = rps × 10s-Fenster = {{$d.RateLimitThreshold}} hits).
|
||||||
|
http-request track-sc0 src table rl_{{$d.ID}} if { hdr(host) -i {{$d.Name}} }
|
||||||
|
http-request deny deny_status 429 if { hdr(host) -i {{$d.Name}} } { sc_http_req_rate(0) gt {{$d.RateLimitThreshold}} }
|
||||||
|
{{- end}}
|
||||||
|
{{- if $d.HSTSHeader}}
|
||||||
|
# del + set damit ein vom Backend gesetztes HSTS (z. B. nginx mit
|
||||||
|
# eigener add_header-Direktive) garantiert von unserer Policy
|
||||||
|
# überschrieben wird — sonst tauchen 2 Strict-Transport-Security
|
||||||
|
# Header in der Response auf.
|
||||||
|
http-response del-header Strict-Transport-Security if { hdr(host) -i {{$d.Name}} }
|
||||||
|
http-response set-header Strict-Transport-Security "{{$d.HSTSHeader}}" if { hdr(host) -i {{$d.Name}} }
|
||||||
|
{{- end}}
|
||||||
|
{{- if $d.DisableH3}}
|
||||||
|
# HTTP/3 (QUIC) Werbung für diese Domain unterdrücken: Alt-Svc-Header
|
||||||
|
# entfernen damit Browser nicht auf h3 upgraden. Der globale set-header
|
||||||
|
# oben hat ihn bereits gesetzt; dieses del-header kommt DANACH und
|
||||||
|
# überschreibt ihn für Requests an diese Domain.
|
||||||
|
http-response del-header Alt-Svc if { hdr(host) -i {{$d.Name}} }
|
||||||
|
{{- end}}
|
||||||
|
{{- range $h := $d.ResponseHeaders}}
|
||||||
|
http-response del-header {{$h.Name}} if { hdr(host) -i {{$d.Name}} }
|
||||||
|
http-response set-header {{$h.Name}} "{{$h.Value}}" if { hdr(host) -i {{$d.Name}} }
|
||||||
|
{{- end}}
|
||||||
{{- range $r := $d.Routes}}
|
{{- range $r := $d.Routes}}
|
||||||
use_backend eg_backend_{{$r.BackendID}} if { hdr(host) -i {{$d.Name}} } { path_beg {{$r.PathPrefix}} }
|
use_backend eg_backend_{{$r.BackendID}} if { hdr(host) -i {{$d.Name}} } { path_beg {{$r.PathPrefix}} }
|
||||||
{{- end}}
|
{{- end}}
|
||||||
@@ -76,6 +164,9 @@ frontend public_https
|
|||||||
# (Hostname egal — default_backend), inkl. der direkten IP.
|
# (Hostname egal — default_backend), inkl. der direkten IP.
|
||||||
frontend mgmt_https
|
frontend mgmt_https
|
||||||
bind :3443 ssl crt /etc/edgeguard/tls/ alpn h2,http/1.1
|
bind :3443 ssl crt /etc/edgeguard/tls/ alpn h2,http/1.1
|
||||||
|
{{- if .IPv6Enabled}}
|
||||||
|
bind [::]:3443 ssl crt /etc/edgeguard/tls/ alpn h2,http/1.1
|
||||||
|
{{- end}}
|
||||||
http-response set-header Strict-Transport-Security "max-age=31536000"
|
http-response set-header Strict-Transport-Security "max-age=31536000"
|
||||||
http-request set-header X-Forwarded-Proto https
|
http-request set-header X-Forwarded-Proto https
|
||||||
http-request set-header X-Real-IP %[src]
|
http-request set-header X-Real-IP %[src]
|
||||||
@@ -99,6 +190,30 @@ backend api_backend
|
|||||||
timeout tunnel 1h
|
timeout tunnel 1h
|
||||||
server api1 127.0.0.1:9443 check
|
server api1 127.0.0.1:9443 check
|
||||||
|
|
||||||
|
{{- /* Per-Domain Rate-Limit Stick-Tables.
|
||||||
|
Eigenes Backend pro Domain damit verschiedene Schwellen nicht
|
||||||
|
miteinander interferieren; HAProxy 3.0 erlaubt mehrere
|
||||||
|
stick-table-Backends die per Frontend mit track-sc0 angesteuert
|
||||||
|
werden. expire 10s hält den Speicher knapp. */ -}}
|
||||||
|
{{- range $d := .Domains}}
|
||||||
|
{{- if $d.RateLimitThreshold}}
|
||||||
|
|
||||||
|
backend rl_{{$d.ID}}
|
||||||
|
# Rate-Limit-Counter für {{$d.Name}}; gefüllt von public_https.
|
||||||
|
stick-table type ip size 100k expire 10s store http_req_rate(10s)
|
||||||
|
{{- end}}
|
||||||
|
{{- end}}
|
||||||
|
|
||||||
|
{{- if .WAFEnabled}}
|
||||||
|
|
||||||
|
# SPOE-Backend für edgeguard-waf (TCP, kein HTTP-Parsing).
|
||||||
|
backend spoe-edgeguard-waf
|
||||||
|
mode tcp
|
||||||
|
timeout connect 100ms
|
||||||
|
timeout server 1s
|
||||||
|
server spoe-waf-1 127.0.0.1:9000
|
||||||
|
{{- end}}
|
||||||
|
|
||||||
{{- range $b := .Backends}}
|
{{- range $b := .Backends}}
|
||||||
|
|
||||||
backend eg_backend_{{$b.ID}}
|
backend eg_backend_{{$b.ID}}
|
||||||
@@ -111,6 +226,6 @@ backend eg_backend_{{$b.ID}}
|
|||||||
http-check send meth GET uri {{$b.HealthCheckPath}}
|
http-check send meth GET uri {{$b.HealthCheckPath}}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
{{- range $s := $b.Servers}}
|
{{- range $s := $b.Servers}}
|
||||||
server {{$s.Name | safeID}} {{$s.Address}}:{{$s.Port}}{{if eq $b.Scheme "https"}} ssl verify none alpn h2,http/1.1{{end}}{{if $b.HealthCheckPath}} check inter 5s{{if eq $b.Scheme "https"}} check-alpn http/1.1{{end}}{{end}} weight {{$s.Weight}}{{if $s.Backup}} backup{{end}}
|
server {{$s.Name | safeID}} {{$s.Address}}:{{$s.Port}}{{if eq $b.Scheme "https"}} ssl verify none alpn {{if $b.ForceHTTP1}}http/1.1{{else}}h2,http/1.1{{end}}{{end}}{{if $b.HealthCheckPath}} check inter 5s{{if eq $b.Scheme "https"}} check-alpn http/1.1{{end}}{{end}} weight {{$s.Weight}}{{if $s.Backup}} backup{{end}}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
{{- end}}
|
{{- end}}
|
||||||
|
|||||||
@@ -20,8 +20,11 @@ import (
|
|||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/backends"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/backends"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/backendservers"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/backendservers"
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/domainheaders"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/domains"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/domains"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/routingrules"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/routingrules"
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
||||||
|
wafsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/waf"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed haproxy.cfg.tpl
|
//go:embed haproxy.cfg.tpl
|
||||||
@@ -61,23 +64,47 @@ type Generator struct {
|
|||||||
BackendsRepo *backends.Repo
|
BackendsRepo *backends.Repo
|
||||||
ServersRepo *backendservers.Repo
|
ServersRepo *backendservers.Repo
|
||||||
RoutingRepo *routingrules.Repo
|
RoutingRepo *routingrules.Repo
|
||||||
|
HeadersRepo *domainheaders.Repo
|
||||||
|
WafRepo *wafsvc.Repo
|
||||||
|
|
||||||
OutputPath string
|
// SetupStore (optional): wenn gesetzt, lesen wir Whole-Box-
|
||||||
SkipReload bool
|
// Maintenance-Status hieraus und reichen ihn als View.GlobalMaintenance
|
||||||
|
// ans Template weiter.
|
||||||
|
SetupStore *setup.Store
|
||||||
|
|
||||||
|
OutputPath string
|
||||||
|
SPOEConfigPath string
|
||||||
|
SkipReload bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(pool *pgxpool.Pool) *Generator {
|
func New(pool *pgxpool.Pool) *Generator {
|
||||||
return &Generator{
|
return &Generator{
|
||||||
Pool: pool,
|
Pool: pool,
|
||||||
DomainsRepo: domains.New(pool),
|
DomainsRepo: domains.New(pool),
|
||||||
BackendsRepo: backends.New(pool),
|
BackendsRepo: backends.New(pool),
|
||||||
ServersRepo: backendservers.New(pool),
|
ServersRepo: backendservers.New(pool),
|
||||||
RoutingRepo: routingrules.New(pool),
|
RoutingRepo: routingrules.New(pool),
|
||||||
|
HeadersRepo: domainheaders.New(pool),
|
||||||
|
WafRepo: wafsvc.New(pool),
|
||||||
|
SetupStore: setup.NewStore(setup.DefaultDir),
|
||||||
|
SPOEConfigPath: filepath.Join(configgen.EtcEdgeguard, "haproxy", "coraza-spoe.cfg"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *Generator) Name() string { return "haproxy" }
|
func (g *Generator) Name() string { return "haproxy" }
|
||||||
|
|
||||||
|
func (g *Generator) RenderToString(ctx context.Context) (string, error) {
|
||||||
|
view, err := g.loadView(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("haproxy: load state: %w", err)
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tpl.Execute(&buf, view); err != nil {
|
||||||
|
return "", fmt.Errorf("haproxy: render template: %w", err)
|
||||||
|
}
|
||||||
|
return buf.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (g *Generator) Render(ctx context.Context) error {
|
func (g *Generator) Render(ctx context.Context) error {
|
||||||
view, err := g.loadView(ctx)
|
view, err := g.loadView(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -94,6 +121,17 @@ func (g *Generator) Render(ctx context.Context) error {
|
|||||||
if err := configgen.AtomicWrite(out, buf.Bytes(), 0o644); err != nil {
|
if err := configgen.AtomicWrite(out, buf.Bytes(), 0o644); err != nil {
|
||||||
return fmt.Errorf("haproxy: write: %w", err)
|
return fmt.Errorf("haproxy: write: %w", err)
|
||||||
}
|
}
|
||||||
|
// Write SPOE config whenever WAF is enabled; remove it when disabled
|
||||||
|
// so HAProxy doesn't fail on a missing backend reference.
|
||||||
|
if view.WAFEnabled {
|
||||||
|
spoeOut := g.SPOEConfigPath
|
||||||
|
if spoeOut == "" {
|
||||||
|
spoeOut = filepath.Join(configgen.EtcEdgeguard, "haproxy", "coraza-spoe.cfg")
|
||||||
|
}
|
||||||
|
if err := configgen.AtomicWrite(spoeOut, []byte(spoeCfg), 0o644); err != nil {
|
||||||
|
return fmt.Errorf("haproxy: write spoe config: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
if g.SkipReload {
|
if g.SkipReload {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -103,6 +141,29 @@ func (g *Generator) Render(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// spoeCfg is the static SPOE configuration for edgeguard-waf.
|
||||||
|
// HAProxy 3.x format: [<engine-name>] section + spoe-agent / spoe-message
|
||||||
|
// (no square brackets around spoe-agent/spoe-message keywords).
|
||||||
|
// spoeCfg uses `option continue-on-error` so that HAProxy never blocks
|
||||||
|
// a request when the SPOE agent is slow or unavailable. Without this,
|
||||||
|
// a timeout during CRS engine initialization would block all traffic,
|
||||||
|
// including domains without WAF configured.
|
||||||
|
const spoeCfg = `# Generated by edgeguard-api. DO NOT EDIT.
|
||||||
|
[edgeguard-waf]
|
||||||
|
spoe-agent edgeguard-waf-agent
|
||||||
|
messages edgeguard-waf-req
|
||||||
|
option var-prefix waf
|
||||||
|
option continue-on-error
|
||||||
|
timeout hello 100ms
|
||||||
|
timeout idle 30s
|
||||||
|
timeout processing 1s
|
||||||
|
use-backend spoe-edgeguard-waf
|
||||||
|
|
||||||
|
spoe-message edgeguard-waf-req
|
||||||
|
args src=src method=method uri=url ver=req.ver headers=req.hdrs host=req.hdr(host)
|
||||||
|
event on-frontend-http-request
|
||||||
|
`
|
||||||
|
|
||||||
// View is what the template consumes. Routes per domain are pre-
|
// View is what the template consumes. Routes per domain are pre-
|
||||||
// joined here so the template can stay declarative; Servers leben pro
|
// joined here so the template can stay declarative; Servers leben pro
|
||||||
// BackendView, damit das Template einen `backend …`-Block mit den N
|
// BackendView, damit das Template einen `backend …`-Block mit den N
|
||||||
@@ -110,11 +171,66 @@ func (g *Generator) Render(ctx context.Context) error {
|
|||||||
type View struct {
|
type View struct {
|
||||||
Domains []DomainView
|
Domains []DomainView
|
||||||
Backends []BackendView
|
Backends []BackendView
|
||||||
|
|
||||||
|
// HTTPDomains: aktive Domains mit HTTPToHTTPS=false. Diese bekommen
|
||||||
|
// in public_http eigene use_backend-Regeln statt dem globalen Redirect.
|
||||||
|
HTTPDomains []DomainView
|
||||||
|
|
||||||
|
// GlobalMaintenance: wenn true emittiert public_https einen
|
||||||
|
// 503-Block ganz am Anfang (vor allen anderen ACLs), der
|
||||||
|
// alle Customer-Domains gleichzeitig stilllegt. mgmt_https
|
||||||
|
// (:3443) bleibt unverändert. Whole-Box-Mode für
|
||||||
|
// OS-Upgrades / Wartungsfenster.
|
||||||
|
GlobalMaintenance bool
|
||||||
|
GlobalMaintenanceMessage string
|
||||||
|
|
||||||
|
// IPv6Enabled: wenn true fügt das Template zusätzliche
|
||||||
|
// bind-Direktiven für [::]:80, [::]:443 und [::]:3443 hinzu.
|
||||||
|
IPv6Enabled bool
|
||||||
|
|
||||||
|
// WAFEnabled: wenn true wird der SPOE-Filter für edgeguard-waf
|
||||||
|
// in public_https eingebunden und das spoe-Backend gerendert.
|
||||||
|
// Wird gesetzt sobald mindestens eine Domain WAF enabled hat.
|
||||||
|
WAFEnabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type DomainView struct {
|
type DomainView struct {
|
||||||
models.Domain
|
models.Domain
|
||||||
Routes []RouteView
|
Routes []RouteView
|
||||||
|
|
||||||
|
// HSTSHeader: fertige Header-Value `max-age=…; includeSubDomains; preload`,
|
||||||
|
// nur gesetzt wenn HSTSEnabled — Template muss nichts mehr zusammenbauen.
|
||||||
|
HSTSHeader string
|
||||||
|
|
||||||
|
// MaintMessage: aus MaintenanceMessage übernommen, leer → Default.
|
||||||
|
// Bereits HAProxy-safe (kein " enthalten).
|
||||||
|
MaintMessage string
|
||||||
|
|
||||||
|
// RedirectFromHost: aliasing-Host der nach Name umgeleitet werden soll.
|
||||||
|
// Nur gesetzt wenn WWWRedirect != "":
|
||||||
|
// to-naked → Name="example.com" → "www.example.com"
|
||||||
|
// to-www → Name="www.example.com" → "example.com" (strip www.-Prefix)
|
||||||
|
RedirectFromHost string
|
||||||
|
|
||||||
|
// ResponseHeaders: Custom-Headers die HAProxy auf jede Response für
|
||||||
|
// diese Domain setzt. Werte sind bereits HAProxy-safe escaped
|
||||||
|
// (Quotes → ', Newlines → Space).
|
||||||
|
ResponseHeaders []ResponseHeaderView
|
||||||
|
|
||||||
|
// MaxBodyBytes: Content-Length-Cap in Bytes (max_body_kb * 1024).
|
||||||
|
// 0 = aus, kein Rendering.
|
||||||
|
MaxBodyBytes int
|
||||||
|
|
||||||
|
// RateLimitThreshold: requests-pro-10s über die Stick-Table.
|
||||||
|
// 0 = aus, kein Rendering. = RateLimitRPS * 10.
|
||||||
|
RateLimitThreshold int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResponseHeaderView: pre-escaped Name/Value damit das Template
|
||||||
|
// nicht escapen muss.
|
||||||
|
type ResponseHeaderView struct {
|
||||||
|
Name string
|
||||||
|
Value string
|
||||||
}
|
}
|
||||||
|
|
||||||
type RouteView struct {
|
type RouteView struct {
|
||||||
@@ -144,6 +260,17 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list routing rules: %w", err)
|
return nil, fmt.Errorf("list routing rules: %w", err)
|
||||||
}
|
}
|
||||||
|
headers, err := g.HeadersRepo.ListAll(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list domain headers: %w", err)
|
||||||
|
}
|
||||||
|
headersByDomain := map[int64][]ResponseHeaderView{}
|
||||||
|
for _, h := range headers {
|
||||||
|
headersByDomain[h.DomainID] = append(headersByDomain[h.DomainID], ResponseHeaderView{
|
||||||
|
Name: h.Name,
|
||||||
|
Value: sanitizeHeaderValue(h.Value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
rulesByDomain := map[int64][]RouteView{}
|
rulesByDomain := map[int64][]RouteView{}
|
||||||
for _, r := range rules {
|
for _, r := range rules {
|
||||||
@@ -180,11 +307,125 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) {
|
|||||||
if !d.Active {
|
if !d.Active {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
domViews = append(domViews, DomainView{
|
dv := DomainView{
|
||||||
Domain: d,
|
Domain: d,
|
||||||
Routes: rulesByDomain[d.ID],
|
Routes: rulesByDomain[d.ID],
|
||||||
})
|
HSTSHeader: buildHSTSHeader(d),
|
||||||
|
MaintMessage: buildMaintMessage(d),
|
||||||
|
RedirectFromHost: buildRedirectFromHost(d),
|
||||||
|
ResponseHeaders: headersByDomain[d.ID],
|
||||||
|
}
|
||||||
|
if d.MaxBodyKB > 0 {
|
||||||
|
dv.MaxBodyBytes = d.MaxBodyKB * 1024
|
||||||
|
}
|
||||||
|
if d.RateLimitRPS > 0 {
|
||||||
|
// Stick-Table store-window ist 10s (siehe Template); deshalb
|
||||||
|
// ist die Threshold-Größe rps * 10.
|
||||||
|
dv.RateLimitThreshold = d.RateLimitRPS * 10
|
||||||
|
}
|
||||||
|
domViews = append(domViews, dv)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &View{Domains: domViews, Backends: activeBackends}, nil
|
httpDomains := make([]DomainView, 0)
|
||||||
|
for _, dv := range domViews {
|
||||||
|
if !dv.HTTPToHTTPS && (dv.PrimaryBackendID != nil || len(dv.Routes) > 0) {
|
||||||
|
httpDomains = append(httpDomains, dv)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v := &View{Domains: domViews, Backends: activeBackends, HTTPDomains: httpDomains}
|
||||||
|
|
||||||
|
// Check whether any domain has WAF enabled.
|
||||||
|
if g.WafRepo != nil {
|
||||||
|
if wafEnabled, err := g.WafRepo.ListEnabled(ctx); err == nil {
|
||||||
|
v.WAFEnabled = len(wafEnabled) > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if g.SetupStore != nil {
|
||||||
|
if st, err := g.SetupStore.Load(); err == nil && st != nil {
|
||||||
|
v.GlobalMaintenance = st.MaintenanceMode
|
||||||
|
v.GlobalMaintenanceMessage = sanitizeHeaderValue(st.MaintenanceMessage)
|
||||||
|
if v.GlobalMaintenanceMessage == "" && v.GlobalMaintenance {
|
||||||
|
v.GlobalMaintenanceMessage = "EdgeGuard maintenance in progress."
|
||||||
|
}
|
||||||
|
v.IPv6Enabled = st.IPv6Enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildHSTSHeader baut den fertigen Strict-Transport-Security Wert.
|
||||||
|
// Leer wenn HSTS für die Domain aus ist — das Template prüft dann.
|
||||||
|
func buildHSTSHeader(d models.Domain) string {
|
||||||
|
if !d.HSTSEnabled {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
maxAge := d.HSTSMaxAge
|
||||||
|
if maxAge <= 0 {
|
||||||
|
maxAge = 31536000
|
||||||
|
}
|
||||||
|
out := fmt.Sprintf("max-age=%d", maxAge)
|
||||||
|
if d.HSTSSubdomains {
|
||||||
|
out += "; includeSubDomains"
|
||||||
|
}
|
||||||
|
if d.HSTSPreload {
|
||||||
|
out += "; preload"
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildMaintMessage liefert die Wartungs-Meldung HAProxy-safe (ohne ").
|
||||||
|
// Default-Text wenn nichts gesetzt ist.
|
||||||
|
func buildMaintMessage(d models.Domain) string {
|
||||||
|
if !d.MaintenanceMode {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
msg := ""
|
||||||
|
if d.MaintenanceMessage != nil {
|
||||||
|
msg = *d.MaintenanceMessage
|
||||||
|
}
|
||||||
|
msg = strings.TrimSpace(msg)
|
||||||
|
if msg == "" {
|
||||||
|
msg = "Service temporarily unavailable for maintenance."
|
||||||
|
}
|
||||||
|
// HAProxy kennt kein Escape innerhalb von "…". Lieber " durch ' tauschen
|
||||||
|
// und Newlines flatten, damit der Template-Output garantiert parst.
|
||||||
|
msg = strings.ReplaceAll(msg, `"`, "'")
|
||||||
|
msg = strings.ReplaceAll(msg, "\n", " ")
|
||||||
|
msg = strings.ReplaceAll(msg, "\r", " ")
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizeHeaderValue macht den Wert HAProxy-safe für `set-header "…"`.
|
||||||
|
// HAProxy versteht innerhalb einer "…"-Sequenz keine Escapes, deshalb
|
||||||
|
// werden Quotes durch ' ersetzt und CR/LF entfernt (sonst sprengt ein
|
||||||
|
// böser Wert die Config oder ermöglicht Header-Injection).
|
||||||
|
func sanitizeHeaderValue(v string) string {
|
||||||
|
v = strings.ReplaceAll(v, `"`, "'")
|
||||||
|
v = strings.ReplaceAll(v, "\n", " ")
|
||||||
|
v = strings.ReplaceAll(v, "\r", " ")
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRedirectFromHost gibt den Alias-Host zurück der nach Name umgeleitet
|
||||||
|
// werden soll, oder "" wenn kein Redirect konfiguriert ist.
|
||||||
|
func buildRedirectFromHost(d models.Domain) string {
|
||||||
|
switch d.WWWRedirect {
|
||||||
|
case "to-naked":
|
||||||
|
// Name ist die nackte Form → wir leiten www.Name → Name um.
|
||||||
|
if strings.HasPrefix(strings.ToLower(d.Name), "www.") {
|
||||||
|
// User-Fehlkonfiguration — Name beginnt schon mit www. Skip.
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "www." + d.Name
|
||||||
|
case "to-www":
|
||||||
|
// Name ist die www-Form → wir leiten Name-ohne-www → Name um.
|
||||||
|
lower := strings.ToLower(d.Name)
|
||||||
|
if !strings.HasPrefix(lower, "www.") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return d.Name[4:]
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,13 @@ func TestRender_BaselineHasFrontendsAndApiBackend(t *testing.T) {
|
|||||||
"backend api_backend",
|
"backend api_backend",
|
||||||
"server api1 127.0.0.1:9443 check",
|
"server api1 127.0.0.1:9443 check",
|
||||||
"bind :443 ssl crt /etc/edgeguard/tls/",
|
"bind :443 ssl crt /etc/edgeguard/tls/",
|
||||||
|
// HTTP/3 (QUIC) zusätzlich zum h2/http1.1-Listener.
|
||||||
|
"bind quic4@:443 ssl crt /etc/edgeguard/tls/ alpn h3",
|
||||||
|
// limited-quic global muss gesetzt sein, sonst weigert sich
|
||||||
|
// HAProxy 3.0 das quic4-bind anzunehmen (OpenSSL-Kompat-Layer).
|
||||||
|
"limited-quic",
|
||||||
|
// Alt-Svc damit Browser auf h3 upgraden.
|
||||||
|
`Alt-Svc "h3=\":443\"; ma=86400"`,
|
||||||
"path_beg /.well-known/acme-challenge/",
|
"path_beg /.well-known/acme-challenge/",
|
||||||
"http-request redirect scheme https",
|
"http-request redirect scheme https",
|
||||||
// Client-IP-Weiterleitung an Backends — XFF kommt aus
|
// Client-IP-Weiterleitung an Backends — XFF kommt aus
|
||||||
@@ -54,6 +61,326 @@ func TestRender_BaselineHasFrontendsAndApiBackend(t *testing.T) {
|
|||||||
t.Errorf("missing %q in baseline output:\n%s", w, out)
|
t.Errorf("missing %q in baseline output:\n%s", w, out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Globales HSTS auf public_https darf NICHT mehr drin sein —
|
||||||
|
// das wird jetzt pro Domain via ACL gesetzt (siehe HSTS-Test).
|
||||||
|
// mgmt_https hat aber weiterhin ein globales HSTS.
|
||||||
|
publicIdx := strings.Index(out, "frontend public_https")
|
||||||
|
mgmtIdx := strings.Index(out, "frontend mgmt_https")
|
||||||
|
if publicIdx < 0 || mgmtIdx < 0 || publicIdx >= mgmtIdx {
|
||||||
|
t.Fatalf("frontend ordering unexpected:\n%s", out)
|
||||||
|
}
|
||||||
|
publicBlock := out[publicIdx:mgmtIdx]
|
||||||
|
if strings.Contains(publicBlock, "set-header Strict-Transport-Security") {
|
||||||
|
t.Errorf("public_https soll KEIN globales HSTS mehr enthalten (pro-Domain ACL):\n%s", publicBlock)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRender_HSTSPerDomain(t *testing.T) {
|
||||||
|
v := View{
|
||||||
|
Domains: []DomainView{
|
||||||
|
{
|
||||||
|
Domain: models.Domain{
|
||||||
|
ID: 1, Name: "a.example.com", Active: true,
|
||||||
|
HSTSEnabled: true, HSTSMaxAge: 63072000,
|
||||||
|
HSTSSubdomains: true, HSTSPreload: true,
|
||||||
|
},
|
||||||
|
HSTSHeader: "max-age=63072000; includeSubDomains; preload",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Domain: models.Domain{
|
||||||
|
ID: 2, Name: "b.example.com", Active: true,
|
||||||
|
HSTSEnabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
out := renderView(t, v)
|
||||||
|
for _, w := range []string{
|
||||||
|
// Erst löschen (gegen Backend-set HSTS), dann setzen.
|
||||||
|
`http-response del-header Strict-Transport-Security if { hdr(host) -i a.example.com }`,
|
||||||
|
`http-response set-header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" if { hdr(host) -i a.example.com }`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(out, w) {
|
||||||
|
t.Errorf("missing %q in per-domain HSTS output:\n%s", w, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "Strict-Transport-Security \"\" if { hdr(host) -i b.example.com }") ||
|
||||||
|
strings.Contains(out, "if { hdr(host) -i b.example.com }") && strings.Contains(out, "Strict-Transport-Security") &&
|
||||||
|
strings.Contains(out, "b.example.com") && strings.Count(out, "Strict-Transport-Security") > 2 {
|
||||||
|
// HSTS soll für Domain ohne HSTSEnabled gar nicht erst gerendert werden.
|
||||||
|
// (mgmt_https hat noch eins, plus die eine Zeile von a.example.com → 2 Vorkommen erwartet.)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRender_MaintenanceModeBlocksWith503(t *testing.T) {
|
||||||
|
msg := `Wartung — bitte später wiederkommen.`
|
||||||
|
v := View{
|
||||||
|
Domains: []DomainView{
|
||||||
|
{
|
||||||
|
Domain: models.Domain{
|
||||||
|
ID: 1, Name: "down.example.com", Active: true,
|
||||||
|
MaintenanceMode: true, MaintenanceMessage: &msg,
|
||||||
|
},
|
||||||
|
MaintMessage: msg,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
out := renderView(t, v)
|
||||||
|
want := `http-request return status 503 content-type "text/plain; charset=utf-8" string "Wartung — bitte später wiederkommen." if { hdr(host) -i down.example.com }`
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Errorf("missing maintenance-503 line:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRender_WWWRedirectToNaked(t *testing.T) {
|
||||||
|
v := View{
|
||||||
|
Domains: []DomainView{
|
||||||
|
{
|
||||||
|
Domain: models.Domain{
|
||||||
|
ID: 1, Name: "example.com", Active: true,
|
||||||
|
WWWRedirect: "to-naked",
|
||||||
|
},
|
||||||
|
RedirectFromHost: "www.example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
out := renderView(t, v)
|
||||||
|
want := `http-request redirect prefix https://example.com code 301 if { hdr(host) -i www.example.com }`
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Errorf("missing www→naked redirect line:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRender_WWWRedirectToWWW(t *testing.T) {
|
||||||
|
v := View{
|
||||||
|
Domains: []DomainView{
|
||||||
|
{
|
||||||
|
Domain: models.Domain{
|
||||||
|
ID: 1, Name: "www.example.com", Active: true,
|
||||||
|
WWWRedirect: "to-www",
|
||||||
|
},
|
||||||
|
RedirectFromHost: "example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
out := renderView(t, v)
|
||||||
|
want := `http-request redirect prefix https://www.example.com code 301 if { hdr(host) -i example.com }`
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Errorf("missing naked→www redirect line:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildHSTSHeader(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
d models.Domain
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"disabled", models.Domain{HSTSEnabled: false}, ""},
|
||||||
|
{"defaults", models.Domain{HSTSEnabled: true}, "max-age=31536000"},
|
||||||
|
{"explicit", models.Domain{HSTSEnabled: true, HSTSMaxAge: 7200}, "max-age=7200"},
|
||||||
|
{"sub", models.Domain{HSTSEnabled: true, HSTSMaxAge: 60, HSTSSubdomains: true}, "max-age=60; includeSubDomains"},
|
||||||
|
{"sub+preload", models.Domain{HSTSEnabled: true, HSTSMaxAge: 60, HSTSSubdomains: true, HSTSPreload: true}, "max-age=60; includeSubDomains; preload"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := buildHSTSHeader(tc.d); got != tc.want {
|
||||||
|
t.Errorf("buildHSTSHeader: got %q want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildMaintMessage(t *testing.T) {
|
||||||
|
msg := `He said "hi"` + "\n" + `and left`
|
||||||
|
d := models.Domain{MaintenanceMode: true, MaintenanceMessage: &msg}
|
||||||
|
got := buildMaintMessage(d)
|
||||||
|
// Quotes durch ' ersetzt, Newline → Space.
|
||||||
|
want := `He said 'hi' and left`
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("buildMaintMessage: got %q want %q", got, want)
|
||||||
|
}
|
||||||
|
if buildMaintMessage(models.Domain{MaintenanceMode: false}) != "" {
|
||||||
|
t.Errorf("buildMaintMessage should be empty when MaintenanceMode is off")
|
||||||
|
}
|
||||||
|
if got := buildMaintMessage(models.Domain{MaintenanceMode: true}); got == "" {
|
||||||
|
t.Errorf("buildMaintMessage should emit fallback text when no message set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRender_GlobalMaintenanceBlocksAllCustomerTraffic(t *testing.T) {
|
||||||
|
v := View{
|
||||||
|
GlobalMaintenance: true,
|
||||||
|
GlobalMaintenanceMessage: "Wartung läuft.",
|
||||||
|
Domains: []DomainView{
|
||||||
|
{Domain: models.Domain{ID: 1, Name: "site.example.com", Active: true}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
out := renderView(t, v)
|
||||||
|
want := `http-request return status 503 content-type "text/plain; charset=utf-8" string "Wartung läuft."`
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Errorf("missing global-maintenance 503 block:\n%s", out)
|
||||||
|
}
|
||||||
|
// Block muss VOR den use_backend-Zeilen IM SELBEN public_https-
|
||||||
|
// Frontend stehen (sonst ineffektiv — HAProxy execut'ed in-order,
|
||||||
|
// return-actions terminieren die Chain). public_http hat ein
|
||||||
|
// frühes use_backend api_backend für ACME — das hier nicht
|
||||||
|
// matchen.
|
||||||
|
pubIdx := strings.Index(out, "frontend public_https")
|
||||||
|
mgmtIdxStart := strings.Index(out, "frontend mgmt_https")
|
||||||
|
if pubIdx < 0 || mgmtIdxStart < 0 {
|
||||||
|
t.Fatalf("frontends not found in output:\n%s", out)
|
||||||
|
}
|
||||||
|
publicBlock := out[pubIdx:mgmtIdxStart]
|
||||||
|
idxBlock := strings.Index(publicBlock, want)
|
||||||
|
idxUseBackend := strings.Index(publicBlock, "use_backend")
|
||||||
|
if idxBlock < 0 || (idxUseBackend > 0 && idxBlock > idxUseBackend) {
|
||||||
|
t.Errorf("global-maintenance block must precede use_backend in public_https\n block at %d, use_backend at %d", idxBlock, idxUseBackend)
|
||||||
|
}
|
||||||
|
// mgmt_https darf NICHT betroffen sein.
|
||||||
|
mgmtIdx := strings.Index(out, "frontend mgmt_https")
|
||||||
|
if mgmtIdx > 0 {
|
||||||
|
mgmtBlock := out[mgmtIdx:]
|
||||||
|
if strings.Contains(mgmtBlock, want) {
|
||||||
|
t.Errorf("mgmt_https must NOT contain global-maintenance block:\n%s", mgmtBlock)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRender_GlobalMaintenanceOff_NoBlock(t *testing.T) {
|
||||||
|
v := View{
|
||||||
|
Domains: []DomainView{
|
||||||
|
{Domain: models.Domain{ID: 1, Name: "site.example.com", Active: true}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
out := renderView(t, v)
|
||||||
|
if strings.Contains(out, "Whole-Box-Maintenance") {
|
||||||
|
t.Errorf("global-maintenance comment should not be rendered when off:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRender_RateLimitEmitsStickTableAndDeny(t *testing.T) {
|
||||||
|
v := View{
|
||||||
|
Domains: []DomainView{
|
||||||
|
{
|
||||||
|
Domain: models.Domain{
|
||||||
|
ID: 7, Name: "api.example.com", Active: true,
|
||||||
|
RateLimitRPS: 50,
|
||||||
|
},
|
||||||
|
RateLimitThreshold: 500, // 50 rps × 10s
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
out := renderView(t, v)
|
||||||
|
for _, w := range []string{
|
||||||
|
"backend rl_7",
|
||||||
|
"stick-table type ip size 100k expire 10s store http_req_rate(10s)",
|
||||||
|
`http-request track-sc0 src table rl_7 if { hdr(host) -i api.example.com }`,
|
||||||
|
`http-request deny deny_status 429 if { hdr(host) -i api.example.com } { sc_http_req_rate(0) gt 500 }`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(out, w) {
|
||||||
|
t.Errorf("missing %q in rate-limit output:\n%s", w, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRender_NoRateLimitNoStickTable(t *testing.T) {
|
||||||
|
v := View{
|
||||||
|
Domains: []DomainView{
|
||||||
|
{
|
||||||
|
Domain: models.Domain{ID: 9, Name: "a.example.com", Active: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
out := renderView(t, v)
|
||||||
|
if strings.Contains(out, "backend rl_9") {
|
||||||
|
t.Errorf("stick-table backend rendered for domain without rate-limit:\n%s", out)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "track-sc0") {
|
||||||
|
t.Errorf("track-sc0 emitted without rate-limit:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRender_BodySizeDeny413(t *testing.T) {
|
||||||
|
v := View{
|
||||||
|
Domains: []DomainView{
|
||||||
|
{
|
||||||
|
Domain: models.Domain{
|
||||||
|
ID: 1, Name: "upload.example.com", Active: true,
|
||||||
|
MaxBodyKB: 2048,
|
||||||
|
},
|
||||||
|
MaxBodyBytes: 2048 * 1024,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
out := renderView(t, v)
|
||||||
|
want := `http-request deny deny_status 413 if { hdr(host) -i upload.example.com } { req.hdr_val(content-length) -m int gt 2097152 }`
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Errorf("missing 413 body-size deny:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRender_CustomResponseHeaders(t *testing.T) {
|
||||||
|
v := View{
|
||||||
|
Domains: []DomainView{
|
||||||
|
{
|
||||||
|
Domain: models.Domain{ID: 1, Name: "x.example.com", Active: true},
|
||||||
|
ResponseHeaders: []ResponseHeaderView{
|
||||||
|
{Name: "X-Frame-Options", Value: "DENY"},
|
||||||
|
{Name: "Content-Security-Policy", Value: "default-src 'self'"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
out := renderView(t, v)
|
||||||
|
for _, w := range []string{
|
||||||
|
// del + set pro Custom-Header, damit Upstream-Werte
|
||||||
|
// garantiert überschrieben werden.
|
||||||
|
`http-response del-header X-Frame-Options if { hdr(host) -i x.example.com }`,
|
||||||
|
`http-response set-header X-Frame-Options "DENY" if { hdr(host) -i x.example.com }`,
|
||||||
|
`http-response del-header Content-Security-Policy if { hdr(host) -i x.example.com }`,
|
||||||
|
`http-response set-header Content-Security-Policy "default-src 'self'" if { hdr(host) -i x.example.com }`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(out, w) {
|
||||||
|
t.Errorf("missing %q:\n%s", w, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeHeaderValue(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
`plain`: `plain`,
|
||||||
|
`with "quotes"`: `with 'quotes'`,
|
||||||
|
"with\nnewline": "with newline",
|
||||||
|
"crlf\r\nattack": "crlf attack",
|
||||||
|
`csp default-src 'self'`: `csp default-src 'self'`,
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := sanitizeHeaderValue(in); got != want {
|
||||||
|
t.Errorf("sanitizeHeaderValue(%q) = %q want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildRedirectFromHost(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
d models.Domain
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"none", models.Domain{Name: "example.com"}, ""},
|
||||||
|
{"to-naked", models.Domain{Name: "example.com", WWWRedirect: "to-naked"}, "www.example.com"},
|
||||||
|
{"to-naked invalid (name already has www)", models.Domain{Name: "www.example.com", WWWRedirect: "to-naked"}, ""},
|
||||||
|
{"to-www", models.Domain{Name: "www.example.com", WWWRedirect: "to-www"}, "example.com"},
|
||||||
|
{"to-www invalid (name lacks www)", models.Domain{Name: "example.com", WWWRedirect: "to-www"}, ""},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := buildRedirectFromHost(tc.d); got != tc.want {
|
||||||
|
t.Errorf("buildRedirectFromHost: got %q want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRender_DomainRoutesEmitUseBackend(t *testing.T) {
|
func TestRender_DomainRoutesEmitUseBackend(t *testing.T) {
|
||||||
|
|||||||
235
internal/kea/kea.go
Normal file
235
internal/kea/kea.go
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
// Package kea renders the Kea DHCPv4 server config from the dhcp_*
|
||||||
|
// tables and manages the kea-dhcp4-server service lifecycle.
|
||||||
|
//
|
||||||
|
// The config is built as a Go struct and json-marshalled (NOT a text
|
||||||
|
// template) so the output is always syntactically valid JSON. Managed
|
||||||
|
// at /etc/edgeguard/kea/kea-dhcp4.conf (edgeguard-owned); postinst
|
||||||
|
// symlinks /etc/kea/kea-dhcp4.conf to it.
|
||||||
|
//
|
||||||
|
// Safety: the service runs ONLY when dhcp_settings.enabled is true on
|
||||||
|
// THIS node (a DHCP server is network-sensitive; default off). enabled
|
||||||
|
// → enable + restart; disabled → disable + stop.
|
||||||
|
package kea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
|
||||||
|
dhcpsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/dhcp"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ConfPath = configgen.EtcEdgeguard + "/kea/kea-dhcp4.conf"
|
||||||
|
serviceName = "kea-dhcp4-server"
|
||||||
|
leaseFile = "/var/lib/kea/kea-leases4.csv"
|
||||||
|
keaBinary = "/usr/sbin/kea-dhcp4"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Generator struct {
|
||||||
|
Pool *pgxpool.Pool
|
||||||
|
Repo *dhcpsvc.Repo
|
||||||
|
SkipReload bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(pool *pgxpool.Pool) *Generator {
|
||||||
|
return &Generator{Pool: pool, Repo: dhcpsvc.New(pool)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Generator) Name() string { return "kea" }
|
||||||
|
|
||||||
|
// ── Kea config JSON shape ────────────────────────────────────────────
|
||||||
|
|
||||||
|
type keaConfig struct {
|
||||||
|
Dhcp4 dhcp4 `json:"Dhcp4"`
|
||||||
|
}
|
||||||
|
type dhcp4 struct {
|
||||||
|
InterfacesConfig ifcfg `json:"interfaces-config"`
|
||||||
|
LeaseDatabase leaseDB `json:"lease-database"`
|
||||||
|
ValidLifetime int `json:"valid-lifetime"`
|
||||||
|
MaxValidLifetime int `json:"max-valid-lifetime"`
|
||||||
|
OptionData []optionData `json:"option-data,omitempty"`
|
||||||
|
Subnet4 []subnet4 `json:"subnet4"`
|
||||||
|
Loggers []logger `json:"loggers"`
|
||||||
|
}
|
||||||
|
type ifcfg struct {
|
||||||
|
Interfaces []string `json:"interfaces"`
|
||||||
|
}
|
||||||
|
type leaseDB struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Persist bool `json:"persist"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
type optionData struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Data string `json:"data"`
|
||||||
|
}
|
||||||
|
type subnet4 struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Subnet string `json:"subnet"`
|
||||||
|
Pools []pool `json:"pools,omitempty"`
|
||||||
|
OptionData []optionData `json:"option-data,omitempty"`
|
||||||
|
Reservations []reservation `json:"reservations,omitempty"`
|
||||||
|
}
|
||||||
|
type pool struct {
|
||||||
|
Pool string `json:"pool"`
|
||||||
|
}
|
||||||
|
type reservation struct {
|
||||||
|
HWAddress string `json:"hw-address"`
|
||||||
|
IPAddress string `json:"ip-address"`
|
||||||
|
Hostname string `json:"hostname,omitempty"`
|
||||||
|
}
|
||||||
|
type logger struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Severity string `json:"severity"`
|
||||||
|
OutputOptions []outOpt `json:"output_options"`
|
||||||
|
}
|
||||||
|
type outOpt struct {
|
||||||
|
Output string `json:"output"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildConfig assembliert die Kea-Config aus dem DB-State.
|
||||||
|
func (g *Generator) buildConfig(ctx context.Context) (*keaConfig, *bool, error) {
|
||||||
|
settings, err := g.Repo.GetSettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("get dhcp settings: %w", err)
|
||||||
|
}
|
||||||
|
subnets, err := g.Repo.ListSubnets(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("list subnets: %w", err)
|
||||||
|
}
|
||||||
|
resv, err := g.Repo.ListAllReservations(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("list reservations: %w", err)
|
||||||
|
}
|
||||||
|
bySubnet := map[int64][]reservation{}
|
||||||
|
for _, r := range resv {
|
||||||
|
if !r.Active {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bySubnet[r.SubnetID] = append(bySubnet[r.SubnetID], reservation{
|
||||||
|
HWAddress: r.MACAddress, IPAddress: r.IPAddress, Hostname: r.Hostname,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ifaceSet := map[string]bool{}
|
||||||
|
ifaces := []string{} // nie nil → JSON "[]" statt "null" (Kea lehnt null ab)
|
||||||
|
var sn4 []subnet4
|
||||||
|
|
||||||
|
for _, s := range subnets {
|
||||||
|
if !s.Active {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !ifaceSet[s.InterfaceName] {
|
||||||
|
ifaceSet[s.InterfaceName] = true
|
||||||
|
ifaces = append(ifaces, s.InterfaceName)
|
||||||
|
}
|
||||||
|
sub := subnet4{ID: s.ID, Subnet: s.SubnetCIDR}
|
||||||
|
if s.PoolStart != "" && s.PoolEnd != "" {
|
||||||
|
sub.Pools = []pool{{Pool: s.PoolStart + " - " + s.PoolEnd}}
|
||||||
|
}
|
||||||
|
if s.Gateway != "" {
|
||||||
|
sub.OptionData = append(sub.OptionData, optionData{Name: "routers", Data: s.Gateway})
|
||||||
|
}
|
||||||
|
dns := s.DNSServers
|
||||||
|
if dns == "" {
|
||||||
|
dns = settings.DNSServers
|
||||||
|
}
|
||||||
|
if dns != "" {
|
||||||
|
sub.OptionData = append(sub.OptionData, optionData{Name: "domain-name-servers", Data: normalizeCSV(dns)})
|
||||||
|
}
|
||||||
|
sub.Reservations = bySubnet[s.ID]
|
||||||
|
sn4 = append(sn4, sub)
|
||||||
|
}
|
||||||
|
|
||||||
|
d := dhcp4{
|
||||||
|
InterfacesConfig: ifcfg{Interfaces: ifaces},
|
||||||
|
LeaseDatabase: leaseDB{Type: "memfile", Persist: true, Name: leaseFile},
|
||||||
|
ValidLifetime: settings.DefaultLease,
|
||||||
|
MaxValidLifetime: settings.MaxLease,
|
||||||
|
Subnet4: sn4,
|
||||||
|
Loggers: []logger{{
|
||||||
|
Name: "kea-dhcp4", Severity: "INFO",
|
||||||
|
OutputOptions: []outOpt{{Output: "stdout"}},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
if settings.DNSServers != "" {
|
||||||
|
d.OptionData = append(d.OptionData, optionData{Name: "domain-name-servers", Data: normalizeCSV(settings.DNSServers)})
|
||||||
|
}
|
||||||
|
if settings.DomainName != "" {
|
||||||
|
d.OptionData = append(d.OptionData, optionData{Name: "domain-name", Data: settings.DomainName})
|
||||||
|
}
|
||||||
|
if d.Subnet4 == nil {
|
||||||
|
d.Subnet4 = []subnet4{}
|
||||||
|
}
|
||||||
|
return &keaConfig{Dhcp4: d}, &settings.Enabled, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Generator) RenderToString(ctx context.Context) (string, error) {
|
||||||
|
cfg, _, err := g.buildConfig(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
b, err := json.MarshalIndent(cfg, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(b) + "\n", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Generator) Render(ctx context.Context) error {
|
||||||
|
cfg, enabled, err := g.buildConfig(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default-off / disabled: Service stoppen + disablen, nichts weiter.
|
||||||
|
if enabled == nil || !*enabled {
|
||||||
|
if g.SkipReload {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_ = configgen.DisableService(serviceName)
|
||||||
|
_ = configgen.StopService(serviceName)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := json.MarshalIndent(cfg, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := configgen.AtomicWrite(ConfPath, append(b, '\n'), 0o644); err != nil {
|
||||||
|
return fmt.Errorf("write kea config: %w", err)
|
||||||
|
}
|
||||||
|
if g.SkipReload {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Best-effort Config-Test (verhindert Restart mit kaputter Semantik).
|
||||||
|
if _, statErr := os.Stat(keaBinary); statErr == nil {
|
||||||
|
if out, terr := exec.Command(keaBinary, "-t", ConfPath).CombinedOutput(); terr != nil {
|
||||||
|
return fmt.Errorf("kea-dhcp4 -t rejected config: %w (output: %s)", terr, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := configgen.EnableService(serviceName); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return configgen.RestartService(serviceName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeCSV trimmt Whitespace um Komma-getrennte Werte (Kea will
|
||||||
|
// "a,b,c" ohne Leerzeichen-Toleranz-Probleme).
|
||||||
|
func normalizeCSV(s string) string {
|
||||||
|
parts := strings.Split(s, ",")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
if t := strings.TrimSpace(p); t != "" {
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(out, ",")
|
||||||
|
}
|
||||||
89
internal/kea/kea_test.go
Normal file
89
internal/kea/kea_test.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
package kea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Guarded integration test: set EG_FWTEST_DSN (sonst skip).
|
||||||
|
func TestRender_DHCPConfig(t *testing.T) {
|
||||||
|
dsn := os.Getenv("EG_FWTEST_DSN")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("set EG_FWTEST_DSN to run the kea 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 dhcp_reservations`,
|
||||||
|
`DELETE FROM dhcp_subnets`,
|
||||||
|
`UPDATE dhcp_settings SET enabled=true, default_lease=3600, max_lease=7200, domain_name='lan', dns_servers='1.1.1.1, 8.8.8.8' WHERE id=1`,
|
||||||
|
} {
|
||||||
|
if _, err := pool.Exec(ctx, q); err != nil {
|
||||||
|
t.Fatalf("seed (%s): %v", q, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var subID int64
|
||||||
|
if err := pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO dhcp_subnets (name, interface_name, subnet_cidr, pool_start, pool_end, gateway, dns_servers, active)
|
||||||
|
VALUES ('lan','eth1','10.0.0.0/24','10.0.0.100','10.0.0.200','10.0.0.1','',true) RETURNING id`).Scan(&subID); err != nil {
|
||||||
|
t.Fatalf("seed subnet: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, `
|
||||||
|
INSERT INTO dhcp_reservations (subnet_id, mac_address, ip_address, hostname, active)
|
||||||
|
VALUES ($1,'aa:bb:cc:dd:ee:ff','10.0.0.50','printer',true)`, subID); err != nil {
|
||||||
|
t.Fatalf("seed reservation: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := New(pool).RenderToString(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("render: %v", err)
|
||||||
|
}
|
||||||
|
if !json.Valid([]byte(out)) {
|
||||||
|
t.Fatalf("rendered config is not valid JSON:\n%s", out)
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
`"10.0.0.0/24"`,
|
||||||
|
`"10.0.0.100 - 10.0.0.200"`,
|
||||||
|
`"aa:bb:cc:dd:ee:ff"`,
|
||||||
|
`"routers"`,
|
||||||
|
`"eth1"`,
|
||||||
|
`"hw-address"`,
|
||||||
|
`"valid-lifetime": 3600`,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Errorf("rendered config missing %q\n----\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort: echte Kea-Validierung, falls die Binary da ist.
|
||||||
|
if _, statErr := os.Stat(keaBinary); statErr == nil {
|
||||||
|
f, _ := os.CreateTemp(t.TempDir(), "kea-*.conf")
|
||||||
|
_, _ = f.WriteString(out)
|
||||||
|
f.Close()
|
||||||
|
if combined, err := exec.Command(keaBinary, "-t", f.Name()).CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("kea-dhcp4 -t rejected rendered config: %v\n%s", err, combined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
72
internal/keepalived/keepalived.conf.tpl
Normal file
72
internal/keepalived/keepalived.conf.tpl
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
global_defs {
|
||||||
|
router_id {{ .RouterID }}
|
||||||
|
script_user root
|
||||||
|
enable_script_security
|
||||||
|
}
|
||||||
|
|
||||||
|
vrrp_script chk_edgeguard {
|
||||||
|
script "/usr/lib/edgeguard/keepalived-check.sh"
|
||||||
|
interval 2
|
||||||
|
weight -50
|
||||||
|
fall 3
|
||||||
|
rise 2
|
||||||
|
}
|
||||||
|
{{ if .GWCheckIP }}
|
||||||
|
vrrp_script chk_gateway {
|
||||||
|
script "/usr/lib/edgeguard/keepalived-gw-check.sh {{ .GWCheckIP }}"
|
||||||
|
interval 5
|
||||||
|
weight -110
|
||||||
|
fall 2
|
||||||
|
rise 2
|
||||||
|
}
|
||||||
|
{{ end }}
|
||||||
|
{{ if .HBInterface }}
|
||||||
|
vrrp_sync_group VG_1 {
|
||||||
|
group {
|
||||||
|
VI_1
|
||||||
|
VI_HB
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{{ end }}
|
||||||
|
vrrp_instance VI_1 {
|
||||||
|
state {{ .State }}
|
||||||
|
interface {{ .Interface }}
|
||||||
|
virtual_router_id {{ .RouterID }}
|
||||||
|
priority {{ .Priority }}
|
||||||
|
advert_int 1
|
||||||
|
{{ if .SrcIP }} unicast_src_ip {{ .SrcIP }}
|
||||||
|
unicast_peer {
|
||||||
|
{{ .PeerIP }}
|
||||||
|
}
|
||||||
|
{{ end }} authentication {
|
||||||
|
auth_type PASS
|
||||||
|
auth_pass {{ .AuthPass }}
|
||||||
|
}
|
||||||
|
virtual_ipaddress {
|
||||||
|
{{ range .VIPs }} {{ .Address }}/{{ .Prefix }} dev {{ .Device }}
|
||||||
|
{{ end }} }
|
||||||
|
track_script {
|
||||||
|
chk_edgeguard
|
||||||
|
{{ if .GWCheckIP }} chk_gateway
|
||||||
|
{{ end }} }
|
||||||
|
notify_master "/usr/lib/edgeguard/keepalived-master.sh"
|
||||||
|
notify_backup "/usr/lib/edgeguard/keepalived-backup.sh"
|
||||||
|
notify_fault "/usr/lib/edgeguard/keepalived-backup.sh"
|
||||||
|
}
|
||||||
|
{{ if .HBInterface }}
|
||||||
|
vrrp_instance VI_HB {
|
||||||
|
state {{ .State }}
|
||||||
|
interface {{ .HBInterface }}
|
||||||
|
virtual_router_id {{ .HBRouterID }}
|
||||||
|
priority {{ .Priority }}
|
||||||
|
advert_int 1
|
||||||
|
{{ if .HBSrcIP }} unicast_src_ip {{ .HBSrcIP }}
|
||||||
|
unicast_peer {
|
||||||
|
{{ .HBPeerIP }}
|
||||||
|
}
|
||||||
|
{{ end }} authentication {
|
||||||
|
auth_type PASS
|
||||||
|
auth_pass {{ .AuthPass }}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{{ end }}
|
||||||
210
internal/keepalived/keepalived.go
Normal file
210
internal/keepalived/keepalived.go
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
// Package keepalived rendert /etc/keepalived/keepalived.conf aus
|
||||||
|
// cluster_settings (VIP/VRRP-Config) und ha_nodes (local vs. peer).
|
||||||
|
//
|
||||||
|
// Split-Brain-Strategie: kein Auto-Promote. notify_master loggt nur
|
||||||
|
// und sendet einen internen Alert. Promotion ist immer manuell via
|
||||||
|
// "edgeguard-ctl promote" — das ist die einzig sichere Option ohne
|
||||||
|
// externes Quorum in einem 2-Node-Cluster.
|
||||||
|
package keepalived
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
_ "embed"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"text/template"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
const ConfPath = "/etc/keepalived/keepalived.conf"
|
||||||
|
|
||||||
|
//go:embed keepalived.conf.tpl
|
||||||
|
var cfgTpl string
|
||||||
|
|
||||||
|
var tpl = template.Must(template.New("keepalived").Parse(cfgTpl))
|
||||||
|
|
||||||
|
// VIPEntry ist eine einzelne VIP-Adresse die keepalived verwaltet.
|
||||||
|
type VIPEntry struct {
|
||||||
|
Address string // z.B. 89.163.205.100
|
||||||
|
Prefix int // z.B. 24
|
||||||
|
Device string // z.B. eth0
|
||||||
|
}
|
||||||
|
|
||||||
|
// View ist der Template-Kontext.
|
||||||
|
type View struct {
|
||||||
|
State string // MASTER | BACKUP
|
||||||
|
Interface string // Interface für VRRP-Advertisements (VI_1)
|
||||||
|
RouterID int
|
||||||
|
Priority int // MASTER=200, BACKUP=100
|
||||||
|
SrcIP string // eigene Public-IP (unicast_src_ip)
|
||||||
|
PeerIP string // Peer-Public-IP (unicast_peer)
|
||||||
|
AuthPass string
|
||||||
|
VIPs []VIPEntry // alle is_vip=true Einträge aus ip_addresses
|
||||||
|
// Dual-path VRRP (Split-Brain-Schutz, Migration 0033)
|
||||||
|
HBInterface string
|
||||||
|
HBSrcIP string
|
||||||
|
HBPeerIP string
|
||||||
|
HBRouterID int
|
||||||
|
// GW-Tracking
|
||||||
|
GWCheckIP string
|
||||||
|
}
|
||||||
|
|
||||||
|
type generator struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
localID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(pool *pgxpool.Pool, localID string) configgen.Generator {
|
||||||
|
return &generator{pool: pool, localID: localID}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *generator) Name() string { return "keepalived" }
|
||||||
|
|
||||||
|
func (g *generator) Render(ctx context.Context) error {
|
||||||
|
cs, vips, local, peer, err := g.loadData(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("keepalived: load: %w", err)
|
||||||
|
}
|
||||||
|
if len(vips) == 0 {
|
||||||
|
// Keine VIPs konfiguriert → keepalived.conf nicht schreiben.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
v := g.buildView(cs, vips, local, peer)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tpl.Execute(&buf, v); err != nil {
|
||||||
|
return fmt.Errorf("keepalived: template: %w", err)
|
||||||
|
}
|
||||||
|
if err := configgen.AtomicWrite(ConfPath, buf.Bytes(), 0o640); err != nil {
|
||||||
|
return fmt.Errorf("keepalived: write: %w", err)
|
||||||
|
}
|
||||||
|
if err := reloadKeepalived(); err != nil {
|
||||||
|
return fmt.Errorf("keepalived: reload: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *generator) loadData(ctx context.Context) (*models.ClusterSettings, []VIPEntry, *models.HANode, *models.HANode, error) {
|
||||||
|
var cs models.ClusterSettings
|
||||||
|
row := g.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, vip_address, vip_interface, vip_auth_pass, vrrp_router_id,
|
||||||
|
hb_interface, hb_src_ip, hb_peer_ip, hb_router_id, gw_check_ip
|
||||||
|
FROM cluster_settings WHERE id = 1`)
|
||||||
|
if err := row.Scan(&cs.ID, &cs.VIPAddress, &cs.VIPInterface, &cs.VIPAuthPass, &cs.VRRPRouterID,
|
||||||
|
&cs.HBInterface, &cs.HBSrcIP, &cs.HBPeerIP, &cs.HBRouterID, &cs.GWCheckIP); err != nil {
|
||||||
|
return nil, nil, nil, nil, fmt.Errorf("cluster_settings: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alle VIPs aus ip_addresses (is_vip=true, active=true) inkl. Interface-Name.
|
||||||
|
vipRows, err := g.pool.Query(ctx, `
|
||||||
|
SELECT ia.address, ia.prefix, ni.name
|
||||||
|
FROM ip_addresses ia
|
||||||
|
JOIN network_interfaces ni ON ia.interface_id = ni.id
|
||||||
|
WHERE ia.is_vip = true AND ia.active = true
|
||||||
|
ORDER BY ni.name, ia.address`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, nil, fmt.Errorf("ip_addresses: %w", err)
|
||||||
|
}
|
||||||
|
defer vipRows.Close()
|
||||||
|
var vips []VIPEntry
|
||||||
|
for vipRows.Next() {
|
||||||
|
var v VIPEntry
|
||||||
|
if err := vipRows.Scan(&v.Address, &v.Prefix, &v.Device); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
vips = append(vips, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
nodeRows, err := g.pool.Query(ctx, `SELECT id, fqdn, role, pg_role, public_ip, status FROM ha_nodes ORDER BY joined_at`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, nil, fmt.Errorf("ha_nodes: %w", err)
|
||||||
|
}
|
||||||
|
defer nodeRows.Close()
|
||||||
|
|
||||||
|
var local, peer *models.HANode
|
||||||
|
for nodeRows.Next() {
|
||||||
|
n := &models.HANode{}
|
||||||
|
if err := nodeRows.Scan(&n.ID, &n.FQDN, &n.Role, &n.PGRole, &n.PublicIP, &n.Status); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if n.ID == g.localID {
|
||||||
|
local = n
|
||||||
|
} else {
|
||||||
|
peer = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if local == nil {
|
||||||
|
return nil, nil, nil, nil, fmt.Errorf("local node %s not in ha_nodes", g.localID)
|
||||||
|
}
|
||||||
|
return &cs, vips, local, peer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *generator) buildView(cs *models.ClusterSettings, vips []VIPEntry, local, peer *models.HANode) View {
|
||||||
|
v := View{
|
||||||
|
RouterID: cs.VRRPRouterID,
|
||||||
|
VIPs: vips,
|
||||||
|
Interface: deref(cs.VIPInterface),
|
||||||
|
AuthPass: deref(cs.VIPAuthPass),
|
||||||
|
HBInterface: deref(cs.HBInterface),
|
||||||
|
HBSrcIP: deref(cs.HBSrcIP),
|
||||||
|
HBPeerIP: deref(cs.HBPeerIP),
|
||||||
|
HBRouterID: cs.HBRouterID,
|
||||||
|
GWCheckIP: deref(cs.GWCheckIP),
|
||||||
|
}
|
||||||
|
if v.Interface == "" {
|
||||||
|
v.Interface = "eth0"
|
||||||
|
}
|
||||||
|
if v.AuthPass == "" {
|
||||||
|
v.AuthPass = "edgeguard"
|
||||||
|
}
|
||||||
|
if v.HBRouterID == 0 {
|
||||||
|
v.HBRouterID = 52
|
||||||
|
}
|
||||||
|
|
||||||
|
// pg_role=standby ist das härtere Signal — ein Standby-Node ist niemals
|
||||||
|
// MASTER, auch wenn role='primary' noch aus dem Join-Prozess stammt.
|
||||||
|
// Reihenfolge: standby → BACKUP; sonst primary-Check.
|
||||||
|
if local.PGRole == "standby" {
|
||||||
|
v.State = "BACKUP"
|
||||||
|
v.Priority = 100
|
||||||
|
} else if local.PGRole == "primary" || local.Role == "primary" {
|
||||||
|
v.State = "MASTER"
|
||||||
|
v.Priority = 200
|
||||||
|
} else {
|
||||||
|
v.State = "BACKUP"
|
||||||
|
v.Priority = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
if local.PublicIP != nil {
|
||||||
|
v.SrcIP = *local.PublicIP
|
||||||
|
}
|
||||||
|
if peer != nil && peer.PublicIP != nil {
|
||||||
|
v.PeerIP = *peer.PublicIP
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func reloadKeepalived() error {
|
||||||
|
if _, err := os.Stat("/run/keepalived.pid"); os.IsNotExist(err) {
|
||||||
|
// keepalived läuft noch nicht — erster Render beim Start.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cmd := exec.Command("sudo", "-n", "/usr/bin/systemctl", "reload-or-restart", "keepalived.service")
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return fmt.Errorf("sudo systemctl reload-or-restart keepalived.service: %w (output: %s)", err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func deref(s *string) string {
|
||||||
|
if s == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return *s
|
||||||
|
}
|
||||||
@@ -365,23 +365,16 @@ func (c *Client) Trial() (*Result, error) {
|
|||||||
daysLeft := int(remaining.Hours()/24) + 1
|
daysLeft := int(remaining.Hours()/24) + 1
|
||||||
expiresAt := info.StartedAt.Add(TrialDuration)
|
expiresAt := info.StartedAt.Add(TrialDuration)
|
||||||
|
|
||||||
// Trial schaltet alle Pro-Features frei, Limits bleiben 0 (= Skip-
|
// Trial schaltet alle Features frei, Limits bleiben 0 (= Skip-
|
||||||
// Enforcement-Konvention). nmg-Backend prüft `lim > 0 && count >= lim`
|
// Enforcement-Konvention). Enforcement: `lim > 0 && count >= lim`.
|
||||||
// und blockiert in Trial-Mode niemanden.
|
|
||||||
return &Result{
|
return &Result{
|
||||||
Valid: true,
|
Valid: true,
|
||||||
Type: "trial",
|
Type: "trial",
|
||||||
Status: "active",
|
Status: "active",
|
||||||
Product: "NetCell MailGuard",
|
Product: "NetCell EdgeGuard",
|
||||||
ExpiresAt: &expiresAt,
|
ExpiresAt: &expiresAt,
|
||||||
GracePeriod: daysLeft <= 2,
|
GracePeriod: daysLeft <= 2,
|
||||||
Features: map[string]bool{
|
Features: map[string]bool{},
|
||||||
"reporting": true,
|
|
||||||
"eu_portal": true,
|
|
||||||
"digest": true,
|
|
||||||
"whitelabel": true,
|
|
||||||
"rest_api_write": true,
|
|
||||||
},
|
|
||||||
Limits: map[string]int64{
|
Limits: map[string]int64{
|
||||||
"max_domains": 0, // 0 = Skip-Enforcement (Trial-Konvention)
|
"max_domains": 0, // 0 = Skip-Enforcement (Trial-Konvention)
|
||||||
"max_nodes": 0,
|
"max_nodes": 0,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ type Backend struct {
|
|||||||
HealthCheckPath *string `gorm:"column:health_check_path" json:"health_check_path,omitempty"`
|
HealthCheckPath *string `gorm:"column:health_check_path" json:"health_check_path,omitempty"`
|
||||||
LBAlgorithm string `gorm:"column:lb_algorithm" json:"lb_algorithm"`
|
LBAlgorithm string `gorm:"column:lb_algorithm" json:"lb_algorithm"`
|
||||||
WebSocket bool `gorm:"column:websocket" json:"websocket"`
|
WebSocket bool `gorm:"column:websocket" json:"websocket"`
|
||||||
|
ForceHTTP1 bool `gorm:"column:force_http1" json:"force_http1"`
|
||||||
Active bool `gorm:"column:active" json:"active"`
|
Active bool `gorm:"column:active" json:"active"`
|
||||||
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
||||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||||
|
|||||||
24
internal/models/cluster_settings.go
Normal file
24
internal/models/cluster_settings.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// ClusterSettings ist die Singleton-Tabelle (id=1) für VIP/VRRP-
|
||||||
|
// und Replikations-Konfiguration. Angelegt in Migration 0029.
|
||||||
|
// hb_* = zweite VRRP-Instanz für Split-Brain-Schutz (0033).
|
||||||
|
// gw_check_ip = Gateway-IP für vrrp_script chk_gateway (0033).
|
||||||
|
type ClusterSettings struct {
|
||||||
|
ID int `gorm:"column:id;primaryKey" json:"id"`
|
||||||
|
VIPAddress *string `gorm:"column:vip_address" json:"vip_address,omitempty"`
|
||||||
|
VIPInterface *string `gorm:"column:vip_interface" json:"vip_interface,omitempty"`
|
||||||
|
VIPAuthPass *string `gorm:"column:vip_auth_pass" json:"vip_auth_pass,omitempty"`
|
||||||
|
VRRPRouterID int `gorm:"column:vrrp_router_id" json:"vrrp_router_id"`
|
||||||
|
HBInterface *string `gorm:"column:hb_interface" json:"hb_interface,omitempty"`
|
||||||
|
HBSrcIP *string `gorm:"column:hb_src_ip" json:"hb_src_ip,omitempty"`
|
||||||
|
HBPeerIP *string `gorm:"column:hb_peer_ip" json:"hb_peer_ip,omitempty"`
|
||||||
|
HBRouterID int `gorm:"column:hb_router_id" json:"hb_router_id"`
|
||||||
|
GWCheckIP *string `gorm:"column:gw_check_ip" json:"gw_check_ip,omitempty"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
||||||
|
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ClusterSettings) TableName() string { return "cluster_settings" }
|
||||||
53
internal/models/dhcp.go
Normal file
53
internal/models/dhcp.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// DHCPSettings ist die node-lokale Singleton-Konfiguration des Kea-DHCPv4-
|
||||||
|
// Servers (ob diese Node DHCP betreibt + globale Defaults).
|
||||||
|
type DHCPSettings struct {
|
||||||
|
ID int `gorm:"column:id;primaryKey" json:"id"`
|
||||||
|
Enabled bool `gorm:"column:enabled" json:"enabled"`
|
||||||
|
DefaultLease int `gorm:"column:default_lease" json:"default_lease"`
|
||||||
|
MaxLease int `gorm:"column:max_lease" json:"max_lease"`
|
||||||
|
DomainName string `gorm:"column:domain_name" json:"domain_name"`
|
||||||
|
DNSServers string `gorm:"column:dns_servers" json:"dns_servers"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
||||||
|
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (DHCPSettings) TableName() string { return "dhcp_settings" }
|
||||||
|
|
||||||
|
// DHCPSubnet ist ein vom DHCP-Server bedientes Subnetz (an ein Interface
|
||||||
|
// per NAME gebunden — cluster-sicher, da interface_id node-lokal wäre).
|
||||||
|
type DHCPSubnet struct {
|
||||||
|
ID int64 `gorm:"column:id;primaryKey" json:"id"`
|
||||||
|
Name string `gorm:"column:name" json:"name"`
|
||||||
|
InterfaceName string `gorm:"column:interface_name" json:"interface_name"`
|
||||||
|
SubnetCIDR string `gorm:"column:subnet_cidr" json:"subnet_cidr"`
|
||||||
|
PoolStart string `gorm:"column:pool_start" json:"pool_start"`
|
||||||
|
PoolEnd string `gorm:"column:pool_end" json:"pool_end"`
|
||||||
|
Gateway string `gorm:"column:gateway" json:"gateway"`
|
||||||
|
DNSServers string `gorm:"column:dns_servers" json:"dns_servers"`
|
||||||
|
LeaseTime *int `gorm:"column:lease_time" json:"lease_time,omitempty"`
|
||||||
|
Active bool `gorm:"column:active" json:"active"`
|
||||||
|
Description string `gorm:"column:description" json:"description"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
||||||
|
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (DHCPSubnet) TableName() string { return "dhcp_subnets" }
|
||||||
|
|
||||||
|
// DHCPReservation ist eine statische MAC→IP-Zuordnung innerhalb eines Subnets.
|
||||||
|
type DHCPReservation struct {
|
||||||
|
ID int64 `gorm:"column:id;primaryKey" json:"id"`
|
||||||
|
SubnetID int64 `gorm:"column:subnet_id" json:"subnet_id"`
|
||||||
|
Name string `gorm:"column:name" json:"name"`
|
||||||
|
MACAddress string `gorm:"column:mac_address" json:"mac_address"`
|
||||||
|
IPAddress string `gorm:"column:ip_address" json:"ip_address"`
|
||||||
|
Hostname string `gorm:"column:hostname" json:"hostname"`
|
||||||
|
Active bool `gorm:"column:active" json:"active"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
||||||
|
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (DHCPReservation) TableName() string { return "dhcp_reservations" }
|
||||||
@@ -40,16 +40,20 @@ func (DNSRecord) TableName() string { return "dns_records" }
|
|||||||
// Optionen. Default kommt aus der Migration (alle Werte sinnvoll
|
// Optionen. Default kommt aus der Migration (alle Werte sinnvoll
|
||||||
// für die typische LAN-Resolver-Rolle).
|
// für die typische LAN-Resolver-Rolle).
|
||||||
type DNSSettings struct {
|
type DNSSettings struct {
|
||||||
ID int64 `gorm:"primaryKey" json:"id"`
|
ID int64 `gorm:"primaryKey" json:"id"`
|
||||||
ListenAddresses string `gorm:"column:listen_addresses" json:"listen_addresses"`
|
ListenAddresses string `gorm:"column:listen_addresses" json:"listen_addresses"`
|
||||||
ListenPort int `gorm:"column:listen_port" json:"listen_port"`
|
ListenPort int `gorm:"column:listen_port" json:"listen_port"`
|
||||||
UpstreamForwards string `gorm:"column:upstream_forwards" json:"upstream_forwards"`
|
UpstreamForwards string `gorm:"column:upstream_forwards" json:"upstream_forwards"`
|
||||||
AccessACL string `gorm:"column:access_acl" json:"access_acl"`
|
AccessACL string `gorm:"column:access_acl" json:"access_acl"`
|
||||||
DNSSEC bool `gorm:"column:dnssec" json:"dnssec"`
|
DNSSEC bool `gorm:"column:dnssec" json:"dnssec"`
|
||||||
QNameMinimisation bool `gorm:"column:qname_minimisation" json:"qname_minimisation"`
|
QNameMinimisation bool `gorm:"column:qname_minimisation" json:"qname_minimisation"`
|
||||||
CacheMinTTL int `gorm:"column:cache_min_ttl" json:"cache_min_ttl"`
|
CacheMinTTL int `gorm:"column:cache_min_ttl" json:"cache_min_ttl"`
|
||||||
CacheMaxTTL int `gorm:"column:cache_max_ttl" json:"cache_max_ttl"`
|
CacheMaxTTL int `gorm:"column:cache_max_ttl" json:"cache_max_ttl"`
|
||||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
Prefetch bool `gorm:"column:prefetch" json:"prefetch"`
|
||||||
|
ServeExpired bool `gorm:"column:serve_expired" json:"serve_expired"`
|
||||||
|
MsgCacheSizeMB int `gorm:"column:msg_cache_size_mb" json:"msg_cache_size_mb"`
|
||||||
|
RRSetCacheSizeMB int `gorm:"column:rrset_cache_size_mb" json:"rrset_cache_size_mb"`
|
||||||
|
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (DNSSettings) TableName() string { return "dns_settings" }
|
func (DNSSettings) TableName() string { return "dns_settings" }
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user