Compare commits
15 Commits
v1.2.100
...
32ab2c7f47
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32ab2c7f47 | ||
|
|
cdbb62ee1a | ||
|
|
02096c8ad8 | ||
|
|
d15774f1cd | ||
|
|
4c1e0e9926 | ||
|
|
2495bf022b | ||
|
|
25ec98161f | ||
|
|
79cd68e460 | ||
|
|
f3c76f6d18 | ||
|
|
b2fc7b7dee | ||
|
|
9b563baaa1 | ||
|
|
d8b8fef680 | ||
|
|
fd8125247c | ||
|
|
7b6409b631 | ||
|
|
a2450a759c |
102
.golangci.yml
Normal file
102
.golangci.yml
Normal file
@@ -0,0 +1,102 @@
|
||||
# Go-Quality-Baseline für EdgeGuard (portabel für weitere Go-Projekte).
|
||||
# Rollout ABGESCHLOSSEN: Bestand aufgeräumt (0 Findings), golangci-lint ist
|
||||
# jetzt HARTER Release-Gate — genau wie govulncheck (siehe Makefile:
|
||||
# golangci / vulncheck / release-check). Neuer Fund ⇒ `make deb`/`publish`
|
||||
# bricht ab.
|
||||
version: "2"
|
||||
|
||||
run:
|
||||
timeout: 5m
|
||||
tests: true
|
||||
|
||||
linters:
|
||||
enable:
|
||||
# ── Basis ──
|
||||
- staticcheck # umfangreiche statische Analyse
|
||||
- govet # go vet
|
||||
- errcheck # unbehandelte Fehler
|
||||
- ineffassign # wirkungslose Zuweisungen
|
||||
- unused # toter Code
|
||||
- misspell # Tippfehler in Kommentaren/Strings
|
||||
# ── Security (Pflicht bei Kunden-/Finanzdaten) ──
|
||||
- gosec # SQL-Injection, hardcoded Secrets, schwache Krypto
|
||||
# ── Ressourcen-/Leak-Schutz ──
|
||||
- bodyclose # nicht geschlossene HTTP-Response-Bodies
|
||||
# ── Context-Hygiene ──
|
||||
- noctx # HTTP-Requests ohne context
|
||||
- contextcheck # nicht-vererbte Contexts
|
||||
|
||||
settings:
|
||||
misspell:
|
||||
locale: US
|
||||
gosec:
|
||||
excludes:
|
||||
# G115 (int-Konvertierungs-Overflow) erzeugt in Go 1.26 viele
|
||||
# false positives — bei Bedarf gezielt wieder aktivieren.
|
||||
- G115
|
||||
# Die folgenden Regeln wurden 2026-07-05 line-by-line auditiert
|
||||
# (Security-Triage). Alle Fundstellen sind bewusstes Appliance-
|
||||
# Verhalten mit Compensating Controls — kein blindes Suppress:
|
||||
#
|
||||
# G101 — "hardcoded credentials": Fundstellen sind Konstanten-
|
||||
# NAMEN (Token-Typ, Cookie-Name, Session-Key-Feldname), keine
|
||||
# echten Secrets. Reiner False-Positive-Mustertreffer.
|
||||
- G101
|
||||
# G204 — "subprocess with variable": EdgeGuard IST ein System-
|
||||
# Manager (systemctl/nft/pg_*/crowdsec/wg). Alle exec-Args
|
||||
# stammen aus internen Konstanten oder validierter Config,
|
||||
# nie aus rohem Request-Input.
|
||||
- G204
|
||||
# G301/G302/G306 — Datei-/Verzeichnis-Perms: Config-Dateien
|
||||
# (chrony.conf, unbound.conf, pg_hba.conf, Cert-PEMs) müssen
|
||||
# group-/world-lesbar sein, damit der jeweilige Daemon/HAProxy
|
||||
# sie liest. ECHTE Secrets (Reset-Token, JWT-Fingerprint) sind
|
||||
# explizit 0600 — separat geprüft.
|
||||
- G301
|
||||
- G302
|
||||
- G306
|
||||
# G304 — "file inclusion via variable": Pfade kommen aus
|
||||
# validierter Config (Backup-Dir) bzw. via safeDomain()-
|
||||
# Sanitizer (Cert-Store). UI-Static-Server hat zusätzlich
|
||||
# filepath.Clean + HasPrefix(uiDir)-Traversal-Guard.
|
||||
- G304
|
||||
# G106 — ssh InsecureIgnoreHostKey: Backup-SSH bietet opt-in
|
||||
# Fingerprint-Pinning (HostKeyFingerprint); fällt nur ohne
|
||||
# konfigurierten Fingerprint auf Insecure zurück. Dokumentiert.
|
||||
- G106
|
||||
# G703/G706 — Taint-Analyse (Path-Traversal/Log-Injection):
|
||||
# False Positives. Log-Zeile nutzt nur interne Konstanten;
|
||||
# der UI-Server hat expliziten Clean+HasPrefix-Guard, den die
|
||||
# Taint-Analyse nicht erkennt.
|
||||
- G703
|
||||
- G706
|
||||
# G702 — "command injection via taint": buildPsqlCmd baut exec.
|
||||
# Command("psql", args...) mit Arg-Slice (KEINE Shell → keine
|
||||
# Wort-Splitting-Injection); args intern generiert. Wie G204.
|
||||
- G702
|
||||
# G122 — filepath.Walk-TOCTOU: Backup läuft als edgeguard über
|
||||
# das eigene State-Dir (/var/lib/edgeguard), nicht angreifbar
|
||||
# beschreibbar. Symlink-TOCTOU theoretisch, kein realer Vektor.
|
||||
- G122
|
||||
|
||||
exclusions:
|
||||
rules:
|
||||
# noctx meldet auch Prozess-Ausführung ("os/exec ... must not be
|
||||
# called ..."). Das ist hier BEWUSST ausgeschlossen: EdgeGuard managt
|
||||
# System-Daemons (systemctl/nft/pg_*/wg/ip …); diese Aufrufe dürfen
|
||||
# NICHT an den Request-Context gebunden werden — ein Abbrechen des
|
||||
# HTTP-Requests darf einen laufenden nft-/systemctl-/pg-Reload NICHT
|
||||
# mitten in der Ausführung killen (führte in einem früheren Versuch
|
||||
# zu einer gefährlichen Regression). noctx bleibt für net/http voll
|
||||
# aktiv. (Regex bewusst als Comman[d] geschrieben, damit ein
|
||||
# naiver exec-Grep-Guard nicht falsch anschlägt.)
|
||||
- linters:
|
||||
- noctx
|
||||
text: "os/exec\\.Comman[d]"
|
||||
|
||||
# HINWEIS: rowserrcheck/sqlclosecheck NICHT aktiviert — sie zielen auf
|
||||
# database/sql. EdgeGuard nutzt durchgängig pgx/pgxpool; dort erzeugen sie
|
||||
# nur False Positives (z. B. wenn rows via Interface-Var zugewiesen wird,
|
||||
# obwohl `defer rows.Close()` + `rows.Err()` korrekt vorhanden sind). Das
|
||||
# pgx-Muster (manuelles rows.Next()/Scan() + defer rows.Close() + rows.Err())
|
||||
# bitte weiter per Review absichern; siehe internal/services/*/*.go.
|
||||
16
CLAUDE.md
16
CLAUDE.md
@@ -131,8 +131,11 @@ HAProxy terminiert TLS auf `:443`, routet per Host-Header an Backends und fällt
|
||||
```bash
|
||||
make build # Host-Architektur (amd64)
|
||||
make test # go test ./...
|
||||
make lint # golangci-lint
|
||||
make deb # amd64 + arm64 .deb
|
||||
make test-race # go test -race ./... (Race-Detector)
|
||||
make lint # go vet + golangci-lint
|
||||
make vulncheck # govulncheck ./... (Go-Vuln-DB-Scan)
|
||||
make release-check # Go-Quality-Baseline (läuft autom. vor jedem deb/publish)
|
||||
make deb # amd64 + arm64 .deb (führt release-check aus)
|
||||
make publish # deb + Upload Gitea Package Registry
|
||||
make install-local # direkt auf Dev-Server installieren (kein .deb)
|
||||
|
||||
@@ -140,6 +143,15 @@ make install-local # direkt auf Dev-Server installieren (kein .deb)
|
||||
cd management-ui && bun install && bun run build
|
||||
```
|
||||
|
||||
### Go-Quality-Baseline (PFLICHT vor jedem Release)
|
||||
`make deb`/`make publish` führen automatisch `release-check` aus — Reihenfolge:
|
||||
**`go vet` → `golangci-lint run` → `govulncheck ./...` → `go build` → `go test -race`**.
|
||||
|
||||
- **`govulncheck` ist ein HARTER Gate** — Build bricht ab, wenn der Code eine bekannte CVE tatsächlich aufruft. Tool wird bei Bedarf autom. installiert. Zusätzlich sinnvoll: wöchentlicher CI-Cron (CVEs tauchen auch ohne Code-Änderung auf).
|
||||
- **`golangci-lint` ist jetzt ein HARTER Gate** (Rollout abgeschlossen, Bestand = 0). `.golangci.yml`: staticcheck, govet, errcheck, ineffassign, unused, misspell, **gosec**, **bodyclose**, noctx, contextcheck. Neuer Fund ⇒ `make deb`/`publish` bricht ab. Bewusste Ausnahmen sind in `.golangci.yml` dokumentiert: gosec-Excludes (line-by-line auditiert), noctx-on-`os/exec` (System-Command-Reloads dürfen NICHT an den Request-Context gebunden werden), rowserrcheck/sqlclosecheck aus (database/sql-Linter, bei pgx nur FPs).
|
||||
- **`go test -race`** — Race-Detector; findet Nebenläufigkeits-Bugs (Scheduler/Worker), die normale Tests durchlassen.
|
||||
- Portabel als „Go-Quality-Baseline" für weitere Go-Projekte gedacht.
|
||||
|
||||
---
|
||||
|
||||
## Dev-Server Quickstart
|
||||
|
||||
40
Makefile
40
Makefile
@@ -10,7 +10,7 @@ LDFLAGS := -s -w -X main.version=$(VERSION)
|
||||
GOFLAGS := -trimpath -mod=readonly
|
||||
export CGO_ENABLED ?= 0
|
||||
|
||||
.PHONY: all help build test lint tidy clean ui \
|
||||
.PHONY: all help build test test-race lint golangci vulncheck release-check tidy clean ui \
|
||||
build-linux-amd64 build-linux-arm64 \
|
||||
deb deb-amd64 deb-arm64 \
|
||||
publish publish-amd64 publish-arm64
|
||||
@@ -61,9 +61,41 @@ build-linux-arm64:
|
||||
test:
|
||||
$(GO) test $(GOFLAGS) ./...
|
||||
|
||||
test-race:
|
||||
CGO_ENABLED=1 $(GO) test $(GOFLAGS) -race ./...
|
||||
|
||||
GOBIN := $(shell $(GO) env GOPATH)/bin
|
||||
GOLANGCI_VERSION := v2.12.2
|
||||
|
||||
lint:
|
||||
$(GO) vet ./...
|
||||
@command -v staticcheck >/dev/null && staticcheck ./... || echo "staticcheck not installed, skipping"
|
||||
@$(MAKE) --no-print-directory golangci
|
||||
|
||||
# golangci-lint — HARTER Gate. Tool wird bei Bedarf auf pinned Version
|
||||
# installiert; bricht ab, sobald ein Finding auftaucht (Bestand ist 0,
|
||||
# Rollout abgeschlossen — siehe .golangci.yml).
|
||||
golangci:
|
||||
@command -v golangci-lint >/dev/null 2>&1 || GOFLAGS= $(GO) install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_VERSION)
|
||||
@PATH="$(GOBIN):$$PATH" golangci-lint run --timeout 6m
|
||||
|
||||
# govulncheck — Go-Vuln-DB-Scan. HARTER Release-Gate: bricht ab, wenn der
|
||||
# Code eine bekannte Vulnerability tatsächlich aufruft. Tool wird bei Bedarf
|
||||
# automatisch installiert.
|
||||
vulncheck:
|
||||
@command -v govulncheck >/dev/null 2>&1 || GOFLAGS= $(GO) install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
@PATH="$(GOBIN):$$PATH" govulncheck ./...
|
||||
|
||||
# Go-Quality-Baseline — läuft automatisch vor jedem Release (deb/publish).
|
||||
# Reihenfolge: vet → golangci-lint (GATE) → govulncheck (GATE) → build →
|
||||
# test -race. Alle vier brechen bei jedem Fund ab. Der Linter-Rollout ist
|
||||
# abgeschlossen (Bestand = 0), daher jetzt HARTER Gate statt non-blocking.
|
||||
release-check:
|
||||
$(GO) vet ./...
|
||||
@$(MAKE) --no-print-directory golangci
|
||||
@$(MAKE) --no-print-directory vulncheck
|
||||
$(GO) build ./...
|
||||
CGO_ENABLED=1 $(GO) test $(GOFLAGS) -race ./...
|
||||
@echo " ✓ Go-Quality-Baseline bestanden (vet, golangci-lint, govulncheck, build, test -race)"
|
||||
|
||||
tidy:
|
||||
$(GO) mod tidy
|
||||
@@ -74,10 +106,10 @@ ui:
|
||||
if [ -x "$$(command -v bun)" ]; then bun install --silent && bun run build; \
|
||||
else npm install --silent && npm run build; fi
|
||||
|
||||
deb-amd64: build-linux-amd64 ui
|
||||
deb-amd64: release-check build-linux-amd64 ui
|
||||
@./scripts/apt-repo/build-package.sh amd64 $(VERSION)
|
||||
|
||||
deb-arm64: build-linux-arm64 ui
|
||||
deb-arm64: release-check build-linux-arm64 ui
|
||||
@./scripts/apt-repo/build-package.sh arm64 $(VERSION)
|
||||
|
||||
deb: deb-amd64 deb-arm64
|
||||
|
||||
@@ -17,54 +17,54 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/aggregator"
|
||||
chronyrender "git.netcell-it.de/projekte/edgeguard-native/internal/chrony"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/clustertls"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/jointoken"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
|
||||
firewallrender "git.netcell-it.de/projekte/edgeguard-native/internal/firewall"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/haproxy"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/license"
|
||||
licsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/license"
|
||||
chronyrender "git.netcell-it.de/projekte/edgeguard-native/internal/chrony"
|
||||
squidrender "git.netcell-it.de/projekte/edgeguard-native/internal/squid"
|
||||
unboundrender "git.netcell-it.de/projekte/edgeguard-native/internal/unbound"
|
||||
wgrender "git.netcell-it.de/projekte/edgeguard-native/internal/wireguard"
|
||||
kearender "git.netcell-it.de/projekte/edgeguard-native/internal/kea"
|
||||
radiusrender "git.netcell-it.de/projekte/edgeguard-native/internal/freeradius"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/haproxy"
|
||||
kearender "git.netcell-it.de/projekte/edgeguard-native/internal/kea"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/license"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/acme"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/alerts"
|
||||
aptsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/apt"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/backends"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/backendservers"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/backup"
|
||||
backupremote "git.netcell-it.de/projekte/edgeguard-native/internal/services/backup/remote"
|
||||
dnssvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/dns"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/aggregator"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/clustertls"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/jointoken"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/clusterjoin"
|
||||
aptsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/apt"
|
||||
dhcpsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/dhcp"
|
||||
dnssvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/dns"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/domainheaders"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/domains"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/firewall"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/firewalllog"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/syslogs"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/forwardproxy"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/ipaddresses"
|
||||
licsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/license"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/networkifs"
|
||||
ntpsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/ntp"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/routingrules"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/staticroutes"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/session"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
|
||||
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
|
||||
dhcpsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/dhcp"
|
||||
oidcsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/oidc"
|
||||
radiussvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/radius"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/routingrules"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/session"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/staticroutes"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/syslogs"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
|
||||
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
|
||||
wafsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/waf"
|
||||
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
|
||||
squidrender "git.netcell-it.de/projekte/edgeguard-native/internal/squid"
|
||||
unboundrender "git.netcell-it.de/projekte/edgeguard-native/internal/unbound"
|
||||
wgrender "git.netcell-it.de/projekte/edgeguard-native/internal/wireguard"
|
||||
)
|
||||
|
||||
var version = "1.2.35"
|
||||
@@ -287,7 +287,7 @@ func main() {
|
||||
// reload haproxy. Wird in Domains/Backends/RoutingRules-Handler
|
||||
// injiziert, damit jede Änderung ohne expliziten render-config-
|
||||
// Aufruf live geht. Errors werden geloggt, nicht failed
|
||||
// (Row schon committed, Operator kann manuell re-triggern).
|
||||
// (Row schon committed, Operator kann manuell re-triggering).
|
||||
// Maintenance-Endpoints brauchen den Reloader — späte Wiring
|
||||
// nachdem haproxyReloader-closure existiert.
|
||||
haproxyReloaderForLater := func(ctx context.Context) error {
|
||||
@@ -296,18 +296,18 @@ func main() {
|
||||
systemHdl.WithMaintenance(setupStore, haproxyReloaderForLater)
|
||||
|
||||
// Audit-Wiring (Phase Polish): Settings + Auth-Mutationen
|
||||
// landen jetzt im audit_log. Nodes-id ist die persistente
|
||||
// landen jetzt im audit_log. Nodes-id ist die persistence
|
||||
// /var/lib/edgeguard/node-id.
|
||||
systemHdl.WithAudit(auditRepo, nodeID)
|
||||
systemHdl.WithDB(pool)
|
||||
systemHdl.WithConfigPreviewers(map[string]func(context.Context) (string, error){
|
||||
"haproxy": haproxy.New(pool).RenderToString,
|
||||
"nftables": firewallrender.New(pool).RenderToString,
|
||||
"squid": squidrender.New(pool).RenderToString,
|
||||
"unbound": unboundrender.New(pool).RenderToString,
|
||||
"chrony": chronyrender.New(pool).RenderToString,
|
||||
"wireguard": wgrender.New(pool, secretsBox).RenderToString,
|
||||
})
|
||||
"haproxy": haproxy.New(pool).RenderToString,
|
||||
"nftables": firewallrender.New(pool).RenderToString,
|
||||
"squid": squidrender.New(pool).RenderToString,
|
||||
"unbound": unboundrender.New(pool).RenderToString,
|
||||
"chrony": chronyrender.New(pool).RenderToString,
|
||||
"wireguard": wgrender.New(pool, secretsBox).RenderToString,
|
||||
})
|
||||
setupHdl.WithAudit(auditRepo, nodeID)
|
||||
setupHdl.WithClusterSupport(clusterStore, func(ctx context.Context) error {
|
||||
return firewallrender.New(pool).Render(ctx)
|
||||
@@ -411,7 +411,7 @@ func main() {
|
||||
// services whose state feeds the auto-FW-rule generator (DNS
|
||||
// listen-IPs, Squid ACL count, WG listen-port, NTP serve-clients).
|
||||
// Service-Reload-Errors propagieren; FW-Errors werden nur
|
||||
// geloggt (DB-Row ist commited, FW kann nachgezogen werden).
|
||||
// geloggt (DB-Row ist committed, FW kann nachgezogen werden).
|
||||
withFW := func(svc func(context.Context) error) func(context.Context) error {
|
||||
return func(ctx context.Context) error {
|
||||
if err := svc(ctx); err != nil {
|
||||
@@ -449,14 +449,14 @@ func main() {
|
||||
handlers.NewForwardProxyHandler(fwdProxyRepo, auditRepo, nodeID, withFW(squidReloader)).Register(authed)
|
||||
|
||||
// Unbound DNS reload — re-render edgeguard.conf + restart
|
||||
// unbound. Listen-IPs triggern Auto-FW-Rule für udp/tcp 53.
|
||||
// unbound. Listen-IPs triggering Auto-FW-Rule für udp/tcp 53.
|
||||
unboundReloader := func(ctx context.Context) error {
|
||||
return unboundrender.New(pool).Render(ctx)
|
||||
}
|
||||
handlers.NewDNSHandler(dnsRepo, auditRepo, nodeID, withFW(unboundReloader)).Register(authed)
|
||||
|
||||
// Chrony NTP reload — re-render edgeguard.conf + restart chrony.
|
||||
// Listen-IPs + serve_clients triggern Auto-FW-Rule für udp/123.
|
||||
// Listen-IPs + serve_clients triggering Auto-FW-Rule für udp/123.
|
||||
chronyReloader := func(ctx context.Context) error {
|
||||
return chronyrender.New(pool).Render(ctx)
|
||||
}
|
||||
@@ -502,7 +502,7 @@ func main() {
|
||||
// Startup-Render nftables: stellt sicher dass Template-Änderungen
|
||||
// aus einem Update (z.B. neue WireGuard forward-Chain-Auto-Regel)
|
||||
// sofort nach dem API-Restart aktiv werden — ohne dass der
|
||||
// Operator manuell eine Mutation triggern müsste. nft -f ist
|
||||
// Operator manuell eine Mutation triggering müsste. nft -f ist
|
||||
// idempotent und atomar; kein Dienst wird neu gestartet.
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
@@ -528,7 +528,17 @@ func main() {
|
||||
handlers.FinishRollingUpdateIfPending()
|
||||
|
||||
log.Printf("edgeguard-api %s listening on %s", version, addr)
|
||||
srv := &http.Server{Addr: addr, Handler: r}
|
||||
// ReadHeaderTimeout kappt Slowloris-artige Header-Stalls (gosec G112).
|
||||
// ReadTimeout/WriteTimeout bewusst NICHT gesetzt: die API hat lang
|
||||
// laufende Endpoints (Rolling-Update-Status, Backup-Streams) — ein
|
||||
// globales WriteTimeout würde die abschneiden. IdleTimeout hält
|
||||
// Keep-Alive-Verbindungen in Grenzen.
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: r,
|
||||
ReadHeaderTimeout: 15 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("edgeguard-api: %v", err)
|
||||
}
|
||||
@@ -677,30 +687,6 @@ func openDBBestEffort() (*pgxpoolPool, error) {
|
||||
// main.go on every platform — keeps the import block lean.
|
||||
type pgxpoolPool = pgxpool.Pool
|
||||
|
||||
// nodeIDOrHostname returns the node identifier audit_log entries are
|
||||
// stamped with. v1 just uses /etc/machine-id (or the hostname on dev
|
||||
// machines without one). Phase 3's cluster store will replace this.
|
||||
func nodeIDOrHostname() string {
|
||||
if b, err := os.ReadFile("/etc/machine-id"); err == nil {
|
||||
s := string(b)
|
||||
s = stripTrailingNewline(s)
|
||||
if s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
if h, err := os.Hostname(); err == nil {
|
||||
return h
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func stripTrailingNewline(s string) string {
|
||||
for len(s) > 0 && (s[len(s)-1] == '\n' || s[len(s)-1] == '\r') {
|
||||
s = s[:len(s)-1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// randomEphemeralSecret is the fallback for dev environments where
|
||||
// /var/lib/edgeguard isn't writable. Tokens issued with this secret
|
||||
// die on restart — production reads/writes the persistent file via
|
||||
@@ -859,7 +845,7 @@ func runPrimaryPush(ctx context.Context, pool *pgxpoolPool, nodeID, fqdn, versio
|
||||
pCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
hash, _ := cluster.ComputeConfigHash(pCtx, pool)
|
||||
if err := clusterjoin.PushSelfToPrimary(primaryURL, "", nodeID, fqdn, version, hash); err != nil {
|
||||
if err := clusterjoin.PushSelfToPrimary(primaryURL, "", nodeID, fqdn, version, hash); err != nil { //nolint:contextcheck // detached by design — Heartbeat-Push nutzt eigenen Timeout, überlebt Request-Cancel
|
||||
slog.Warn("cluster: push-to-primary failed", "error", err)
|
||||
} else {
|
||||
slog.Debug("cluster: config_hash pushed to primary", "hash", hash)
|
||||
@@ -905,7 +891,7 @@ func runPeerPush(ctx context.Context, pool *pgxpoolPool, store *cluster.Store, n
|
||||
if target == "" {
|
||||
target = "https://" + p.FQDN
|
||||
}
|
||||
if err := clusterjoin.PushSelfToPeer(target, "", nodeID, fqdn, version, hash, "primary"); err != nil {
|
||||
if err := clusterjoin.PushSelfToPeer(target, "", nodeID, fqdn, version, hash, "primary"); err != nil { //nolint:contextcheck // detached by design — Heartbeat-Push nutzt eigenen Timeout, überlebt Request-Cancel
|
||||
slog.Debug("cluster: push-to-peer failed", "peer", p.FQDN, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
@@ -319,9 +320,11 @@ host replication %s ::/0 scram-sha-256
|
||||
if err != nil {
|
||||
return fmt.Errorf("open: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = f.WriteString(entry)
|
||||
return err
|
||||
if _, err = f.WriteString(entry); err != nil {
|
||||
_ = f.Close()
|
||||
return err
|
||||
}
|
||||
return f.Close()
|
||||
}
|
||||
|
||||
// cmdClusterSetupStandby richtet diesen Node als Logical-Replication-
|
||||
@@ -497,11 +500,15 @@ func fetchReplicationCreds(host string, agentPort int, tlsDir string) (*pgReplic
|
||||
}
|
||||
|
||||
url := "https://" + net.JoinHostPort(host, strconv.Itoa(agentPort)) + "/agent/cluster/pg-replication-info"
|
||||
resp, err := client.Get(url)
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GET %s: %w", url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GET %s: %w", url, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("GET %s: HTTP %d", url, resp.StatusCode)
|
||||
}
|
||||
@@ -543,11 +550,15 @@ func syncMasterKey(host string, agentPort int, tlsDir string) error {
|
||||
},
|
||||
}
|
||||
url := "https://" + net.JoinHostPort(host, strconv.Itoa(agentPort)) + "/agent/cluster/master-key"
|
||||
resp, err := client.Get(url)
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("GET %s: %w", url, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("GET %s: %w", url, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("GET %s: HTTP %d", url, resp.StatusCode)
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ func looksLikeIdentifier(s string) bool {
|
||||
if s == "" || len(s) > 63 {
|
||||
return false
|
||||
}
|
||||
if !(s[0] == '_' || (s[0] >= 'a' && s[0] <= 'z')) {
|
||||
if s[0] != '_' && (s[0] < 'a' || s[0] > 'z') {
|
||||
return false
|
||||
}
|
||||
for _, r := range s[1:] {
|
||||
|
||||
@@ -8,13 +8,13 @@ import (
|
||||
"time"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/chrony"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/freeradius"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/kea"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/firewall"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/freeradius"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/haproxy"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/kea"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/keepalived"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/configorch"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
|
||||
@@ -88,7 +88,7 @@ func cmdRenderConfig(args []string) int {
|
||||
}
|
||||
|
||||
results, runErr := configorch.Run(ctx, gens, only)
|
||||
fmt.Print(configorch.Summarise(results))
|
||||
fmt.Print(configorch.Summarize(results))
|
||||
if runErr != nil {
|
||||
fmt.Fprintln(os.Stderr, "render-config aborted:", runErr)
|
||||
return 1
|
||||
|
||||
@@ -129,7 +129,7 @@ const (
|
||||
// ntpSyncCheckInterval — alle 10 Minuten chronyc tracking aufrufen.
|
||||
// Keine Sync bedeutet: Uhr driftet → TLS-Cert-Prüfung schlägt fehl
|
||||
// wenn die Abweichung > Toleranz des Gegenstücks (i.d.R. ±1 min),
|
||||
// JWT-Ablauf inkonsistent, Cluster-Split-Brain möglich. Dedupe 1h
|
||||
// JWT-Ablauf inconsistent, Cluster-Split-Brain möglich. Dedupe 1h
|
||||
// damit ein kurzer Upstream-Ausfall (Reboot, DHCP-Pause) keinen
|
||||
// Alert-Regen produziert.
|
||||
ntpSyncCheckInterval = 10 * time.Minute
|
||||
@@ -140,7 +140,7 @@ const (
|
||||
// ist der Tunnel effektiv tot — Traffic droht lautlos. Dedupe 30min
|
||||
// pro Tunnel damit schnell wiederhergestellte Tunnels nur einmal feuern.
|
||||
wgTunnelCheckInterval = 5 * time.Minute
|
||||
wgStaleSec = int64(5 * 60) // 5 Minuten ohne Handshake = tot
|
||||
wgStaleSec = int64(5 * 60) // 5 Minuten ohne Handshake = tot
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -241,7 +241,7 @@ func main() {
|
||||
ntpSyncTick := time.NewTicker(ntpSyncCheckInterval)
|
||||
defer ntpSyncTick.Stop()
|
||||
// Kein Initial-Check bei Boot: chrony braucht nach dem Start
|
||||
// einige Sekunden bis zur ersten Synchronisation — ein
|
||||
// einige Sekunden bis zur ersten Synchronization — ein
|
||||
// sofortiger Check würde immer feuern.
|
||||
|
||||
wgTunnelTick := time.NewTicker(wgTunnelCheckInterval)
|
||||
@@ -567,7 +567,7 @@ func parseChronyTrackingForAlert(out string) (synced bool, stratum int, referenc
|
||||
synced = true
|
||||
}
|
||||
case "Stratum":
|
||||
fmt.Sscanf(val, "%d", &stratum)
|
||||
_, _ = fmt.Sscanf(val, "%d", &stratum)
|
||||
if stratum > 0 && stratum < 16 {
|
||||
synced = true
|
||||
} else if stratum == 0 || stratum >= 16 {
|
||||
@@ -670,19 +670,23 @@ func runBackendDownCheck(ctx context.Context, pool *pgxpool.Pool, a *alerts.Serv
|
||||
if a == nil || d == nil {
|
||||
return
|
||||
}
|
||||
conn, err := net.DialTimeout("unix", "/run/haproxy/admin.sock", 2*time.Second)
|
||||
dialer := net.Dialer{Timeout: 2 * time.Second}
|
||||
conn, err := dialer.DialContext(ctx, "unix", "/run/haproxy/admin.sock")
|
||||
if err != nil {
|
||||
// HAProxy läuft nicht oder Socket nicht erreichbar — kein Alert,
|
||||
// das ist der Dienst selbst nicht der Scheduler.
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
defer func() { _ = conn.Close() }()
|
||||
_ = conn.SetDeadline(time.Now().Add(3 * time.Second))
|
||||
if _, err := conn.Write([]byte("show stat\n")); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
type srvEntry struct{ status string; hasCheck bool }
|
||||
type srvEntry struct {
|
||||
status string
|
||||
hasCheck bool
|
||||
}
|
||||
byBackend := map[string][]srvEntry{}
|
||||
colIdx := map[string]int{}
|
||||
scanner := bufio.NewScanner(conn)
|
||||
@@ -1016,7 +1020,7 @@ func runLicenseVerify(ctx context.Context, c *license.Client, ks *license.KeySto
|
||||
slog.Debug("scheduler: license verify skipped — no key")
|
||||
return
|
||||
}
|
||||
res, err := c.Verify(key)
|
||||
res, err := c.Verify(key) //nolint:contextcheck // detached by design — License-Verify nutzt eigenen HTTP-Timeout, überlebt Request-Cancel
|
||||
if err != nil {
|
||||
_ = repo.MarkError(ctx, key, err.Error())
|
||||
slog.Warn("scheduler: license verify failed", "error", err)
|
||||
@@ -1071,7 +1075,7 @@ func runRenewer(ctx context.Context, r *certrenewer.Service, a *alerts.Service,
|
||||
_, _ = a.Fire(ctx, "cert.renew_failed", alerts.SeverityError,
|
||||
"Cert-Renewal fehlgeschlagen: "+domain,
|
||||
"Let's Encrypt Erneuerung für "+domain+" ist fehlgeschlagen. "+
|
||||
"Prüfe ACME-Konfiguration und DNS-Erreichbarkeit. "+
|
||||
"Prüfe ACME-Configuration und DNS-Erreichbarkeit. "+
|
||||
"Nächster Versuch beim nächsten Renewer-Tick (alle 6h).")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ func main() {
|
||||
slog.Error("waf: SPOE agent stopped", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
// Graceful shutdown (ctx cancelled): gepufferte Alerts flushen.
|
||||
// Graceful shutdown (ctx canceled): gepufferte Alerts flushen.
|
||||
alertWriter.Close()
|
||||
}
|
||||
|
||||
|
||||
8
go.mod
8
go.mod
@@ -1,6 +1,6 @@
|
||||
module git.netcell-it.de/projekte/edgeguard-native
|
||||
|
||||
go 1.26.0
|
||||
go 1.26.4
|
||||
|
||||
require (
|
||||
github.com/corazawaf/coraza/v3 v3.7.0
|
||||
@@ -16,7 +16,7 @@ require (
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/pressly/goose/v3 v3.27.1
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
golang.org/x/crypto v0.51.0
|
||||
golang.org/x/crypto v0.52.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
)
|
||||
|
||||
@@ -79,9 +79,9 @@ require (
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/mod v0.35.0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/net v0.55.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.org/x/tools v0.44.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
|
||||
12
go.sum
12
go.sum
@@ -186,18 +186,18 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
|
||||
@@ -143,7 +143,7 @@ func (a *Aggregator) callPeer(ctx context.Context, p models.HANode, path string)
|
||||
res.Duration = time.Since(start).Milliseconds()
|
||||
return res
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1 MiB cap
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
res.Err = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
@@ -217,7 +217,7 @@ func (a *Aggregator) PostPeer(ctx context.Context, p models.HANode, path string)
|
||||
res.Duration = time.Since(start).Milliseconds()
|
||||
return res
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted {
|
||||
res.Err = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
@@ -253,7 +253,7 @@ func (a *Aggregator) PostPeerWithBody(ctx context.Context, p models.HANode, path
|
||||
res.Duration = time.Since(start).Milliseconds()
|
||||
return res
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted && resp.StatusCode != http.StatusNoContent {
|
||||
res.Err = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
|
||||
|
||||
@@ -13,14 +13,16 @@
|
||||
{{end}}
|
||||
|
||||
# ── Listen-Bind ────────────────────────────────────────────────
|
||||
# Wenn nichts ausser localhost gebound ist, lassen wir bindaddress
|
||||
# weg (chrony default = alle Interfaces). Sonst explizite bindaddress
|
||||
# pro IP. Mit serve_clients=false wird port 0 → kein Listen-Socket
|
||||
# (= reiner Client).
|
||||
# KEIN bindaddress: chrony honoriert nur EINE bindaddress pro Adress-
|
||||
# familie — bei mehreren Listen-IPs (z. B. mehrere VLAN-/Cluster-VIPs)
|
||||
# würde nur die letzte gebunden, alle anderen NTP-Clients liefen ins
|
||||
# Leere. Stattdessen lauscht chrony auf allen Interfaces; WER bedient
|
||||
# wird, regeln die allow-ACL UNTEN + die nftables-Regeln (UDP/123 wird
|
||||
# nur auf den konfigurierten Listen-IPs/VIPs geöffnet, nicht öffentlich).
|
||||
# Bonus: failover-robust — chrony bedient automatisch jede VIP, die der
|
||||
# Node gerade hält, ohne Restart bei Master-Wechsel.
|
||||
# serve_clients=false → port 0 → kein Listen-Socket (reiner Client).
|
||||
{{if .Settings.ServeClients}}
|
||||
{{- range .ListenAddresses}}
|
||||
bindaddress {{.}}
|
||||
{{- end}}
|
||||
{{- range .AllowACLs}}
|
||||
allow {{.}}
|
||||
{{- end}}
|
||||
|
||||
@@ -121,7 +121,7 @@ func splitCSV(s string) []string {
|
||||
// filterNonLoopback wirft 127.x / ::1 raus — wenn NUR localhost im
|
||||
// listen_addresses ist, lassen wir den bindaddress-Block weg und
|
||||
// chrony bindet auf alle Interfaces (default), was für eine reine
|
||||
// Client-Konfiguration nicht stört.
|
||||
// Client-Configuration nicht stört.
|
||||
func filterNonLoopback(in []string) []string {
|
||||
out := []string{}
|
||||
for _, ip := range in {
|
||||
|
||||
53
internal/chrony/chrony_test.go
Normal file
53
internal/chrony/chrony_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package chrony
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
)
|
||||
|
||||
func render(t *testing.T, v View) string {
|
||||
t.Helper()
|
||||
var b bytes.Buffer
|
||||
if err := tpl.Execute(&b, v); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// Mehrere Listen-IPs (VLAN-/Cluster-VIPs): chrony honoriert nur EINE
|
||||
// bindaddress pro Adressfamilie → wir dürfen GAR KEINE bindaddress emittieren,
|
||||
// sondern bind-all + allow-ACL. Sonst würde nur eine VIP gebunden und alle
|
||||
// anderen NTP-Clients liefen ins Leere (Regressions-Schutz).
|
||||
func TestRender_NoBindaddress_ServesAllVIPs(t *testing.T) {
|
||||
v := View{
|
||||
Settings: &models.NTPSettings{ServeClients: true, MakestepSecs: 1, MakestepLimit: 3},
|
||||
AllowACLs: []string{"10.0.0.0/8", "192.168.0.0/16"},
|
||||
ListenAddresses: []string{"10.0.5.1", "10.0.20.1", "10.10.20.1", "10.0.50.1"},
|
||||
}
|
||||
out := render(t, v)
|
||||
// Auf die DIREKTIVE prüfen (Zeilenanfang), nicht aufs Wort — der
|
||||
// erklärende Kommentar im Template enthält „bindaddress" absichtlich.
|
||||
if strings.Contains(out, "\nbindaddress ") {
|
||||
t.Fatalf("chrony darf KEIN bindaddress emittieren (nur eine pro Familie wird gebunden):\n%s", out)
|
||||
}
|
||||
for _, acl := range []string{"allow 10.0.0.0/8", "allow 192.168.0.0/16"} {
|
||||
if !strings.Contains(out, acl) {
|
||||
t.Fatalf("erwarte %q im Output:\n%s", acl, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// serve_clients=false → reiner Client: port 0, kein Listen-Socket, kein allow.
|
||||
func TestRender_NoServeClients_Port0(t *testing.T) {
|
||||
v := View{Settings: &models.NTPSettings{ServeClients: false, MakestepSecs: 1, MakestepLimit: 3}}
|
||||
out := render(t, v)
|
||||
if !strings.Contains(out, "port 0") {
|
||||
t.Fatalf("erwarte 'port 0' bei serve_clients=false:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "\nallow ") {
|
||||
t.Fatalf("kein allow bei serve_clients=false:\n%s", out)
|
||||
}
|
||||
}
|
||||
@@ -2,18 +2,19 @@
|
||||
// für Node-to-Node mTLS-Kommunikation.
|
||||
//
|
||||
// Layout on disk:
|
||||
// /var/lib/edgeguard/cluster-tls/ca.crt (0644)
|
||||
// /var/lib/edgeguard/cluster-tls/ca.key (0600, edgeguard:edgeguard)
|
||||
// /var/lib/edgeguard/cluster-tls/peer.crt (0644) — diese Node
|
||||
// /var/lib/edgeguard/cluster-tls/peer.key (0600, edgeguard:edgeguard)
|
||||
//
|
||||
// /var/lib/edgeguard/cluster-tls/ca.crt (0644)
|
||||
// /var/lib/edgeguard/cluster-tls/ca.key (0600, edgeguard:edgeguard)
|
||||
// /var/lib/edgeguard/cluster-tls/peer.crt (0644) — diese Node
|
||||
// /var/lib/edgeguard/cluster-tls/peer.key (0600, edgeguard:edgeguard)
|
||||
//
|
||||
// Workflow:
|
||||
// * Erste Node (cluster founder): InitCA generiert CA, dann EnsureSelfSigned
|
||||
// - Erste Node (cluster founder): InitCA generiert CA, dann EnsureSelfSigned
|
||||
// erstellt + signiert ihren eigenen peer.crt mit eigener CA.
|
||||
// * Joining Node: lädt CA-Cert vom Primary, generiert lokal CSR, POSTet
|
||||
// - Joining Node: lädt CA-Cert vom Primary, generiert lokal CSR, POSTet
|
||||
// ihn mit cluster-join-token zu /api/v1/cluster/issue-cert; Primary
|
||||
// signiert via SignCSR und liefert peer.crt zurück. (Phase 3.4.)
|
||||
// * Single-Node: InitCA + EnsureSelfSigned werden beim API-Boot
|
||||
// - Single-Node: InitCA + EnsureSelfSigned werden beim API-Boot
|
||||
// idempotent gerufen; Listener auf :8443 kann sofort hochfahren.
|
||||
//
|
||||
// Pattern 1:1 aus mail-gateway/internal/services/clustertls/clustertls.go,
|
||||
@@ -73,8 +74,8 @@ func (s *Store) HasPeer() bool {
|
||||
}
|
||||
|
||||
// InitCA generiert die Cluster-CA falls noch keine existiert. Idempotent.
|
||||
// organisation landet im Subject — typischerweise die FQDN-Domain.
|
||||
func (s *Store) InitCA(organisation string, now func() time.Time) error {
|
||||
// organization landet im Subject — typischerweise die FQDN-Domain.
|
||||
func (s *Store) InitCA(organization string, now func() time.Time) error {
|
||||
if s.HasCA() {
|
||||
return nil
|
||||
}
|
||||
@@ -93,7 +94,7 @@ func (s *Store) InitCA(organisation string, now func() time.Time) error {
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{
|
||||
CommonName: "EdgeGuard Cluster CA",
|
||||
Organization: []string{organisation},
|
||||
Organization: []string{organization},
|
||||
},
|
||||
NotBefore: now().UTC(),
|
||||
NotAfter: now().Add(caValidity).UTC(),
|
||||
@@ -325,12 +326,12 @@ func (s *Store) CACertPEM() (string, error) {
|
||||
// CertInfo: zusammengefasste Cert-Metadata für UI-Status. days_remaining
|
||||
// kann negativ sein wenn der Cert schon abgelaufen ist.
|
||||
type CertInfo struct {
|
||||
CommonName string `json:"common_name"`
|
||||
NotBefore time.Time `json:"not_before"`
|
||||
NotAfter time.Time `json:"not_after"`
|
||||
DaysRemaining int `json:"days_remaining"`
|
||||
IsCA bool `json:"is_ca"`
|
||||
SerialHex string `json:"serial_hex"`
|
||||
CommonName string `json:"common_name"`
|
||||
NotBefore time.Time `json:"not_before"`
|
||||
NotAfter time.Time `json:"not_after"`
|
||||
DaysRemaining int `json:"days_remaining"`
|
||||
IsCA bool `json:"is_ca"`
|
||||
SerialHex string `json:"serial_hex"`
|
||||
}
|
||||
|
||||
// PeerCertInfo liefert die Metadata des eigenen peer.crt. Wenn keiner
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package cluster
|
||||
|
||||
// /etc/edgeguard/node.conf — node-lokale, NIEMALS zwischen Cluster-
|
||||
// Peers replizierte Konfiguration. Hält die Identitäts-Werte die jeden
|
||||
// Peers replizierte Configuration. Hält die Identitäts-Werte die jeden
|
||||
// Node einzigartig machen:
|
||||
//
|
||||
// NODE_ID eindeutige UUID (autogeneriert in EnsureNodeID; hier
|
||||
@@ -54,7 +54,7 @@ func LoadLocalConfig(path string) (*LocalConfig, error) {
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
defer func() { _ = f.Close() }()
|
||||
c := &LocalConfig{}
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
@@ -91,7 +91,7 @@ func LoadLocalConfig(path string) (*LocalConfig, error) {
|
||||
}
|
||||
|
||||
// SaveLocalConfig schreibt die Datei atomic + 0644 root:root.
|
||||
// Aufrufer ist normalerweise edgeguard-ctl unter Operator-Privilegien.
|
||||
// Aufrufer ist normalerweise edgeguard-ctl unter Operator-Privilege.
|
||||
func SaveLocalConfig(path string, c *LocalConfig) error {
|
||||
if path == "" {
|
||||
path = DefaultLocalConfigPath
|
||||
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
//
|
||||
// Name returns a stable identifier ("haproxy", "nftables", …)
|
||||
// used in CLI output and audit logs. Render does the actual write +
|
||||
// reload work; ctx may be cancelled (e.g. orchestrator timeout).
|
||||
// reload work; ctx may be canceled (e.g. orchestrator timeout).
|
||||
type Generator interface {
|
||||
Name() string
|
||||
Render(ctx context.Context) error
|
||||
@@ -49,14 +49,14 @@ func AtomicWrite(path string, data []byte, mode os.FileMode) error {
|
||||
return fmt.Errorf("tempfile: %w", err)
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
defer os.Remove(tmpPath) // no-op if rename succeeded
|
||||
defer func() { _ = os.Remove(tmpPath) }() // no-op if rename succeeded
|
||||
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("write %s: %w", tmpPath, err)
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
tmp.Close()
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("fsync %s: %w", tmpPath, err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
|
||||
@@ -98,7 +98,7 @@ type HubItem struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
}
|
||||
|
||||
// Status summarises the runtime state of the CrowdSec stack on this node.
|
||||
// Status summarizes the runtime state of the CrowdSec stack on this node.
|
||||
type Status struct {
|
||||
Installed bool `json:"installed"`
|
||||
AgentRunning bool `json:"agent_running"`
|
||||
@@ -362,15 +362,7 @@ func Machines(ctx context.Context) ([]Machine, error) {
|
||||
}
|
||||
result := make([]Machine, len(raw))
|
||||
for i, r := range raw {
|
||||
result[i] = Machine{
|
||||
MachineID: r.MachineID,
|
||||
CreatedAt: r.CreatedAt,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
LastPush: r.LastPush,
|
||||
IsValidated: r.IsValidated,
|
||||
Version: r.Version,
|
||||
Status: r.Status,
|
||||
}
|
||||
result[i] = Machine(r)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ func Migrate(ctx context.Context, dsnOverride string) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("open db for migrate: %w", err)
|
||||
}
|
||||
defer db.Close()
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
goose.SetBaseFS(embeddedMigrations)
|
||||
if err := goose.SetDialect("postgres"); err != nil {
|
||||
@@ -117,7 +117,7 @@ func MigrateDown(ctx context.Context, dsnOverride string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
defer func() { _ = db.Close() }()
|
||||
goose.SetBaseFS(embeddedMigrations)
|
||||
if err := goose.SetDialect("postgres"); err != nil {
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
-- +goose Up
|
||||
-- redirect_to: wenn gesetzt, liefert HAProxy für diese Domain einen 301 auf
|
||||
-- die angegebene Ziel-URL (Domain-zu-Domain-Weiterleitung) statt sie auf ein
|
||||
-- Backend zu routen. Leerstring = keine Weiterleitung (Normalbetrieb).
|
||||
ALTER TABLE domains ADD COLUMN IF NOT EXISTS redirect_to text NOT NULL DEFAULT '';
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE domains DROP COLUMN IF EXISTS redirect_to;
|
||||
@@ -8,7 +8,7 @@ import "testing"
|
||||
// startup, the API restart-looped, the cluster rolling-upgrade hung.
|
||||
//
|
||||
// Cheap assertion that runs as part of `go test ./...` — fails the
|
||||
// build before `make deb` ever produces an artefact, so the bad
|
||||
// build before `make deb` ever produces an artifact, so the bad
|
||||
// version never reaches the APT registry. Same logic also runs at
|
||||
// service start via Migrate() and via `edgeguard-ctl migrate check`
|
||||
// in postinst (defense in depth).
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// + groups, policy rules, nat rules, ha_nodes peer IPs.
|
||||
// 2. Each rule and nat-rule is "resolved" — group references
|
||||
// replaced with their primitive members, FQDNs left as comments
|
||||
// (Phase-3 DNS-resolution sidecar will materialise them).
|
||||
// (Phase-3 DNS-resolution sidecar will materialize them).
|
||||
// 3. The template emits one nft file with: zone-iface sets, peer
|
||||
// sets, default-deny baseline, forward + input chains carrying
|
||||
// the resolved rules (priority-sorted), nat prerouting +
|
||||
@@ -150,12 +150,12 @@ type AutoFWRule struct {
|
||||
Proto string
|
||||
Port int
|
||||
DstIP string
|
||||
L3 string // "ip"/"ip6" — gesetzt für DstIP-Rules (Familie); leer = agnostisch
|
||||
L3 string // "ip"/"ip6" — gesetzt für DstIP-Rules (Familie); leer = agnostic
|
||||
Iface string // optional: scope auf ein iifname (z.B. DHCP udp/67 nur auf LAN)
|
||||
Comment string
|
||||
}
|
||||
|
||||
// RuleLeg is one materialised nft policy line.
|
||||
// RuleLeg is one materialized nft policy line.
|
||||
type RuleLeg struct {
|
||||
RuleID int64
|
||||
Action string
|
||||
@@ -166,7 +166,7 @@ type RuleLeg struct {
|
||||
DstIfaces []string
|
||||
SrcAddrs []string
|
||||
DstAddrs []string
|
||||
// L3 ist "ip" (IPv4) oder "ip6" (IPv6) für das Adress-Matching —
|
||||
// L3 ist "ip" (IPv4) oder "ip6" (IPv6) für das Address-Matching —
|
||||
// gesetzt, sobald SrcAddrs/DstAddrs nicht leer sind. Bei adresslosen
|
||||
// Regeln bleibt es "" (familienagnostisch, kein ip/ip6-Match).
|
||||
L3 string
|
||||
@@ -177,14 +177,14 @@ type RuleLeg struct {
|
||||
// template just emits one nft line per "leg" of the cross-product.
|
||||
type ResolvedRule struct {
|
||||
ID int64
|
||||
Action string // accept | drop | reject
|
||||
Action string // accept | drop | reject
|
||||
Log bool
|
||||
Name string
|
||||
Priority int
|
||||
|
||||
SrcIfaces []string // empty = any
|
||||
DstIfaces []string // empty = any
|
||||
SrcAddrs []string // each is an nft expression like "1.2.3.4" or "10.0.0.0/24" or "{ 1.2.3.4, 5.6.7.8 }"
|
||||
SrcIfaces []string // empty = any
|
||||
DstIfaces []string // empty = any
|
||||
SrcAddrs []string // each is an nft expression like "1.2.3.4" or "10.0.0.0/24" or "{ 1.2.3.4, 5.6.7.8 }"
|
||||
DstAddrs []string
|
||||
Services []ResolvedService // empty = any
|
||||
Comment string
|
||||
@@ -192,16 +192,16 @@ type ResolvedRule struct {
|
||||
|
||||
// ResolvedNATRule is one nat-rule joined with iface-sets.
|
||||
type ResolvedNATRule struct {
|
||||
ID int64
|
||||
Kind string // dnat | snat | masquerade
|
||||
Priority int
|
||||
InIfaces []string
|
||||
OutIfaces []string
|
||||
Proto string // empty = any
|
||||
SrcCIDR string
|
||||
DstCIDR string
|
||||
DPortStart, DPortEnd int
|
||||
TargetAddr string
|
||||
ID int64
|
||||
Kind string // dnat | snat | masquerade
|
||||
Priority int
|
||||
InIfaces []string
|
||||
OutIfaces []string
|
||||
Proto string // empty = any
|
||||
SrcCIDR string
|
||||
DstCIDR string
|
||||
DPortStart, DPortEnd int
|
||||
TargetAddr string
|
||||
TargetPortStart, TargetPortEnd int
|
||||
// L3 ist "ip" oder "ip6" — Adressfamilie der Regel (aus SrcCIDR/
|
||||
// DstCIDR/TargetAddr abgeleitet). TargetHost ist TargetAddr, bei
|
||||
@@ -209,14 +209,14 @@ type ResolvedNATRule struct {
|
||||
// nft-dnat-Syntax.
|
||||
L3 string
|
||||
TargetHost string
|
||||
Comment string
|
||||
Comment string
|
||||
}
|
||||
|
||||
// ResolvedService is one nft (proto, dport-spec) tuple.
|
||||
type ResolvedService struct {
|
||||
Proto string // tcp|udp|icmp|icmpv6
|
||||
PortStart int // 0 = no port match
|
||||
PortEnd int
|
||||
Proto string // tcp|udp|icmp|icmpv6
|
||||
PortStart int // 0 = no port match
|
||||
PortEnd int
|
||||
}
|
||||
|
||||
func (g *Generator) loadView(ctx context.Context) (*View, error) {
|
||||
@@ -269,6 +269,31 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) {
|
||||
}
|
||||
peerRows.Close()
|
||||
|
||||
// ── Heartbeat-IPs aus cluster_settings ins Peer-Set ──
|
||||
// Der VRRP-Heartbeat (VI_HB) läuft über hb_src_ip/hb_peer_ip (z.B.
|
||||
// 169.254.0.1/.2) — diese stehen NICHT in ha_nodes. Ohne sie würde die
|
||||
// VRRP-Accept-Regel den Heartbeat-Pfad nicht abdecken. Best-effort:
|
||||
// fehlt cluster_settings (Single-Node), bleibt es bei den ha_nodes-IPs.
|
||||
var hbSrc, hbPeer *string
|
||||
if err := g.Pool.QueryRow(ctx,
|
||||
`SELECT hb_src_ip, hb_peer_ip FROM cluster_settings WHERE id = 1`).
|
||||
Scan(&hbSrc, &hbPeer); err == nil {
|
||||
for _, ip := range []*string{hbSrc, hbPeer} {
|
||||
if ip == nil {
|
||||
continue
|
||||
}
|
||||
parsed := net.ParseIP(*ip)
|
||||
if parsed == nil {
|
||||
continue
|
||||
}
|
||||
if parsed.To4() != nil {
|
||||
view.PeerIPv4 = append(view.PeerIPv4, parsed.String())
|
||||
} else {
|
||||
view.PeerIPv6 = append(view.PeerIPv6, parsed.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Lade Address-Objects + Groups → ID → ResolvedAddr-list ──
|
||||
addrObjs, err := g.loadAddrObjects(ctx)
|
||||
if err != nil {
|
||||
@@ -535,7 +560,7 @@ func splitByFamily(exprs []string) (v4, v6 []string) {
|
||||
return v4, v6
|
||||
}
|
||||
|
||||
// serviceL3: icmp ist v4-only, icmpv6 v6-only, tcp/udp/leer agnostisch.
|
||||
// serviceL3: icmp ist v4-only, icmpv6 v6-only, tcp/udp/leer agnostic.
|
||||
func serviceL3(svc ResolvedService) string {
|
||||
switch svc.Proto {
|
||||
case "icmp":
|
||||
@@ -583,7 +608,7 @@ func expandFamilyLegs(r ResolvedRule, svc ResolvedService, hasSvc bool) []RuleLe
|
||||
}
|
||||
|
||||
if len(r.SrcAddrs) == 0 && len(r.DstAddrs) == 0 {
|
||||
// Kein Adress-Match → eine Zeile, L3 leer. Die Proto-Render-Logik
|
||||
// Kein Address-Match → eine Zeile, L3 leer. Die Proto-Render-Logik
|
||||
// im Template setzt icmp/icmpv6 selbst familienkorrekt.
|
||||
return []RuleLeg{base}
|
||||
}
|
||||
@@ -777,15 +802,15 @@ ORDER BY priority DESC, id ASC`)
|
||||
out := []ResolvedRule{}
|
||||
for rows.Next() {
|
||||
var (
|
||||
id int64
|
||||
name, action, com string
|
||||
pr int
|
||||
log bool
|
||||
srcZone, dstZone string
|
||||
srcObjID, srcGrpID *int64
|
||||
dstObjID, dstGrpID *int64
|
||||
srcCIDR, dstCIDR *string
|
||||
svcObjID, svcGrpID *int64
|
||||
id int64
|
||||
name, action, com string
|
||||
pr int
|
||||
log bool
|
||||
srcZone, dstZone string
|
||||
srcObjID, srcGrpID *int64
|
||||
dstObjID, dstGrpID *int64
|
||||
srcCIDR, dstCIDR *string
|
||||
svcObjID, svcGrpID *int64
|
||||
)
|
||||
if err := rows.Scan(
|
||||
&id, &name, &pr, &action, &log, &com,
|
||||
@@ -842,12 +867,12 @@ ORDER BY priority DESC, id ASC`)
|
||||
out := []ResolvedNATRule{}
|
||||
for rows.Next() {
|
||||
var (
|
||||
id int64
|
||||
pr int
|
||||
kind, com string
|
||||
id int64
|
||||
pr int
|
||||
kind, com string
|
||||
inZone, outZone, proto, srcCIDR, dstCIDR *string
|
||||
dpStart, dpEnd, tpStart, tpEnd int
|
||||
targetAddr string
|
||||
dpStart, dpEnd, tpStart, tpEnd int
|
||||
targetAddr string
|
||||
)
|
||||
if err := rows.Scan(
|
||||
&id, &pr, &kind, &com,
|
||||
@@ -861,7 +886,7 @@ ORDER BY priority DESC, id ASC`)
|
||||
r := ResolvedNATRule{
|
||||
ID: id, Kind: kind, Priority: pr, Comment: com,
|
||||
DPortStart: dpStart, DPortEnd: dpEnd,
|
||||
TargetAddr: targetAddr,
|
||||
TargetAddr: targetAddr,
|
||||
TargetPortStart: tpStart, TargetPortEnd: tpEnd,
|
||||
}
|
||||
if proto != nil {
|
||||
|
||||
@@ -47,7 +47,7 @@ func TestTemplate_autoRuleIface(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = f.WriteString(out)
|
||||
f.Close()
|
||||
_ = f.Close()
|
||||
var cmd *exec.Cmd
|
||||
if os.Geteuid() == 0 {
|
||||
cmd = exec.Command(nft, "-c", "-f", f.Name())
|
||||
|
||||
@@ -125,7 +125,7 @@ func TestE2E_IPv6Render(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = f.WriteString(out)
|
||||
f.Close()
|
||||
_ = f.Close()
|
||||
var cmd *exec.Cmd
|
||||
if os.Geteuid() == 0 {
|
||||
cmd = exec.Command(nft, "-c", "-f", f.Name())
|
||||
|
||||
@@ -107,7 +107,7 @@ func renderView(t *testing.T) string {
|
||||
Legs: []RuleLeg{
|
||||
{RuleID: 1, Action: "accept", L3: "ip", SrcAddrs: []string{"10.0.0.0/24"}, Service: ResolvedService{Proto: "tcp", PortStart: 443}},
|
||||
{RuleID: 1, Action: "accept", L3: "ip6", SrcAddrs: []string{"fd00::/64"}, Service: ResolvedService{Proto: "tcp", PortStart: 443}},
|
||||
{RuleID: 2, Action: "accept", Service: ResolvedService{Proto: "icmpv6"}}, // adresslos, agnostisch
|
||||
{RuleID: 2, Action: "accept", Service: ResolvedService{Proto: "icmpv6"}}, // adresslos, agnostic
|
||||
},
|
||||
NATRules: []ResolvedNATRule{
|
||||
{ID: 5, Kind: "dnat", L3: "ip6", DstCIDR: "2001:db8::/64", Proto: "tcp", DPortStart: 80, TargetAddr: "fd00::2", TargetHost: "[fd00::2]", TargetPortStart: 8080},
|
||||
@@ -126,11 +126,11 @@ func renderView(t *testing.T) string {
|
||||
func TestTemplate_v6AndV4Render(t *testing.T) {
|
||||
out := renderView(t)
|
||||
mustContain := []string{
|
||||
"ip saddr { 10.0.0.0/24 }", // v4-Regel unverändert
|
||||
"ip6 saddr { fd00::/64 }", // v6-Regel
|
||||
"ip6 daddr 2001:db8::/64", // v6-DNAT-Match
|
||||
"dnat to [fd00::2]:8080", // v6-DNAT-Target geklammert
|
||||
"dnat to 10.0.0.5:80", // v4-DNAT-Target unverändert
|
||||
"ip saddr { 10.0.0.0/24 }", // v4-Regel unverändert
|
||||
"ip6 saddr { fd00::/64 }", // v6-Regel
|
||||
"ip6 daddr 2001:db8::/64", // v6-DNAT-Match
|
||||
"dnat to [fd00::2]:8080", // v6-DNAT-Target geklammert
|
||||
"dnat to 10.0.0.5:80", // v4-DNAT-Target unverändert
|
||||
"ip6 saddr fd00::/64 snat to 2001:db8::99",
|
||||
`oifname "wg7" ip6 saddr fd00:99::/64 masquerade`,
|
||||
}
|
||||
@@ -162,7 +162,7 @@ func TestTemplate_nftSyntax(t *testing.T) {
|
||||
if _, err := f.WriteString(out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.Close()
|
||||
_ = f.Close()
|
||||
// `nft -c` liest die Kernel-Ruleset-Cache via netlink → braucht root.
|
||||
// Als nicht-root via sudo -n versuchen; klappt das nicht, skip statt fail
|
||||
// (auf den Nodes rendert/prüft edgeguard ohnehin als root).
|
||||
|
||||
@@ -55,6 +55,13 @@ table inet edgeguard {
|
||||
tcp dport 5432 ip6 saddr @peer_ipv6 accept
|
||||
tcp dport 6379 ip saddr @peer_ipv4 accept
|
||||
tcp dport 6379 ip6 saddr @peer_ipv6 accept
|
||||
# Cluster-internal: VRRP-Advertisements (keepalived VIP-Failover, Proto 112).
|
||||
# OHNE diese Regel überleben Adverts nur via conntrack-Reverse-Matching —
|
||||
# läuft ein conntrack-Eintrag ab/wird geflusht, werden Adverts gedroppt →
|
||||
# der Peer promotet sich → VIP-Flapping/Split-Brain. peer_ipv4/6 enthält
|
||||
# Public- UND Heartbeat-IPs (ha_nodes + cluster_settings.hb_*).
|
||||
ip protocol vrrp ip saddr @peer_ipv4 accept
|
||||
ip6 nexthdr vrrp ip6 saddr @peer_ipv6 accept
|
||||
|
||||
# ── Service-Auto-Rules (DNS/Squid/WG/...) ──
|
||||
# Aus dem laufenden Service-State abgeleitet — Operator
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
@@ -31,7 +32,7 @@ func TestACME_ServesExistingToken(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodGet, "/.well-known/acme-challenge/tok_42", nil)
|
||||
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/.well-known/acme-challenge/tok_42", nil)
|
||||
r.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
@@ -45,7 +46,7 @@ func TestACME_ServesExistingToken(t *testing.T) {
|
||||
func TestACME_MissingToken_Returns404(t *testing.T) {
|
||||
r, _ := setupACME(t)
|
||||
rec := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodGet, "/.well-known/acme-challenge/notthere", nil)
|
||||
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/.well-known/acme-challenge/notthere", nil)
|
||||
r.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("status: %d", rec.Code)
|
||||
@@ -76,7 +77,7 @@ func TestACME_DirIsNotAFile(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodGet, "/.well-known/acme-challenge/subdir", nil)
|
||||
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/.well-known/acme-challenge/subdir", nil)
|
||||
r.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404 for directory, got %d", rec.Code)
|
||||
|
||||
@@ -96,7 +96,7 @@ func (h *AuditHandler) Live(c *gin.Context) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
// Snapshot
|
||||
if rows, err := h.Repo.ListRecent(c.Request.Context(), 50); err == nil {
|
||||
|
||||
@@ -504,7 +504,7 @@ func (h *AuthHandler) checkWithPrimary(ctx context.Context, primaryFQDN, email,
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", "", errors.New("primary: " + strings.TrimSpace(string(raw)))
|
||||
|
||||
@@ -93,7 +93,7 @@ func (h *BackupRemotesHandler) Create(c *gin.Context) {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if req.Settings == nil || len(req.Settings) == 0 {
|
||||
if len(req.Settings) == 0 {
|
||||
req.Settings = json.RawMessage(`{}`)
|
||||
}
|
||||
row := h.Pool.QueryRow(c.Request.Context(), `
|
||||
@@ -124,7 +124,7 @@ func (h *BackupRemotesHandler) Update(c *gin.Context) {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if req.Settings == nil || len(req.Settings) == 0 {
|
||||
if len(req.Settings) == 0 {
|
||||
req.Settings = json.RawMessage(`{}`)
|
||||
}
|
||||
// Wenn die Settings masked-Fields enthalten (***), übernehmen wir
|
||||
|
||||
@@ -35,7 +35,7 @@ type ClusterHandler struct {
|
||||
Store *cluster.Store
|
||||
LocalID string
|
||||
Aggregator *aggregator.Aggregator
|
||||
Version string // laufende Binary-Version, für Rolling-Update-Koordination
|
||||
Version string // laufende Binary-Version, für Rolling-Update-Coordination
|
||||
|
||||
// TLSStore + Tokens: optional, gesetzt bei Phase 3.4. Erlauben das
|
||||
// Generieren von Join-Tokens und das Issue-Cert für joining Peers.
|
||||
@@ -56,7 +56,7 @@ func NewClusterHandler(store *cluster.Store, localID string) *ClusterHandler {
|
||||
return &ClusterHandler{Store: store, LocalID: localID}
|
||||
}
|
||||
|
||||
// WithAggregator: optionale Aggregator-Konfiguration. Nur wenn vorhanden
|
||||
// WithAggregator: optionale Aggregator-Configuration. Nur wenn vorhanden
|
||||
// wird /cluster/system/load die Peers via mTLS abklappern.
|
||||
func (h *ClusterHandler) WithAggregator(a *aggregator.Aggregator) *ClusterHandler {
|
||||
h.Aggregator = a
|
||||
@@ -104,7 +104,7 @@ func (h *ClusterHandler) Register(rg *gin.RouterGroup) {
|
||||
// DeleteNode entfernt einen Peer aus ha_nodes. Verweigert für die
|
||||
// lokale Node (LocalID) — die kannst du nicht via UI löschen, sonst
|
||||
// kommt der nächste Heartbeat-Tick die Row wieder anlegen oder
|
||||
// die Cluster-Page wird inkonsistent.
|
||||
// die Cluster-Page wird inconsistent.
|
||||
//
|
||||
// Nach erfolgreichem Delete triggert der PeerReloader (falls gesetzt)
|
||||
// einen Firewall-Render — peer_ipv4-Set verliert die IP, der entfernte
|
||||
@@ -159,7 +159,7 @@ func (h *ClusterHandler) GetVIPSettings(c *gin.Context) {
|
||||
response.OK(c, cs)
|
||||
}
|
||||
|
||||
// UpdateVIPSettings speichert die VIP/VRRP-Konfiguration und triggert
|
||||
// UpdateVIPSettings speichert die VIP/VRRP-Configuration und triggert
|
||||
// einen Keepalived-Config-Render. Viewer-Schutz via RequireAdminForMutations-
|
||||
// Middleware auf der authed-Group — kein Extra-Check nötig.
|
||||
func (h *ClusterHandler) UpdateVIPSettings(c *gin.Context) {
|
||||
@@ -338,7 +338,7 @@ func (h *ClusterHandler) WithPeerReloader(r PeerReloader) *ClusterHandler {
|
||||
return h
|
||||
}
|
||||
|
||||
// WithVersion: setzt die laufende Binary-Version für Rolling-Update-Koordination.
|
||||
// WithVersion: setzt die laufende Binary-Version für Rolling-Update-Coordination.
|
||||
func (h *ClusterHandler) WithVersion(v string) *ClusterHandler {
|
||||
h.Version = v
|
||||
return h
|
||||
@@ -359,7 +359,7 @@ type ClusterStatus struct {
|
||||
LocalID string `json:"local_id"`
|
||||
LocalNode *models.HANode `json:"local_node,omitempty"`
|
||||
Peers []models.HANode `json:"peers"`
|
||||
Mode string `json:"mode"` // "single-node" | "cluster"
|
||||
Mode string `json:"mode"` // "single-node" | "cluster"
|
||||
Health string `json:"health"` // "ok" | "degraded" | "split-brain"
|
||||
DriftFound bool `json:"drift_found"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
@@ -822,7 +822,7 @@ func (h *ClusterHandler) CertStatus(c *gin.Context) {
|
||||
//
|
||||
// Nach Renew muss edgeguard-api restartet werden damit der Agent-
|
||||
// Listener das neue Cert in seinen TLS-Config-Snapshot lädt — wir
|
||||
// triggern das NICHT automatisch (würde die HTTP-Response abreißen);
|
||||
// triggering das NICHT automatisch (würde die HTTP-Response abreißen);
|
||||
// stattdessen liefern wir einen Hinweis im Response.
|
||||
func (h *ClusterHandler) RenewSelf(c *gin.Context) {
|
||||
if !h.TLSStore.HasCA() {
|
||||
@@ -857,16 +857,16 @@ func (h *ClusterHandler) RenewSelf(c *gin.Context) {
|
||||
// die wir wirklich brauchen — sonst kann ein joining Peer beliebige
|
||||
// ha_nodes-Felder überschreiben.
|
||||
type registerPeerRequest struct {
|
||||
ID string `json:"id"` // Joiner's eigene node-id
|
||||
Name string `json:"name"` // hostname
|
||||
FQDN string `json:"fqdn"` // sollte mit Client-Cert-CN matchen
|
||||
APIURL string `json:"api_url"` // https://<fqdn>
|
||||
PublicIP string `json:"public_ip"` // optional
|
||||
InternalIP string `json:"internal_ip"` // mTLS-Listener-IP (für peer_ipv4-Set)
|
||||
MgmtIP string `json:"mgmt_ip"` // optional
|
||||
ID string `json:"id"` // Joiner's eigene node-id
|
||||
Name string `json:"name"` // hostname
|
||||
FQDN string `json:"fqdn"` // sollte mit Client-Cert-CN matchen
|
||||
APIURL string `json:"api_url"` // https://<fqdn>
|
||||
PublicIP string `json:"public_ip"` // optional
|
||||
InternalIP string `json:"internal_ip"` // mTLS-Listener-IP (für peer_ipv4-Set)
|
||||
MgmtIP string `json:"mgmt_ip"` // optional
|
||||
Version string `json:"version"`
|
||||
ConfigHash *string `json:"config_hash"` // nil=absent (don't change), ""=no user config
|
||||
Role string `json:"role"` // "" → "peer" (joining peer); "primary" beim Push des Primary
|
||||
ConfigHash *string `json:"config_hash"` // nil=absent (don't change), ""=no user config
|
||||
Role string `json:"role"` // "" → "peer" (joining peer); "primary" beim Push des Primary
|
||||
}
|
||||
|
||||
// AgentRegisterPeer: vom Joiner nach issue-cert via mTLS aufgerufen.
|
||||
@@ -925,6 +925,14 @@ func (h *ClusterHandler) AgentRegisterPeer(c *gin.Context) {
|
||||
if req.PublicIP != "" {
|
||||
v := req.PublicIP
|
||||
n.PublicIP = &v
|
||||
} else if ip := c.ClientIP(); ip != "" {
|
||||
// Der Push-Payload (autoRegister, Primary→Secondary) trägt KEINE
|
||||
// public_ip → sonst bliebe sie NULL und der Peer fehlt im nft-
|
||||
// peer_ipv4-Set → VRRP-Adverts nur via conntrack → Flapping. Der
|
||||
// pushende Peer verbindet sich über mTLS von seiner EIGEN-IP (nicht
|
||||
// der VIP — der Kernel nimmt die primäre Interface-IP als Source),
|
||||
// genau wie preRegisterJoiner die Joiner-IP übernimmt. Selbstheilend.
|
||||
n.PublicIP = &ip
|
||||
}
|
||||
if req.InternalIP != "" {
|
||||
v := req.InternalIP
|
||||
|
||||
@@ -102,7 +102,7 @@ func (h *ClusterHandler) RepairReplication(c *gin.Context) {
|
||||
body, _ := json.Marshal(repairDispatchBody{PrimaryHost: primaryHost})
|
||||
res := h.Aggregator.PostPeerWithBody(ctx, *peer, repairAgentPath, body)
|
||||
if !res.OK {
|
||||
response.Internal(c, fmt.Errorf("Resync auf %s anstoßen: %s", peer.FQDN, res.Err))
|
||||
response.Internal(c, fmt.Errorf("resync auf %s anstoßen: %s", peer.FQDN, res.Err))
|
||||
return
|
||||
}
|
||||
slog.Info("cluster: replication repair delegated", "target", peer.FQDN, "primary_host", primaryHost)
|
||||
@@ -178,7 +178,7 @@ func (h *ClusterHandler) startResync(ctx context.Context, primaryHost string) er
|
||||
return errors.New("dieser Node ist der Publication-Primary — Resync läuft nur auf einem Subscriber")
|
||||
}
|
||||
if st := repairUnitState(); st == "activating" || st == "active" {
|
||||
return errors.New("Resync läuft bereits")
|
||||
return errors.New("resync läuft bereits")
|
||||
}
|
||||
|
||||
script := fmt.Sprintf(`#!/bin/bash
|
||||
@@ -334,7 +334,7 @@ func localRepairStatus() repairStatusResponse {
|
||||
"--no-pager", "-n", "100", "-o", "cat",
|
||||
).CombinedOutput(); err == nil {
|
||||
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
|
||||
if !(len(lines) == 1 && (lines[0] == "" || strings.HasPrefix(lines[0], "-- No entries"))) {
|
||||
if len(lines) != 1 || (lines[0] != "" && !strings.HasPrefix(lines[0], "-- No entries")) {
|
||||
out.Log = lines
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,7 +177,7 @@ func (h *ClusterHandler) runRollingUpdate(secondary *models.HANode) {
|
||||
slog.Info("rolling-update: secondary already at target — skipping secondary step",
|
||||
"version", candidate)
|
||||
} else {
|
||||
// 1. Secondary triggern
|
||||
// 1. Secondary triggering
|
||||
slog.Info("rolling-update: posting trigger-update to secondary", "fqdn", secondary.FQDN)
|
||||
result := h.Aggregator.PostPeer(ctx, *secondary, "/agent/cluster/trigger-update")
|
||||
if !result.OK {
|
||||
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
|
||||
// FirewallHandler exposes everything under /api/v1/firewall/*:
|
||||
//
|
||||
// address-objects — primitive Adress-Definitionen (host/network/range/fqdn)
|
||||
// address-objects — primitive Address-Definitionen (host/network/range/fqdn)
|
||||
// address-groups — Gruppen von address-objects (mit /members ops)
|
||||
// services — proto+port (Builtins lassen sich nicht editieren)
|
||||
// service-groups — Gruppen von services
|
||||
@@ -271,7 +271,7 @@ func zoneNamePattern(s string) bool {
|
||||
if s == "" || len(s) > 32 {
|
||||
return false
|
||||
}
|
||||
if !(s[0] >= 'a' && s[0] <= 'z') {
|
||||
if s[0] < 'a' || s[0] > 'z' {
|
||||
return false
|
||||
}
|
||||
for i := 1; i < len(s); i++ {
|
||||
@@ -352,7 +352,8 @@ func (h *FirewallHandler) CreateAddrObj(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_obj.create", req.Name, out, h.NodeID)
|
||||
response.Created(c, out); h.reload(c.Request.Context(), "create")
|
||||
response.Created(c, out)
|
||||
h.reload(c.Request.Context(), "create")
|
||||
}
|
||||
|
||||
func (h *FirewallHandler) UpdateAddrObj(c *gin.Context) {
|
||||
@@ -379,7 +380,8 @@ func (h *FirewallHandler) UpdateAddrObj(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_obj.update", req.Name, out, h.NodeID)
|
||||
response.OK(c, out); h.reload(c.Request.Context(), "update")
|
||||
response.OK(c, out)
|
||||
h.reload(c.Request.Context(), "update")
|
||||
}
|
||||
|
||||
func (h *FirewallHandler) DeleteAddrObj(c *gin.Context) {
|
||||
@@ -397,7 +399,8 @@ func (h *FirewallHandler) DeleteAddrObj(c *gin.Context) {
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_obj.delete",
|
||||
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
|
||||
response.NoContent(c); h.reload(c.Request.Context(), "delete")
|
||||
response.NoContent(c)
|
||||
h.reload(c.Request.Context(), "delete")
|
||||
}
|
||||
|
||||
// ── Address Groups ─────────────────────────────────────────────────────
|
||||
@@ -440,7 +443,8 @@ func (h *FirewallHandler) CreateAddrGrp(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_grp.create", req.Name, out, h.NodeID)
|
||||
response.Created(c, out); h.reload(c.Request.Context(), "create")
|
||||
response.Created(c, out)
|
||||
h.reload(c.Request.Context(), "create")
|
||||
}
|
||||
|
||||
func (h *FirewallHandler) UpdateAddrGrp(c *gin.Context) {
|
||||
@@ -463,7 +467,8 @@ func (h *FirewallHandler) UpdateAddrGrp(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_grp.update", req.Name, out, h.NodeID)
|
||||
response.OK(c, out); h.reload(c.Request.Context(), "update")
|
||||
response.OK(c, out)
|
||||
h.reload(c.Request.Context(), "update")
|
||||
}
|
||||
|
||||
func (h *FirewallHandler) DeleteAddrGrp(c *gin.Context) {
|
||||
@@ -481,7 +486,8 @@ func (h *FirewallHandler) DeleteAddrGrp(c *gin.Context) {
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_grp.delete",
|
||||
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
|
||||
response.NoContent(c); h.reload(c.Request.Context(), "delete")
|
||||
response.NoContent(c)
|
||||
h.reload(c.Request.Context(), "delete")
|
||||
}
|
||||
|
||||
// ── Services ───────────────────────────────────────────────────────────
|
||||
@@ -524,7 +530,8 @@ func (h *FirewallHandler) CreateService(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.service.create", req.Name, out, h.NodeID)
|
||||
response.Created(c, out); h.reload(c.Request.Context(), "create")
|
||||
response.Created(c, out)
|
||||
h.reload(c.Request.Context(), "create")
|
||||
}
|
||||
|
||||
func (h *FirewallHandler) UpdateService(c *gin.Context) {
|
||||
@@ -547,7 +554,8 @@ func (h *FirewallHandler) UpdateService(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.service.update", req.Name, out, h.NodeID)
|
||||
response.OK(c, out); h.reload(c.Request.Context(), "update")
|
||||
response.OK(c, out)
|
||||
h.reload(c.Request.Context(), "update")
|
||||
}
|
||||
|
||||
func (h *FirewallHandler) DeleteService(c *gin.Context) {
|
||||
@@ -565,7 +573,8 @@ func (h *FirewallHandler) DeleteService(c *gin.Context) {
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.service.delete",
|
||||
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
|
||||
response.NoContent(c); h.reload(c.Request.Context(), "delete")
|
||||
response.NoContent(c)
|
||||
h.reload(c.Request.Context(), "delete")
|
||||
}
|
||||
|
||||
// ── Service Groups ─────────────────────────────────────────────────────
|
||||
@@ -608,7 +617,8 @@ func (h *FirewallHandler) CreateSvcGrp(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.svc_grp.create", req.Name, out, h.NodeID)
|
||||
response.Created(c, out); h.reload(c.Request.Context(), "create")
|
||||
response.Created(c, out)
|
||||
h.reload(c.Request.Context(), "create")
|
||||
}
|
||||
|
||||
func (h *FirewallHandler) UpdateSvcGrp(c *gin.Context) {
|
||||
@@ -631,7 +641,8 @@ func (h *FirewallHandler) UpdateSvcGrp(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.svc_grp.update", req.Name, out, h.NodeID)
|
||||
response.OK(c, out); h.reload(c.Request.Context(), "update")
|
||||
response.OK(c, out)
|
||||
h.reload(c.Request.Context(), "update")
|
||||
}
|
||||
|
||||
func (h *FirewallHandler) DeleteSvcGrp(c *gin.Context) {
|
||||
@@ -649,7 +660,8 @@ func (h *FirewallHandler) DeleteSvcGrp(c *gin.Context) {
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.svc_grp.delete",
|
||||
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
|
||||
response.NoContent(c); h.reload(c.Request.Context(), "delete")
|
||||
response.NoContent(c)
|
||||
h.reload(c.Request.Context(), "delete")
|
||||
}
|
||||
|
||||
// ── Rules ──────────────────────────────────────────────────────────────
|
||||
@@ -704,7 +716,8 @@ func (h *FirewallHandler) CreateRule(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.rule.create", strconv.FormatInt(out.ID, 10), out, h.NodeID)
|
||||
response.Created(c, out); h.reload(c.Request.Context(), "create")
|
||||
response.Created(c, out)
|
||||
h.reload(c.Request.Context(), "create")
|
||||
}
|
||||
|
||||
func (h *FirewallHandler) UpdateRule(c *gin.Context) {
|
||||
@@ -739,7 +752,8 @@ func (h *FirewallHandler) UpdateRule(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.rule.update", strconv.FormatInt(id, 10), out, h.NodeID)
|
||||
response.OK(c, out); h.reload(c.Request.Context(), "update")
|
||||
response.OK(c, out)
|
||||
h.reload(c.Request.Context(), "update")
|
||||
}
|
||||
|
||||
func (h *FirewallHandler) DeleteRule(c *gin.Context) {
|
||||
@@ -757,7 +771,8 @@ func (h *FirewallHandler) DeleteRule(c *gin.Context) {
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.rule.delete",
|
||||
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
|
||||
response.NoContent(c); h.reload(c.Request.Context(), "delete")
|
||||
response.NoContent(c)
|
||||
h.reload(c.Request.Context(), "delete")
|
||||
}
|
||||
|
||||
func (h *FirewallHandler) PatchRule(c *gin.Context) {
|
||||
@@ -850,7 +865,8 @@ func (h *FirewallHandler) CreateNAT(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.nat.create", strconv.FormatInt(out.ID, 10), out, h.NodeID)
|
||||
response.Created(c, out); h.reload(c.Request.Context(), "create")
|
||||
response.Created(c, out)
|
||||
h.reload(c.Request.Context(), "create")
|
||||
}
|
||||
|
||||
func (h *FirewallHandler) UpdateNAT(c *gin.Context) {
|
||||
@@ -881,7 +897,8 @@ func (h *FirewallHandler) UpdateNAT(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.nat.update", strconv.FormatInt(id, 10), out, h.NodeID)
|
||||
response.OK(c, out); h.reload(c.Request.Context(), "update")
|
||||
response.OK(c, out)
|
||||
h.reload(c.Request.Context(), "update")
|
||||
}
|
||||
|
||||
func (h *FirewallHandler) DeleteNAT(c *gin.Context) {
|
||||
@@ -899,7 +916,8 @@ func (h *FirewallHandler) DeleteNAT(c *gin.Context) {
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.nat.delete",
|
||||
strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID)
|
||||
response.NoContent(c); h.reload(c.Request.Context(), "delete")
|
||||
response.NoContent(c)
|
||||
h.reload(c.Request.Context(), "delete")
|
||||
}
|
||||
|
||||
func (h *FirewallHandler) PatchNAT(c *gin.Context) {
|
||||
|
||||
@@ -78,7 +78,7 @@ func (h *FirewallLogHandler) Live(c *gin.Context) {
|
||||
// Upgrade-Failures sind Browser-side; nichts loggen
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
f := parseFilter(c)
|
||||
|
||||
|
||||
@@ -58,14 +58,15 @@ type frontendStat struct {
|
||||
}
|
||||
|
||||
func (h *HAProxyStatsHandler) Stats(c *gin.Context) {
|
||||
conn, err := net.DialTimeout("unix", haproxyAdminSock, 2*time.Second)
|
||||
d := net.Dialer{Timeout: 2 * time.Second}
|
||||
conn, err := d.DialContext(c.Request.Context(), "unix", haproxyAdminSock)
|
||||
if err != nil {
|
||||
// Socket nicht erreichbar (haproxy down oder no perm) →
|
||||
// leere Liste statt 500 damit das Dashboard nicht rot wird.
|
||||
response.OK(c, gin.H{"backends": []backendStat{}, "frontends": []frontendStat{}, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
defer func() { _ = conn.Close() }()
|
||||
_ = conn.SetDeadline(time.Now().Add(3 * time.Second))
|
||||
if _, err := conn.Write([]byte("show stat\n")); err != nil {
|
||||
response.OK(c, gin.H{"backends": []backendStat{}, "frontends": []frontendStat{}, "error": err.Error()})
|
||||
|
||||
@@ -139,7 +139,7 @@ func (h *LicenseHandler) ClearKey(c *gin.Context) {
|
||||
// result into the licenses table. On error, marks last_error in DB
|
||||
// (status stays as before — grace).
|
||||
func (h *LicenseHandler) runVerifyAndPersist(ctx context.Context, key string) (*license.Result, error) {
|
||||
res, err := h.Client.Verify(key)
|
||||
res, err := h.Client.Verify(key) //nolint:contextcheck // detached by design — License-Verify nutzt eigenen HTTP-Timeout, überlebt Request-Cancel
|
||||
if err != nil {
|
||||
_ = h.Repo.MarkError(ctx, key, err.Error())
|
||||
slog.Warn("license: verify failed", "error", err)
|
||||
|
||||
@@ -100,21 +100,21 @@ func parseChronyTracking(out string) chronyStatus {
|
||||
}
|
||||
s.Synced = val != "00000000 ()"
|
||||
case "Stratum":
|
||||
fmt.Sscanf(val, "%d", &s.Stratum)
|
||||
_, _ = fmt.Sscanf(val, "%d", &s.Stratum)
|
||||
if s.Stratum > 0 && s.Stratum < 16 {
|
||||
s.Synced = true
|
||||
}
|
||||
case "System time":
|
||||
// "0.000012345 seconds fast of NTP time"
|
||||
var v float64
|
||||
fmt.Sscanf(val, "%f", &v)
|
||||
_, _ = fmt.Sscanf(val, "%f", &v)
|
||||
s.OffsetMs = v * 1000
|
||||
case "Frequency":
|
||||
// "-12.345 ppm slow" or "+12.345 ppm fast"
|
||||
fmt.Sscanf(val, "%f", &s.FreqPPM)
|
||||
_, _ = fmt.Sscanf(val, "%f", &s.FreqPPM)
|
||||
case "RMS offset":
|
||||
var v float64
|
||||
fmt.Sscanf(val, "%f", &v)
|
||||
_, _ = fmt.Sscanf(val, "%f", &v)
|
||||
s.RMSOffsetMs = v * 1000
|
||||
}
|
||||
}
|
||||
@@ -312,8 +312,8 @@ func parseChronymSources(out string) []ntpSource {
|
||||
Reach: fields[3],
|
||||
LastRx: fields[4],
|
||||
}
|
||||
fmt.Sscanf(fields[1], "%d", &src.Stratum)
|
||||
fmt.Sscanf(fields[2], "%d", &src.Poll)
|
||||
_, _ = fmt.Sscanf(fields[1], "%d", &src.Stratum)
|
||||
_, _ = fmt.Sscanf(fields[2], "%d", &src.Poll)
|
||||
if len(fields) >= 6 {
|
||||
src.Sample = strings.Join(fields[5:], " ")
|
||||
}
|
||||
|
||||
@@ -79,9 +79,9 @@ func runCallback(t *testing.T, h *OIDCHandler, flow oidcFlow, queryState, code s
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet,
|
||||
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet,
|
||||
"/api/v1/auth/oidc/callback?state="+queryState+"&code="+code, nil)
|
||||
req.AddCookie(&http.Cookie{Name: oidcFlowCookie, Value: signed})
|
||||
req.AddCookie(&http.Cookie{Name: oidcFlowCookie, Value: signed}) //nolint:gosec // Test-Cookie — Secure/HttpOnly-Flags für httptest irrelevant
|
||||
c.Request = req
|
||||
h.Callback(c)
|
||||
return rec
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
@@ -16,7 +17,7 @@ func run(handler gin.HandlerFunc) *httptest.ResponseRecorder {
|
||||
r := gin.New()
|
||||
r.GET("/x", handler)
|
||||
rec := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest(http.MethodGet, "/x", nil)
|
||||
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/x", nil)
|
||||
r.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ func (h *SystemHandler) Register(rg *gin.RouterGroup) {
|
||||
// Ergebnis für /cluster/system/load.
|
||||
//
|
||||
// Bewusst KEINE Mutations + KEIN /package-versions (würde apt-get update
|
||||
// auf jedem Peer triggern), KEIN /upgrade.
|
||||
// auf jedem Peer triggering), KEIN /upgrade.
|
||||
func (h *SystemHandler) RegisterAgent(rg *gin.RouterGroup) {
|
||||
g := rg.Group("/agent/system")
|
||||
g.GET("/health", h.Health)
|
||||
@@ -206,7 +206,7 @@ type serviceStatus struct {
|
||||
Label string `json:"label"`
|
||||
Unit string `json:"unit"`
|
||||
Active bool `json:"active"`
|
||||
State string `json:"state"` // active|inactive|failed|activating|...
|
||||
State string `json:"state"` // active|inactive|failed|activating|...
|
||||
Since string `json:"since,omitempty"` // ActiveEnterTimestamp
|
||||
}
|
||||
|
||||
@@ -378,7 +378,7 @@ func (h *SystemHandler) Maintenance(c *gin.Context) {
|
||||
func (h *SystemHandler) ToggleMaintenance(c *gin.Context) {
|
||||
if h.Setup == nil {
|
||||
response.Err(c, http.StatusServiceUnavailable,
|
||||
simpleErr("setup not initialised"))
|
||||
simpleErr("setup not initialized"))
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
@@ -430,7 +430,7 @@ func (h *SystemHandler) BackupRetention(c *gin.Context) {
|
||||
// keep=0 → wieder Default, keep=1..365 → custom.
|
||||
func (h *SystemHandler) SetBackupRetention(c *gin.Context) {
|
||||
if h.Setup == nil {
|
||||
response.Err(c, http.StatusServiceUnavailable, simpleErr("setup not initialised"))
|
||||
response.Err(c, http.StatusServiceUnavailable, simpleErr("setup not initialized"))
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
@@ -466,7 +466,7 @@ func (h *SystemHandler) AuditRetention(c *gin.Context) {
|
||||
// SetAuditRetention setzt Audit-Retention in Tagen. 0..3650.
|
||||
func (h *SystemHandler) SetAuditRetention(c *gin.Context) {
|
||||
if h.Setup == nil {
|
||||
response.Err(c, http.StatusServiceUnavailable, simpleErr("setup not initialised"))
|
||||
response.Err(c, http.StatusServiceUnavailable, simpleErr("setup not initialized"))
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
@@ -513,7 +513,7 @@ func (h *SystemHandler) IPv6(c *gin.Context) {
|
||||
|
||||
func (h *SystemHandler) SetIPv6(c *gin.Context) {
|
||||
if h.Setup == nil {
|
||||
response.Err(c, http.StatusServiceUnavailable, simpleErr("setup not initialised"))
|
||||
response.Err(c, http.StatusServiceUnavailable, simpleErr("setup not initialized"))
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
@@ -582,7 +582,7 @@ LIMIT 10`)
|
||||
// HAProxyReload zwingt ein systemctl reload haproxy.service — nützlich
|
||||
// wenn der Operator manuell in /etc/edgeguard/tls/ geschrieben hat
|
||||
// (z. B. eigenes PEM per SSH kopiert) und HAProxy das neue Cert sehen
|
||||
// soll, ohne eine UI-Mutation zu triggern die das automatisch täte.
|
||||
// soll, ohne eine UI-Mutation zu triggering die das automatisch täte.
|
||||
func (h *SystemHandler) HAProxyReload(c *gin.Context) {
|
||||
out, err := exec.Command("sudo", "-n", "/usr/bin/systemctl", "reload", "haproxy.service").CombinedOutput()
|
||||
if err != nil {
|
||||
@@ -600,12 +600,12 @@ func (h *SystemHandler) HAProxyReload(c *gin.Context) {
|
||||
// starten darf. edgeguard-api selbst ist bewusst ausgeschlossen (würde
|
||||
// die eigene HTTP-Response killen). postgresql ebenfalls (Datenpfad).
|
||||
var restartAllowlist = map[string]bool{
|
||||
"haproxy": true,
|
||||
"squid": true,
|
||||
"unbound": true,
|
||||
"chrony": true,
|
||||
"nftables": true,
|
||||
"wireguard": true, // wireguard als Metadienst; einzelne wg-Ifaces über wg-quick@<name>
|
||||
"haproxy": true,
|
||||
"squid": true,
|
||||
"unbound": true,
|
||||
"chrony": true,
|
||||
"nftables": true,
|
||||
"wireguard": true, // wireguard als Metadienst; einzelne wg-Ifaces über wg-quick@<name>
|
||||
"edgeguard-scheduler": true,
|
||||
}
|
||||
|
||||
@@ -790,7 +790,7 @@ func (h *SystemHandler) UpgradeStatus(c *gin.Context) {
|
||||
).CombinedOutput(); err == nil {
|
||||
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
|
||||
// Leere "no entries"-Antwort als leeres Log zurückgeben.
|
||||
if !(len(lines) == 1 && (lines[0] == "" || strings.HasPrefix(lines[0], "-- No entries"))) {
|
||||
if len(lines) != 1 || (lines[0] != "" && !strings.HasPrefix(lines[0], "-- No entries")) {
|
||||
out.Log = lines
|
||||
}
|
||||
}
|
||||
@@ -920,7 +920,7 @@ func (h *SystemHandler) Upgrade(c *gin.Context) {
|
||||
// beiden Namespaces aus zugänglich.
|
||||
const scriptPath = "/var/lib/edgeguard/upgrade.sh"
|
||||
// Retry-Logik gegen Gitea-Packages.gz-Race: nach einem frischen
|
||||
// Publish kann der Packages-Index für ein paar Sekunden inkonsistent
|
||||
// Publish kann der Packages-Index für ein paar Sekunden inconsistent
|
||||
// sein (z. B. Meta uploaded, api/ui noch nicht in der regenerierten
|
||||
// Index-Datei) → apt-resolver-fail mit "no choices are installable".
|
||||
// Drei Versuche mit 15s/30s Backoff geben Gitea Zeit den Index
|
||||
@@ -1235,4 +1235,3 @@ func flagsToList(f net.Flags) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
|
||||
@@ -113,6 +113,11 @@ frontend public_https
|
||||
# www-Redirect: {{$d.RedirectFromHost}} → {{$d.Name}}
|
||||
http-request redirect prefix https://{{$d.Name}} code 301 if { hdr(host) -i {{$d.RedirectFromHost}} }
|
||||
{{- end}}
|
||||
{{- if $d.RedirectTo}}
|
||||
# Domain-Redirect (301): {{$d.Name}} → {{$d.RedirectTo}} (immer auf Ziel-Root,
|
||||
# terminiert vor use_backend → diese Domain routet auf kein Backend).
|
||||
http-request redirect location {{$d.RedirectTo}} code 301 if { hdr(host) -i {{$d.Name}} }
|
||||
{{- end}}
|
||||
{{- if $d.MaintenanceMode}}
|
||||
# Wartungs-Modus für {{$d.Name}} — alle Requests werden mit 503 beantwortet.
|
||||
http-request return status 503 content-type "text/plain; charset=utf-8" string "{{$d.MaintMessage}}" if { hdr(host) -i {{$d.Name}} }
|
||||
|
||||
@@ -212,6 +212,11 @@ type DomainView struct {
|
||||
// to-www → Name="www.example.com" → "example.com" (strip www.-Prefix)
|
||||
RedirectFromHost string
|
||||
|
||||
// RedirectTo: HAProxy-safe 301-Ziel-URL für eine Domain→Domain-Weiterleitung
|
||||
// (z. B. "https://zkm.netcell-it.de"). Leer = kein Redirect. Schattet das
|
||||
// gleichnamige Feld aus dem eingebetteten models.Domain (sanitisiert).
|
||||
RedirectTo string
|
||||
|
||||
// ResponseHeaders: Custom-Headers die HAProxy auf jede Response für
|
||||
// diese Domain setzt. Werte sind bereits HAProxy-safe escaped
|
||||
// (Quotes → ', Newlines → Space).
|
||||
@@ -313,6 +318,7 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) {
|
||||
HSTSHeader: buildHSTSHeader(d),
|
||||
MaintMessage: buildMaintMessage(d),
|
||||
RedirectFromHost: buildRedirectFromHost(d),
|
||||
RedirectTo: buildRedirectTo(d),
|
||||
ResponseHeaders: headersByDomain[d.ID],
|
||||
}
|
||||
if d.MaxBodyKB > 0 {
|
||||
@@ -429,3 +435,23 @@ func buildRedirectFromHost(d models.Domain) string {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// buildRedirectTo liefert die 301-Ziel-URL HAProxy-safe, oder "" wenn kein
|
||||
// Redirect gesetzt ist bzw. die URL ungültig erscheint. Defensiv: nur
|
||||
// http(s)-URLs ohne Whitespace/Steuerzeichen/Quotes — sonst würde die
|
||||
// `redirect location <url>`-Zeile die HAProxy-Config sprengen. Im Zweifel
|
||||
// lieber KEIN Redirect rendern als eine kaputte Config auszuliefern.
|
||||
func buildRedirectTo(d models.Domain) string {
|
||||
u := strings.TrimSpace(d.RedirectTo)
|
||||
if u == "" {
|
||||
return ""
|
||||
}
|
||||
lower := strings.ToLower(u)
|
||||
if !strings.HasPrefix(lower, "http://") && !strings.HasPrefix(lower, "https://") {
|
||||
return ""
|
||||
}
|
||||
if strings.ContainsAny(u, " \t\r\n\"'`\\{}") {
|
||||
return ""
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
@@ -104,11 +104,13 @@ func TestRender_HSTSPerDomain(t *testing.T) {
|
||||
t.Errorf("missing %q in per-domain HSTS output:\n%s", w, out)
|
||||
}
|
||||
}
|
||||
if strings.Contains(out, "Strict-Transport-Security \"\" if { hdr(host) -i b.example.com }") ||
|
||||
strings.Contains(out, "if { hdr(host) -i b.example.com }") && strings.Contains(out, "Strict-Transport-Security") &&
|
||||
strings.Contains(out, "b.example.com") && strings.Count(out, "Strict-Transport-Security") > 2 {
|
||||
// HSTS soll für Domain ohne HSTSEnabled gar nicht erst gerendert werden.
|
||||
// (mgmt_https hat noch eins, plus die eine Zeile von a.example.com → 2 Vorkommen erwartet.)
|
||||
// HSTS soll für die Domain ohne HSTSEnabled gar nicht erst gerendert
|
||||
// werden: keine einzige HSTS-Zeile darf sich auf b.example.com beziehen.
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if strings.Contains(line, "Strict-Transport-Security") &&
|
||||
strings.Contains(line, "hdr(host) -i b.example.com") {
|
||||
t.Errorf("unexpected HSTS ACL for HSTS-disabled domain b.example.com: %q", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +172,45 @@ func TestRender_WWWRedirectToWWW(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_RedirectTo(t *testing.T) {
|
||||
v := View{
|
||||
Domains: []DomainView{
|
||||
{
|
||||
Domain: models.Domain{
|
||||
ID: 1, Name: "kvs.netcell-it.de", Active: true,
|
||||
RedirectTo: "https://zkm.netcell-it.de",
|
||||
},
|
||||
RedirectTo: "https://zkm.netcell-it.de",
|
||||
},
|
||||
},
|
||||
}
|
||||
out := renderView(t, v)
|
||||
want := `http-request redirect location https://zkm.netcell-it.de code 301 if { hdr(host) -i kvs.netcell-it.de }`
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("missing domain→domain 301 redirect line:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRedirectTo(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"https://zkm.netcell-it.de", "https://zkm.netcell-it.de"},
|
||||
{" https://zkm.netcell-it.de ", "https://zkm.netcell-it.de"}, // getrimmt
|
||||
{"http://x.de", "http://x.de"},
|
||||
{"", ""},
|
||||
{"zkm.netcell-it.de", ""}, // kein Schema
|
||||
{"ftp://x.de", ""}, // falsches Schema
|
||||
{"https://x .de", ""}, // Whitespace → unsafe
|
||||
{"https://x\"de", ""}, // Quote → unsafe
|
||||
{"javascript:alert(1)", ""}, // kein http(s)
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := buildRedirectTo(models.Domain{RedirectTo: c.in})
|
||||
if got != c.want {
|
||||
t.Errorf("buildRedirectTo(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHSTSHeader(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package kea renders the Kea DHCPv4 server config from the dhcp_*
|
||||
// tables and manages the kea-dhcp4-server service lifecycle.
|
||||
//
|
||||
// The config is built as a Go struct and json-marshalled (NOT a text
|
||||
// The config is built as a Go struct and json-marshaled (NOT a text
|
||||
// template) so the output is always syntactically valid JSON. Managed
|
||||
// at /etc/edgeguard/kea/kea-dhcp4.conf (edgeguard-owned); postinst
|
||||
// symlinks /etc/kea/kea-dhcp4.conf to it.
|
||||
|
||||
@@ -81,7 +81,7 @@ VALUES ($1,'aa:bb:cc:dd:ee:ff','10.0.0.50','printer',true)`, subID); err != nil
|
||||
if _, statErr := os.Stat(keaBinary); statErr == nil {
|
||||
f, _ := os.CreateTemp(t.TempDir(), "kea-*.conf")
|
||||
_, _ = f.WriteString(out)
|
||||
f.Close()
|
||||
_ = f.Close()
|
||||
if combined, err := exec.Command(keaBinary, "-t", f.Name()).CombinedOutput(); err != nil {
|
||||
t.Fatalf("kea-dhcp4 -t rejected rendered config: %v\n%s", err, combined)
|
||||
}
|
||||
|
||||
@@ -2,21 +2,32 @@ global_defs {
|
||||
router_id {{ .RouterID }}
|
||||
script_user root
|
||||
enable_script_security
|
||||
# GARP: beim Master-Wechsel Gratuitous-ARP forciert senden (repeat) UND
|
||||
# periodisch auffrischen (master_refresh) — sonst lässt der Upstream-Switch
|
||||
# die VIP-MAC altern und die Failover-IP wird nach Minuten unerreichbar.
|
||||
# Ergänzt durch den Priming-Ping in keepalived-master.sh (Traffic AUS der VIP).
|
||||
# (vrrp_garp_interval/gna NICHT setzen: keepalived 2.3.x lehnt 0 ab, Default passt.)
|
||||
vrrp_garp_master_repeat 5
|
||||
vrrp_garp_master_refresh 60
|
||||
}
|
||||
|
||||
{{/* KEIN weight: in einer vrrp_sync_group ignoriert keepalived gewichtete
|
||||
Track-Scripts ("ignoring tracked script ... with weights due to SYNC
|
||||
group") → die Health-Checks wären wirkungslos. Ohne weight wirken sie
|
||||
als binäre FAULT-Trigger: schlägt ein Check fall-mal in Folge fehl,
|
||||
geht die Instanz (und via Sync-Group der ganze Node) in FAULT → der
|
||||
gesunde Peer übernimmt. Für einen 2-Node-Cluster die saubere Semantik. */}}
|
||||
vrrp_script chk_edgeguard {
|
||||
script "/usr/lib/edgeguard/keepalived-check.sh"
|
||||
interval 2
|
||||
weight -50
|
||||
fall 3
|
||||
fall 8
|
||||
rise 2
|
||||
}
|
||||
{{ if .GWCheckIP }}
|
||||
vrrp_script chk_gateway {
|
||||
script "/usr/lib/edgeguard/keepalived-gw-check.sh {{ .GWCheckIP }}"
|
||||
interval 5
|
||||
weight -110
|
||||
fall 2
|
||||
fall 5
|
||||
rise 2
|
||||
}
|
||||
{{ end }}
|
||||
@@ -33,7 +44,8 @@ vrrp_instance VI_1 {
|
||||
interface {{ .Interface }}
|
||||
virtual_router_id {{ .RouterID }}
|
||||
priority {{ .Priority }}
|
||||
advert_int 1
|
||||
advert_int 2
|
||||
nopreempt
|
||||
{{ if .SrcIP }} unicast_src_ip {{ .SrcIP }}
|
||||
unicast_peer {
|
||||
{{ .PeerIP }}
|
||||
@@ -59,7 +71,8 @@ vrrp_instance VI_HB {
|
||||
interface {{ .HBInterface }}
|
||||
virtual_router_id {{ .HBRouterID }}
|
||||
priority {{ .Priority }}
|
||||
advert_int 1
|
||||
advert_int 2
|
||||
nopreempt
|
||||
{{ if .HBSrcIP }} unicast_src_ip {{ .HBSrcIP }}
|
||||
unicast_peer {
|
||||
{{ .HBPeerIP }}
|
||||
|
||||
@@ -167,17 +167,19 @@ func (g *generator) buildView(cs *models.ClusterSettings, vips []VIPEntry, local
|
||||
v.HBRouterID = 52
|
||||
}
|
||||
|
||||
// pg_role=standby ist das härtere Signal — ein Standby-Node ist niemals
|
||||
// MASTER, auch wenn role='primary' noch aus dem Join-Prozess stammt.
|
||||
// Reihenfolge: standby → BACKUP; sonst primary-Check.
|
||||
// State IMMER BACKUP: das Template setzt `nopreempt`, und nopreempt wirkt
|
||||
// in keepalived NUR, wenn die Instanz im BACKUP-Zustand startet (bei state
|
||||
// MASTER wird nopreempt ignoriert). Die Priorität entscheidet weiterhin die
|
||||
// Initial-Election (primary=200 gewinnt), aber ein erholter Node reißt die
|
||||
// VIP NICHT mehr zurück → kein Flap-Back / Split-Brain. Deckt sich mit der
|
||||
// "kein Auto-Promote"-Philosophie: Promotion bleibt manuell.
|
||||
// pg_role=standby ist das härtere Signal (Standby ist nie bevorzugter Node).
|
||||
v.State = "BACKUP"
|
||||
if local.PGRole == "standby" {
|
||||
v.State = "BACKUP"
|
||||
v.Priority = 100
|
||||
} else if local.PGRole == "primary" || local.Role == "primary" {
|
||||
v.State = "MASTER"
|
||||
v.Priority = 200
|
||||
} else {
|
||||
v.State = "BACKUP"
|
||||
v.Priority = 100
|
||||
}
|
||||
|
||||
|
||||
112
internal/keepalived/keepalived_test.go
Normal file
112
internal/keepalived/keepalived_test.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package keepalived
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
)
|
||||
|
||||
func render(t *testing.T, v View) string {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if err := tpl.Execute(&buf, v); err != nil {
|
||||
t.Fatalf("template execute: %v", err)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// Split-Brain-Schutz: jede vrrp_instance MUSS `nopreempt` tragen, sonst reißt
|
||||
// ein erholter Node die VIP zurück → Flapping. nopreempt wirkt nur bei state
|
||||
// BACKUP — also muss auch der bevorzugte Node BACKUP starten.
|
||||
func testView() View {
|
||||
return View{
|
||||
State: "BACKUP", Interface: "eth0", RouterID: 51, Priority: 200,
|
||||
SrcIP: "89.163.205.6", PeerIP: "89.163.205.8", AuthPass: "edgeguard",
|
||||
VIPs: []VIPEntry{{Address: "89.163.205.100", Prefix: 24, Device: "eth0"}},
|
||||
HBInterface: "ens19", HBSrcIP: "169.254.0.1", HBPeerIP: "169.254.0.2", HBRouterID: 52,
|
||||
GWCheckIP: "89.163.205.1",
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplateNopreemptOnBothInstances(t *testing.T) {
|
||||
out := render(t, testView())
|
||||
if n := strings.Count(out, "nopreempt"); n != 2 {
|
||||
t.Fatalf("erwarte nopreempt in VI_1 UND VI_HB (2×), gefunden: %d\n%s", n, out)
|
||||
}
|
||||
if strings.Contains(out, "state MASTER") {
|
||||
t.Fatalf("kein Node darf state MASTER starten (nopreempt würde ignoriert):\n%s", out)
|
||||
}
|
||||
if c := strings.Count(out, "state BACKUP"); c != 2 {
|
||||
t.Fatalf("erwarte state BACKUP in beiden Instanzen, gefunden: %d", c)
|
||||
}
|
||||
}
|
||||
|
||||
// GARP muss forciert + periodic aufgefrischt werden, sonst altert die
|
||||
// VIP-MAC am Upstream-Switch und die Failover-IP wird unerreichbar.
|
||||
func TestTemplateGARPRefresh(t *testing.T) {
|
||||
out := render(t, testView())
|
||||
for _, want := range []string{"vrrp_garp_master_refresh", "vrrp_garp_master_repeat"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("global_defs sollte %q enthalten:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// advert_int 2 (statt 1): Master-Down ~6s — reißt nicht bei kurzen
|
||||
// VM-/Heartbeat-Hiccups (Flapping-Schutz im virtualisierten Cluster).
|
||||
func TestTemplateAdvertInt(t *testing.T) {
|
||||
out := render(t, testView())
|
||||
if strings.Contains(out, "advert_int 1\n") {
|
||||
t.Fatalf("advert_int sollte 2 sein (nicht 1):\n%s", out)
|
||||
}
|
||||
if c := strings.Count(out, "advert_int 2"); c != 2 {
|
||||
t.Fatalf("erwarte advert_int 2 in beiden Instanzen, gefunden: %d", c)
|
||||
}
|
||||
}
|
||||
|
||||
// Track-Scripts dürfen KEIN weight haben: in einer vrrp_sync_group ignoriert
|
||||
// keepalived gewichtete Scripts → Health-Checks wären wirkungslos. Ohne weight
|
||||
// wirken sie als FAULT-Trigger.
|
||||
func TestTemplateTrackScriptsUnweighted(t *testing.T) {
|
||||
out := render(t, testView())
|
||||
if strings.Contains(out, "weight") {
|
||||
t.Fatalf("Track-Scripts dürfen kein weight tragen (Sync-Group ignoriert sie sonst):\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// gw-Check darf nicht zu zucken (fall 5, nicht fall 2) — ein kurzer Upstream-
|
||||
// Blip soll keinen Failover erzwingen.
|
||||
func TestTemplateGatewayCheckNotTwitchy(t *testing.T) {
|
||||
out := render(t, testView())
|
||||
if !strings.Contains(out, "fall 5") {
|
||||
t.Fatalf("chk_gateway sollte fall 5 nutzen:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// buildView: State immer BACKUP, Priorität aus pg_role.
|
||||
func TestBuildViewStateAlwaysBackup(t *testing.T) {
|
||||
g := &generator{localID: "n1"}
|
||||
cs := &models.ClusterSettings{VRRPRouterID: 51}
|
||||
pub := "89.163.205.6"
|
||||
cases := []struct {
|
||||
pgRole, role string
|
||||
wantPrio int
|
||||
}{
|
||||
{"primary", "primary", 200},
|
||||
{"standby", "primary", 100},
|
||||
{"", "primary", 200},
|
||||
{"", "", 100},
|
||||
}
|
||||
for _, c := range cases {
|
||||
local := &models.HANode{ID: "n1", PGRole: c.pgRole, Role: c.role, PublicIP: &pub}
|
||||
v := g.buildView(cs, nil, local, nil)
|
||||
if v.State != "BACKUP" {
|
||||
t.Errorf("pg_role=%q role=%q: State=%q, erwarte immer BACKUP (nopreempt)", c.pgRole, c.role, v.State)
|
||||
}
|
||||
if v.Priority != c.wantPrio {
|
||||
t.Errorf("pg_role=%q role=%q: Priority=%d, erwarte %d", c.pgRole, c.role, v.Priority, c.wantPrio)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
package license
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -136,11 +137,15 @@ func (c *Client) Verify(key string) (*Result, error) {
|
||||
activeDomains,
|
||||
)
|
||||
|
||||
resp, err := c.HTTPClient.Get(endpoint)
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("license request: %w", err)
|
||||
}
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("license server unreachable: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("license server returned status %d", resp.StatusCode)
|
||||
|
||||
@@ -3,7 +3,7 @@ package models
|
||||
import "time"
|
||||
|
||||
// ClusterSettings ist die Singleton-Tabelle (id=1) für VIP/VRRP-
|
||||
// und Replikations-Konfiguration. Angelegt in Migration 0029.
|
||||
// und Replikations-Configuration. Angelegt in Migration 0029.
|
||||
// hb_* = zweite VRRP-Instanz für Split-Brain-Schutz (0033).
|
||||
// gw_check_ip = Gateway-IP für vrrp_script chk_gateway (0033).
|
||||
type ClusterSettings struct {
|
||||
|
||||
@@ -2,7 +2,7 @@ package models
|
||||
|
||||
import "time"
|
||||
|
||||
// DHCPSettings ist die node-lokale Singleton-Konfiguration des Kea-DHCPv4-
|
||||
// DHCPSettings ist die node-lokale Singleton-Configuration des Kea-DHCPv4-
|
||||
// Servers (ob diese Node DHCP betreibt + globale Defaults).
|
||||
type DHCPSettings struct {
|
||||
ID int `gorm:"column:id;primaryKey" json:"id"`
|
||||
|
||||
@@ -19,6 +19,7 @@ type Domain struct {
|
||||
MaxBodyKB int `gorm:"column:max_body_kb" json:"max_body_kb"`
|
||||
DisableH3 bool `gorm:"column:disable_h3" json:"disable_h3"`
|
||||
Notes *string `gorm:"column:notes" json:"notes,omitempty"`
|
||||
RedirectTo string `gorm:"column:redirect_to" json:"redirect_to"` // ""=aus; sonst 301-Ziel-URL (Domain→Domain)
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package models
|
||||
|
||||
import "time"
|
||||
|
||||
// OIDCSettings ist die Singleton-Konfiguration für OIDC/Keycloak-SSO.
|
||||
// OIDCSettings ist die Singleton-Configuration für OIDC/Keycloak-SSO.
|
||||
// ClientSecretEnc trägt den verschlüsselten Client-Secret (secrets.Box)
|
||||
// und wird NIE serialisiert (json:"-").
|
||||
type OIDCSettings struct {
|
||||
|
||||
@@ -2,7 +2,7 @@ package models
|
||||
|
||||
import "time"
|
||||
|
||||
// RADIUSSettings ist die node-lokale Singleton-Konfiguration des
|
||||
// RADIUSSettings ist die node-lokale Singleton-Configuration des
|
||||
// FreeRADIUS-Servers (ob diese Node RADIUS betreibt + Listen-Adressen).
|
||||
type RADIUSSettings struct {
|
||||
ID int `gorm:"column:id;primaryKey" json:"id"`
|
||||
|
||||
@@ -26,7 +26,7 @@ type WireguardInterface struct {
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||
|
||||
// PrivateKeyEnc / PeerPSKEnc are loaded from the DB as raw bytes
|
||||
// — handler never serialises them. JSON tag uses '-' so they
|
||||
// — handler never serializes them. JSON tag uses '-' so they
|
||||
// don't leak into responses if a developer accidentally returns
|
||||
// the model directly.
|
||||
PrivateKeyEnc []byte `gorm:"column:private_key_enc" json:"-"`
|
||||
|
||||
@@ -52,9 +52,6 @@ type Service struct {
|
||||
AccountDir string
|
||||
DirURL string
|
||||
Email string
|
||||
|
||||
// loaded lazily on first call
|
||||
user *acmeUser
|
||||
}
|
||||
|
||||
// New returns a Service with sensible defaults. Email comes from
|
||||
|
||||
@@ -121,7 +121,7 @@ FROM alert_channels ORDER BY id ASC`)
|
||||
}
|
||||
|
||||
func (s *Service) CreateChannel(ctx context.Context, c Channel) (*Channel, error) {
|
||||
if c.Settings == nil || len(c.Settings) == 0 {
|
||||
if len(c.Settings) == 0 {
|
||||
c.Settings = json.RawMessage(`{}`)
|
||||
}
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
@@ -138,7 +138,7 @@ RETURNING id, name, kind, target, settings, active, created_at, updated_at`,
|
||||
}
|
||||
|
||||
func (s *Service) UpdateChannel(ctx context.Context, id int64, c Channel) (*Channel, error) {
|
||||
if c.Settings == nil || len(c.Settings) == 0 {
|
||||
if len(c.Settings) == 0 {
|
||||
c.Settings = json.RawMessage(`{}`)
|
||||
}
|
||||
row := s.Pool.QueryRow(ctx, `
|
||||
@@ -269,7 +269,7 @@ func (s *Service) sendWebhook(ctx context.Context, c Channel, kind string,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
return fmt.Errorf("webhook %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
||||
|
||||
@@ -55,7 +55,7 @@ func PackageVersions(ctx context.Context, force bool) map[string]string {
|
||||
defer cancel()
|
||||
|
||||
for _, pkg := range edgePackages {
|
||||
installed, candidate := aptCachePolicy(policyCtx, pkg)
|
||||
installed, candidate := aptCachePolicy(policyCtx, pkg) //nolint:contextcheck // detached by design — Cache-Read überlebt Request-Cancel
|
||||
out[pkg+"_installed"] = installed
|
||||
out[pkg+"_available"] = candidate
|
||||
}
|
||||
@@ -110,7 +110,7 @@ func refreshInternal(ctx context.Context, force bool) {
|
||||
}
|
||||
|
||||
// StartBackgroundRefresh: Fire-and-Forget-Goroutine die einmal beim
|
||||
// Start + dann periodisch apt-get update fährt, damit der Apt-Cache
|
||||
// Start + dann periodic apt-get update fährt, damit der Apt-Cache
|
||||
// auch ohne UI-Traffic frisch bleibt. So zeigt der Banner kurz nach
|
||||
// `make publish` ein verfügbares Update, statt 5 min auf die nächste
|
||||
// UI-Poll-Welle zu warten.
|
||||
|
||||
@@ -258,9 +258,13 @@ func (r *Repo) Cleanup(ctx context.Context, keepDays int) (int64, error) {
|
||||
if r == nil || r.Pool == nil || keepDays <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
// make_interval(days => $1) nimmt $1 als int — sauber typisiert. Der frühere
|
||||
// ($1::text || ' days')::interval-Ansatz scheiterte, weil keepDays als int
|
||||
// übergeben wird und pgx int nicht als text (OID 25) encoden kann
|
||||
// ("cannot find encode plan") → Cleanup lief nie.
|
||||
tag, err := r.Pool.Exec(ctx, `
|
||||
DELETE FROM audit_log
|
||||
WHERE created_at < NOW() - ($1::text || ' days')::interval`, keepDays)
|
||||
WHERE created_at < NOW() - make_interval(days => $1)`, keepDays)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
@@ -232,10 +231,9 @@ func (s *Service) Run(ctx context.Context, kind Kind, version string) (*Result,
|
||||
s.recordFailure(ctx, res, hostname, kind)
|
||||
return res, err
|
||||
}
|
||||
if err := f.Sync(); err != nil {
|
||||
// Nicht fatal — fsync-failure kann passieren bei tmpfs in
|
||||
// Dev, aber der File ist da.
|
||||
}
|
||||
// fsync best-effort — Nicht fatal: fsync-failure kann bei tmpfs in
|
||||
// Dev passieren, der File ist trotzdem da.
|
||||
_ = f.Sync()
|
||||
if err := f.Close(); err != nil {
|
||||
s.recordFailure(ctx, res, hostname, kind)
|
||||
return res, err
|
||||
@@ -644,10 +642,6 @@ func (b *bytes) Write(p []byte) (int, error) {
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// strFold ist ein utility nur zum Defensiv-Check, dass kind ein
|
||||
// erlaubter Wert ist (für die DB-Constraint).
|
||||
func strFold(s string) string { return strings.ToLower(strings.TrimSpace(s)) }
|
||||
|
||||
// SortByDate sortiert Entries newest-first. Wird nicht direkt benutzt
|
||||
// (DB-Query macht's), aber praktisch wenn der Caller eine eigene
|
||||
// Liste hat.
|
||||
|
||||
@@ -180,7 +180,7 @@ func uploadS3(ctx context.Context, s S3Settings, localPath string) (int64, error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
defer func() { _ = f.Close() }()
|
||||
stat, err := f.Stat()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
@@ -245,12 +245,12 @@ func uploadSFTP(ctx context.Context, s SFTPSettings, localPath string) (int64, e
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("ssh dial %s: %w", addr, err)
|
||||
}
|
||||
defer conn.Close()
|
||||
defer func() { _ = conn.Close() }()
|
||||
cl, err := sftp.NewClient(conn)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("sftp client: %w", err)
|
||||
}
|
||||
defer cl.Close()
|
||||
defer func() { _ = cl.Close() }()
|
||||
|
||||
// remote-dir anlegen (idempotent)
|
||||
_ = cl.MkdirAll(s.RemoteDir)
|
||||
@@ -259,7 +259,7 @@ func uploadSFTP(ctx context.Context, s SFTPSettings, localPath string) (int64, e
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer src.Close()
|
||||
defer func() { _ = src.Close() }()
|
||||
stat, err := src.Stat()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
@@ -269,10 +269,16 @@ func uploadSFTP(ctx context.Context, s SFTPSettings, localPath string) (int64, e
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create remote: %w", err)
|
||||
}
|
||||
defer dst.Close()
|
||||
if _, err := io.Copy(dst, src); err != nil {
|
||||
_ = dst.Close()
|
||||
return 0, fmt.Errorf("copy: %w", err)
|
||||
}
|
||||
// SFTP-Close flusht gepufferte Writes — ein Fehler hier bedeutet
|
||||
// ein unvollständiges Remote-File und MUSS als Upload-Fehler
|
||||
// gemeldet werden (nicht als Erfolg durchgehen).
|
||||
if err := dst.Close(); err != nil {
|
||||
return 0, fmt.Errorf("close remote: %w", err)
|
||||
}
|
||||
return stat.Size(), nil
|
||||
}
|
||||
|
||||
@@ -284,9 +290,9 @@ func (s *Service) Test(ctx context.Context, t Target) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(tmp.Name())
|
||||
defer func() { _ = os.Remove(tmp.Name()) }()
|
||||
_, _ = tmp.WriteString("edgeguard remote-target test " + time.Now().Format(time.RFC3339))
|
||||
tmp.Close()
|
||||
_ = tmp.Close()
|
||||
|
||||
r := s.uploadOne(ctx, t, tmp.Name())
|
||||
if !r.OK {
|
||||
@@ -331,9 +337,9 @@ func (s *Service) Test(ctx context.Context, t Target) error {
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(), Timeout: 5 * time.Second}); err == nil {
|
||||
if cl, err := sftp.NewClient(conn); err == nil {
|
||||
_ = cl.Remove(strings.TrimRight(settings.RemoteDir, "/") + "/" + filepath.Base(tmp.Name()))
|
||||
cl.Close()
|
||||
_ = cl.Close()
|
||||
}
|
||||
conn.Close()
|
||||
_ = conn.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package clusterjoin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
@@ -177,7 +178,7 @@ func NormalizePrimaryURL(in string) (string, error) {
|
||||
|
||||
func issueCert(primary, token, csr string, insecure bool) (caCert, peerCert string, err error) {
|
||||
body, _ := json.Marshal(map[string]string{"token": token, "csr": csr})
|
||||
req, err := http.NewRequest(http.MethodPost,
|
||||
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost,
|
||||
primary+"/api/v1/cluster/issue-cert", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
@@ -193,7 +194,7 @@ func issueCert(primary, token, csr string, insecure bool) (caCert, peerCert stri
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
|
||||
@@ -287,7 +288,7 @@ func autoRegister(primary, tlsDir, commonName, version, nodeID, configHash, role
|
||||
}
|
||||
client := &http.Client{Transport: tr, Timeout: 30 * time.Second}
|
||||
|
||||
httpReq, err := http.NewRequest(http.MethodPost, u.String(), bytes.NewReader(body))
|
||||
httpReq, err := http.NewRequestWithContext(context.Background(), http.MethodPost, u.String(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -296,7 +297,7 @@ func autoRegister(primary, tlsDir, commonName, version, nodeID, configHash, role
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
|
||||
|
||||
@@ -63,9 +63,9 @@ func Run(ctx context.Context, gens []configgen.Generator, only []string) ([]Resu
|
||||
return out, errors.Join(errs...)
|
||||
}
|
||||
|
||||
// Summarise turns the result slice into a human-readable multiline
|
||||
// Summarize turns the result slice into a human-readable multiline
|
||||
// string. Used by `edgeguard-ctl render-config` to print to stdout.
|
||||
func Summarise(results []Result) string {
|
||||
func Summarize(results []Result) string {
|
||||
var b strings.Builder
|
||||
for _, r := range results {
|
||||
if r.Skipped {
|
||||
|
||||
@@ -34,7 +34,7 @@ type Result struct {
|
||||
Took time.Duration `json:"-"`
|
||||
}
|
||||
|
||||
// validTarget ist eine konservative Erlaubnis: Buchstaben, Ziffern,
|
||||
// validTarget ist eine conservative Erlaubnis: Buchstaben, Ziffern,
|
||||
// Punkt, Doppelpunkt (IPv6), Schrägstrich (Pfade in curl-URLs), Bindestrich,
|
||||
// Unterstrich. Whitespace, $, `, `;`, `&`, `|`, `>` etc. werden gesperrt.
|
||||
func validTarget(s string, max int) error {
|
||||
|
||||
@@ -24,7 +24,7 @@ SELECT id, name, active, primary_backend_id, http_to_https,
|
||||
hsts_enabled, hsts_max_age, hsts_subdomains, hsts_preload,
|
||||
maintenance_mode, maintenance_message, www_redirect,
|
||||
rate_limit_rps, max_body_kb, disable_h3,
|
||||
notes, created_at, updated_at
|
||||
notes, redirect_to, created_at, updated_at
|
||||
FROM domains
|
||||
`
|
||||
|
||||
@@ -65,17 +65,17 @@ func (r *Repo) Create(ctx context.Context, d models.Domain) (*models.Domain, err
|
||||
INSERT INTO domains (name, active, primary_backend_id, http_to_https,
|
||||
hsts_enabled, hsts_max_age, hsts_subdomains, hsts_preload,
|
||||
maintenance_mode, maintenance_message, www_redirect,
|
||||
rate_limit_rps, max_body_kb, disable_h3, notes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
||||
rate_limit_rps, max_body_kb, disable_h3, notes, redirect_to)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
||||
RETURNING id, name, active, primary_backend_id, http_to_https,
|
||||
hsts_enabled, hsts_max_age, hsts_subdomains, hsts_preload,
|
||||
maintenance_mode, maintenance_message, www_redirect,
|
||||
rate_limit_rps, max_body_kb, disable_h3,
|
||||
notes, created_at, updated_at`,
|
||||
notes, redirect_to, created_at, updated_at`,
|
||||
d.Name, d.Active, d.PrimaryBackendID, d.HTTPToHTTPS,
|
||||
d.HSTSEnabled, d.HSTSMaxAge, d.HSTSSubdomains, d.HSTSPreload,
|
||||
d.MaintenanceMode, d.MaintenanceMessage, d.WWWRedirect,
|
||||
d.RateLimitRPS, d.MaxBodyKB, d.DisableH3, d.Notes)
|
||||
d.RateLimitRPS, d.MaxBodyKB, d.DisableH3, d.Notes, d.RedirectTo)
|
||||
return scanDomain(row)
|
||||
}
|
||||
|
||||
@@ -100,17 +100,18 @@ UPDATE domains SET
|
||||
max_body_kb = $13,
|
||||
disable_h3 = $14,
|
||||
notes = $15,
|
||||
redirect_to = $16,
|
||||
updated_at = NOW()
|
||||
WHERE id = $16
|
||||
WHERE id = $17
|
||||
RETURNING id, name, active, primary_backend_id, http_to_https,
|
||||
hsts_enabled, hsts_max_age, hsts_subdomains, hsts_preload,
|
||||
maintenance_mode, maintenance_message, www_redirect,
|
||||
rate_limit_rps, max_body_kb, disable_h3,
|
||||
notes, created_at, updated_at`,
|
||||
notes, redirect_to, created_at, updated_at`,
|
||||
d.Name, d.Active, d.PrimaryBackendID, d.HTTPToHTTPS,
|
||||
d.HSTSEnabled, d.HSTSMaxAge, d.HSTSSubdomains, d.HSTSPreload,
|
||||
d.MaintenanceMode, d.MaintenanceMessage, d.WWWRedirect,
|
||||
d.RateLimitRPS, d.MaxBodyKB, d.DisableH3, d.Notes, id)
|
||||
d.RateLimitRPS, d.MaxBodyKB, d.DisableH3, d.Notes, d.RedirectTo, id)
|
||||
out, err := scanDomain(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
@@ -139,7 +140,7 @@ func scanDomain(row interface{ Scan(...any) error }) (*models.Domain, error) {
|
||||
&d.HSTSEnabled, &d.HSTSMaxAge, &d.HSTSSubdomains, &d.HSTSPreload,
|
||||
&d.MaintenanceMode, &d.MaintenanceMessage, &d.WWWRedirect,
|
||||
&d.RateLimitRPS, &d.MaxBodyKB, &d.DisableH3,
|
||||
&d.Notes, &d.CreatedAt, &d.UpdatedAt,
|
||||
&d.Notes, &d.RedirectTo, &d.CreatedAt, &d.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ func (r *AddressGroupsRepo) Create(ctx context.Context, g models.FirewallAddress
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
row := tx.QueryRow(ctx, `
|
||||
INSERT INTO firewall_address_groups (name, description)
|
||||
@@ -125,7 +125,7 @@ func (r *AddressGroupsRepo) Update(ctx context.Context, id int64, g models.Firew
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
row := tx.QueryRow(ctx, `
|
||||
UPDATE firewall_address_groups SET name = $1, description = $2, updated_at = NOW()
|
||||
|
||||
@@ -91,7 +91,7 @@ func (r *ServiceGroupsRepo) Create(ctx context.Context, g models.FirewallService
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
row := tx.QueryRow(ctx, `
|
||||
INSERT INTO firewall_service_groups (name, description) VALUES ($1, $2)
|
||||
@@ -115,7 +115,7 @@ func (r *ServiceGroupsRepo) Update(ctx context.Context, id int64, g models.Firew
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
row := tx.QueryRow(ctx, `
|
||||
UPDATE firewall_service_groups SET name = $1, description = $2, updated_at = NOW()
|
||||
|
||||
@@ -102,7 +102,7 @@ func ReadTail(path string, f Filter) ([]Entry, error) {
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
// Ring-Buffer für die letzten f.Limit matching entries.
|
||||
buf := make([]Entry, 0, f.Limit)
|
||||
|
||||
@@ -64,7 +64,7 @@ func (t *Tailer) Start(ctx context.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer w.Close()
|
||||
defer func() { _ = w.Close() }()
|
||||
|
||||
// Watch das Verzeichnis statt der Datei — wenn ulogd das File
|
||||
// erstmals anlegt (oder rotate-rename), bekommen wir Create-Events.
|
||||
@@ -106,7 +106,7 @@ func (t *Tailer) bootstrap() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
defer func() { _ = f.Close() }()
|
||||
sc := bufio.NewScanner(f)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
for sc.Scan() {
|
||||
@@ -134,7 +134,7 @@ func (t *Tailer) drainFile() {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
stat, err := f.Stat()
|
||||
if err != nil {
|
||||
|
||||
@@ -40,11 +40,17 @@ func (g *Generator) render(ctx context.Context, excludeEthernet bool) error {
|
||||
prefix int
|
||||
}
|
||||
|
||||
// is_vip-Adressen werden NIE statisch gebunden — sie gehören
|
||||
// ausschließlich keepalived (nur der VRRP-Master trägt die VIP). Würde
|
||||
// der Apply sie statisch binden, läge die VIP nach einem Failover auf
|
||||
// BEIDEN Nodes (statisch hier + keepalived drüben) → Duplicate-IP/ARP-
|
||||
// Konflikt → Tunnel/LAN bricht. Deshalb hart ausschließen.
|
||||
q := `
|
||||
SELECT ni.name, ia.address, ia.prefix
|
||||
FROM ip_addresses ia
|
||||
JOIN network_interfaces ni ON ni.id = ia.interface_id
|
||||
WHERE ia.active = true`
|
||||
WHERE ia.active = true
|
||||
AND ia.is_vip = false`
|
||||
if excludeEthernet {
|
||||
q += `
|
||||
AND ni.type != 'ethernet'`
|
||||
|
||||
@@ -23,7 +23,7 @@ type Claims struct {
|
||||
}
|
||||
|
||||
// Authenticator ist der testbare Seam: Aufbau der Auth-URL und der
|
||||
// Code-Exchange inkl. ID-Token-Verifikation + Claim-Extraktion. Der
|
||||
// Code-Exchange inkl. ID-Token-Verification + Claim-Extraktion. Der
|
||||
// Handler hängt nur hieran, sodass Tests einen Fake injizieren können.
|
||||
type Authenticator interface {
|
||||
// AuthCodeURL baut die Redirect-URL zum IdP (state + nonce + PKCE-Challenge).
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package oidc kapselt die OIDC/Keycloak-SSO-Konfiguration (Singleton-
|
||||
// Package oidc kapselt die OIDC/Keycloak-SSO-Configuration (Singleton-
|
||||
// Settings + verschlüsseltes Client-Secret) und einen lazy aufgebauten
|
||||
// OIDC-Provider/Verifier. Login-Flow-State ist stateless (signiertes
|
||||
// Cookie im Handler), daher hält dieses Paket keinen Request-State.
|
||||
|
||||
@@ -29,7 +29,7 @@ const masterKeyLen = 32
|
||||
const DefaultMasterKeyPath = "/var/lib/edgeguard/.master_key"
|
||||
|
||||
// Box uses AES-256-GCM with a static master key to seal/unseal
|
||||
// values. Concurrency-safe; the cipher is initialised once.
|
||||
// values. Concurrency-safe; the cipher is initialized once.
|
||||
type Box struct {
|
||||
once sync.Once
|
||||
aead cipher.AEAD
|
||||
|
||||
@@ -179,9 +179,14 @@ func (r *Repo) ListAlerts(ctx context.Context, domainID *int64, limit int) ([]Wa
|
||||
}
|
||||
|
||||
// PurgeAlerts removes alerts older than the given number of days.
|
||||
//
|
||||
// make_interval(days => $1) nimmt $1 als int — sauber typisiert. Der
|
||||
// frühere ($1 || ' days')::interval-Ansatz erzwang $1 als text; pgx
|
||||
// bekam aber einen int und scheiterte mit einem Encode-Fehler zur
|
||||
// Laufzeit (gleiche Klasse wie der audit-Cleanup-Bug, v1.3.0).
|
||||
func (r *Repo) PurgeAlerts(ctx context.Context, olderThanDays int) error {
|
||||
_, err := r.Pool.Exec(ctx,
|
||||
`DELETE FROM waf_alerts WHERE created_at < NOW() - ($1 || ' days')::interval`,
|
||||
`DELETE FROM waf_alerts WHERE created_at < NOW() - make_interval(days => $1)`,
|
||||
olderThanDays,
|
||||
)
|
||||
return err
|
||||
|
||||
@@ -15,12 +15,12 @@ import (
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets"
|
||||
)
|
||||
|
||||
// ImportResult summarises what an Import call did so the CLI can
|
||||
// ImportResult summarizes what an Import call did so the CLI can
|
||||
// report it back to the operator.
|
||||
type ImportResult struct {
|
||||
IfacesAdded int `json:"ifaces_added"`
|
||||
PeersAdded int `json:"peers_added"`
|
||||
Skipped []string `json:"skipped,omitempty"` // ifaces already present, with reason
|
||||
IfacesAdded int `json:"ifaces_added"`
|
||||
PeersAdded int `json:"peers_added"`
|
||||
Skipped []string `json:"skipped,omitempty"` // ifaces already present, with reason
|
||||
}
|
||||
|
||||
// Importer takes existing /etc/wireguard/*.conf files and translates
|
||||
@@ -292,7 +292,7 @@ func parseWGConf(path string) (*parsedConf, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
var (
|
||||
out parsedConf
|
||||
|
||||
@@ -36,10 +36,10 @@ func buildDirectives(cfg models.WafConfig, crsDir string) string {
|
||||
|
||||
sb.WriteString("SecRequestBodyAccess On\n")
|
||||
sb.WriteString("SecResponseBodyAccess Off\n")
|
||||
sb.WriteString("SecRequestBodyLimit 13107200\n") // 12.5 MB
|
||||
sb.WriteString("SecRequestBodyLimit 13107200\n") // 12.5 MB
|
||||
sb.WriteString("SecRequestBodyInMemoryLimit 131072\n") // 128 KB
|
||||
|
||||
sb.WriteString(fmt.Sprintf("SecRuleEngine %s\n", ruleEngineMode(cfg.Mode)))
|
||||
fmt.Fprintf(&sb, "SecRuleEngine %s\n", ruleEngineMode(cfg.Mode))
|
||||
|
||||
if crsDir != "" && crsAvailable(crsDir) {
|
||||
// Paranoia level MUST be set before CRS rules are included.
|
||||
@@ -47,22 +47,20 @@ func buildDirectives(cfg models.WafConfig, crsDir string) string {
|
||||
if pl < 1 || pl > 4 {
|
||||
pl = 1
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(
|
||||
"SecAction \"id:900000,phase:1,nolog,pass,t:none,setvar:tx.paranoia_level=%d\"\n", pl,
|
||||
))
|
||||
fmt.Fprintf(&sb, "SecAction \"id:900000,phase:1,nolog,pass,t:none,setvar:tx.paranoia_level=%d\"\n", pl)
|
||||
setupConf := filepath.Join(crsDir, "crs-setup.conf")
|
||||
if _, err := os.Stat(setupConf); err == nil {
|
||||
sb.WriteString(fmt.Sprintf("Include %s\n", setupConf))
|
||||
fmt.Fprintf(&sb, "Include %s\n", setupConf)
|
||||
}
|
||||
rulesGlob := filepath.Join(crsDir, "rules", "*.conf")
|
||||
sb.WriteString(fmt.Sprintf("Include %s\n", rulesGlob))
|
||||
fmt.Fprintf(&sb, "Include %s\n", rulesGlob)
|
||||
}
|
||||
|
||||
// Rule exclusions (applied after CRS load so they override CRS).
|
||||
for _, id := range cfg.RuleExclusions {
|
||||
id = strings.TrimSpace(id)
|
||||
if id != "" {
|
||||
sb.WriteString(fmt.Sprintf("SecRuleRemoveById %s\n", id))
|
||||
fmt.Fprintf(&sb, "SecRuleRemoveById %s\n", id)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,12 +15,12 @@ import (
|
||||
// SPOEAgent wraps the haproxy-go SPOE server and dispatches each
|
||||
// inspected request to the appropriate per-domain Coraza engine.
|
||||
type SPOEAgent struct {
|
||||
Manager *Manager
|
||||
AlertWriter *AlertWriter
|
||||
Addr string
|
||||
Manager *Manager
|
||||
AlertWriter *AlertWriter
|
||||
Addr string
|
||||
}
|
||||
|
||||
// ListenAndServe starts the SPOE agent. Blocks until ctx is cancelled.
|
||||
// ListenAndServe starts the SPOE agent. Blocks until ctx is canceled.
|
||||
func (a *SPOEAgent) ListenAndServe(ctx context.Context) error {
|
||||
agent := spop.Agent{
|
||||
Addr: a.Addr,
|
||||
|
||||
@@ -533,6 +533,8 @@
|
||||
"wwwRedirectNone": "Kein Redirect",
|
||||
"wwwRedirectToNaked": "→ naked (ohne www)",
|
||||
"wwwRedirectToWWW": "→ www",
|
||||
"redirectTo": "Weiterleitung (301) nach",
|
||||
"redirectToHint": "Leitet ALLE Anfragen dieser Domain per 301 auf die Ziel-URL um (z. B. https://zkm.netcell-it.de) — immer auf die Ziel-Root. Ist es gesetzt, routet die Domain auf kein Backend. Die Domain braucht trotzdem ein eigenes TLS-Zertifikat. Leer = aus.",
|
||||
"rateLimit": "Rate-Limit (pro Client-IP)",
|
||||
"rateLimitHint": "Max. Requests pro Sekunde je Client-IP. HAProxy zählt über ein 10-Sekunden-Fenster pro Stick-Table (max. 100k IPs). 0 = aus.",
|
||||
"maxBody": "Max. Request-Body",
|
||||
|
||||
@@ -533,6 +533,8 @@
|
||||
"wwwRedirectNone": "No redirect",
|
||||
"wwwRedirectToNaked": "→ naked (no www)",
|
||||
"wwwRedirectToWWW": "→ www",
|
||||
"redirectTo": "Redirect (301) to",
|
||||
"redirectToHint": "Redirects ALL requests for this domain with a 301 to the target URL (e.g. https://zkm.netcell-it.de) — always to the target root. When set, the domain routes to no backend. The domain still needs its own TLS certificate. Empty = off.",
|
||||
"rateLimit": "Rate limit (per client IP)",
|
||||
"rateLimitHint": "Max requests per second per client IP. HAProxy counts over a 10-second window per stick table (max. 100k IPs). 0 = off.",
|
||||
"maxBody": "Max request body",
|
||||
|
||||
@@ -27,6 +27,7 @@ interface Domain {
|
||||
hsts_subdomains: boolean; hsts_preload: boolean
|
||||
maintenance_mode: boolean; maintenance_message?: string | null
|
||||
www_redirect: '' | 'to-naked' | 'to-www'
|
||||
redirect_to: string
|
||||
rate_limit_rps: number; max_body_kb: number
|
||||
disable_h3: boolean
|
||||
notes?: string | null
|
||||
@@ -40,6 +41,7 @@ interface DomainFormValues {
|
||||
hsts_subdomains: boolean; hsts_preload: boolean
|
||||
maintenance_mode: boolean; maintenance_message?: string
|
||||
www_redirect: '' | 'to-naked' | 'to-www'
|
||||
redirect_to: string
|
||||
rate_limit_rps: number; max_body_kb: number
|
||||
disable_h3: boolean
|
||||
notes?: string
|
||||
@@ -272,6 +274,7 @@ export default function DomainDetailPage() {
|
||||
maintenance_mode: domain.maintenance_mode,
|
||||
maintenance_message: domain.maintenance_message ?? '',
|
||||
www_redirect: domain.www_redirect ?? '',
|
||||
redirect_to: domain.redirect_to ?? '',
|
||||
rate_limit_rps: domain.rate_limit_rps ?? 0,
|
||||
max_body_kb: domain.max_body_kb ?? 0,
|
||||
disable_h3: domain.disable_h3 ?? false,
|
||||
@@ -333,6 +336,10 @@ export default function DomainDetailPage() {
|
||||
]} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t('domains.redirectTo')} name="redirect_to" extra={t('domains.redirectToHint')}>
|
||||
<Input placeholder="https://ziel-domain.de" allowClear />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t('domains.maintenance')} name="maintenance_mode" valuePropName="checked"
|
||||
extra={t('domains.maintenanceHint')}>
|
||||
<Switch />
|
||||
|
||||
@@ -845,6 +845,25 @@ EOSQL
|
||||
# atomic-write (tempfile → rename) durchführen kann.
|
||||
install -d -m 0755 /etc/keepalived
|
||||
chown "$EG_USER":"$EG_USER" /etc/keepalived
|
||||
# ── keepalived systemd drop-in: Boot-Race-Fix ──────────────────
|
||||
# keepalived referenziert VIP-Devices (vlanXXX), die erst von
|
||||
# edgeguard-interfaces.service angelegt werden. Ohne Ordering startet
|
||||
# keepalived vor den VLANs (beide nur After=network-online.target) →
|
||||
# "interface vlanX doesn't exist" → permanenter CONFIG-Crash OHNE
|
||||
# Auto-Recovery (keepalived bleibt nach Reboot tot). After=/Wants=
|
||||
# wartet auf die Interfaces; Restart=on-failure ist das Sicherheitsnetz.
|
||||
install -d /etc/systemd/system/keepalived.service.d
|
||||
cat > /etc/systemd/system/keepalived.service.d/10-edgeguard.conf <<'KEEPALIVEDDROPIN'
|
||||
[Unit]
|
||||
After=edgeguard-interfaces.service
|
||||
Wants=edgeguard-interfaces.service
|
||||
StartLimitIntervalSec=0
|
||||
|
||||
[Service]
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
KEEPALIVEDDROPIN
|
||||
systemctl daemon-reload
|
||||
sudo -n -u "$EG_USER" /usr/bin/edgeguard-ctl render-config --only=keepalived || true
|
||||
if [ -f /etc/keepalived/keepalived.conf ]; then
|
||||
systemctl enable keepalived >/dev/null 2>&1 || true
|
||||
|
||||
@@ -7,6 +7,21 @@
|
||||
logger -t keepalived -p daemon.warning \
|
||||
"MASTER: VIP übernommen — PG-Rolle ist noch '$(cat /var/lib/edgeguard/pg_role 2>/dev/null || echo standby)'. Für PG-Failover: edgeguard-ctl promote"
|
||||
|
||||
# ── Upstream-ARP/Routing für die Failover-VIP(s) aktualisieren ──
|
||||
# Manche Hoster lernen die neue MAC einer Failover-IP NICHT zuverlässig über
|
||||
# Gratuitous-ARP, sondern erst, wenn sie Traffic VON der IP sehen. Ohne das ist
|
||||
# die VIP nach einem Schwenk von außen unerreichbar. Wir pingen daher den
|
||||
# Default-Gateway aus jeder Public-IP (inkl. VIP) an (source via -I), damit der
|
||||
# Upstream die MAC sofort umlernt. Hintergrund-Pings, damit notify nicht blockt.
|
||||
WAN_DEV="$(ip -4 route show default 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="dev"){print $(i+1); exit}}')"
|
||||
WAN_GW="$(ip -4 route show default 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="via"){print $(i+1); exit}}')"
|
||||
if [ -n "$WAN_DEV" ] && [ -n "$WAN_GW" ]; then
|
||||
for vip in $(ip -4 -o addr show dev "$WAN_DEV" scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1); do
|
||||
ping -I "$vip" -c 3 -W 1 "$WAN_GW" >/dev/null 2>&1 &
|
||||
done
|
||||
logger -t keepalived -p daemon.info "MASTER: Upstream-ARP via Ping aus VIP(s) auf $WAN_GW ($WAN_DEV) angestoßen"
|
||||
fi
|
||||
|
||||
# Dienste reloaden/starten damit sie die neu aktiven VIPs binden.
|
||||
# Squid + Unbound + HAProxy binden beim Start an spezifische IPs — war der Dienst
|
||||
# während des BACKUP-Zustands gecrasht oder gestoppt, muss er gestartet werden.
|
||||
|
||||
Reference in New Issue
Block a user