Compare commits
12 Commits
v1.2.100
...
d15774f1cd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d15774f1cd | ||
|
|
4c1e0e9926 | ||
|
|
2495bf022b | ||
|
|
25ec98161f | ||
|
|
79cd68e460 | ||
|
|
f3c76f6d18 | ||
|
|
b2fc7b7dee | ||
|
|
9b563baaa1 | ||
|
|
d8b8fef680 | ||
|
|
fd8125247c | ||
|
|
7b6409b631 | ||
|
|
a2450a759c |
42
.golangci.yml
Normal file
42
.golangci.yml
Normal file
@@ -0,0 +1,42 @@
|
||||
# Go-Quality-Baseline für EdgeGuard (portabel für weitere Go-Projekte).
|
||||
# Rollout: erst NON-BLOCKING laufen lassen (Bestand aufräumen), dann in der
|
||||
# CI/Makefile auf harten Gate stellen. govulncheck ist bereits ein harter
|
||||
# Release-Gate (siehe Makefile: vulncheck / release-check).
|
||||
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
|
||||
- rowserrcheck # fehlendes rows.Err() (database/sql-Muster)
|
||||
- sqlclosecheck # fehlendes rows/stmt.Close()
|
||||
# ── 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
|
||||
|
||||
# HINWEIS: rowserrcheck/sqlclosecheck zielen auf database/sql. EdgeGuard nutzt
|
||||
# pgx/pgxpool — dort greifen sie nur teilweise. 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`** (`.golangci.yml`: staticcheck, govet, errcheck, ineffassign, unused, misspell, **gosec**, **bodyclose**, rowserrcheck, sqlclosecheck, noctx, contextcheck) läuft im **Rollout noch NON-BLOCKING** (Bestand aufräumen), danach auf harten Gate stellen.
|
||||
- **`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
|
||||
|
||||
34
Makefile
34
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 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,35 @@ build-linux-arm64:
|
||||
test:
|
||||
$(GO) test $(GOFLAGS) ./...
|
||||
|
||||
test-race:
|
||||
CGO_ENABLED=1 $(GO) test $(GOFLAGS) -race ./...
|
||||
|
||||
lint:
|
||||
$(GO) vet ./...
|
||||
@command -v staticcheck >/dev/null && staticcheck ./... || echo "staticcheck not installed, skipping"
|
||||
@command -v golangci-lint >/dev/null 2>&1 && golangci-lint run \
|
||||
|| echo "golangci-lint nicht installiert (go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest) — übersprungen"
|
||||
|
||||
GOBIN := $(shell $(GO) env GOPATH)/bin
|
||||
|
||||
# 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 (Rollout: NON-BLOCKING) → govulncheck (GATE)
|
||||
# → build → test -race. govulncheck bricht bei jedem Fund ab; der Linter ist
|
||||
# in der Rollout-Phase noch non-blocking (Bestand aufräumen, dann auf Gate).
|
||||
release-check:
|
||||
$(GO) vet ./...
|
||||
@command -v golangci-lint >/dev/null 2>&1 && (golangci-lint run || echo " ⚠ golangci-lint-Findings (non-blocking, Rollout-Phase)") \
|
||||
|| echo " golangci-lint nicht installiert — übersprungen (Rollout-Phase)"
|
||||
@$(MAKE) --no-print-directory vulncheck
|
||||
$(GO) build ./...
|
||||
CGO_ENABLED=1 $(GO) test $(GOFLAGS) -race ./...
|
||||
@echo " ✓ Go-Quality-Baseline bestanden (vet, govulncheck, build, test -race)"
|
||||
|
||||
tidy:
|
||||
$(GO) mod tidy
|
||||
@@ -74,10 +100,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
|
||||
|
||||
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=
|
||||
|
||||
@@ -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}}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -170,6 +170,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
|
||||
|
||||
@@ -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 + periodisch 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'`
|
||||
|
||||
@@ -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