Compare commits
30 Commits
32ab2c7f47
...
v1.3.26
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3215da8a84 | ||
|
|
6f69697705 | ||
|
|
33cfc1a90d | ||
|
|
3e05c7fe49 | ||
|
|
924540d7a9 | ||
|
|
561816d79d | ||
|
|
cab78eb3d1 | ||
|
|
f61f82d36f | ||
|
|
7aa2a907d5 | ||
|
|
99df6f731d | ||
|
|
bab82f8d5b | ||
|
|
7ff6575790 | ||
|
|
51e5fe83d9 | ||
|
|
b70db4ccf0 | ||
|
|
f1f7df74f7 | ||
|
|
de153dc13a | ||
|
|
5c268425c1 | ||
|
|
0846eaa05b | ||
|
|
d395e3ea68 | ||
|
|
f0be5be496 | ||
|
|
235b5c3b9a | ||
|
|
37f729381d | ||
|
|
088910ee19 | ||
|
|
09e6e0c4f7 | ||
|
|
18ee243a44 | ||
|
|
4856779db8 | ||
|
|
8e4759ccc6 | ||
|
|
96290253c8 | ||
|
|
dca40761a4 | ||
|
|
0ac91a7c59 |
15
Makefile
15
Makefile
@@ -104,7 +104,7 @@ ui:
|
||||
@echo " -> management-ui (vite build, version $(VERSION))"
|
||||
@cd management-ui && \
|
||||
if [ -x "$$(command -v bun)" ]; then bun install --silent && bun run build; \
|
||||
else npm install --silent && npm run build; fi
|
||||
else npm install --include=dev --silent && npm run build; fi
|
||||
|
||||
deb-amd64: release-check build-linux-amd64 ui
|
||||
@./scripts/apt-repo/build-package.sh amd64 $(VERSION)
|
||||
@@ -114,17 +114,18 @@ deb-arm64: release-check build-linux-arm64 ui
|
||||
|
||||
deb: deb-amd64 deb-arm64
|
||||
|
||||
GITEA_DEB_URL := https://git.netcell-it.de/api/packages/projekte/debian/pool/trixie/main/upload
|
||||
|
||||
# Direktes `make publish` bleibt als Handnotbremse erhalten, veröffentlicht
|
||||
# aber immer nach stable — für Testing-Releases + das Stable-Promotion-
|
||||
# Gate (verify_channel_debs, Version-Bump, Git-Tag) scripts/release.sh nutzen.
|
||||
publish-amd64: deb-amd64
|
||||
@./scripts/apt-repo/publish.sh $(VERSION) amd64
|
||||
@./scripts/apt-repo/publish.sh $(VERSION) amd64 stable
|
||||
@echo " -> cleanup-old (keep last $${KEEP:-10})"
|
||||
@./scripts/apt-repo/cleanup-old.sh
|
||||
@./scripts/apt-repo/cleanup-old.sh stable
|
||||
|
||||
publish-arm64: deb-arm64
|
||||
@./scripts/apt-repo/publish.sh $(VERSION) arm64
|
||||
@./scripts/apt-repo/publish.sh $(VERSION) arm64 stable
|
||||
@echo " -> cleanup-old (keep last $${KEEP:-10})"
|
||||
@./scripts/apt-repo/cleanup-old.sh
|
||||
@./scripts/apt-repo/cleanup-old.sh stable
|
||||
|
||||
publish: publish-amd64 publish-arm64
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -22,6 +23,7 @@ import (
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/clustertls"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/jointoken"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/crowdsec"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
|
||||
firewallrender "git.netcell-it.de/projekte/edgeguard-native/internal/firewall"
|
||||
radiusrender "git.netcell-it.de/projekte/edgeguard-native/internal/freeradius"
|
||||
@@ -307,6 +309,7 @@ func main() {
|
||||
"unbound": unboundrender.New(pool).RenderToString,
|
||||
"chrony": chronyrender.New(pool).RenderToString,
|
||||
"wireguard": wgrender.New(pool, secretsBox).RenderToString,
|
||||
"crowdsec-whitelist": crowdsec.NewWhitelistGenerator(pool).RenderToString,
|
||||
})
|
||||
setupHdl.WithAudit(auditRepo, nodeID)
|
||||
setupHdl.WithClusterSupport(clusterStore, func(ctx context.Context) error {
|
||||
@@ -323,6 +326,14 @@ func main() {
|
||||
return haproxy.New(pool).Render(ctx)
|
||||
}
|
||||
|
||||
// Domain-Mutationen rendern zusätzlich die CrowdSec-Admin-Whitelist neu
|
||||
// (Flag crowdsec_trusted → host-genaue Ausnahme). No-op ohne CrowdSec.
|
||||
// Beide laufen unabhängig; Fehler werden zusammengefasst (nur geloggt).
|
||||
crowdsecWL := crowdsec.NewWhitelistGenerator(pool)
|
||||
domainsReloader := func(ctx context.Context) error {
|
||||
return errors.Join(haproxy.New(pool).Render(ctx), crowdsecWL.Render(ctx))
|
||||
}
|
||||
|
||||
authed := v1.Group("")
|
||||
authed.Use(requireAuth, handlers.RequireAdminForMutations())
|
||||
setupHdl.RegisterAuthed(authed)
|
||||
@@ -335,7 +346,7 @@ func main() {
|
||||
WithAudit(auditRepo, nodeID)
|
||||
oidcHdl.RegisterPublic(v1)
|
||||
oidcHdl.RegisterAdmin(authed)
|
||||
handlers.NewDomainsHandler(domainsRepo, routingRepo, domainHeadersRepo, auditRepo, nodeID, haproxyReloader).Register(authed)
|
||||
handlers.NewDomainsHandler(domainsRepo, routingRepo, domainHeadersRepo, auditRepo, nodeID, domainsReloader).Register(authed)
|
||||
handlers.NewBackendsHandler(backendsRepo, auditRepo, nodeID, haproxyReloader).Register(authed)
|
||||
handlers.NewBackendServersHandler(backendServersRepo, auditRepo, nodeID, haproxyReloader).Register(authed)
|
||||
handlers.NewRoutingRulesHandler(routingRepo, auditRepo, nodeID, haproxyReloader).Register(authed)
|
||||
|
||||
230
cmd/edgeguard-ctl/cluster_reconcile.go
Normal file
230
cmd/edgeguard-ctl/cluster_reconcile.go
Normal file
@@ -0,0 +1,230 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// cmdClusterReconcileReplication bringt Publication, Grants und Subscription
|
||||
// idempotent in den Soll-Zustand. Verhindert die zwei Fehlermodi, die sich
|
||||
// beim Nachrüsten von Features zeigen:
|
||||
// - Eine `FOR TABLE`-Publication nimmt später per Migration hinzugekommene
|
||||
// Shared-Tables NICHT automatisch auf → sie replizieren nie (Standby
|
||||
// läuft nach Failover ohne WAF/OIDC/DHCP/RADIUS-Config).
|
||||
// - Der GRANT SELECT für den Replikations-User ist ein Snapshot bei Setup;
|
||||
// neue Tabellen fehlen → tablesync hängt in `d` (Permission).
|
||||
//
|
||||
// Rollen-Selbsterkennung (idempotent, läuft im postinst nach migrate):
|
||||
//
|
||||
// Publisher (hat Publication):
|
||||
// - GRANT SELECT auf ALLE Tabellen (+ DEFAULT PRIVILEGES) für den
|
||||
// Replikations-User.
|
||||
// - Publication-Mitgliedschaft angleichen: fehlende Shared-Tables ADD,
|
||||
// fälschlich enthaltene node-lokale (localOnlyTables) DROP.
|
||||
// Subscriber (hat Subscription):
|
||||
// - Frisch zu synchronisierende Shared-Tables lokal TRUNCATE (Primary =
|
||||
// Source of Truth; verhindert Duplicate-Key beim Initial-COPY einer
|
||||
// per Migration seed-befüllten Singleton-Tabelle), dann REFRESH.
|
||||
// Single-Node (weder noch): nichts zu tun.
|
||||
//
|
||||
// Best-effort: Fehler werden geloggt, brechen aber ein Paket-Upgrade nie ab.
|
||||
func cmdClusterReconcileReplication(_ []string) int {
|
||||
hasPub := psqlDBBool("edgeguard",
|
||||
fmt.Sprintf("SELECT EXISTS(SELECT 1 FROM pg_publication WHERE pubname='%s')", egPubName))
|
||||
hasSub := psqlDBBool("edgeguard",
|
||||
fmt.Sprintf("SELECT EXISTS(SELECT 1 FROM pg_subscription WHERE subname='%s')", egSubName))
|
||||
|
||||
switch {
|
||||
case hasPub:
|
||||
reconcilePublisher()
|
||||
case hasSub:
|
||||
reconcileSubscriber()
|
||||
default:
|
||||
// Standalone-Node — keine Replikation eingerichtet.
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// reconcilePublisher gleicht Grants + Publication-Mitgliedschaft an.
|
||||
func reconcilePublisher() {
|
||||
// 1. Grants IMMER neu setzen (idempotent, deckt neue Tabellen ab).
|
||||
grantSQL := fmt.Sprintf(
|
||||
"GRANT SELECT ON ALL TABLES IN SCHEMA public TO %s;\n"+
|
||||
"ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO %s;",
|
||||
egReplUser, egReplUser)
|
||||
if err := psqlDBExec("edgeguard", grantSQL); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "reconcile: GRANT SELECT fehlgeschlagen:", err)
|
||||
} else {
|
||||
fmt.Printf("✓ reconcile: SELECT-Grants für %q aktualisiert\n", egReplUser)
|
||||
}
|
||||
|
||||
// 2. Publication-Mitgliedschaft angleichen.
|
||||
desired, err := desiredSharedTables()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "reconcile: Tabellen-Liste:", err)
|
||||
return
|
||||
}
|
||||
current, err := publicationTables()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "reconcile: Publication-Liste:", err)
|
||||
return
|
||||
}
|
||||
desiredSet := toSet(desired)
|
||||
currentSet := toSet(current)
|
||||
|
||||
var toAdd, toDrop []string
|
||||
for _, t := range desired {
|
||||
if !currentSet[t] {
|
||||
toAdd = append(toAdd, t)
|
||||
}
|
||||
}
|
||||
for _, t := range current {
|
||||
if !desiredSet[t] {
|
||||
toDrop = append(toDrop, t) // node-lokale, die fälschlich drin sind
|
||||
}
|
||||
}
|
||||
sort.Strings(toAdd)
|
||||
sort.Strings(toDrop)
|
||||
|
||||
if len(toAdd) > 0 {
|
||||
if err := psqlDBExec("edgeguard", fmt.Sprintf(
|
||||
"ALTER PUBLICATION %s ADD TABLE %s;", egPubName, strings.Join(toAdd, ", "))); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "reconcile: ADD TABLE fehlgeschlagen:", err)
|
||||
} else {
|
||||
fmt.Printf("✓ reconcile: %d Tabelle(n) zur Publication hinzugefügt: %s\n",
|
||||
len(toAdd), strings.Join(toAdd, ", "))
|
||||
}
|
||||
}
|
||||
if len(toDrop) > 0 {
|
||||
if err := psqlDBExec("edgeguard", fmt.Sprintf(
|
||||
"ALTER PUBLICATION %s DROP TABLE %s;", egPubName, strings.Join(toDrop, ", "))); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "reconcile: DROP TABLE fehlgeschlagen:", err)
|
||||
} else {
|
||||
fmt.Printf("✓ reconcile: %d node-lokale Tabelle(n) aus Publication entfernt: %s\n",
|
||||
len(toDrop), strings.Join(toDrop, ", "))
|
||||
}
|
||||
}
|
||||
if len(toAdd) == 0 && len(toDrop) == 0 {
|
||||
fmt.Println("✓ reconcile: Publication bereits im Soll-Zustand")
|
||||
}
|
||||
}
|
||||
|
||||
// reconcileSubscriber zieht neu publizierte Tabellen nach: erst lokal leeren
|
||||
// (Primary = Source of Truth, verhindert Duplicate-Key beim Initial-COPY),
|
||||
// dann REFRESH PUBLICATION. Bereits synchronisierte Tabellen bleiben unberührt.
|
||||
func reconcileSubscriber() {
|
||||
desired, err := desiredSharedTables()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "reconcile: Tabellen-Liste:", err)
|
||||
return
|
||||
}
|
||||
synced, err := subscriptionRelTables()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "reconcile: subscription_rel-Liste:", err)
|
||||
return
|
||||
}
|
||||
syncedSet := toSet(synced)
|
||||
|
||||
var fresh []string
|
||||
for _, t := range desired {
|
||||
if !syncedSet[t] {
|
||||
fresh = append(fresh, t)
|
||||
}
|
||||
}
|
||||
sort.Strings(fresh)
|
||||
|
||||
if len(fresh) > 0 {
|
||||
// Nur frisch zu synchronisierende Shared-Tables leeren — nie eine
|
||||
// bereits replizierte oder node-lokale Tabelle.
|
||||
if err := psqlDBExec("edgeguard",
|
||||
fmt.Sprintf("TRUNCATE %s;", strings.Join(fresh, ", "))); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "reconcile: TRUNCATE (neue Tabellen) fehlgeschlagen:", err)
|
||||
} else {
|
||||
fmt.Printf("✓ reconcile: %d neue Tabelle(n) für Initial-Sync geleert: %s\n",
|
||||
len(fresh), strings.Join(fresh, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
// REFRESH ist NICHT transaktionssicher → einzelnes Statement, autocommit.
|
||||
if err := psqlDBExec("edgeguard",
|
||||
fmt.Sprintf("ALTER SUBSCRIPTION %s REFRESH PUBLICATION;", egSubName)); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "reconcile: REFRESH PUBLICATION fehlgeschlagen:", err)
|
||||
} else {
|
||||
fmt.Printf("✓ reconcile: Subscription %q refresht\n", egSubName)
|
||||
}
|
||||
}
|
||||
|
||||
// desiredSharedTables = alle public-Tabellen minus localOnlyTables.
|
||||
func desiredSharedTables() ([]string, error) {
|
||||
out, err := psqlDBRun("edgeguard", []string{"-tA", "-c",
|
||||
`SELECT tablename FROM pg_tables WHERE schemaname='public'`})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list tables: %w", err)
|
||||
}
|
||||
return filterSharedTables(splitLines(string(out))), nil
|
||||
}
|
||||
|
||||
// filterSharedTables entfernt localOnlyTables aus der Tabellenliste. Pure
|
||||
// Funktion — unit-testbar.
|
||||
func filterSharedTables(all []string) []string {
|
||||
excluded := toSet(localOnlyTables)
|
||||
var out []string
|
||||
for _, t := range all {
|
||||
if t != "" && !excluded[t] {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// publicationTables listet die aktuell in edgeguard_shared publizierten Tabellen.
|
||||
func publicationTables() ([]string, error) {
|
||||
out, err := psqlDBRun("edgeguard", []string{"-tA", "-c",
|
||||
fmt.Sprintf("SELECT tablename FROM pg_publication_tables WHERE pubname='%s'", egPubName)})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return splitLines(string(out)), nil
|
||||
}
|
||||
|
||||
// subscriptionRelTables listet die Tabellen, die die Subscription bereits kennt.
|
||||
func subscriptionRelTables() ([]string, error) {
|
||||
out, err := psqlDBRun("edgeguard", []string{"-tA", "-c",
|
||||
fmt.Sprintf(`SELECT c.relname FROM pg_subscription_rel r
|
||||
JOIN pg_class c ON c.oid = r.srrelid
|
||||
JOIN pg_subscription s ON s.oid = r.srsubid
|
||||
WHERE s.subname = '%s'`, egSubName)})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return splitLines(string(out)), nil
|
||||
}
|
||||
|
||||
func psqlDBBool(db, sql string) bool {
|
||||
out, err := psqlDBRun(db, []string{"-tA", "-c", sql})
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(string(out)) == "t"
|
||||
}
|
||||
|
||||
func splitLines(s string) []string {
|
||||
var out []string
|
||||
for _, l := range strings.Split(strings.TrimSpace(s), "\n") {
|
||||
if l = strings.TrimSpace(l); l != "" {
|
||||
out = append(out, l)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func toSet(items []string) map[string]bool {
|
||||
m := make(map[string]bool, len(items))
|
||||
for _, it := range items {
|
||||
m[it] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
27
cmd/edgeguard-ctl/cluster_reconcile_test.go
Normal file
27
cmd/edgeguard-ctl/cluster_reconcile_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFilterSharedTables(t *testing.T) {
|
||||
all := []string{
|
||||
"backends", "domains", "waf_configs", "oidc_settings",
|
||||
"ip_addresses", "network_interfaces", "alert_events", "waf_alerts",
|
||||
"ha_nodes", "goose_db_version", "radius_users", "",
|
||||
}
|
||||
got := filterSharedTables(all)
|
||||
want := []string{"backends", "domains", "oidc_settings", "radius_users", "waf_configs"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("filterSharedTables()\n got=%v\nwant=%v", got, want)
|
||||
}
|
||||
// node-lokale müssen raus sein (inkl. der frisch node-lokal gemachten).
|
||||
for _, local := range []string{"ip_addresses", "network_interfaces", "alert_events", "waf_alerts", "ha_nodes", "goose_db_version"} {
|
||||
for _, g := range got {
|
||||
if g == local {
|
||||
t.Errorf("localOnly-Tabelle %q darf NICHT in shared-Liste sein", local)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,7 @@ var localOnlyTables = []string{
|
||||
"join_tokens_used", // Token-Tracking nur auf Primary relevant
|
||||
"audit_log", // Lokales Audit-Protokoll
|
||||
"alert_events", // Lokale Laufzeit-Events
|
||||
"waf_alerts", // Lokale WAF-Detection-Events (wie alert_events)
|
||||
"backups", // Backup-Historie ist per-Node
|
||||
"goose_db_version", // Migration-Tracking, internes Tool-State
|
||||
}
|
||||
|
||||
@@ -74,6 +74,8 @@ func main() {
|
||||
os.Exit(cmdClusterInitReplication(os.Args[2:]))
|
||||
case "cluster-setup-standby":
|
||||
os.Exit(cmdClusterSetupStandby(os.Args[2:]))
|
||||
case "cluster-reconcile-replication":
|
||||
os.Exit(cmdClusterReconcileReplication(os.Args[2:]))
|
||||
case "promote":
|
||||
os.Exit(cmdPromote(os.Args[2:]))
|
||||
case "cluster-leave", "dump-config":
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/chrony"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/crowdsec"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/database"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/firewall"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/freeradius"
|
||||
@@ -65,6 +66,7 @@ func cmdRenderConfig(args []string) int {
|
||||
cn := chrony.New(pool)
|
||||
ke := kea.New(pool)
|
||||
fr := freeradius.New(pool, secrets.New(""))
|
||||
cw := crowdsec.NewWhitelistGenerator(pool)
|
||||
if skipReload {
|
||||
hap.SkipReload = true
|
||||
fw.SkipReload = true
|
||||
@@ -74,6 +76,7 @@ func cmdRenderConfig(args []string) int {
|
||||
cn.SkipReload = true
|
||||
ke.SkipReload = true
|
||||
fr.SkipReload = true
|
||||
cw.SkipReload = true
|
||||
}
|
||||
|
||||
// keepalived: Node-ID aus node.conf für Prioritäts-Berechnung
|
||||
@@ -82,7 +85,7 @@ func cmdRenderConfig(args []string) int {
|
||||
ka = keepalived.New(pool, lc.NodeID)
|
||||
}
|
||||
|
||||
gens := []configgen.Generator{hap, fw, sq, wg, ub, cn, ke, fr}
|
||||
gens := []configgen.Generator{hap, fw, sq, wg, ub, cn, ke, fr, cw}
|
||||
if ka != nil {
|
||||
gens = append(gens, ka)
|
||||
}
|
||||
|
||||
@@ -104,6 +104,12 @@ const (
|
||||
auditCleanupInterval = 24 * time.Hour
|
||||
auditRetentionDays = 90
|
||||
|
||||
// alertRetentionDays — alert_events wächst sonst unbegrenzt (node-lokale
|
||||
// Health-Events: backend.down, mem.high, cert.expiring …). Läuft im
|
||||
// selben täglichen Tick wie der Audit-Cleanup. Fester Default, kein
|
||||
// Setup-Override (Events sind reine Diagnose-History).
|
||||
alertRetentionDays = 90
|
||||
|
||||
// backendDownCheckInterval — alle 2 Minuten HAProxy-Stats lesen und
|
||||
// prüfen ob ein Backend komplett ausgefallen ist (alle Server DOWN).
|
||||
// Dedupe 12h pro Backend → kein Alert-Spam. Frischer Alert wenn das
|
||||
@@ -185,7 +191,8 @@ func main() {
|
||||
auditRepo := audit.New(pool)
|
||||
alertDedupe := newDedupe(12 * time.Hour)
|
||||
|
||||
if renewer != nil {
|
||||
// ACME nur auf dem VIP-Master (siehe Tick-Kommentar unten).
|
||||
if renewer != nil && nodeHoldsVIP(ctx, pool) {
|
||||
runRenewer(ctx, renewer, alertSvc, alertDedupe)
|
||||
}
|
||||
runLicenseVerify(ctx, licClient, licKeyStore, licRepo, nodeID, alertSvc, alertDedupe)
|
||||
@@ -252,7 +259,13 @@ func main() {
|
||||
for {
|
||||
select {
|
||||
case <-renewTick.C:
|
||||
if renewer != nil {
|
||||
// ACME-HTTP-01-Challenges laufen auf :80 der VIP → nur der
|
||||
// VIP-Master kann sie bestehen. Ein BACKUP-Node scheitert IMMER
|
||||
// mit 403 (invalid authorization) und setzt tls_certs.status lokal
|
||||
// auf "error" → Divergenz zur replizierten Row (Primary=active) →
|
||||
// Config-Drift-Banner + Log-Noise. Renewal daher nur am VIP-Master;
|
||||
// die Cert-Row/PEM repliziert von dort ohnehin auf den Standby.
|
||||
if renewer != nil && nodeHoldsVIP(ctx, pool) {
|
||||
runRenewer(ctx, renewer, alertSvc, alertDedupe)
|
||||
}
|
||||
runCertExpiryCheck(ctx, tlsRepo, alertSvc, alertDedupe)
|
||||
@@ -272,6 +285,7 @@ func main() {
|
||||
runDiskCheck(ctx, alertSvc, alertDedupe)
|
||||
case <-auditTick.C:
|
||||
runAuditCleanup(ctx, auditRepo, setupStore)
|
||||
runAlertCleanup(ctx, alertSvc)
|
||||
case <-backendDownTick.C:
|
||||
runBackendDownCheck(ctx, pool, alertSvc, alertDedupe)
|
||||
case <-memTick.C:
|
||||
@@ -317,6 +331,29 @@ func runAuditCleanup(ctx context.Context, r *audit.Repo, setupStore *setup.Store
|
||||
}
|
||||
}
|
||||
|
||||
// runAlertCleanup löscht alert_events älter als alertRetentionDays.
|
||||
// Schutz vor unbounded growth — auf einer aktiven Box feuern backend.down/
|
||||
// mem.high/cert.expiring über Monate tausende Rows (die Tabelle ist
|
||||
// node-lokal, wird also nirgends sonst abgeräumt). Best-effort: Fehler
|
||||
// werden nur geloggt.
|
||||
func runAlertCleanup(ctx context.Context, a *alerts.Service) {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
n, err := a.Cleanup(cctx, alertRetentionDays)
|
||||
if err != nil {
|
||||
slog.Warn("scheduler: alert cleanup failed",
|
||||
"keep_days", alertRetentionDays, "error", err)
|
||||
return
|
||||
}
|
||||
if n > 0 {
|
||||
slog.Info("scheduler: alert cleanup",
|
||||
"deleted", n, "keep_days", alertRetentionDays)
|
||||
}
|
||||
}
|
||||
|
||||
// runDiskCheck prüft die Belegung von / via statfs. Fire-Schwellen:
|
||||
// - >= 90% → Critical (error). Box ist akut gefährdet — beim
|
||||
// nächsten Backup-Run oder größeren apt-Update droht "no space
|
||||
@@ -592,7 +629,7 @@ func runWGClientTunnelCheck(ctx context.Context, pool *pgxpool.Pool, a *alerts.S
|
||||
// Alle aktiven Client-Interfaces aus DB laden.
|
||||
type wgIface struct{ name string }
|
||||
rows, err := pool.Query(ctx,
|
||||
`SELECT name FROM wg_interfaces WHERE mode = 'client' AND active = true ORDER BY name`)
|
||||
`SELECT name FROM wireguard_interfaces WHERE mode = 'client' AND active = true ORDER BY name`)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
@@ -663,6 +700,56 @@ func runWGClientTunnelCheck(ctx context.Context, pool *pgxpool.Pool, a *alerts.S
|
||||
|
||||
var egBackendRE = regexp.MustCompile(`^eg_backend_(\d+)$`)
|
||||
|
||||
// nodeHoldsVIP meldet true, wenn dieser Node aktuell mindestens eine
|
||||
// is_vip-Adresse lokal trägt — also der keepalived-MASTER ist. Nur der
|
||||
// Master hält die VLAN-Gateway-VIPs und erreicht damit die Backend-
|
||||
// Subnetze; ein BACKUP-Node hat KEINE VLAN-IP und sieht deshalb JEDES
|
||||
// Backend als L4-down. Spiegelt SystemHandler.VIPStatus (net.Interfaces,
|
||||
// kein Shell-out).
|
||||
func nodeHoldsVIP(ctx context.Context, pool *pgxpool.Pool) bool {
|
||||
if pool == nil {
|
||||
return false
|
||||
}
|
||||
rows, err := pool.Query(ctx,
|
||||
`SELECT address FROM ip_addresses WHERE is_vip = true AND active = true`)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer rows.Close()
|
||||
var vips []string
|
||||
for rows.Next() {
|
||||
var addr string
|
||||
if err := rows.Scan(&addr); err == nil {
|
||||
vips = append(vips, addr)
|
||||
}
|
||||
}
|
||||
if len(vips) == 0 {
|
||||
return false
|
||||
}
|
||||
local := make(map[string]bool)
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, ifc := range ifaces {
|
||||
addrs, err := ifc.Addrs()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, a := range addrs {
|
||||
if ipnet, ok := a.(*net.IPNet); ok {
|
||||
local[ipnet.IP.String()] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, v := range vips {
|
||||
if local[v] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// runBackendDownCheck liest HAProxy-Stats via Admin-Socket und feuert
|
||||
// einen Error-Alert für jedes Backend bei dem alle Server DOWN sind
|
||||
// (und mind. einer einen echten Health-Check hat). Dedupe 12h pro Backend.
|
||||
@@ -670,6 +757,14 @@ func runBackendDownCheck(ctx context.Context, pool *pgxpool.Pool, a *alerts.Serv
|
||||
if a == nil || d == nil {
|
||||
return
|
||||
}
|
||||
// Nur auf dem VIP-Master prüfen. Ein BACKUP-Node hält die VLAN-
|
||||
// Gateway-VIPs nicht und kann die Backend-Subnetze gar nicht erreichen
|
||||
// → jeder Health-Check läuft L4TOUT → Dauer-"backend.down"-Fehlalarm
|
||||
// (Hauptquelle des alert_events-Spams). Der Master bedient den Traffic
|
||||
// und sieht die echten Backend-States.
|
||||
if !nodeHoldsVIP(ctx, pool) {
|
||||
return
|
||||
}
|
||||
dialer := net.Dialer{Timeout: 2 * time.Second}
|
||||
conn, err := dialer.DialContext(ctx, "unix", "/run/haproxy/admin.sock")
|
||||
if err != nil {
|
||||
|
||||
16
go.mod
16
go.mod
@@ -2,6 +2,8 @@ module git.netcell-it.de/projekte/edgeguard-native
|
||||
|
||||
go 1.26.4
|
||||
|
||||
toolchain go1.26.6
|
||||
|
||||
require (
|
||||
github.com/corazawaf/coraza/v3 v3.7.0
|
||||
github.com/coreos/go-oidc/v3 v3.18.0
|
||||
@@ -16,7 +18,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.52.0
|
||||
golang.org/x/crypto v0.56.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
)
|
||||
|
||||
@@ -78,12 +80,12 @@ require (
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/mod v0.35.0 // indirect
|
||||
golang.org/x/net v0.55.0 // indirect
|
||||
golang.org/x/sync v0.20.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
|
||||
golang.org/x/mod v0.38.0 // indirect
|
||||
golang.org/x/net v0.57.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
golang.org/x/tools v0.48.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
rsc.io/binaryregexp v0.2.0 // indirect
|
||||
|
||||
32
go.sum
32
go.sum
@@ -186,24 +186,24 @@ 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.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.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
|
||||
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
|
||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
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.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=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
107
internal/crowdsec/whitelist.go
Normal file
107
internal/crowdsec/whitelist.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package crowdsec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
|
||||
)
|
||||
|
||||
// WhitelistPath ist die aus dem Domain-Flag crowdsec_trusted gerenderte
|
||||
// CrowdSec-Parser-Whitelist. s02-enrich läuft vor den Scenarios, sodass
|
||||
// whitelisted Events gar nicht erst in http-crawl-non_statics o. Ä. zählen.
|
||||
const WhitelistPath = "/etc/crowdsec/parsers/s02-enrich/edgeguard-admin-hosts-whitelist.yaml"
|
||||
|
||||
// WhitelistGenerator rendert eine host-genaue CrowdSec-Whitelist aus allen
|
||||
// Domains mit crowdsec_trusted=true. Vertrauenswürdige Admin-Panels (SPAs, die
|
||||
// pro Aktion viele /api/-Requests feuern) würden sonst das Scenario
|
||||
// http-crawl-non_statics auslösen und die Admin-IP bannen. No-op, wenn CrowdSec auf diesem Node nicht
|
||||
// installiert ist (managed-wenn-installiert).
|
||||
type WhitelistGenerator struct {
|
||||
pool *pgxpool.Pool
|
||||
SkipReload bool
|
||||
}
|
||||
|
||||
func NewWhitelistGenerator(pool *pgxpool.Pool) *WhitelistGenerator {
|
||||
return &WhitelistGenerator{pool: pool}
|
||||
}
|
||||
|
||||
func (g *WhitelistGenerator) Name() string { return "crowdsec-whitelist" }
|
||||
|
||||
func (g *WhitelistGenerator) Render(ctx context.Context) error {
|
||||
// Managed-wenn-installiert: ohne CrowdSec kein Whitelist-File.
|
||||
if !IsInstalled() {
|
||||
return nil
|
||||
}
|
||||
hosts, err := g.trustedHosts(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("crowdsec-whitelist: query: %w", err)
|
||||
}
|
||||
// Direktes Schreiben (kein tmp+rename): /etc/crowdsec/parsers/... ist
|
||||
// root-owned, edgeguard darf nur die eine (postinst-chownte) Datei
|
||||
// überschreiben — analog chrony/unbound.
|
||||
if err := os.WriteFile(WhitelistPath, renderWhitelist(hosts), 0o644); err != nil {
|
||||
return fmt.Errorf("crowdsec-whitelist: write %s: %w", WhitelistPath, err)
|
||||
}
|
||||
if g.SkipReload {
|
||||
return nil
|
||||
}
|
||||
return configgen.ReloadService("crowdsec")
|
||||
}
|
||||
|
||||
// RenderToString gibt die gerenderte Whitelist zurück (Config-Preview), ohne zu
|
||||
// schreiben oder zu reloaden.
|
||||
func (g *WhitelistGenerator) RenderToString(ctx context.Context) (string, error) {
|
||||
hosts, err := g.trustedHosts(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(renderWhitelist(hosts)), nil
|
||||
}
|
||||
|
||||
func (g *WhitelistGenerator) trustedHosts(ctx context.Context) ([]string, error) {
|
||||
rows, err := g.pool.Query(ctx,
|
||||
`SELECT name FROM domains WHERE crowdsec_trusted = true AND active = true ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var hosts []string
|
||||
for rows.Next() {
|
||||
var n string
|
||||
if err := rows.Scan(&n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hosts = append(hosts, n)
|
||||
}
|
||||
return hosts, rows.Err()
|
||||
}
|
||||
|
||||
// renderWhitelist baut die CrowdSec-Parser-Whitelist-YAML. Ohne vertrauens-
|
||||
// würdige Hosts bleibt die Ausdrucksliste leer → `in []` matcht nie → es wird
|
||||
// nichts whitelisted (Datei bleibt gültig). Pure Funktion (testbar).
|
||||
func renderWhitelist(hosts []string) []byte {
|
||||
quoted := make([]string, 0, len(hosts))
|
||||
for _, h := range hosts {
|
||||
h = strings.TrimSpace(h)
|
||||
if h == "" {
|
||||
continue
|
||||
}
|
||||
// Einfachquote + eingebettete Quotes verdoppeln (expr-String-Literal).
|
||||
quoted = append(quoted, "'"+strings.ReplaceAll(h, "'", "''")+"'")
|
||||
}
|
||||
var b bytes.Buffer
|
||||
b.WriteString("# Generated by edgeguard-api from domains.crowdsec_trusted. DO NOT EDIT.\n")
|
||||
b.WriteString("name: edgeguard/admin-hosts-whitelist\n")
|
||||
b.WriteString("description: Trusted admin panels (SPA fires many /api/ requests) exempted from CrowdSec - not a crawl.\n")
|
||||
b.WriteString("whitelist:\n")
|
||||
b.WriteString(" reason: edgeguard trusted admin host (SPA, not crawl/probing)\n")
|
||||
b.WriteString(" expression:\n")
|
||||
fmt.Fprintf(&b, " - \"evt.Parsed.http_host in [%s]\"\n", strings.Join(quoted, ", "))
|
||||
return b.Bytes()
|
||||
}
|
||||
35
internal/crowdsec/whitelist_test.go
Normal file
35
internal/crowdsec/whitelist_test.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package crowdsec
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderWhitelist(t *testing.T) {
|
||||
t.Run("hosts werden host-genau eingetragen", func(t *testing.T) {
|
||||
out := string(renderWhitelist([]string{"control.netcell-it.de", "admin.example.com"}))
|
||||
if !strings.Contains(out, "evt.Parsed.http_host in ['control.netcell-it.de', 'admin.example.com']") {
|
||||
t.Fatalf("erwartete host-Liste fehlt:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "name: edgeguard/admin-hosts-whitelist") {
|
||||
t.Fatalf("Parser-Name fehlt:\n%s", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("leere Liste → in [] (matcht nie, Datei gültig)", func(t *testing.T) {
|
||||
out := string(renderWhitelist(nil))
|
||||
if !strings.Contains(out, "evt.Parsed.http_host in []") {
|
||||
t.Fatalf("erwarte leeres in []:\n%s", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("leere/whitespace-Hosts werden gefiltert, Quotes escaped", func(t *testing.T) {
|
||||
out := string(renderWhitelist([]string{" ", "a'b.de", ""}))
|
||||
if !strings.Contains(out, "'a''b.de'") {
|
||||
t.Fatalf("Quote-Escaping falsch:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "'', ") || strings.Contains(out, "[''") {
|
||||
t.Fatalf("leere Hosts nicht gefiltert:\n%s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
27
internal/database/migrations/0044_backend_server_timeout.sql
Normal file
27
internal/database/migrations/0044_backend_server_timeout.sql
Normal file
@@ -0,0 +1,27 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
-- Per-Backend `timeout server` (Sekunden). NULL = defaults-Timeout (60s,
|
||||
-- siehe haproxy.cfg.tpl). Gedacht für Upstreams die LANGE für die Antwort
|
||||
-- brauchen und dabei NICHT streamen — z. B. KI-/Inferenz-Server, die eine
|
||||
-- gepufferte Antwort erst nach Minuten schicken. Ohne Override kappt der
|
||||
-- 60s-defaults-Timeout diese Requests.
|
||||
--
|
||||
-- Bewusst NULL-per-default: Backends ohne Langläufer-Workload behalten den
|
||||
-- kurzen Timeout (Connection-Hygiene / Slowloris-Schutz, vgl. v1.3.2).
|
||||
-- Der Renderer setzt `timeout server <N>s` NUR wenn ein Wert gesetzt ist.
|
||||
--
|
||||
-- CHECK 1..86400: mind. 1s, max. 24h — verhindert 0/negativ (würde HAProxy-
|
||||
-- Config sprengen bzw. „unendlich" bedeuten) und absurd hohe Werte.
|
||||
ALTER TABLE backends
|
||||
ADD COLUMN IF NOT EXISTS server_timeout_seconds INTEGER
|
||||
CONSTRAINT backends_server_timeout_range
|
||||
CHECK (server_timeout_seconds IS NULL
|
||||
OR (server_timeout_seconds BETWEEN 1 AND 86400));
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE backends DROP COLUMN IF EXISTS server_timeout_seconds;
|
||||
-- +goose StatementEnd
|
||||
@@ -0,0 +1,22 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
-- Quittieren von Alarmen. acknowledged_at = NULL → offen (zählt im Dashboard).
|
||||
-- Gesetzt → quittiert (bleibt als History sichtbar, zählt aber nicht mehr auf
|
||||
-- der Startseiten-Karte "Aktuelle Alerts"). alert_events ist node-lokal
|
||||
-- (localOnlyTables) → kein Replikations-Effekt.
|
||||
ALTER TABLE alert_events
|
||||
ADD COLUMN IF NOT EXISTS acknowledged_at TIMESTAMPTZ;
|
||||
|
||||
-- Teil-Index für den Dashboard-Query (nur offene, newest-first).
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_events_open
|
||||
ON alert_events (fired_at DESC)
|
||||
WHERE acknowledged_at IS NULL;
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
DROP INDEX IF EXISTS idx_alert_events_open;
|
||||
ALTER TABLE alert_events DROP COLUMN IF EXISTS acknowledged_at;
|
||||
-- +goose StatementEnd
|
||||
19
internal/database/migrations/0046_waf_crs_plugins.sql
Normal file
19
internal/database/migrations/0046_waf_crs_plugins.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
-- CRS-App-Exclusion-Plugins pro Domain (OWASP-CRS-Plugin-System). Liste von
|
||||
-- Plugin-Namen (z. B. 'nextcloud','wordpress','drupal'). Der WAF-Renderer
|
||||
-- inkludiert je gewähltem Plugin dessen config/before/after-Dateien aus
|
||||
-- <crsDir>/plugins/ an den korrekten Punkten (config+before VOR den CRS-Rules,
|
||||
-- after DANACH) → pfad-genaue, upstream-gepflegte App-Ausnahmen statt manueller
|
||||
-- SecRuleRemoveById-IDs. waf_configs ist repliziert; der Renderer läuft pro
|
||||
-- Node lokal, daher kein Cross-Node-Effekt außer der Config selbst.
|
||||
ALTER TABLE waf_configs
|
||||
ADD COLUMN IF NOT EXISTS crs_plugins TEXT[] NOT NULL DEFAULT '{}';
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE waf_configs DROP COLUMN IF EXISTS crs_plugins;
|
||||
-- +goose StatementEnd
|
||||
34
internal/database/migrations/0047_waf_app_profiles.sql
Normal file
34
internal/database/migrations/0047_waf_app_profiles.sql
Normal file
@@ -0,0 +1,34 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
-- Benutzerdefinierte WAF-App-Profile: benannte, wiederverwendbare Bündel von
|
||||
-- CRS-Rule-Exclusions (reine Rule-IDs/Ranges — keine SecLang-Ausführung, sicher).
|
||||
-- Wirken wie die eingebauten OWASP-Plugins, sind aber im UI erstellbar/editierbar
|
||||
-- und werden pro Domain zugewiesen (waf_configs.app_profiles). Die Auflösung in
|
||||
-- effektive SecRuleRemoveById-Zeilen passiert im WAF-Agent (ListAllWithDomain).
|
||||
--
|
||||
-- Repliziert (Config, kein node-lokaler Zustand) → vom cluster-reconcile
|
||||
-- automatisch in edgeguard_shared aufgenommen (nicht in localOnlyTables).
|
||||
CREATE TABLE IF NOT EXISTS waf_app_profiles (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
rule_exclusions TEXT[] NOT NULL DEFAULT '{}',
|
||||
builtin BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Zuweisung Profil→Domain: Liste von Profil-Namen je waf_config. Beim Bauen der
|
||||
-- Engine werden ihre rule_exclusions in die effektiven Ausnahmen der Domain
|
||||
-- gemischt (zusätzlich zu den domain-eigenen rule_exclusions).
|
||||
ALTER TABLE waf_configs
|
||||
ADD COLUMN IF NOT EXISTS app_profiles TEXT[] NOT NULL DEFAULT '{}';
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE waf_configs DROP COLUMN IF EXISTS app_profiles;
|
||||
DROP TABLE IF EXISTS waf_app_profiles;
|
||||
-- +goose StatementEnd
|
||||
@@ -0,0 +1,18 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
-- Pro-Domain-Flag: vertrauenswuerdiges Admin-Panel → von CrowdSec ausnehmen.
|
||||
-- Admin-SPAs feuern viele /api/-Requests pro Aktion und triggern sonst das
|
||||
-- Scenario http-crawl-non_statics (False-Positive-Ban der Admin-IP, die oft
|
||||
-- dynamisch ist). Der crowdsec-Whitelist-Renderer schreibt aus allen Domains
|
||||
-- mit crowdsec_trusted=true eine host-genaue CrowdSec-Parser-Whitelist
|
||||
-- (evt.Parsed.http_host). Repliziert (Config, node-lokal gerendert) → ueberlebt
|
||||
-- auch einen Node-Neuaufbau, weil aus der DB gerendert.
|
||||
ALTER TABLE domains ADD COLUMN IF NOT EXISTS crowdsec_trusted BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE domains DROP COLUMN IF EXISTS crowdsec_trusted;
|
||||
-- +goose StatementEnd
|
||||
@@ -18,7 +18,10 @@ import (
|
||||
// PUT /api/v1/alerts/channels/:id
|
||||
// DELETE /api/v1/alerts/channels/:id
|
||||
// POST /api/v1/alerts/test — Test-Event in alle aktiven Channels
|
||||
// GET /api/v1/alerts/events?limit=N — History
|
||||
// GET /api/v1/alerts/events?limit=N&open=true — History (open=nur offene)
|
||||
// POST /api/v1/alerts/events/acknowledge — Bulk-Quittieren {ids:[…]}
|
||||
// POST /api/v1/alerts/events/acknowledge-all — alle offenen quittieren
|
||||
// POST /api/v1/alerts/events/delete — Bulk-Löschen {ids:[…]}
|
||||
type AlertsHandler struct {
|
||||
Service *alerts.Service
|
||||
Audit *audit.Repo
|
||||
@@ -37,6 +40,9 @@ func (h *AlertsHandler) Register(rg *gin.RouterGroup) {
|
||||
g.DELETE("/channels/:id", h.DeleteChannel)
|
||||
g.POST("/test", h.TestFire)
|
||||
g.GET("/events", h.ListEvents)
|
||||
g.POST("/events/acknowledge", h.AcknowledgeEvents)
|
||||
g.POST("/events/acknowledge-all", h.AcknowledgeAllEvents)
|
||||
g.POST("/events/delete", h.DeleteEvents)
|
||||
}
|
||||
|
||||
func (h *AlertsHandler) ListChannels(c *gin.Context) {
|
||||
@@ -125,10 +131,64 @@ func (h *AlertsHandler) ListEvents(c *gin.Context) {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
out, err := h.Service.ListEvents(c.Request.Context(), limit)
|
||||
// ?open=true → nur offene (nicht quittierte) Events. Nutzt die
|
||||
// Dashboard-Karte, damit Quittieren die Meldung verschwinden lässt.
|
||||
openOnly := c.Query("open") == "true"
|
||||
out, err := h.Service.ListEvents(c.Request.Context(), limit, openOnly)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"events": out})
|
||||
}
|
||||
|
||||
// eventIDsRequest ist der Body für Bulk-Quittieren/-Löschen.
|
||||
type eventIDsRequest struct {
|
||||
IDs []int64 `json:"ids"`
|
||||
}
|
||||
|
||||
// AcknowledgeEvents quittiert die übergebenen Event-IDs.
|
||||
func (h *AlertsHandler) AcknowledgeEvents(c *gin.Context) {
|
||||
var req eventIDsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
n, err := h.Service.Acknowledge(c.Request.Context(), req.IDs)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "alert.events.acknowledge",
|
||||
strconv.Itoa(len(req.IDs)), gin.H{"ids": req.IDs, "acknowledged": n}, h.NodeID)
|
||||
response.OK(c, gin.H{"acknowledged": n})
|
||||
}
|
||||
|
||||
// AcknowledgeAllEvents quittiert alle offenen Events.
|
||||
func (h *AlertsHandler) AcknowledgeAllEvents(c *gin.Context) {
|
||||
n, err := h.Service.AcknowledgeAll(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "alert.events.acknowledge_all",
|
||||
"all", gin.H{"acknowledged": n}, h.NodeID)
|
||||
response.OK(c, gin.H{"acknowledged": n})
|
||||
}
|
||||
|
||||
// DeleteEvents löscht die übergebenen Event-IDs endgültig.
|
||||
func (h *AlertsHandler) DeleteEvents(c *gin.Context) {
|
||||
var req eventIDsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
n, err := h.Service.DeleteEvents(c.Request.Context(), req.IDs)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "alert.events.delete",
|
||||
strconv.Itoa(len(req.IDs)), gin.H{"ids": req.IDs, "deleted": n}, h.NodeID)
|
||||
response.OK(c, gin.H{"deleted": n})
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/cluster/jointoken"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
aptsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/apt"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
|
||||
)
|
||||
|
||||
@@ -92,6 +93,8 @@ func (h *ClusterHandler) Register(rg *gin.RouterGroup) {
|
||||
g.GET("/repair-replication/status", h.RepairReplicationStatus)
|
||||
g.GET("/vip-status", h.VIPStatus)
|
||||
g.POST("/vip-test", h.VIPTest)
|
||||
g.GET("/update-channel", h.UpdateChannel)
|
||||
g.POST("/update-channel", h.SetUpdateChannel)
|
||||
if h.TLSStore != nil {
|
||||
g.GET("/cert-status", h.CertStatus)
|
||||
g.POST("/renew-self", h.RenewSelf)
|
||||
@@ -247,6 +250,8 @@ func (h *ClusterHandler) RegisterAgent(rg *gin.RouterGroup) {
|
||||
g.GET("/master-key", h.AgentMasterKey)
|
||||
g.GET("/version", h.AgentVersion)
|
||||
g.POST("/trigger-update", h.AgentTriggerUpdate)
|
||||
g.POST("/set-channel", h.AgentSetChannel)
|
||||
g.GET("/channel", h.AgentChannel)
|
||||
g.GET("/active-ips", h.AgentActiveIPs)
|
||||
g.POST("/vip-cmd", h.AgentVIPCmd)
|
||||
g.GET("/tls-certs", h.AgentTLSCerts)
|
||||
@@ -758,7 +763,10 @@ retry_apt() {
|
||||
while [ $attempt -lt $max ]; do
|
||||
attempt=$((attempt + 1))
|
||||
apt-get update -qq || true
|
||||
if apt-get install -y -qq -o Dpkg::Options::=--force-confold \
|
||||
# --allow-downgrades: nur relevant nach testing→stable-Kanalwechsel
|
||||
# (Testing-Versionen sortieren datumsbasiert höher als Stable-Semver).
|
||||
# No-Op im Normalfall, da die Candidate sonst immer >= installed ist.
|
||||
if apt-get install -y -qq --allow-downgrades -o Dpkg::Options::=--force-confold \
|
||||
edgeguard-api edgeguard-ui edgeguard; then return 0; fi
|
||||
[ $attempt -lt $max ] && sleep $wait_for && wait_for=$((wait_for * 2))
|
||||
done
|
||||
@@ -789,6 +797,131 @@ rm -f /var/lib/edgeguard/upgrade.sh
|
||||
c.JSON(http.StatusAccepted, gin.H{"status": "upgrading"})
|
||||
}
|
||||
|
||||
// ── Update-Kanal (stable/testing) ──────────────────────────────────────
|
||||
//
|
||||
// Kanal-Modell wie enconf (Suite=Codename, Komponente=Kanal, siehe
|
||||
// internal/services/apt.Channel/SetChannel) — an EdgeGuards fixes
|
||||
// Primary/Standby-Paar angepasst statt generischer Server-Flotte: der
|
||||
// Kanal wird auf beiden Nodes synchron gehalten (wie config_hash),
|
||||
// kein Node-Override. Reines Umschreiben der sources.list + `apt-get
|
||||
// update` ist risikofrei (kein Service-Restart, keine VIP-Auswirkung)
|
||||
// — das eigentliche Downgrade/Upgrade auf die neue Kanal-Version läuft
|
||||
// danach ganz normal über den bestehenden (sicheren, Standby-zuerst)
|
||||
// Rolling-Update-Flow, der --allow-downgrades jetzt mit unterstützt.
|
||||
|
||||
type updateChannelResponse struct {
|
||||
Channel string `json:"channel"`
|
||||
PeerChannel string `json:"peer_channel,omitempty"`
|
||||
PeerReached bool `json:"peer_reached"`
|
||||
PeerDrifted bool `json:"peer_drifted"`
|
||||
}
|
||||
|
||||
// UpdateChannel liefert den lokalen Kanal + (falls Cluster) den Kanal
|
||||
// des Peers zur Drift-Erkennung — analog zum config_hash-Vergleich.
|
||||
func (h *ClusterHandler) UpdateChannel(c *gin.Context) {
|
||||
resp := updateChannelResponse{Channel: aptsvc.Channel()}
|
||||
peer := h.peerNode(c.Request.Context())
|
||||
if peer != nil && h.Aggregator != nil {
|
||||
results := h.Aggregator.FanOut(c.Request.Context(), []models.HANode{*peer}, "/agent/cluster/channel", h.LocalID)
|
||||
if len(results) > 0 && results[0].OK {
|
||||
var body struct {
|
||||
Channel string `json:"channel"`
|
||||
}
|
||||
if json.Unmarshal(results[0].Data, &body) == nil {
|
||||
resp.PeerReached = true
|
||||
resp.PeerChannel = body.Channel
|
||||
resp.PeerDrifted = body.Channel != resp.Channel
|
||||
}
|
||||
}
|
||||
}
|
||||
response.OK(c, resp)
|
||||
}
|
||||
|
||||
// SetUpdateChannel setzt den Kanal lokal und — falls ein Peer existiert
|
||||
// — synchron auch auf dem Peer via mTLS. Löst KEIN Paket-Update aus;
|
||||
// das übernimmt der Admin danach ganz normal über den Update-Banner /
|
||||
// Rolling-Update, der die neue Candidate-Version dann bereits sieht.
|
||||
func (h *ClusterHandler) SetUpdateChannel(c *gin.Context) {
|
||||
var req struct {
|
||||
Channel string `json:"channel"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if req.Channel != "stable" && req.Channel != "testing" {
|
||||
response.BadRequest(c, fmt.Errorf("channel must be 'stable' or 'testing'"))
|
||||
return
|
||||
}
|
||||
if err := aptsvc.SetChannel(c.Request.Context(), req.Channel); err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
resp := updateChannelResponse{Channel: req.Channel}
|
||||
if peer := h.peerNode(c.Request.Context()); peer != nil && h.Aggregator != nil {
|
||||
body, _ := json.Marshal(req)
|
||||
result := h.Aggregator.PostPeerWithBody(c.Request.Context(), *peer, "/agent/cluster/set-channel", body)
|
||||
resp.PeerReached = result.OK
|
||||
if !result.OK {
|
||||
slog.Warn("cluster: set-channel on peer failed", "peer", peer.FQDN, "error", result.Err)
|
||||
}
|
||||
}
|
||||
|
||||
if h.Audit != nil {
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "system.update_channel.set",
|
||||
"", gin.H{"channel": req.Channel}, h.NodeID)
|
||||
}
|
||||
response.OK(c, resp)
|
||||
}
|
||||
|
||||
// AgentChannel: mTLS-Peer-Read des lokalen Kanals (für Drift-Anzeige).
|
||||
func (h *ClusterHandler) AgentChannel(c *gin.Context) {
|
||||
response.OK(c, gin.H{"channel": aptsvc.Channel()})
|
||||
}
|
||||
|
||||
// AgentSetChannel: mTLS-Peer-Write — wird vom Primary aufgerufen um den
|
||||
// Kanal auf diesem (Standby-)Node synchron zu setzen.
|
||||
func (h *ClusterHandler) AgentSetChannel(c *gin.Context) {
|
||||
var req struct {
|
||||
Channel string `json:"channel"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if req.Channel != "stable" && req.Channel != "testing" {
|
||||
response.BadRequest(c, fmt.Errorf("channel must be 'stable' or 'testing'"))
|
||||
return
|
||||
}
|
||||
if err := aptsvc.SetChannel(c.Request.Context(), req.Channel); err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
slog.Info("cluster: update channel set on this node by primary mTLS call",
|
||||
"channel", req.Channel, "client", c.ClientIP())
|
||||
response.OK(c, gin.H{"channel": req.Channel})
|
||||
}
|
||||
|
||||
// peerNode liefert die einzige andere ha_nodes-Row (best-effort, nil
|
||||
// wenn Standalone oder Store fehlt) — gleiches Muster wie in
|
||||
// RollingUpdate für die Secondary-Ermittlung.
|
||||
func (h *ClusterHandler) peerNode(ctx context.Context) *models.HANode {
|
||||
if h.Store == nil {
|
||||
return nil
|
||||
}
|
||||
nodes, err := h.Store.List(ctx)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
for i := range nodes {
|
||||
if nodes[i].ID != h.LocalID {
|
||||
return &nodes[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var errInvalidJoinRequest = simpleError("missing token or csr")
|
||||
|
||||
type simpleError string
|
||||
|
||||
@@ -109,6 +109,40 @@ func SyncTLSCertsFromPrimary(ctx context.Context, pool *pgxpool.Pool, agg *aggre
|
||||
slog.Info("cert-sync: updated", "file", name)
|
||||
}
|
||||
|
||||
// Prune: lokale .pem entfernen, die der Primary NICHT (mehr) hat.
|
||||
// Ohne diesen Schritt bleiben Zertifikate gelöschter Domains auf dem
|
||||
// Secondary als Waisen liegen — der Sync oben ist write-only, „nicht
|
||||
// mitgeschickt" ≠ „gelöscht". Geschützt bleiben:
|
||||
// _default.pem — Self-Signed-Fallback
|
||||
// <lokaler-FQDN>.pem — eigener Node-Cert (steht NICHT im Primary-Payload)
|
||||
// Nur prunen wenn der Payload nicht leer ist — Schutz gegen ein
|
||||
// versehentliches Leerräumen bei unvollständiger Primary-Antwort.
|
||||
if len(payload.Certs) > 0 {
|
||||
protected := map[string]bool{"_default.pem": true}
|
||||
var localFQDN string
|
||||
if err := pool.QueryRow(ctx,
|
||||
`SELECT fqdn FROM ha_nodes WHERE id = $1`, localID).Scan(&localFQDN); err == nil && localFQDN != "" {
|
||||
protected[localFQDN+".pem"] = true
|
||||
}
|
||||
if entries, err := os.ReadDir(tlsCertDir); err == nil {
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if e.IsDir() || !strings.HasSuffix(name, ".pem") || protected[name] {
|
||||
continue
|
||||
}
|
||||
if _, ok := payload.Certs[name]; ok {
|
||||
continue // vom Primary gepflegt — behalten
|
||||
}
|
||||
if err := os.Remove(filepath.Join(tlsCertDir, name)); err != nil {
|
||||
slog.Warn("cert-sync: prune failed", "file", name, "error", err)
|
||||
continue
|
||||
}
|
||||
changed = true
|
||||
slog.Info("cert-sync: pruned orphan", "file", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if changed {
|
||||
if err := exec.Command("sudo", "-n", "/usr/bin/systemctl", "reload", "haproxy.service").Run(); err != nil {
|
||||
slog.Warn("cert-sync: haproxy reload failed", "error", err)
|
||||
|
||||
@@ -273,7 +273,10 @@ retry_apt() {
|
||||
while [ $attempt -lt $max ]; do
|
||||
attempt=$((attempt + 1))
|
||||
apt-get update -qq || true
|
||||
if apt-get install -y -qq -o Dpkg::Options::=--force-confold \
|
||||
# --allow-downgrades: nur relevant nach testing→stable-Kanalwechsel
|
||||
# (Testing-Versionen sortieren datumsbasiert höher als Stable-Semver).
|
||||
# No-Op im Normalfall, da die Candidate sonst immer >= installed ist.
|
||||
if apt-get install -y -qq --allow-downgrades -o Dpkg::Options::=--force-confold \
|
||||
edgeguard-api edgeguard-ui edgeguard; then return 0; fi
|
||||
[ $attempt -lt $max ] && sleep $wait_for && wait_for=$((wait_for * 2))
|
||||
done
|
||||
|
||||
@@ -940,7 +940,10 @@ retry_apt() {
|
||||
attempt=$((attempt + 1))
|
||||
echo "[upgrade] attempt $attempt/$max: apt-get update + install"
|
||||
apt-get update -qq || true
|
||||
if apt-get install -y -qq -o Dpkg::Options::=--force-confold \
|
||||
# --allow-downgrades: nur relevant nach testing→stable-Kanalwechsel
|
||||
# (Testing-Versionen sortieren datumsbasiert höher als Stable-Semver).
|
||||
# No-Op im Normalfall, da die Candidate sonst immer >= installed ist.
|
||||
if apt-get install -y -qq --allow-downgrades -o Dpkg::Options::=--force-confold \
|
||||
edgeguard-api edgeguard-ui edgeguard; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
|
||||
wafsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/waf"
|
||||
intwaf "git.netcell-it.de/projekte/edgeguard-native/internal/waf"
|
||||
)
|
||||
|
||||
// wafRuleIDRe erlaubt nur einzelne CRS-Rule-IDs oder Ranges ("942100" /
|
||||
@@ -45,6 +46,11 @@ func (h *WafHandler) Register(rg *gin.RouterGroup) {
|
||||
g.PUT("/configs/:domain_id", h.Upsert)
|
||||
g.GET("/alerts", h.ListAlerts)
|
||||
g.DELETE("/alerts", h.PurgeAlerts)
|
||||
// Benutzerdefinierte App-Profile (wiederverwendbare Rule-ID-Bündel).
|
||||
g.GET("/profiles", h.ListProfiles)
|
||||
g.POST("/profiles", h.CreateProfile)
|
||||
g.PUT("/profiles/:id", h.UpdateProfile)
|
||||
g.DELETE("/profiles/:id", h.DeleteProfile)
|
||||
}
|
||||
|
||||
// List returns all WAF configs.
|
||||
@@ -80,13 +86,15 @@ func (h *WafHandler) Get(c *gin.Context) {
|
||||
|
||||
// upsertBody is the accepted JSON for PUT /waf/configs/:domain_id.
|
||||
type upsertBody struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Mode string `json:"mode"`
|
||||
ParanoiaLevel int `json:"paranoia_level"`
|
||||
RuleExclusions []string `json:"rule_exclusions"`
|
||||
ExclusionNotes map[string]string `json:"exclusion_notes"`
|
||||
TrustedProxies []string `json:"trusted_proxies"`
|
||||
CustomRules string `json:"custom_rules"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Mode string `json:"mode"`
|
||||
ParanoiaLevel int `json:"paranoia_level"`
|
||||
RuleExclusions []string `json:"rule_exclusions"`
|
||||
CRSPlugins []string `json:"crs_plugins"`
|
||||
AppProfiles []string `json:"app_profiles"`
|
||||
ExclusionNotes map[string]string `json:"exclusion_notes"`
|
||||
TrustedProxies []string `json:"trusted_proxies"`
|
||||
CustomRules string `json:"custom_rules"`
|
||||
}
|
||||
|
||||
// Upsert creates or updates the WAF config for a domain.
|
||||
@@ -110,9 +118,32 @@ func (h *WafHandler) Upsert(c *gin.Context) {
|
||||
if body.RuleExclusions == nil {
|
||||
body.RuleExclusions = []string{}
|
||||
}
|
||||
if body.CRSPlugins == nil {
|
||||
body.CRSPlugins = []string{}
|
||||
}
|
||||
if body.TrustedProxies == nil {
|
||||
body.TrustedProxies = []string{}
|
||||
}
|
||||
if body.AppProfiles == nil {
|
||||
body.AppProfiles = []string{}
|
||||
}
|
||||
// App-Profile: nur trimmen/leere raus. Unbekannte Namen sind harmlos (der
|
||||
// Agent-Resolver ignoriert sie defensiv), aber wir speichern keinen Müll.
|
||||
cleanProfiles := make([]string, 0, len(body.AppProfiles))
|
||||
for _, p := range body.AppProfiles {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
cleanProfiles = append(cleanProfiles, p)
|
||||
}
|
||||
}
|
||||
body.AppProfiles = cleanProfiles
|
||||
// CRS-Plugins müssen aus der bekannten Whitelist stammen — sie werden zu
|
||||
// Include-Pfaden, ein unbekannter Name wäre Pfad-Injection.
|
||||
for _, p := range body.CRSPlugins {
|
||||
if _, ok := intwaf.KnownCRSPlugins[strings.TrimSpace(p)]; !ok {
|
||||
response.BadRequest(c, errors.New("unbekanntes CRS-Plugin: "+p))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if body.ExclusionNotes == nil {
|
||||
body.ExclusionNotes = map[string]string{}
|
||||
@@ -144,6 +175,8 @@ func (h *WafHandler) Upsert(c *gin.Context) {
|
||||
Mode: body.Mode,
|
||||
ParanoiaLevel: body.ParanoiaLevel,
|
||||
RuleExclusions: body.RuleExclusions,
|
||||
CRSPlugins: body.CRSPlugins,
|
||||
AppProfiles: body.AppProfiles,
|
||||
ExclusionNotes: body.ExclusionNotes,
|
||||
TrustedProxies: body.TrustedProxies,
|
||||
CustomRules: body.CustomRules,
|
||||
@@ -211,6 +244,119 @@ func (h *WafHandler) PurgeAlerts(c *gin.Context) {
|
||||
response.OK(c, gin.H{"ok": true, "days": days})
|
||||
}
|
||||
|
||||
// wafProfileNameRe: erlaubte Zeichen für App-Profil-Namen (der Name wird pro
|
||||
// Domain in waf_configs.app_profiles referenziert; kein SecLang-Kontext, aber
|
||||
// sauber begrenzen).
|
||||
var wafProfileNameRe = regexp.MustCompile(`^[A-Za-z0-9 ._-]{1,60}$`)
|
||||
|
||||
// profileBody ist das akzeptierte JSON für Create/Update eines App-Profils.
|
||||
type profileBody struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
RuleExclusions []string `json:"rule_exclusions"`
|
||||
}
|
||||
|
||||
// validateProfileBody normalisiert und prüft den Request-Body. Gibt eine
|
||||
// Fehlermeldung zurück (nil = ok) und mutiert body (trim, nil→[]).
|
||||
func validateProfileBody(body *profileBody) error {
|
||||
body.Name = strings.TrimSpace(body.Name)
|
||||
if !wafProfileNameRe.MatchString(body.Name) {
|
||||
return errors.New("ungültiger Profil-Name (1–60 Zeichen: Buchstaben, Ziffern, Leer, . _ -)")
|
||||
}
|
||||
body.Description = strings.TrimSpace(body.Description)
|
||||
if body.RuleExclusions == nil {
|
||||
body.RuleExclusions = []string{}
|
||||
}
|
||||
for i, ex := range body.RuleExclusions {
|
||||
ex = strings.TrimSpace(ex)
|
||||
if !wafRuleIDRe.MatchString(ex) {
|
||||
return errors.New("ungültige Rule-Exclusion (nur IDs/Ranges erlaubt): " + ex)
|
||||
}
|
||||
body.RuleExclusions[i] = ex
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListProfiles returns all WAF app profiles (built-in first).
|
||||
func (h *WafHandler) ListProfiles(c *gin.Context) {
|
||||
profiles, err := h.Repo.ListProfiles(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"profiles": profiles})
|
||||
}
|
||||
|
||||
// CreateProfile creates a new user-defined app profile.
|
||||
func (h *WafHandler) CreateProfile(c *gin.Context) {
|
||||
var body profileBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if err := validateProfileBody(&body); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
p, err := h.Repo.CreateProfile(c.Request.Context(), body.Name, body.Description, body.RuleExclusions)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "waf.profile.create", body.Name,
|
||||
gin.H{"exclusions": len(body.RuleExclusions)}, h.NodeID)
|
||||
c.JSON(http.StatusOK, gin.H{"profile": p})
|
||||
}
|
||||
|
||||
// UpdateProfile updates a user-defined app profile (built-ins are read-only).
|
||||
func (h *WafHandler) UpdateProfile(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, errors.New("invalid id"))
|
||||
return
|
||||
}
|
||||
var body profileBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if err := validateProfileBody(&body); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
p, err := h.Repo.UpdateProfile(c.Request.Context(), id, body.Name, body.Description, body.RuleExclusions)
|
||||
if err != nil {
|
||||
if errors.Is(err, wafsvc.ErrProfileNotFound) {
|
||||
response.BadRequest(c, errors.New("kein Profil gefunden oder read-only (built-in)"))
|
||||
return
|
||||
}
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "waf.profile.update", body.Name,
|
||||
gin.H{"exclusions": len(body.RuleExclusions)}, h.NodeID)
|
||||
c.JSON(http.StatusOK, gin.H{"profile": p})
|
||||
}
|
||||
|
||||
// DeleteProfile removes a user-defined app profile (built-ins are protected).
|
||||
func (h *WafHandler) DeleteProfile(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, errors.New("invalid id"))
|
||||
return
|
||||
}
|
||||
if err := h.Repo.DeleteProfile(c.Request.Context(), id); err != nil {
|
||||
if errors.Is(err, wafsvc.ErrProfileNotFound) {
|
||||
response.BadRequest(c, errors.New("kein Profil gefunden oder read-only (built-in)"))
|
||||
return
|
||||
}
|
||||
response.Internal(c, err)
|
||||
return
|
||||
}
|
||||
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "waf.profile.delete", strconv.FormatInt(id, 10), nil, h.NodeID)
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// defaultConfig returns a sensible disabled default for a domain
|
||||
// that has no WAF config row yet.
|
||||
func defaultConfig(domainID int64) models.WafConfig {
|
||||
@@ -220,6 +366,8 @@ func defaultConfig(domainID int64) models.WafConfig {
|
||||
Mode: "detection",
|
||||
ParanoiaLevel: 1,
|
||||
RuleExclusions: []string{},
|
||||
CRSPlugins: []string{},
|
||||
AppProfiles: []string{},
|
||||
ExclusionNotes: map[string]string{},
|
||||
TrustedProxies: []string{},
|
||||
CustomRules: "",
|
||||
|
||||
@@ -81,6 +81,13 @@ frontend public_https
|
||||
bind quic6@:443 ssl crt /etc/edgeguard/tls/ alpn h3
|
||||
{{- end}}
|
||||
{{- if .WAFEnabled}}
|
||||
# WAF: Request-Body puffern, damit edgeguard-waf den Body inspizieren
|
||||
# kann (POST/PUT-Payloads: Form-SQLi, JSON-Injection, Datei-Uploads).
|
||||
# Bewusst NUR wenn mind. eine Domain WAF nutzt (.WAFEnabled) — sonst
|
||||
# kein RAM-pro-Connection-Overhead (vgl. Kommentar am Body-Size-Cap).
|
||||
# Puffer bis tune.bufsize (~16KB); größere Bodies werden zur Inspektion
|
||||
# gekappt — typische Injection-Payloads sind klein.
|
||||
option http-buffer-request
|
||||
# WAF: SPOE-Filter — edgeguard-waf inspiziert jeden Request.
|
||||
# filter muss vor allen http-request/http-response-Direktiven stehen.
|
||||
filter spoe engine edgeguard-waf config /etc/edgeguard/haproxy/coraza-spoe.cfg
|
||||
@@ -226,6 +233,11 @@ backend eg_backend_{{$b.ID}}
|
||||
{{- if $b.WebSocket}}
|
||||
timeout tunnel 1h
|
||||
{{- end}}
|
||||
{{- if $b.ServerTimeoutSeconds}}
|
||||
# Override des defaults-`timeout server 60s` für langsame Upstreams
|
||||
# (z. B. KI-Server mit gepufferter Antwort). Wert per Backend gepflegt.
|
||||
timeout server {{$b.ServerTimeoutSeconds}}s
|
||||
{{- end}}
|
||||
{{- if $b.HealthCheckPath}}
|
||||
option httpchk
|
||||
http-check send meth GET uri {{$b.HealthCheckPath}}
|
||||
|
||||
@@ -160,7 +160,7 @@ spoe-agent edgeguard-waf-agent
|
||||
use-backend spoe-edgeguard-waf
|
||||
|
||||
spoe-message edgeguard-waf-req
|
||||
args src=src method=method uri=url ver=req.ver headers=req.hdrs host=req.hdr(host)
|
||||
args src=src method=method uri=url ver=req.ver headers=req.hdrs host=req.hdr(host) body=req.body
|
||||
event on-frontend-http-request
|
||||
`
|
||||
|
||||
|
||||
@@ -554,6 +554,75 @@ func TestRender_WebSocketEmitsTunnelTimeout(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_ServerTimeoutOverride(t *testing.T) {
|
||||
tmo := 300
|
||||
v := View{
|
||||
Backends: []BackendView{
|
||||
{
|
||||
Backend: models.Backend{ID: 11, Name: "ai", Scheme: "http",
|
||||
LBAlgorithm: "roundrobin", ServerTimeoutSeconds: &tmo, Active: true},
|
||||
Servers: []models.BackendServer{
|
||||
{BackendID: 11, Name: "ai-1", Address: "10.0.5.30", Port: 8000, Weight: 100, Active: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
Backend: models.Backend{ID: 12, Name: "web", Scheme: "http",
|
||||
LBAlgorithm: "roundrobin", Active: true},
|
||||
Servers: []models.BackendServer{
|
||||
{BackendID: 12, Name: "web-1", Address: "10.0.5.31", Port: 8080, Weight: 100, Active: true},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
out := renderView(t, v)
|
||||
idxAI := strings.Index(out, "backend eg_backend_11")
|
||||
idxWeb := strings.Index(out, "backend eg_backend_12")
|
||||
if idxAI < 0 || idxWeb < 0 {
|
||||
t.Fatalf("backend sections missing in output:\n%s", out)
|
||||
}
|
||||
aiBlock := out[idxAI:idxWeb]
|
||||
webBlock := out[idxWeb:]
|
||||
// ai (nil-Override gesetzt) soll `timeout server 300s` bekommen …
|
||||
if !strings.Contains(aiBlock, "timeout server 300s") {
|
||||
t.Errorf("ai-Block sollte `timeout server 300s` enthalten:\n%s", aiBlock)
|
||||
}
|
||||
// … web (kein Override) soll KEINE eigene timeout-server-Zeile bekommen.
|
||||
if strings.Contains(webBlock, "timeout server") {
|
||||
t.Errorf("web-Block soll KEIN eigenes `timeout server` enthalten:\n%s", webBlock)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_WAFBuffersRequestBody(t *testing.T) {
|
||||
// Ohne WAF: kein Body-Buffering (kein RAM-Overhead).
|
||||
off := renderView(t, View{WAFEnabled: false})
|
||||
if strings.Contains(off, "option http-buffer-request") {
|
||||
t.Errorf("ohne WAF darf kein `option http-buffer-request` gerendert werden:\n%s", off)
|
||||
}
|
||||
if strings.Contains(off, "filter spoe") {
|
||||
t.Errorf("ohne WAF darf kein SPOE-Filter gerendert werden")
|
||||
}
|
||||
|
||||
// Mit WAF: Body-Buffering VOR dem SPOE-Filter, damit req.body verfügbar ist.
|
||||
on := renderView(t, View{WAFEnabled: true})
|
||||
idxBuf := strings.Index(on, "option http-buffer-request")
|
||||
idxFilter := strings.Index(on, "filter spoe engine edgeguard-waf")
|
||||
if idxBuf < 0 {
|
||||
t.Fatalf("mit WAF muss `option http-buffer-request` gerendert werden:\n%s", on)
|
||||
}
|
||||
if idxFilter < 0 {
|
||||
t.Fatalf("mit WAF muss der SPOE-Filter gerendert werden")
|
||||
}
|
||||
if idxBuf > idxFilter {
|
||||
t.Errorf("`option http-buffer-request` muss VOR dem SPOE-Filter stehen (buf=%d filter=%d)", idxBuf, idxFilter)
|
||||
}
|
||||
|
||||
// Die SPOE-Message muss den Body an den Agent schicken, sonst kann
|
||||
// Coraza ihn nicht inspizieren.
|
||||
if !strings.Contains(spoeCfg, "body=req.body") {
|
||||
t.Errorf("spoeCfg muss `body=req.body` an den WAF-Agent senden:\n%s", spoeCfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_MultiServerPool(t *testing.T) {
|
||||
v := View{
|
||||
Backends: []BackendView{
|
||||
|
||||
@@ -45,7 +45,7 @@ vrrp_instance VI_1 {
|
||||
virtual_router_id {{ .RouterID }}
|
||||
priority {{ .Priority }}
|
||||
advert_int 2
|
||||
nopreempt
|
||||
{{ if .PreemptDelay }}preempt_delay {{ .PreemptDelay }}{{ else }}nopreempt{{ end }}
|
||||
{{ if .SrcIP }} unicast_src_ip {{ .SrcIP }}
|
||||
unicast_peer {
|
||||
{{ .PeerIP }}
|
||||
@@ -72,7 +72,7 @@ vrrp_instance VI_HB {
|
||||
virtual_router_id {{ .HBRouterID }}
|
||||
priority {{ .Priority }}
|
||||
advert_int 2
|
||||
nopreempt
|
||||
{{ if .PreemptDelay }}preempt_delay {{ .PreemptDelay }}{{ else }}nopreempt{{ end }}
|
||||
{{ if .HBSrcIP }} unicast_src_ip {{ .HBSrcIP }}
|
||||
unicast_peer {
|
||||
{{ .HBPeerIP }}
|
||||
|
||||
@@ -54,8 +54,19 @@ type View struct {
|
||||
HBRouterID int
|
||||
// GW-Tracking
|
||||
GWCheckIP string
|
||||
// PreemptDelay > 0 → Node holt die VIP nach Erholung zurück (nach N Sekunden
|
||||
// Stabilität). 0 → nopreempt (bleibt Backup). Siehe buildView.
|
||||
PreemptDelay int
|
||||
}
|
||||
|
||||
// preemptDelaySeconds: der bevorzugte Node (höhere Priorität = PG-Primary) holt
|
||||
// die VIP erst nach dieser Wartezeit zurück — lange genug, dass ein frisch
|
||||
// gebooteter/deployter Node erst wirklich bereit ist (Boot + Service-Start),
|
||||
// bevor er überhaupt preempten darf. Zusammen mit dem gehärteten Health-Check
|
||||
// (haproxy aktiv + :443 gebunden, keepalived-check.sh) verhindert das den
|
||||
// Incident 2026-08-03 (halb-kaputter Node riss die VIP an sich).
|
||||
const preemptDelaySeconds = 120
|
||||
|
||||
type generator struct {
|
||||
pool *pgxpool.Pool
|
||||
localID string
|
||||
@@ -167,14 +178,21 @@ func (g *generator) buildView(cs *models.ClusterSettings, vips []VIPEntry, local
|
||||
v.HBRouterID = 52
|
||||
}
|
||||
|
||||
// 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).
|
||||
// State IMMER BACKUP; die Priorität entscheidet, welcher Node die VIP
|
||||
// bevorzugt hält (PG-Primary=200 > Standby=100). Mit preempt_delay holt
|
||||
// der bevorzugte Node die VIP nach Erholung zurück (VIP-Affinität zum
|
||||
// PG-Primary), aber erst nach preemptDelaySeconds Stabilität.
|
||||
//
|
||||
// Incident 2026-08-03 & Fix: eine frühere preempt_delay-Variante ließ den
|
||||
// Prio-200-Node die VIP zurückholen, sobald der Health-Check ihn für
|
||||
// „gesund" hielt — der prüfte aber NUR die edgeguard-api, nicht ob der Node
|
||||
// wirklich Traffic bedient. Ein halb-kaputter Primary (api up, haproxy/Netz
|
||||
// down) riss so die VIP an sich → Ausfall. Preempt ist wieder aktiv, WEIL
|
||||
// keepalived-check.sh jetzt zusätzlich haproxy-aktiv + :443-gebunden fordert:
|
||||
// ein nicht-bedienender Node geht in FAULT und kann NICHT preempten.
|
||||
// Promotion/PG-Failover bleibt manuell (edgeguard-ctl promote).
|
||||
v.State = "BACKUP"
|
||||
v.PreemptDelay = preemptDelaySeconds
|
||||
if local.PGRole == "standby" {
|
||||
v.Priority = 100
|
||||
} else if local.PGRole == "primary" || local.Role == "primary" {
|
||||
|
||||
@@ -17,32 +17,52 @@ func render(t *testing.T, v View) string {
|
||||
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",
|
||||
GWCheckIP: "89.163.205.1", PreemptDelay: 120,
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplateNopreemptOnBothInstances(t *testing.T) {
|
||||
// VIP-Affinität zum PG-Primary (Incident-2026-08-03-Fix): mit PreemptDelay
|
||||
// tragen BEIDE Instanzen `preempt_delay N` statt nopreempt, damit der
|
||||
// bevorzugte Node die VIP nach Erholung zurückholt — aber erst nach N Sekunden
|
||||
// Stabilität. Kein Node darf `state MASTER` starten (sonst kein sauberes
|
||||
// Election). Preempt ist nur sicher, WEIL keepalived-check.sh haproxy-Bereit-
|
||||
// schaft (aktiv + :443) mitprüft (siehe dortiger Kommentar).
|
||||
func TestTemplatePreemptDelayOnBothInstances(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 c := strings.Count(out, "preempt_delay 120"); c != 2 {
|
||||
t.Fatalf("erwarte preempt_delay 120 in VI_1 UND VI_HB (2×), gefunden: %d\n%s", c, out)
|
||||
}
|
||||
if strings.Contains(out, "nopreempt") {
|
||||
t.Fatalf("bei PreemptDelay>0 darf KEIN nopreempt gerendert werden:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "state MASTER") {
|
||||
t.Fatalf("kein Node darf state MASTER starten (nopreempt würde ignoriert):\n%s", out)
|
||||
t.Fatalf("kein Node darf state MASTER starten:\n%s", out)
|
||||
}
|
||||
if c := strings.Count(out, "state BACKUP"); c != 2 {
|
||||
t.Fatalf("erwarte state BACKUP in beiden Instanzen, gefunden: %d", c)
|
||||
}
|
||||
}
|
||||
|
||||
// Ohne PreemptDelay (==0) fällt das Template auf nopreempt zurück (Node bleibt
|
||||
// Backup, keine VIP-Rückkehr) — der sichere Default, falls Preempt je aus soll.
|
||||
func TestTemplateFallsBackToNopreempt(t *testing.T) {
|
||||
v := testView()
|
||||
v.PreemptDelay = 0
|
||||
out := render(t, v)
|
||||
if c := strings.Count(out, "nopreempt"); c != 2 {
|
||||
t.Fatalf("erwarte nopreempt in beiden Instanzen (2×) bei PreemptDelay=0, gefunden: %d\n%s", c, out)
|
||||
}
|
||||
if strings.Contains(out, "preempt_delay") {
|
||||
t.Fatalf("bei PreemptDelay=0 darf KEIN preempt_delay gerendert werden:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -103,10 +123,13 @@ func TestBuildViewStateAlwaysBackup(t *testing.T) {
|
||||
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)
|
||||
t.Errorf("pg_role=%q role=%q: State=%q, erwarte immer BACKUP", 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)
|
||||
}
|
||||
if v.PreemptDelay != 120 {
|
||||
t.Errorf("pg_role=%q role=%q: PreemptDelay=%d, erwarte 120", c.pgRole, c.role, v.PreemptDelay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@ type Backend struct {
|
||||
LBAlgorithm string `gorm:"column:lb_algorithm" json:"lb_algorithm"`
|
||||
WebSocket bool `gorm:"column:websocket" json:"websocket"`
|
||||
ForceHTTP1 bool `gorm:"column:force_http1" json:"force_http1"`
|
||||
// ServerTimeoutSeconds überschreibt `timeout server` für dieses
|
||||
// Backend (Sekunden). nil = defaults-Timeout (60s). Für langsam
|
||||
// antwortende Upstreams (KI-/Inferenz-Server ohne Streaming).
|
||||
ServerTimeoutSeconds *int `gorm:"column:server_timeout_seconds" json:"server_timeout_seconds,omitempty"`
|
||||
Active bool `gorm:"column:active" json:"active"`
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||
|
||||
@@ -20,6 +20,9 @@ type Domain struct {
|
||||
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)
|
||||
// CrowdSecTrusted: vertrauenswürdiges Admin-Panel → dessen Hostname wird in
|
||||
// die CrowdSec-Whitelist gerendert (Admin-SPA-Traffic ist kein Crawl).
|
||||
CrowdSecTrusted bool `gorm:"column:crowdsec_trusted" json:"crowdsec_trusted"`
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -11,6 +11,14 @@ type WafConfig struct {
|
||||
Mode string `gorm:"column:mode" json:"mode"` // "detection" | "blocking"
|
||||
ParanoiaLevel int `gorm:"column:paranoia_level" json:"paranoia_level"` // 1–4
|
||||
RuleExclusions []string `gorm:"column:rule_exclusions;type:text[]" json:"rule_exclusions"`
|
||||
// CRSPlugins: aktivierte OWASP-CRS-App-Exclusion-Plugins (z. B.
|
||||
// "nextcloud","wordpress"). Der Renderer inkludiert je Plugin dessen
|
||||
// config/before/after-Dateien aus <crsDir>/plugins/.
|
||||
CRSPlugins []string `gorm:"column:crs_plugins;type:text[]" json:"crs_plugins"`
|
||||
// AppProfiles: zugewiesene benutzerdefinierte WAF-App-Profile (Namen aus
|
||||
// waf_app_profiles). Ihre rule_exclusions werden im Agent in die effektiven
|
||||
// Ausnahmen dieser Domain gemischt.
|
||||
AppProfiles []string `gorm:"column:app_profiles;type:text[]" json:"app_profiles"`
|
||||
ExclusionNotes map[string]string `gorm:"column:exclusion_notes;type:jsonb" json:"exclusion_notes"` // rule_id → note
|
||||
TrustedProxies []string `gorm:"column:trusted_proxies;type:text[]" json:"trusted_proxies"`
|
||||
CustomRules string `gorm:"column:custom_rules" json:"custom_rules"`
|
||||
@@ -18,3 +26,18 @@ type WafConfig struct {
|
||||
}
|
||||
|
||||
func (WafConfig) TableName() string { return "waf_configs" }
|
||||
|
||||
// WafAppProfile ist ein benanntes, wiederverwendbares Bündel von CRS-Rule-
|
||||
// Exclusions (reine Rule-IDs/Ranges). Built-in-Profile (builtin=true) sind
|
||||
// read-only; benutzerdefinierte sind im UI editierbar und pro Domain zuweisbar.
|
||||
type WafAppProfile struct {
|
||||
ID int64 `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"column:name;uniqueIndex" json:"name"`
|
||||
Description string `gorm:"column:description" json:"description"`
|
||||
RuleExclusions []string `gorm:"column:rule_exclusions;type:text[]" json:"rule_exclusions"`
|
||||
Builtin bool `gorm:"column:builtin" json:"builtin"`
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (WafAppProfile) TableName() string { return "waf_app_profiles" }
|
||||
|
||||
@@ -62,13 +62,14 @@ type EmailSettings struct {
|
||||
|
||||
// Event ist eine Row in alert_events.
|
||||
type Event struct {
|
||||
ID int64 `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Severity Severity `json:"severity"`
|
||||
Subject string `json:"subject"`
|
||||
Message string `json:"message"`
|
||||
SentTo json.RawMessage `json:"sent_to"`
|
||||
FiredAt time.Time `json:"fired_at"`
|
||||
ID int64 `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Severity Severity `json:"severity"`
|
||||
Subject string `json:"subject"`
|
||||
Message string `json:"message"`
|
||||
SentTo json.RawMessage `json:"sent_to"`
|
||||
FiredAt time.Time `json:"fired_at"`
|
||||
AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"`
|
||||
}
|
||||
|
||||
// SendResult pro Channel — landet als JSON-Array in sent_to.
|
||||
@@ -170,14 +171,19 @@ func (s *Service) DeleteChannel(ctx context.Context, id int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListEvents liefert die letzten N Events newest-first.
|
||||
func (s *Service) ListEvents(ctx context.Context, limit int) ([]Event, error) {
|
||||
// ListEvents liefert die letzten N Events newest-first. Wenn openOnly
|
||||
// gesetzt ist, werden nur noch offene (nicht quittierte) Events geliefert —
|
||||
// das nutzt die Dashboard-Karte, damit Quittieren die Meldung verschwinden
|
||||
// lässt.
|
||||
func (s *Service) ListEvents(ctx context.Context, limit int, openOnly bool) ([]Event, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := s.Pool.Query(ctx, `
|
||||
SELECT id, kind, severity, subject, message, sent_to, fired_at
|
||||
FROM alert_events ORDER BY fired_at DESC, id DESC LIMIT $1`, limit)
|
||||
SELECT id, kind, severity, subject, message, sent_to, fired_at, acknowledged_at
|
||||
FROM alert_events
|
||||
WHERE ($2::bool = false OR acknowledged_at IS NULL)
|
||||
ORDER BY fired_at DESC, id DESC LIMIT $1`, limit, openOnly)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -186,7 +192,7 @@ FROM alert_events ORDER BY fired_at DESC, id DESC LIMIT $1`, limit)
|
||||
for rows.Next() {
|
||||
var e Event
|
||||
if err := rows.Scan(&e.ID, &e.Kind, &e.Severity, &e.Subject,
|
||||
&e.Message, &e.SentTo, &e.FiredAt); err != nil {
|
||||
&e.Message, &e.SentTo, &e.FiredAt, &e.AcknowledgedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, e)
|
||||
@@ -194,6 +200,64 @@ FROM alert_events ORDER BY fired_at DESC, id DESC LIMIT $1`, limit)
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Acknowledge quittiert die angegebenen Events (setzt acknowledged_at=NOW()
|
||||
// bei noch offenen). Liefert die Anzahl geänderter Rows.
|
||||
func (s *Service) Acknowledge(ctx context.Context, ids []int64) (int64, error) {
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
tag, err := s.Pool.Exec(ctx,
|
||||
`UPDATE alert_events SET acknowledged_at = NOW()
|
||||
WHERE id = ANY($1) AND acknowledged_at IS NULL`, ids)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
|
||||
// AcknowledgeAll quittiert alle offenen Events — Backing für den
|
||||
// "Alle quittieren"-Button.
|
||||
func (s *Service) AcknowledgeAll(ctx context.Context) (int64, error) {
|
||||
tag, err := s.Pool.Exec(ctx,
|
||||
`UPDATE alert_events SET acknowledged_at = NOW() WHERE acknowledged_at IS NULL`)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
|
||||
// DeleteEvents löscht die angegebenen Events endgültig. Liefert die Anzahl
|
||||
// gelöschter Rows.
|
||||
func (s *Service) DeleteEvents(ctx context.Context, ids []int64) (int64, error) {
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
tag, err := s.Pool.Exec(ctx,
|
||||
`DELETE FROM alert_events WHERE id = ANY($1)`, ids)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
|
||||
// Cleanup löscht alert_events älter als keepDays und liefert die Anzahl
|
||||
// gelöschter Rows. make_interval(days => $1) nimmt $1 sauber als int —
|
||||
// der frühere ($1 || ' days')::interval-Ansatz erzwang text und scheiterte
|
||||
// unter pgx mit einem Encode-Fehler (vgl. waf PurgeAlerts, v1.3.3).
|
||||
func (s *Service) Cleanup(ctx context.Context, keepDays int) (int64, error) {
|
||||
if keepDays <= 0 {
|
||||
return 0, nil
|
||||
}
|
||||
tag, err := s.Pool.Exec(ctx,
|
||||
`DELETE FROM alert_events WHERE fired_at < NOW() - make_interval(days => $1)`,
|
||||
keepDays,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
|
||||
// Fire dispatch'ed einen Event an alle aktiven Channels und persistiert
|
||||
// das Ergebnis. Non-fatal — Send-Failures werden im sent_to-JSON
|
||||
// dokumentiert, der Event selbst landet in jedem Fall in der History.
|
||||
|
||||
@@ -237,3 +237,61 @@ func AutoUpdateEnabled() bool {
|
||||
_, err := os.Stat(AutoUpdateConfPath)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// ── Update-Kanal (stable/testing) ──────────────────────────────────────
|
||||
//
|
||||
// Kanal-Modell 1:1 von enconf übernommen: Suite = OS-Codename (trixie),
|
||||
// Komponente = Kanal. Kein eigenes Config-File — die sources.list-Zeile
|
||||
// selbst ist die einzige Quelle der Wahrheit (siehe scripts/install.sh
|
||||
// setup_repo(), das dieselbe Zeile beim Erstinstall schreibt).
|
||||
|
||||
// SourcesListPath: vom Installer angelegte apt-Quelle. Root-owned wie
|
||||
// AutoUpdateConfPath — Schreibzugriff nur via sudo tee (Sudoers-Pin im
|
||||
// postinst).
|
||||
const SourcesListPath = "/etc/apt/sources.list.d/edgeguard.list"
|
||||
|
||||
const sourcesListTemplate = "deb [signed-by=/etc/apt/keyrings/nmg.asc] " +
|
||||
"https://git.netcell-it.de/api/packages/projekte/debian trixie %s\n"
|
||||
|
||||
// Channel liest den aktuell konfigurierten Update-Kanal aus dem letzten
|
||||
// Feld der deb-Zeile. Default "stable" wenn die Datei fehlt oder das
|
||||
// letzte Feld kein bekannter Kanal ist (Fail-safe — nie stillschweigend
|
||||
// "testing" annehmen).
|
||||
func Channel() string {
|
||||
data, err := os.ReadFile(SourcesListPath)
|
||||
if err != nil {
|
||||
return "stable"
|
||||
}
|
||||
for _, raw := range strings.Split(string(data), "\n") {
|
||||
line := strings.TrimSpace(raw)
|
||||
if !strings.HasPrefix(line, "deb ") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) == 0 {
|
||||
continue
|
||||
}
|
||||
switch fields[len(fields)-1] {
|
||||
case "stable", "testing":
|
||||
return fields[len(fields)-1]
|
||||
}
|
||||
}
|
||||
return "stable"
|
||||
}
|
||||
|
||||
// SetChannel schreibt die sources.list-Zeile mit dem neuen Kanal und
|
||||
// refresht den apt-Cache sofort — sonst zeigt der Update-Banner bis zum
|
||||
// nächsten 5-min-Throttle-Fenster noch den alten Kanal-Stand.
|
||||
func SetChannel(ctx context.Context, channel string) error {
|
||||
if channel != "stable" && channel != "testing" {
|
||||
return fmt.Errorf("apt: unknown channel %q (expected stable|testing)", channel)
|
||||
}
|
||||
body := fmt.Sprintf(sourcesListTemplate, channel)
|
||||
cmd := exec.Command("sudo", "-n", "/usr/bin/tee", SourcesListPath)
|
||||
cmd.Stdin = strings.NewReader(body)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("sudo tee %s: %w: %s", SourcesListPath, err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
RefreshNow(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -26,8 +26,8 @@ type Repo struct {
|
||||
func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} }
|
||||
|
||||
const baseSelect = `
|
||||
SELECT id, name, scheme, health_check_path, lb_algorithm, websocket, force_http1, active,
|
||||
created_at, updated_at
|
||||
SELECT id, name, scheme, health_check_path, lb_algorithm, websocket, force_http1,
|
||||
server_timeout_seconds, active, created_at, updated_at
|
||||
FROM backends
|
||||
`
|
||||
|
||||
@@ -65,11 +65,13 @@ func (r *Repo) Create(ctx context.Context, b models.Backend) (*models.Backend, e
|
||||
b.LBAlgorithm = "roundrobin"
|
||||
}
|
||||
row := r.Pool.QueryRow(ctx, `
|
||||
INSERT INTO backends (name, scheme, health_check_path, lb_algorithm, websocket, force_http1, active)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, name, scheme, health_check_path, lb_algorithm, websocket, force_http1, active,
|
||||
created_at, updated_at`,
|
||||
b.Name, b.Scheme, b.HealthCheckPath, b.LBAlgorithm, b.WebSocket, b.ForceHTTP1, b.Active)
|
||||
INSERT INTO backends (name, scheme, health_check_path, lb_algorithm, websocket, force_http1,
|
||||
server_timeout_seconds, active)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, name, scheme, health_check_path, lb_algorithm, websocket, force_http1,
|
||||
server_timeout_seconds, active, created_at, updated_at`,
|
||||
b.Name, b.Scheme, b.HealthCheckPath, b.LBAlgorithm, b.WebSocket, b.ForceHTTP1,
|
||||
b.ServerTimeoutSeconds, b.Active)
|
||||
return scanBackend(row)
|
||||
}
|
||||
|
||||
@@ -85,12 +87,14 @@ UPDATE backends SET
|
||||
lb_algorithm = $4,
|
||||
websocket = $5,
|
||||
force_http1 = $6,
|
||||
active = $7,
|
||||
server_timeout_seconds = $7,
|
||||
active = $8,
|
||||
updated_at = NOW()
|
||||
WHERE id = $8
|
||||
RETURNING id, name, scheme, health_check_path, lb_algorithm, websocket, force_http1, active,
|
||||
created_at, updated_at`,
|
||||
b.Name, b.Scheme, b.HealthCheckPath, b.LBAlgorithm, b.WebSocket, b.ForceHTTP1, b.Active, id)
|
||||
WHERE id = $9
|
||||
RETURNING id, name, scheme, health_check_path, lb_algorithm, websocket, force_http1,
|
||||
server_timeout_seconds, active, created_at, updated_at`,
|
||||
b.Name, b.Scheme, b.HealthCheckPath, b.LBAlgorithm, b.WebSocket, b.ForceHTTP1,
|
||||
b.ServerTimeoutSeconds, b.Active, id)
|
||||
out, err := scanBackend(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
@@ -125,7 +129,8 @@ func scanBackend(row interface{ Scan(...any) error }) (*models.Backend, error) {
|
||||
var b models.Backend
|
||||
if err := row.Scan(
|
||||
&b.ID, &b.Name, &b.Scheme,
|
||||
&b.HealthCheckPath, &b.LBAlgorithm, &b.WebSocket, &b.ForceHTTP1, &b.Active,
|
||||
&b.HealthCheckPath, &b.LBAlgorithm, &b.WebSocket, &b.ForceHTTP1,
|
||||
&b.ServerTimeoutSeconds, &b.Active,
|
||||
&b.CreatedAt, &b.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, 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, redirect_to, created_at, updated_at
|
||||
notes, redirect_to, crowdsec_trusted, created_at, updated_at
|
||||
FROM domains
|
||||
`
|
||||
|
||||
@@ -65,17 +65,19 @@ 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, redirect_to)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
||||
rate_limit_rps, max_body_kb, disable_h3, notes, redirect_to,
|
||||
crowdsec_trusted)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $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, redirect_to, created_at, updated_at`,
|
||||
notes, redirect_to, crowdsec_trusted, 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.RedirectTo)
|
||||
d.RateLimitRPS, d.MaxBodyKB, d.DisableH3, d.Notes, d.RedirectTo,
|
||||
d.CrowdSecTrusted)
|
||||
return scanDomain(row)
|
||||
}
|
||||
|
||||
@@ -101,17 +103,19 @@ UPDATE domains SET
|
||||
disable_h3 = $14,
|
||||
notes = $15,
|
||||
redirect_to = $16,
|
||||
crowdsec_trusted = $17,
|
||||
updated_at = NOW()
|
||||
WHERE id = $17
|
||||
WHERE id = $18
|
||||
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, redirect_to, created_at, updated_at`,
|
||||
notes, redirect_to, crowdsec_trusted, 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.RedirectTo, id)
|
||||
d.RateLimitRPS, d.MaxBodyKB, d.DisableH3, d.Notes, d.RedirectTo,
|
||||
d.CrowdSecTrusted, id)
|
||||
out, err := scanDomain(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
@@ -140,7 +144,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.RedirectTo, &d.CreatedAt, &d.UpdatedAt,
|
||||
&d.Notes, &d.RedirectTo, &d.CrowdSecTrusted, &d.CreatedAt, &d.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
124
internal/services/waf/appprofiles.go
Normal file
124
internal/services/waf/appprofiles.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package waf
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
)
|
||||
|
||||
// ErrProfileNotFound wird von Get/Update/Delete zurückgegeben, wenn kein Profil
|
||||
// mit der ID existiert.
|
||||
var ErrProfileNotFound = errors.New("waf app profile not found")
|
||||
|
||||
const profileSelect = `
|
||||
SELECT id, name, description, rule_exclusions, builtin, created_at, updated_at
|
||||
FROM waf_app_profiles
|
||||
`
|
||||
|
||||
func scanProfile(row pgx.Row) (*models.WafAppProfile, error) {
|
||||
var p models.WafAppProfile
|
||||
if err := row.Scan(&p.ID, &p.Name, &p.Description, &p.RuleExclusions, &p.Builtin, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// ListProfiles gibt alle App-Profile zurück (Built-in zuerst, dann alphabetisch).
|
||||
func (r *Repo) ListProfiles(ctx context.Context) ([]models.WafAppProfile, error) {
|
||||
rows, err := r.Pool.Query(ctx, profileSelect+" ORDER BY builtin DESC, name ASC")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]models.WafAppProfile, 0, 16)
|
||||
for rows.Next() {
|
||||
p, err := scanProfile(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, *p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// profilesByName lädt alle Profile in eine Name→Profil-Map (für die Auflösung
|
||||
// im Agent-Loader).
|
||||
func (r *Repo) profilesByName(ctx context.Context) (map[string]models.WafAppProfile, error) {
|
||||
list, err := r.ListProfiles(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := make(map[string]models.WafAppProfile, len(list))
|
||||
for _, p := range list {
|
||||
m[p.Name] = p
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// GetProfile gibt ein Profil per ID zurück, oder ErrProfileNotFound.
|
||||
func (r *Repo) GetProfile(ctx context.Context, id int64) (*models.WafAppProfile, error) {
|
||||
row := r.Pool.QueryRow(ctx, profileSelect+" WHERE id = $1", id)
|
||||
p, err := scanProfile(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrProfileNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// CreateProfile legt ein neues benutzerdefiniertes Profil an (builtin immer
|
||||
// false — Built-ins werden nicht über die API erzeugt).
|
||||
func (r *Repo) CreateProfile(ctx context.Context, name, description string, exclusions []string) (*models.WafAppProfile, error) {
|
||||
if exclusions == nil {
|
||||
exclusions = []string{}
|
||||
}
|
||||
now := time.Now()
|
||||
row := r.Pool.QueryRow(ctx, `
|
||||
INSERT INTO waf_app_profiles (name, description, rule_exclusions, builtin, created_at, updated_at)
|
||||
VALUES ($1,$2,$3,false,$4,$4)
|
||||
RETURNING id, name, description, rule_exclusions, builtin, created_at, updated_at
|
||||
`, name, description, exclusions, now)
|
||||
return scanProfile(row)
|
||||
}
|
||||
|
||||
// UpdateProfile ändert Name/Beschreibung/Ausnahmen eines Profils. Built-in-
|
||||
// Profile sind read-only (WHERE builtin = false) → ErrProfileNotFound, wenn
|
||||
// das Profil fehlt ODER built-in ist.
|
||||
func (r *Repo) UpdateProfile(ctx context.Context, id int64, name, description string, exclusions []string) (*models.WafAppProfile, error) {
|
||||
if exclusions == nil {
|
||||
exclusions = []string{}
|
||||
}
|
||||
row := r.Pool.QueryRow(ctx, `
|
||||
UPDATE waf_app_profiles
|
||||
SET name = $2, description = $3, rule_exclusions = $4, updated_at = $5
|
||||
WHERE id = $1 AND builtin = false
|
||||
RETURNING id, name, description, rule_exclusions, builtin, created_at, updated_at
|
||||
`, id, name, description, exclusions, time.Now())
|
||||
p, err := scanProfile(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrProfileNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// DeleteProfile entfernt ein benutzerdefiniertes Profil. Built-in-Profile sind
|
||||
// geschützt. Gibt ErrProfileNotFound zurück, wenn nichts gelöscht wurde.
|
||||
func (r *Repo) DeleteProfile(ctx context.Context, id int64) error {
|
||||
tag, err := r.Pool.Exec(ctx, `DELETE FROM waf_app_profiles WHERE id = $1 AND builtin = false`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrProfileNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
48
internal/services/waf/appprofiles_test.go
Normal file
48
internal/services/waf/appprofiles_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package waf
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
)
|
||||
|
||||
func TestMergeProfileExclusions(t *testing.T) {
|
||||
base := time.Date(2026, 8, 3, 10, 0, 0, 0, time.UTC)
|
||||
newer := base.Add(1 * time.Hour)
|
||||
profiles := map[string]models.WafAppProfile{
|
||||
"nc": {Name: "nc", RuleExclusions: []string{"942100", "920420"}, UpdatedAt: newer},
|
||||
"wp": {Name: "wp", RuleExclusions: []string{"942100", "941100"}, UpdatedAt: base},
|
||||
}
|
||||
|
||||
t.Run("keine Profile → unverändert", func(t *testing.T) {
|
||||
got, ts := mergeProfileExclusions([]string{"1000"}, base, nil, profiles)
|
||||
if len(got) != 1 || got[0] != "1000" || !ts.Equal(base) {
|
||||
t.Fatalf("got=%v ts=%v", got, ts)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("union dedupliziert, Reihenfolge stabil", func(t *testing.T) {
|
||||
got, ts := mergeProfileExclusions([]string{"942100", "900001"}, base, []string{"nc", "wp"}, profiles)
|
||||
want := []string{"942100", "900001", "920420", "941100"} // 942100 nicht doppelt
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got=%v want=%v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("got=%v want=%v", got, want)
|
||||
}
|
||||
}
|
||||
// effektives updated_at = max(base, nc.newer) = newer
|
||||
if !ts.Equal(newer) {
|
||||
t.Fatalf("ts=%v want=%v (Profil-Edit muss Rebuild ausloesen)", ts, newer)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unbekanntes Profil defensiv ignoriert", func(t *testing.T) {
|
||||
got, ts := mergeProfileExclusions([]string{"1000"}, base, []string{"gibtsnicht"}, profiles)
|
||||
if len(got) != 1 || got[0] != "1000" || !ts.Equal(base) {
|
||||
t.Fatalf("got=%v ts=%v", got, ts)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -22,7 +22,7 @@ func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} }
|
||||
|
||||
const baseSelect = `
|
||||
SELECT id, domain_id, enabled, mode, paranoia_level,
|
||||
rule_exclusions, exclusion_notes, trusted_proxies, custom_rules, updated_at
|
||||
rule_exclusions, crs_plugins, app_profiles, exclusion_notes, trusted_proxies, custom_rules, updated_at
|
||||
FROM waf_configs
|
||||
`
|
||||
|
||||
@@ -30,7 +30,7 @@ func scan(row pgx.Row) (*models.WafConfig, error) {
|
||||
var c models.WafConfig
|
||||
err := row.Scan(
|
||||
&c.ID, &c.DomainID, &c.Enabled, &c.Mode, &c.ParanoiaLevel,
|
||||
&c.RuleExclusions, &c.ExclusionNotes, &c.TrustedProxies, &c.CustomRules, &c.UpdatedAt,
|
||||
&c.RuleExclusions, &c.CRSPlugins, &c.AppProfiles, &c.ExclusionNotes, &c.TrustedProxies, &c.CustomRules, &c.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -82,22 +82,24 @@ func (r *Repo) Upsert(ctx context.Context, c models.WafConfig) (*models.WafConfi
|
||||
row := r.Pool.QueryRow(ctx, `
|
||||
INSERT INTO waf_configs
|
||||
(domain_id, enabled, mode, paranoia_level,
|
||||
rule_exclusions, exclusion_notes, trusted_proxies, custom_rules, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
||||
rule_exclusions, crs_plugins, app_profiles, exclusion_notes, trusted_proxies, custom_rules, updated_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
|
||||
ON CONFLICT (domain_id) DO UPDATE SET
|
||||
enabled = EXCLUDED.enabled,
|
||||
mode = EXCLUDED.mode,
|
||||
paranoia_level = EXCLUDED.paranoia_level,
|
||||
rule_exclusions = EXCLUDED.rule_exclusions,
|
||||
crs_plugins = EXCLUDED.crs_plugins,
|
||||
app_profiles = EXCLUDED.app_profiles,
|
||||
exclusion_notes = EXCLUDED.exclusion_notes,
|
||||
trusted_proxies = EXCLUDED.trusted_proxies,
|
||||
custom_rules = EXCLUDED.custom_rules,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
RETURNING id, domain_id, enabled, mode, paranoia_level,
|
||||
rule_exclusions, exclusion_notes, trusted_proxies, custom_rules, updated_at
|
||||
rule_exclusions, crs_plugins, app_profiles, exclusion_notes, trusted_proxies, custom_rules, updated_at
|
||||
`,
|
||||
c.DomainID, c.Enabled, c.Mode, c.ParanoiaLevel,
|
||||
c.RuleExclusions, c.ExclusionNotes, c.TrustedProxies, c.CustomRules, c.UpdatedAt,
|
||||
c.RuleExclusions, c.CRSPlugins, c.AppProfiles, c.ExclusionNotes, c.TrustedProxies, c.CustomRules, c.UpdatedAt,
|
||||
)
|
||||
return scan(row)
|
||||
}
|
||||
@@ -200,11 +202,23 @@ type DomainConfigPair struct {
|
||||
|
||||
// ListAllWithDomain returns all WAF configs joined with their domain name.
|
||||
// Used by the WAF agent to build the hostname→engine mapping.
|
||||
//
|
||||
// Wichtig: crs_plugins UND app_profiles werden hier geladen — früher fehlte
|
||||
// crs_plugins, dadurch waren die gewählten Built-in-CRS-Plugins im laufenden
|
||||
// Agent nie aktiv. app_profiles (benutzerdefinierte Rule-ID-Bündel) werden hier
|
||||
// in die effektiven rule_exclusions der Domain gemischt und ihr updated_at
|
||||
// fließt in das effektive updated_at ein — so baut der Manager die Engine neu,
|
||||
// sobald ein Profil bearbeitet wird (der Rebuild-Trigger hängt an updated_at).
|
||||
func (r *Repo) ListAllWithDomain(ctx context.Context) ([]DomainConfigPair, error) {
|
||||
profiles, err := r.profilesByName(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT d.name,
|
||||
w.id, w.domain_id, w.enabled, w.mode, w.paranoia_level,
|
||||
w.rule_exclusions, w.trusted_proxies, w.custom_rules, w.updated_at
|
||||
w.rule_exclusions, w.crs_plugins, w.app_profiles,
|
||||
w.trusted_proxies, w.custom_rules, w.updated_at
|
||||
FROM waf_configs w
|
||||
JOIN domains d ON d.id = w.domain_id
|
||||
WHERE d.active = true
|
||||
@@ -221,12 +235,49 @@ func (r *Repo) ListAllWithDomain(ctx context.Context) ([]DomainConfigPair, error
|
||||
if err := rows.Scan(
|
||||
&p.Hostname,
|
||||
&c.ID, &c.DomainID, &c.Enabled, &c.Mode, &c.ParanoiaLevel,
|
||||
&c.RuleExclusions, &c.TrustedProxies, &c.CustomRules, &c.UpdatedAt,
|
||||
&c.RuleExclusions, &c.CRSPlugins, &c.AppProfiles,
|
||||
&c.TrustedProxies, &c.CustomRules, &c.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.RuleExclusions, c.UpdatedAt = mergeProfileExclusions(c.RuleExclusions, c.UpdatedAt, c.AppProfiles, profiles)
|
||||
p.Config = c
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// mergeProfileExclusions vereint die domain-eigenen Ausnahmen mit denen aller
|
||||
// zugewiesenen App-Profile (dedupliziert, stabile Reihenfolge) und hebt das
|
||||
// effektive updated_at auf das Maximum aus Config + zugewiesenen Profilen an.
|
||||
// Pure Funktion (leicht testbar, keine DB).
|
||||
func mergeProfileExclusions(own []string, updatedAt time.Time, assigned []string, profiles map[string]models.WafAppProfile) ([]string, time.Time) {
|
||||
if len(assigned) == 0 {
|
||||
return own, updatedAt
|
||||
}
|
||||
seen := make(map[string]struct{}, len(own))
|
||||
merged := make([]string, 0, len(own))
|
||||
for _, id := range own {
|
||||
if _, ok := seen[id]; !ok {
|
||||
seen[id] = struct{}{}
|
||||
merged = append(merged, id)
|
||||
}
|
||||
}
|
||||
effUpdated := updatedAt
|
||||
for _, name := range assigned {
|
||||
prof, ok := profiles[name]
|
||||
if !ok {
|
||||
continue // unbekanntes/gelöschtes Profil defensiv ignorieren
|
||||
}
|
||||
if prof.UpdatedAt.After(effUpdated) {
|
||||
effUpdated = prof.UpdatedAt
|
||||
}
|
||||
for _, id := range prof.RuleExclusions {
|
||||
if _, ok := seen[id]; !ok {
|
||||
seen[id] = struct{}{}
|
||||
merged = append(merged, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
return merged, effUpdated
|
||||
}
|
||||
|
||||
@@ -48,12 +48,23 @@ func buildDirectives(cfg models.WafConfig, crsDir string) string {
|
||||
pl = 1
|
||||
}
|
||||
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 {
|
||||
fmt.Fprintf(&sb, "Include %s\n", setupConf)
|
||||
includeIfExists(&sb, filepath.Join(crsDir, "crs-setup.conf"))
|
||||
|
||||
// CRS-App-Exclusion-Plugins: config + before laufen VOR den CRS-Rules
|
||||
// (setzen Enable-Vars + pfad-scoped ctl:ruleRemoveById), after DANACH —
|
||||
// exakt nach OWASP-CRS-Plugin-Spec. Es werden NUR die für DIESE Domain
|
||||
// gewählten Plugins inkludiert (per-Domain, nicht global).
|
||||
plugins := resolveCRSPlugins(cfg.CRSPlugins)
|
||||
for _, prefix := range plugins {
|
||||
includeIfExists(&sb, filepath.Join(crsDir, "plugins", prefix+"-config.conf"))
|
||||
includeIfExists(&sb, filepath.Join(crsDir, "plugins", prefix+"-before.conf"))
|
||||
}
|
||||
// rules/*.conf ist ein Glob (kein Stat) — crsAvailable() hat oben bereits
|
||||
// bestätigt, dass mind. eine .conf existiert.
|
||||
fmt.Fprintf(&sb, "Include %s\n", filepath.Join(crsDir, "rules", "*.conf"))
|
||||
for _, prefix := range plugins {
|
||||
includeIfExists(&sb, filepath.Join(crsDir, "plugins", prefix+"-after.conf"))
|
||||
}
|
||||
rulesGlob := filepath.Join(crsDir, "rules", "*.conf")
|
||||
fmt.Fprintf(&sb, "Include %s\n", rulesGlob)
|
||||
}
|
||||
|
||||
// Rule exclusions (applied after CRS load so they override CRS).
|
||||
@@ -78,6 +89,35 @@ func buildDirectives(cfg models.WafConfig, crsDir string) string {
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// KnownCRSPlugins mappt den kurzen Plugin-Namen (gespeichert in
|
||||
// waf_configs.crs_plugins, im UI gewählt) auf sein Datei-Prefix in
|
||||
// <crsDir>/plugins/. Nur diese werden paketiert (postinst) und akzeptiert.
|
||||
var KnownCRSPlugins = map[string]string{
|
||||
"nextcloud": "nextcloud-rule-exclusions",
|
||||
"wordpress": "wordpress-rule-exclusions",
|
||||
"drupal": "drupal-rule-exclusions",
|
||||
}
|
||||
|
||||
// resolveCRSPlugins mappt gewählte Plugin-Namen auf ihre Datei-Prefixe und
|
||||
// filtert unbekannte/leere raus — defensiv, nie ungültige Includes rendern.
|
||||
func resolveCRSPlugins(names []string) []string {
|
||||
out := make([]string, 0, len(names))
|
||||
for _, n := range names {
|
||||
if prefix, ok := KnownCRSPlugins[strings.TrimSpace(n)]; ok {
|
||||
out = append(out, prefix)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// includeIfExists rendert eine Include-Zeile nur, wenn die Datei existiert — so
|
||||
// bricht ein gewähltes-aber-nicht-installiertes Plugin die Config nicht.
|
||||
func includeIfExists(sb *strings.Builder, path string) {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
fmt.Fprintf(sb, "Include %s\n", path)
|
||||
}
|
||||
}
|
||||
|
||||
func ruleEngineMode(mode string) string {
|
||||
switch mode {
|
||||
case "blocking":
|
||||
|
||||
60
internal/waf/engine_plugins_test.go
Normal file
60
internal/waf/engine_plugins_test.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package waf
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||
)
|
||||
|
||||
// mustWrite legt eine Datei (inkl. Verzeichnis) an.
|
||||
func mustWrite(t *testing.T, p, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDirectives_CRSPluginIncludeOrder(t *testing.T) {
|
||||
crs := t.TempDir()
|
||||
mustWrite(t, filepath.Join(crs, "crs-setup.conf"), "# setup\n")
|
||||
mustWrite(t, filepath.Join(crs, "rules", "REQUEST-942.conf"), "# rules\n")
|
||||
mustWrite(t, filepath.Join(crs, "plugins", "nextcloud-rule-exclusions-config.conf"), "# nc config\n")
|
||||
mustWrite(t, filepath.Join(crs, "plugins", "nextcloud-rule-exclusions-before.conf"), "# nc before\n")
|
||||
|
||||
cfg := models.WafConfig{Mode: "blocking", ParanoiaLevel: 1, CRSPlugins: []string{"nextcloud", "unknown-x"}}
|
||||
out := buildDirectives(cfg, crs)
|
||||
|
||||
iSetup := strings.Index(out, "crs-setup.conf")
|
||||
iCfg := strings.Index(out, "nextcloud-rule-exclusions-config.conf")
|
||||
iBefore := strings.Index(out, "nextcloud-rule-exclusions-before.conf")
|
||||
iRules := strings.Index(out, filepath.Join("rules", "*.conf"))
|
||||
if iSetup < 0 || iCfg < 0 || iBefore < 0 || iRules < 0 {
|
||||
t.Fatalf("erwartete Includes fehlen:\n%s", out)
|
||||
}
|
||||
// config + before MÜSSEN vor den CRS-Rules stehen (Plugin-Spec).
|
||||
if iSetup >= iCfg || iCfg >= iBefore || iBefore >= iRules {
|
||||
t.Errorf("falsche Include-Reihenfolge (setup=%d cfg=%d before=%d rules=%d):\n%s", iSetup, iCfg, iBefore, iRules, out)
|
||||
}
|
||||
// Unbekanntes Plugin darf NICHT inkludiert werden (Whitelist).
|
||||
if strings.Contains(out, "unknown-x") {
|
||||
t.Errorf("unbekanntes Plugin wurde inkludiert:\n%s", out)
|
||||
}
|
||||
// Nicht existente after.conf → keine Include-Zeile.
|
||||
if strings.Contains(out, "nextcloud-rule-exclusions-after.conf") {
|
||||
t.Errorf("nicht existente after.conf wurde inkludiert:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCRSPlugins(t *testing.T) {
|
||||
got := resolveCRSPlugins([]string{"nextcloud", " wordpress ", "bogus", ""})
|
||||
want := "nextcloud-rule-exclusions,wordpress-rule-exclusions"
|
||||
if strings.Join(got, ",") != want {
|
||||
t.Errorf("resolveCRSPlugins=%v want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ func (a *SPOEAgent) handle(ctx context.Context, w *encoding.ActionWriter, m *enc
|
||||
httpVer string
|
||||
host string
|
||||
rawHdrs string
|
||||
body []byte // gepufferter Request-Body (via HAProxy option http-buffer-request)
|
||||
)
|
||||
|
||||
// Iterate over the key-value pairs HAProxy sent with this message.
|
||||
@@ -63,6 +64,12 @@ func (a *SPOEAgent) handle(ctx context.Context, w *encoding.ActionWriter, m *enc
|
||||
host = string(entry.ValueBytes())
|
||||
case entry.NameEquals("headers"):
|
||||
rawHdrs = string(entry.ValueBytes())
|
||||
case entry.NameEquals("body"):
|
||||
// Kopieren: entry wird nach Reset() wiederverwendet, der
|
||||
// zugrundeliegende Puffer darf nicht referenziert bleiben.
|
||||
if b := entry.ValueBytes(); len(b) > 0 {
|
||||
body = append([]byte(nil), b...)
|
||||
}
|
||||
}
|
||||
entry.Reset()
|
||||
}
|
||||
@@ -119,6 +126,34 @@ func (a *SPOEAgent) handle(ctx context.Context, w *encoding.ActionWriter, m *enc
|
||||
// Evaluate request headers.
|
||||
interruption := tx.ProcessRequestHeaders()
|
||||
|
||||
// Request-Body inspizieren (POST/PUT-Payloads: Form-SQLi, JSON-Injection,
|
||||
// Uploads). Nur wenn die Header-Phase noch nicht geblockt hat. HAProxy
|
||||
// liefert den Body via `option http-buffer-request` (bis tune.bufsize) —
|
||||
// größere Bodies werden zur Prüfung gekappt. Content-Type kam bereits
|
||||
// über die Header, sodass Coraza urlencoded/multipart/json korrekt parst.
|
||||
if interruption == nil {
|
||||
if len(body) > 0 {
|
||||
if it, _, err := tx.WriteRequestBody(body); err != nil {
|
||||
slog.Warn("waf: WriteRequestBody", "error", err)
|
||||
} else if it != nil {
|
||||
interruption = it
|
||||
}
|
||||
}
|
||||
// ProcessRequestBody MUSS immer laufen — auch ohne Body. In Coraza wird
|
||||
// die GESAMTE Phase 2 (SQLi 942xxx, XSS 941xxx, die den Query-String/ARGS
|
||||
// prüfen) erst hier ausgewertet. Wurde das an len(body)>0 gekoppelt,
|
||||
// blieben GET-Requests ohne Body von allen Phase-2-Regeln ungeprüft →
|
||||
// Query-String-Angriffe (?id=1' OR 1=1, ?x=<script>) liefen komplett
|
||||
// durch. Der häufigste Web-Angriffsvektor war damit blind.
|
||||
if interruption == nil {
|
||||
if it, err := tx.ProcessRequestBody(); err != nil {
|
||||
slog.Warn("waf: ProcessRequestBody", "error", err)
|
||||
} else if it != nil {
|
||||
interruption = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Log all matched rules (detection + blocking).
|
||||
for _, mr := range tx.MatchedRules() {
|
||||
a.sendAlert(host, clientIP, method, uri, mr, interruption != nil)
|
||||
|
||||
50
internal/waf/spoe_phase2_test.go
Normal file
50
internal/waf/spoe_phase2_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package waf
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/corazawaf/coraza/v3"
|
||||
)
|
||||
|
||||
// TestPhase2RequiresProcessRequestBody nagelt die Coraza-Semantik fest, die der
|
||||
// SPOE-Bug verletzt hatte: Phase-2-Regeln (SQLi 942xxx, XSS 941xxx — sie prüfen
|
||||
// ARGS aus dem Query-String) werden ERST von ProcessRequestBody() ausgewertet.
|
||||
// Koppelt man ProcessRequestBody an len(body)>0, bleiben GET-Requests ohne Body
|
||||
// von der gesamten Phase 2 ungeprüft. Dieser Test schlägt fehl, sollte jemand
|
||||
// den Aufruf je wieder body-abhängig machen.
|
||||
func TestPhase2RequiresProcessRequestBody(t *testing.T) {
|
||||
waf, err := coraza.NewWAF(coraza.NewWAFConfig().WithDirectives(
|
||||
"SecRuleEngine DetectionOnly\n" +
|
||||
"SecRule ARGS \"@rx attackpattern\" \"id:1234,phase:2,log,msg:'phase2-arg'\"\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewWAF: %v", err)
|
||||
}
|
||||
tx := waf.NewTransaction()
|
||||
defer func() { tx.ProcessLogging(); _ = tx.Close() }()
|
||||
|
||||
tx.ProcessConnection("1.2.3.4", 0, "", 0)
|
||||
tx.ProcessURI("/?x=attackpattern", "GET", "HTTP/1.1") // GET, kein Body
|
||||
tx.AddRequestHeader("Host", "test")
|
||||
tx.ProcessRequestHeaders()
|
||||
|
||||
// Vor ProcessRequestBody darf die Phase-2-Regel noch NICHT gefeuert haben.
|
||||
if n := len(tx.MatchedRules()); n != 0 {
|
||||
t.Fatalf("vor ProcessRequestBody: %d Matches, erwarte 0", n)
|
||||
}
|
||||
|
||||
if _, err := tx.ProcessRequestBody(); err != nil {
|
||||
t.Fatalf("ProcessRequestBody: %v", err)
|
||||
}
|
||||
|
||||
// Jetzt MUSS die Phase-2-Regel gegen den Query-Arg gefeuert haben.
|
||||
found := false
|
||||
for _, mr := range tx.MatchedRules() {
|
||||
if mr.Rule().ID() == 1234 {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("Phase-2-Regel feuerte auch nach ProcessRequestBody nicht — " +
|
||||
"GET-Query-Args würden ungeprüft bleiben (SPOE-Bug)")
|
||||
}
|
||||
}
|
||||
8
management-ui/go.mod
Normal file
8
management-ui/go.mod
Normal file
@@ -0,0 +1,8 @@
|
||||
// Modul-Grenze, kein eigenständiges Go-Modul mit Code. Verhindert dass
|
||||
// `go vet/build/test ./...` im Root-Modul in management-ui/node_modules/
|
||||
// hineinläuft — npm-Pakete (z.B. flatted) shippen dort teils rohe .go-
|
||||
// Dateien ohne eigenes go.mod, die sich bei jedem bun/npm install ändern
|
||||
// können und sonst ungefragt in EdgeGuards Go-Quality-Gates landen.
|
||||
module git.netcell-it.de/projekte/edgeguard-native/management-ui
|
||||
|
||||
go 1.26
|
||||
@@ -454,6 +454,8 @@
|
||||
"summaryCritical": "{{critical}} kritisch"
|
||||
},
|
||||
"downBackendsAlert": "{{count}} Backend(s) komplett ausgefallen — kein Server UP",
|
||||
"backupNodeBackends": "Standby-Node — Backend-Health lokal nicht aussagekräftig",
|
||||
"backupNodeBackendsDesc": "Dieser Node ist gerade keepalived-BACKUP und hält die VLAN-Gateway-VIPs nicht, kann die Backend-Subnetze also nicht erreichen — die lokalen Health-Checks laufen deshalb alle auf Timeout. Kein Ausfall: der Master-Node bedient den Traffic. Nach einem Failover (VIP übernimmt dieser Node) werden die Backends hier UP.",
|
||||
"maintenanceAlert": "{{count}} Domain(s) im Wartungs-Modus",
|
||||
"onboardingTitle": "Willkommen bei EdgeGuard",
|
||||
"onboardingIntro": "Frische Box — hier die nächsten Schritte um Customer-Traffic zu routen:",
|
||||
@@ -565,7 +567,9 @@
|
||||
"routingRulesHint": "Pfad-Präfix → Backend-Zuordnungen für diese Domain. Niedrigste Prioritätszahl gewinnt; nicht gematchte Anfragen gehen an das Primary-Backend.",
|
||||
"routingRulesEmpty": "Keine Routing-Regeln — alle Anfragen gehen an das Primary-Backend.",
|
||||
"backendUp": "Backend UP",
|
||||
"backendDown": "Backend DOWN"
|
||||
"backendDown": "Backend DOWN",
|
||||
"crowdsecTrusted": "Vertrauenswürdiges Admin-Panel (CrowdSec-Ausnahme)",
|
||||
"crowdsecTrustedHint": "Für Admin-Oberflächen (SPA), die beim Bedienen viele /api/-Requests feuern. Nimmt diesen Host von der CrowdSec-Crawl-Erkennung aus, damit du nicht fälschlich gebannt wirst. Die WAF schützt die Domain weiterhin."
|
||||
},
|
||||
"backends": {
|
||||
"title": "Backends",
|
||||
@@ -592,6 +596,8 @@
|
||||
"websocketHint": "An: erlaubt langlebige WebSocket-/Long-Poll-Verbindungen (z. B. Proxmox-Console, SSH-WS, AsyncAPI) — Tunnel-Idle 1h statt 60s. Aus: strikte HTTP-Timeouts.",
|
||||
"forceHttp1": "HTTP/1.1 erzwingen",
|
||||
"forceHttp1Hint": "Deaktiviert HTTP/2 (h2) auf der Backend-Verbindung — HAProxy handelt nur noch HTTP/1.1 aus. Nötig für Backends die kein h2 unterstützen (z. B. ältere nginx-Konfigurationen ohne h2-Modul, Legacy-Apps).",
|
||||
"serverTimeout": "Antwort-Timeout (timeout server)",
|
||||
"serverTimeoutHint": "Wie lange HAProxy auf die Antwort dieses Backends wartet, bevor es abbricht. Leer = Default (60s). Höher setzen für langsame Upstreams die NICHT streamen (z. B. KI-/Inferenz-Server mit gepufferter Antwort). Achtung: gilt als Inaktivitäts-Timeout — streamende Backends (SSE/chunked) brauchen das meist nicht.",
|
||||
"servers": "Server",
|
||||
"noServers": "kein Server",
|
||||
"noServersWarning": "Ein oder mehrere aktive Backends haben keine Server konfiguriert.",
|
||||
@@ -919,6 +925,17 @@
|
||||
"autoUpdateHint": "Whitelist umfasst nur edgeguard, edgeguard-api, edgeguard-ui. Andere Pakete bleiben unter manueller Kontrolle. Verlangt unattended-upgrades (Distro-Standard auf Trixie). Conf-File: /etc/apt/apt.conf.d/52edgeguard-auto-updates.",
|
||||
"autoUpdateToggled": "Auto-Update-Einstellung gespeichert.",
|
||||
"autoUpdateFailed": "Auto-Update-Toggle fehlgeschlagen",
|
||||
"updateChannelCardTitle": "Update-Kanal",
|
||||
"updateChannelStable": "Stable",
|
||||
"updateChannelTesting": "Testing",
|
||||
"updateChannelApply": "Anwenden",
|
||||
"updateChannelConfirmTitle": "Update-Kanal wechseln?",
|
||||
"updateChannelSwitchTestingWarn": "Testing kann instabile Zwischenstände enthalten. Beide Nodes werden umgestellt.",
|
||||
"updateChannelSwitchStableWarn": "Wechsel zurück nach Stable kann ein Downgrade auf beiden Nodes auslösen (Testing-Versionen sind neuer datiert).",
|
||||
"updateChannelSaved": "Update-Kanal gespeichert (beide Nodes).",
|
||||
"updateChannelFailed": "Update-Kanal-Wechsel fehlgeschlagen",
|
||||
"updateChannelDrift": "Kanal-Drift zum Peer-Node erkannt (Peer: {{peer}}) — beim nächsten Wechsel wird synchronisiert.",
|
||||
"updateChannelHint": "Testing zieht datumsbasierte Zwischenversionen aus dem Testing-Repo, Stable die kuratierten Releases. Kanal gilt für beide HA-Nodes synchron.",
|
||||
"ipv6CardTitle": "IPv6",
|
||||
"ipv6On": "Aktiviert — HAProxy bindet zusätzlich zu IPv4 auf [::]:80, [::]:443 und [::]:3443.",
|
||||
"ipv6Off": "Deaktiviert — HAProxy lauscht nur auf IPv4.",
|
||||
@@ -1503,6 +1520,12 @@
|
||||
"emptyEventsDesc": "Triggers (Cert-Expiry, Backup-Fail, Cluster-Drift, License-Invalid, etc.) haben noch keinen Event gefeuert. Wenn sie feuern, landen sie hier und werden an die konfigurierten Channels zugestellt.",
|
||||
"noChannels": "kein Channel aktiv",
|
||||
"confirmDelete": "Channel {{name}} wirklich löschen?",
|
||||
"acknowledge": "Quittieren",
|
||||
"acknowledgeAll": "Alle quittieren",
|
||||
"acknowledged": "Quittiert",
|
||||
"acked": "quittiert",
|
||||
"open": "offen",
|
||||
"confirmDeleteEvents": "{{n}} Event(s) endgültig löschen?",
|
||||
"kindWebhook": "Webhook (Slack/Discord/Teams/HTTP-Endpoint)",
|
||||
"kindEmail": "E-Mail (SMTP)",
|
||||
"smtp": {
|
||||
@@ -1523,7 +1546,8 @@
|
||||
"time": "Zeit",
|
||||
"severity": "Severity",
|
||||
"subject": "Betreff",
|
||||
"delivered": "Gesendet"
|
||||
"delivered": "Gesendet",
|
||||
"status": "Status"
|
||||
}
|
||||
},
|
||||
"remotes": {
|
||||
@@ -1869,7 +1893,11 @@
|
||||
"deleteFailed": "Löschen fehlgeschlagen",
|
||||
"secretSet": "Gespeichert — leer lassen, um es unverändert zu lassen.",
|
||||
"secretUnset": "Noch nichts gespeichert.",
|
||||
"tabs": { "settings": "Einstellungen", "clients": "Clients (NAS)", "users": "Benutzer" },
|
||||
"tabs": {
|
||||
"settings": "Einstellungen",
|
||||
"clients": "Clients (NAS)",
|
||||
"users": "Benutzer"
|
||||
},
|
||||
"settings": {
|
||||
"enabled": "RADIUS auf dieser Node aktiv",
|
||||
"listen": "Listen-Adressen"
|
||||
@@ -1923,6 +1951,9 @@
|
||||
"enabled": "Aktiviert",
|
||||
"mode": "Modus",
|
||||
"paranoia": "Paranoia-Level",
|
||||
"crsPlugins": "App-Profile (CRS-Plugins)",
|
||||
"crsPluginsHint": "Offizielle OWASP-CRS-Exclusion-Plugins für bekannte Apps — deaktivieren automatisch die typischen False-Positive-Regeln pfad-genau (z.B. Nextcloud-WebDAV, WordPress-Editor). Sauberer als manuelle Regel-IDs.",
|
||||
"crsPluginsPlaceholder": "App-Profile wählen (optional)",
|
||||
"exclusions": "Regel-Ausnahmen",
|
||||
"exclusionsHint": "Kommagetrennte Regel-IDs die deaktiviert werden (z.B. 920350, 941130).",
|
||||
"trustedProxies": "Vertrauenswürdige Proxys",
|
||||
@@ -1933,11 +1964,19 @@
|
||||
"saveFailed": "WAF-Konfiguration konnte nicht gespeichert werden.",
|
||||
"noExclusions": "Noch keine Regelausnahmen.",
|
||||
"noNote": "Keine Notiz",
|
||||
"exclusionsAddHint": "Ausnahmen über den Alarme-Tab hinzufügen — \"Als Ausnahme\" auf einem Alarm klicken."
|
||||
"exclusionsAddHint": "Regel oben suchen und hinzufügen — oder im Alarme-Tab per \"Als Ausnahme\" auf einem Alarm.",
|
||||
"exclusionAddPlaceholder": "Regel suchen (ID oder Beschreibung)…",
|
||||
"exclusionAddNote": "Notiz (optional)",
|
||||
"exclusionAddNotFound": "Keine Regel gefunden",
|
||||
"exclusionAddBtn": "Hinzufügen",
|
||||
"appProfiles": "Eigene App-Profile",
|
||||
"appProfilesHint": "Wiederverwendbare, selbst gepflegte Profile (Regel-ID-Bündel) — zentral unter „App-Profile“ anlegen und hier pro Domain zuweisen. Ergänzt die OWASP-Plugins oben.",
|
||||
"appProfilesPlaceholder": "Eigene Profile wählen (optional)"
|
||||
},
|
||||
"tabs": {
|
||||
"domains": "Domains",
|
||||
"alerts": "Alarme"
|
||||
"alerts": "Alarme",
|
||||
"profiles": "App-Profile"
|
||||
},
|
||||
"alerts": {
|
||||
"total": "Einträge",
|
||||
@@ -1966,6 +2005,39 @@
|
||||
"exceptionModalHint": "Optional: Begründung warum diese Regel ein False Positive für diese Domain ist.",
|
||||
"exceptionNotePlaceholder": "z.B. Unsere API verwendet nicht-standardisierte Header die diese Regel auslösen.",
|
||||
"alreadyExcluded": "Bereits Ausnahme"
|
||||
},
|
||||
"profiles": {
|
||||
"intro": "Wiederverwendbare Ausnahme-Profile (Bündel von CRS-Regel-IDs). Einmal anlegen, pro Domain zuweisen. Die eingebauten OWASP-Plugins sind read-only und werden pro Domain gewählt.",
|
||||
"new": "Neues Profil",
|
||||
"empty": "Noch keine eigenen Profile. Lege eins an, um Regel-Ausnahmen wiederzuverwenden.",
|
||||
"builtinInfo": "Eingebaute OWASP-CRS-Plugins (dateibasiert, gepflegt). Read-only — pro Domain im WAF-Drawer wählbar. „Als Vorlage“ erstellt daraus ein leeres eigenes Profil zum Befüllen.",
|
||||
"typeBuiltin": "OWASP",
|
||||
"typeCustom": "Eigen",
|
||||
"col": {
|
||||
"name": "Name",
|
||||
"type": "Typ",
|
||||
"description": "Beschreibung",
|
||||
"rules": "Ausnahmen"
|
||||
},
|
||||
"edit": "Bearbeiten",
|
||||
"delete": "Löschen",
|
||||
"clone": "Klonen",
|
||||
"asTemplate": "Als Vorlage",
|
||||
"deleteConfirm": "Profil wirklich löschen? Zuweisungen an Domains verlieren dann diese Ausnahmen.",
|
||||
"createTitle": "Neues App-Profil",
|
||||
"editTitle": "App-Profil bearbeiten",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "z.B. Meine WebApp",
|
||||
"description": "Beschreibung",
|
||||
"descriptionPlaceholder": "Wofür ist dieses Profil? (optional)",
|
||||
"rules": "Regel-Ausnahmen",
|
||||
"rulesHint": "CRS-Regel-IDs die für zugewiesene Domains deaktiviert werden. Durchsuchbar nach ID oder Beschreibung.",
|
||||
"rulesPlaceholder": "Regel-IDs suchen und hinzufügen…",
|
||||
"saved": "Profil gespeichert.",
|
||||
"saveFailed": "Profil konnte nicht gespeichert werden.",
|
||||
"deleted": "Profil gelöscht.",
|
||||
"deleteFailed": "Profil konnte nicht gelöscht werden.",
|
||||
"cloneSuffix": "Kopie"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -454,6 +454,8 @@
|
||||
"summaryCritical": "{{critical}} critical"
|
||||
},
|
||||
"downBackendsAlert": "{{count}} backend(s) completely down — no server UP",
|
||||
"backupNodeBackends": "Standby node — local backend health is not meaningful",
|
||||
"backupNodeBackendsDesc": "This node is currently the keepalived BACKUP and does not hold the VLAN gateway VIPs, so it cannot reach the backend subnets — local health checks all time out. This is not an outage: the master node serves the traffic. After a failover (this node takes over the VIP), the backends will show UP here.",
|
||||
"maintenanceAlert": "{{count}} domain(s) in maintenance mode",
|
||||
"onboardingTitle": "Welcome to EdgeGuard",
|
||||
"onboardingIntro": "Fresh box — here are the next steps to route customer traffic:",
|
||||
@@ -565,7 +567,9 @@
|
||||
"routingRulesHint": "Path-prefix → backend mappings for this domain. Lowest priority number wins; unmatched requests go to the primary backend.",
|
||||
"routingRulesEmpty": "No routing rules — all requests go to the primary backend.",
|
||||
"backendUp": "backend UP",
|
||||
"backendDown": "backend DOWN"
|
||||
"backendDown": "backend DOWN",
|
||||
"crowdsecTrusted": "Trusted admin panel (CrowdSec exemption)",
|
||||
"crowdsecTrustedHint": "For admin UIs (SPAs) that fire many /api/ requests while you use them. Exempts this host from CrowdSec crawl detection so you don't get falsely banned. The WAF still protects the domain."
|
||||
},
|
||||
"backends": {
|
||||
"title": "Backends",
|
||||
@@ -592,6 +596,8 @@
|
||||
"websocketHint": "On: allow long-lived WebSocket / long-poll connections (Proxmox console, SSH-over-WS, AsyncAPI) — tunnel idle 1h instead of 60s. Off: strict HTTP timeouts.",
|
||||
"forceHttp1": "Force HTTP/1.1",
|
||||
"forceHttp1Hint": "Disables HTTP/2 (h2) on the backend connection — HAProxy negotiates HTTP/1.1 only. Required for backends that don't support h2 (e.g. older nginx configs without the h2 module, legacy apps).",
|
||||
"serverTimeout": "Response timeout (timeout server)",
|
||||
"serverTimeoutHint": "How long HAProxy waits for this backend's response before aborting. Empty = default (60s). Raise it for slow upstreams that do NOT stream (e.g. AI/inference servers with a buffered response). Note: this is an inactivity timeout — streaming backends (SSE/chunked) usually don't need it.",
|
||||
"servers": "Servers",
|
||||
"noServers": "no server",
|
||||
"noServersWarning": "One or more active backends have no servers configured.",
|
||||
@@ -919,6 +925,17 @@
|
||||
"autoUpdateHint": "Whitelist covers edgeguard, edgeguard-api, edgeguard-ui only. Other packages stay under manual control. Requires unattended-upgrades (Trixie distro default). Conf file: /etc/apt/apt.conf.d/52edgeguard-auto-updates.",
|
||||
"autoUpdateToggled": "Auto-update setting saved.",
|
||||
"autoUpdateFailed": "Auto-update toggle failed",
|
||||
"updateChannelCardTitle": "Update channel",
|
||||
"updateChannelStable": "Stable",
|
||||
"updateChannelTesting": "Testing",
|
||||
"updateChannelApply": "Apply",
|
||||
"updateChannelConfirmTitle": "Switch update channel?",
|
||||
"updateChannelSwitchTestingWarn": "Testing may contain unstable interim builds. Both nodes will be switched.",
|
||||
"updateChannelSwitchStableWarn": "Switching back to stable may trigger a downgrade on both nodes (testing versions are dated newer).",
|
||||
"updateChannelSaved": "Update channel saved (both nodes).",
|
||||
"updateChannelFailed": "Update channel switch failed",
|
||||
"updateChannelDrift": "Channel drift detected against the peer node (peer: {{peer}}) — will sync on the next switch.",
|
||||
"updateChannelHint": "Testing pulls date-stamped interim builds from the testing repo, stable pulls curated releases. The channel applies to both HA nodes in sync.",
|
||||
"ipv6CardTitle": "IPv6",
|
||||
"ipv6On": "Enabled — HAProxy binds on [::]:80, [::]:443 and [::]:3443 in addition to IPv4.",
|
||||
"ipv6Off": "Disabled — HAProxy listens on IPv4 only.",
|
||||
@@ -1503,6 +1520,12 @@
|
||||
"emptyEventsDesc": "Triggers (cert expiry, backup failure, cluster drift, license invalid, etc.) haven't fired any events yet. When they do, they land here and get delivered to the configured channels.",
|
||||
"noChannels": "no active channel",
|
||||
"confirmDelete": "Really delete channel {{name}}?",
|
||||
"acknowledge": "Acknowledge",
|
||||
"acknowledgeAll": "Acknowledge all",
|
||||
"acknowledged": "Acknowledged",
|
||||
"acked": "acknowledged",
|
||||
"open": "open",
|
||||
"confirmDeleteEvents": "Permanently delete {{n}} event(s)?",
|
||||
"kindWebhook": "Webhook (Slack/Discord/Teams/HTTP endpoint)",
|
||||
"kindEmail": "Email (SMTP)",
|
||||
"smtp": {
|
||||
@@ -1523,7 +1546,8 @@
|
||||
"time": "Time",
|
||||
"severity": "Severity",
|
||||
"subject": "Subject",
|
||||
"delivered": "Delivered"
|
||||
"delivered": "Delivered",
|
||||
"status": "Status"
|
||||
}
|
||||
},
|
||||
"remotes": {
|
||||
@@ -1869,7 +1893,11 @@
|
||||
"deleteFailed": "Delete failed",
|
||||
"secretSet": "Stored — leave empty to keep unchanged.",
|
||||
"secretUnset": "Nothing stored yet.",
|
||||
"tabs": { "settings": "Settings", "clients": "Clients (NAS)", "users": "Users" },
|
||||
"tabs": {
|
||||
"settings": "Settings",
|
||||
"clients": "Clients (NAS)",
|
||||
"users": "Users"
|
||||
},
|
||||
"settings": {
|
||||
"enabled": "RADIUS active on this node",
|
||||
"listen": "Listen addresses"
|
||||
@@ -1923,6 +1951,9 @@
|
||||
"enabled": "Enabled",
|
||||
"mode": "Mode",
|
||||
"paranoia": "Paranoia Level",
|
||||
"crsPlugins": "App profiles (CRS plugins)",
|
||||
"crsPluginsHint": "Official OWASP CRS exclusion plugins for well-known apps — automatically disable the typical false-positive rules in a path-scoped way (e.g. Nextcloud WebDAV, WordPress editor). Cleaner than manual rule IDs.",
|
||||
"crsPluginsPlaceholder": "Select app profiles (optional)",
|
||||
"exclusions": "Rule Exclusions",
|
||||
"exclusionsHint": "Comma-separated rule IDs to disable (e.g. 920350, 941130).",
|
||||
"trustedProxies": "Trusted Proxies",
|
||||
@@ -1933,11 +1964,19 @@
|
||||
"saveFailed": "Failed to save WAF configuration.",
|
||||
"noExclusions": "No rule exclusions yet.",
|
||||
"noNote": "No note",
|
||||
"exclusionsAddHint": "Add exceptions via the Alerts tab — click \"Add exception\" on an alert."
|
||||
"exclusionsAddHint": "Search for a rule above and add it — or via the Alerts tab with \"Add exception\" on an alert.",
|
||||
"exclusionAddPlaceholder": "Search rule (ID or description)…",
|
||||
"exclusionAddNote": "Note (optional)",
|
||||
"exclusionAddNotFound": "No rule found",
|
||||
"exclusionAddBtn": "Add",
|
||||
"appProfiles": "Custom App Profiles",
|
||||
"appProfilesHint": "Reusable, self-maintained profiles (bundles of rule IDs) — create them centrally under “App Profiles” and assign them here per domain. Complements the OWASP plugins above.",
|
||||
"appProfilesPlaceholder": "Select custom profiles (optional)"
|
||||
},
|
||||
"tabs": {
|
||||
"domains": "Domains",
|
||||
"alerts": "Alerts"
|
||||
"alerts": "Alerts",
|
||||
"profiles": "App Profiles"
|
||||
},
|
||||
"alerts": {
|
||||
"total": "entries",
|
||||
@@ -1966,6 +2005,39 @@
|
||||
"exceptionModalHint": "Optional: describe why this rule is a false positive for this domain.",
|
||||
"exceptionNotePlaceholder": "e.g. Our custom API uses non-standard headers that trigger this rule.",
|
||||
"alreadyExcluded": "Already excluded"
|
||||
},
|
||||
"profiles": {
|
||||
"intro": "Reusable exclusion profiles (bundles of CRS rule IDs). Create once, assign per domain. The built-in OWASP plugins are read-only and selected per domain.",
|
||||
"new": "New Profile",
|
||||
"empty": "No custom profiles yet. Create one to reuse rule exclusions.",
|
||||
"builtinInfo": "Built-in OWASP CRS plugins (file-based, maintained). Read-only — selectable per domain in the WAF drawer. “Use as template” creates an empty custom profile from it to fill in.",
|
||||
"typeBuiltin": "OWASP",
|
||||
"typeCustom": "Custom",
|
||||
"col": {
|
||||
"name": "Name",
|
||||
"type": "Type",
|
||||
"description": "Description",
|
||||
"rules": "Exclusions"
|
||||
},
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"clone": "Clone",
|
||||
"asTemplate": "Use as template",
|
||||
"deleteConfirm": "Really delete this profile? Domains using it will lose these exclusions.",
|
||||
"createTitle": "New App Profile",
|
||||
"editTitle": "Edit App Profile",
|
||||
"name": "Name",
|
||||
"namePlaceholder": "e.g. My WebApp",
|
||||
"description": "Description",
|
||||
"descriptionPlaceholder": "What is this profile for? (optional)",
|
||||
"rules": "Rule Exclusions",
|
||||
"rulesHint": "CRS rule IDs disabled for assigned domains. Searchable by ID or description.",
|
||||
"rulesPlaceholder": "Search and add rule IDs…",
|
||||
"saved": "Profile saved.",
|
||||
"saveFailed": "Could not save profile.",
|
||||
"deleted": "Profile deleted.",
|
||||
"deleteFailed": "Could not delete profile.",
|
||||
"cloneSuffix": "Copy"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@ant-design/icons'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import apiClient, { isEnvelope } from '../../api/client'
|
||||
@@ -41,6 +42,7 @@ interface AlertEvent {
|
||||
message: string
|
||||
sent_to: SendResult[]
|
||||
fired_at: string
|
||||
acknowledged_at?: string | null
|
||||
}
|
||||
|
||||
interface ChannelFormValues {
|
||||
@@ -69,6 +71,14 @@ export default function AlertsPage() {
|
||||
const qc = useQueryClient()
|
||||
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
|
||||
|
||||
// Tab über ?tab= steuerbar, damit der Dashboard-Deeplink
|
||||
// (/alerts?tab=events) direkt auf der Event-History landet statt auf
|
||||
// dem Channels-Tab. Default bleibt 'channels' für den Direktaufruf.
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const activeTab = searchParams.get('tab') === 'events' ? 'events' : 'channels'
|
||||
const setActiveTab = (key: string) =>
|
||||
setSearchParams(key === 'events' ? { tab: 'events' } : {}, { replace: true })
|
||||
|
||||
const channels = useQuery({
|
||||
queryKey: ['alerts', 'channels'],
|
||||
queryFn: async () => {
|
||||
@@ -90,6 +100,29 @@ export default function AlertsPage() {
|
||||
const [form] = Form.useForm<ChannelFormValues>()
|
||||
const [filterSev, setFilterSev] = useState<string | undefined>()
|
||||
const [filterKind, setFilterKind] = useState<string | undefined>()
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([])
|
||||
|
||||
// Nach Quittieren/Löschen sowohl die Event-Liste als auch die Dashboard-
|
||||
// Karte (queryKey ['alerts','events','recent']) invalidieren — Prefix-Match.
|
||||
const refreshEvents = () => {
|
||||
setSelectedIds([])
|
||||
void qc.invalidateQueries({ queryKey: ['alerts', 'events'] })
|
||||
}
|
||||
const ackMut = useMutation({
|
||||
mutationFn: (ids: number[]) => apiClient.post('/alerts/events/acknowledge', { ids }),
|
||||
onSuccess: () => { message.success(t('alerts.acknowledged')); refreshEvents() },
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
const ackAllMut = useMutation({
|
||||
mutationFn: () => apiClient.post('/alerts/events/acknowledge-all'),
|
||||
onSuccess: () => { message.success(t('alerts.acknowledged')); refreshEvents() },
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
const delEventsMut = useMutation({
|
||||
mutationFn: (ids: number[]) => apiClient.post('/alerts/events/delete', { ids }),
|
||||
onSuccess: () => { message.success(t('common.delete')); refreshEvents() },
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const kindOptions = useMemo(() => {
|
||||
const kinds = [...new Set((events.data ?? []).map(e => e.kind))].sort()
|
||||
@@ -267,6 +300,14 @@ export default function AlertsPage() {
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('alerts.col.status'), key: 'ack', width: 110,
|
||||
render: (_, r: AlertEvent) => r.acknowledged_at
|
||||
? <Tooltip title={dayjs(r.acknowledged_at).format('YYYY-MM-DD HH:mm')}>
|
||||
<Tag color="green">{t('alerts.acked')}</Tag>
|
||||
</Tooltip>
|
||||
: <Tag color="gold">{t('alerts.open')}</Tag>,
|
||||
},
|
||||
]
|
||||
|
||||
const kind = Form.useWatch('kind', form)
|
||||
@@ -304,7 +345,7 @@ export default function AlertsPage() {
|
||||
description={t('alerts.scopeDesc')}
|
||||
/>
|
||||
|
||||
<Tabs items={[
|
||||
<Tabs activeKey={activeTab} onChange={setActiveTab} items={[
|
||||
{
|
||||
key: 'channels',
|
||||
label: t('alerts.tabs.channels'),
|
||||
@@ -342,33 +383,62 @@ export default function AlertsPage() {
|
||||
label: t('alerts.tabs.events'),
|
||||
children: (
|
||||
<Card size="small">
|
||||
<Space className="mb-8">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder={t('alerts.col.severity')}
|
||||
style={{ width: 150 }}
|
||||
value={filterSev}
|
||||
onChange={setFilterSev}
|
||||
options={[
|
||||
{ value: 'info', label: <Tag color="blue">INFO</Tag> },
|
||||
{ value: 'warning', label: <Tag color="orange">WARNING</Tag> },
|
||||
{ value: 'error', label: <Tag color="red">ERROR</Tag> },
|
||||
{ value: 'critical', label: <Tag color="magenta">CRITICAL</Tag> },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
placeholder={t('alerts.col.kind')}
|
||||
style={{ width: 220 }}
|
||||
value={filterKind}
|
||||
onChange={setFilterKind}
|
||||
options={kindOptions}
|
||||
/>
|
||||
</Space>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }} className="mb-8">
|
||||
<Space>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder={t('alerts.col.severity')}
|
||||
style={{ width: 150 }}
|
||||
value={filterSev}
|
||||
onChange={setFilterSev}
|
||||
options={[
|
||||
{ value: 'info', label: <Tag color="blue">INFO</Tag> },
|
||||
{ value: 'warning', label: <Tag color="orange">WARNING</Tag> },
|
||||
{ value: 'error', label: <Tag color="red">ERROR</Tag> },
|
||||
{ value: 'critical', label: <Tag color="magenta">CRITICAL</Tag> },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
placeholder={t('alerts.col.kind')}
|
||||
style={{ width: 220 }}
|
||||
value={filterKind}
|
||||
onChange={setFilterKind}
|
||||
options={kindOptions}
|
||||
/>
|
||||
</Space>
|
||||
<Space>
|
||||
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
|
||||
<Button size="small" disabled={isViewer || selectedIds.length === 0}
|
||||
loading={ackMut.isPending} onClick={() => ackMut.mutate(selectedIds)}>
|
||||
{t('alerts.acknowledge')}{selectedIds.length > 0 ? ` (${selectedIds.length})` : ''}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Popconfirm title={t('alerts.confirmDeleteEvents', { n: selectedIds.length })}
|
||||
onConfirm={() => delEventsMut.mutate(selectedIds)}
|
||||
disabled={isViewer || selectedIds.length === 0}>
|
||||
<Button size="small" danger disabled={isViewer || selectedIds.length === 0}
|
||||
loading={delEventsMut.isPending}>
|
||||
{t('common.delete')}{selectedIds.length > 0 ? ` (${selectedIds.length})` : ''}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
|
||||
<Button size="small" type="primary" ghost disabled={isViewer}
|
||||
loading={ackAllMut.isPending} onClick={() => ackAllMut.mutate()}>
|
||||
{t('alerts.acknowledgeAll')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</div>
|
||||
<Table size="small" rowKey="id" loading={events.isFetching}
|
||||
dataSource={filteredEvents} columns={evColumns}
|
||||
pagination={{ pageSize: 25 }}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedIds,
|
||||
onChange: (keys) => setSelectedIds(keys as number[]),
|
||||
}}
|
||||
rowClassName={(r) => (r.acknowledged_at ? 'eg-row-muted' : '')}
|
||||
pagination={{ defaultPageSize: 25 }}
|
||||
locale={{ emptyText: (
|
||||
<EmptyState
|
||||
icon={<BellOutlined />}
|
||||
|
||||
@@ -20,13 +20,17 @@ interface Backend {
|
||||
id: number; name: string; scheme: string
|
||||
health_check_path?: string | null
|
||||
lb_algorithm: 'roundrobin' | 'leastconn' | 'source'
|
||||
websocket: boolean; force_http1: boolean; active: boolean
|
||||
websocket: boolean; force_http1: boolean
|
||||
server_timeout_seconds?: number | null
|
||||
active: boolean
|
||||
}
|
||||
interface BackendFormValues {
|
||||
name: string; scheme: 'http' | 'https'
|
||||
health_check_path?: string
|
||||
lb_algorithm: 'roundrobin' | 'leastconn' | 'source'
|
||||
websocket: boolean; force_http1: boolean; active: boolean
|
||||
websocket: boolean; force_http1: boolean
|
||||
server_timeout_seconds?: number | null
|
||||
active: boolean
|
||||
domain_ids?: number[]
|
||||
}
|
||||
interface BackendServer {
|
||||
@@ -174,6 +178,7 @@ export default function BackendDetailPage() {
|
||||
lb_algorithm: backend.lb_algorithm,
|
||||
websocket: backend.websocket,
|
||||
force_http1: backend.force_http1,
|
||||
server_timeout_seconds: backend.server_timeout_seconds ?? undefined,
|
||||
active: backend.active,
|
||||
domain_ids: attached.map(d => d.id),
|
||||
}}
|
||||
@@ -205,6 +210,11 @@ export default function BackendDetailPage() {
|
||||
extra={t('backends.forceHttp1Hint')}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('backends.serverTimeout')} name="server_timeout_seconds"
|
||||
extra={t('backends.serverTimeoutHint')}>
|
||||
<InputNumber min={1} max={86400} step={30}
|
||||
style={{ width: '100%' }} addonAfter="s" placeholder="60 (default)" />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('backends.active')} name="active" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
@@ -26,6 +26,7 @@ interface Backend {
|
||||
lb_algorithm: 'roundrobin' | 'leastconn' | 'source'
|
||||
websocket: boolean
|
||||
force_http1: boolean
|
||||
server_timeout_seconds?: number | null
|
||||
active: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
@@ -266,6 +267,7 @@ export default function BackendsPage() {
|
||||
<Space size={4}>
|
||||
<Tag>{v}</Tag>
|
||||
{row.websocket && <Tag color="cyan">WS</Tag>}
|
||||
{row.server_timeout_seconds ? <Tag color="gold">{row.server_timeout_seconds}s</Tag> : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -350,7 +350,7 @@ export default function HistoryTab() {
|
||||
loading={list.isFetching}
|
||||
dataSource={list.data ?? []}
|
||||
columns={columns}
|
||||
pagination={{ pageSize: 25, showSizeChanger: true, pageSizeOptions: [25, 50, 100] }}
|
||||
pagination={{ defaultPageSize: 25, showSizeChanger: true, pageSizeOptions: [25, 50, 100] }}
|
||||
locale={{ emptyText: t('backups.empty') }}
|
||||
/>
|
||||
|
||||
|
||||
@@ -225,7 +225,7 @@ function DecisionsTab() {
|
||||
loading={isLoading}
|
||||
dataSource={decisions ?? []}
|
||||
columns={columns}
|
||||
pagination={{ pageSize: 20 }}
|
||||
pagination={{ defaultPageSize: 20 }}
|
||||
/>
|
||||
<Modal
|
||||
title={t('cs.decision.banModal')}
|
||||
@@ -321,7 +321,7 @@ function AlertsTab() {
|
||||
loading={isLoading}
|
||||
dataSource={alerts ?? []}
|
||||
columns={columns}
|
||||
pagination={{ pageSize: 20 }}
|
||||
pagination={{ defaultPageSize: 20 }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -379,7 +379,7 @@ function BouncersTab() {
|
||||
loading={isLoading}
|
||||
dataSource={bouncers ?? []}
|
||||
columns={columns}
|
||||
pagination={{ pageSize: 20 }}
|
||||
pagination={{ defaultPageSize: 20 }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -436,7 +436,7 @@ function MachinesTab() {
|
||||
loading={isLoading}
|
||||
dataSource={machines ?? []}
|
||||
columns={columns}
|
||||
pagination={{ pageSize: 20 }}
|
||||
pagination={{ defaultPageSize: 20 }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -515,7 +515,7 @@ function CollectionsTab() {
|
||||
loading={isLoading}
|
||||
dataSource={collections ?? []}
|
||||
columns={columns}
|
||||
pagination={{ pageSize: 50 }}
|
||||
pagination={{ defaultPageSize: 50 }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -201,7 +201,7 @@ export default function DashboardPage() {
|
||||
})
|
||||
const recentAlerts = useQuery({
|
||||
queryKey: ['alerts', 'events', 'recent'],
|
||||
queryFn: () => fetchList<AlertEvent>('/alerts/events?limit=10', 'events'),
|
||||
queryFn: () => fetchList<AlertEvent>('/alerts/events?limit=10&open=true', 'events'),
|
||||
refetchInterval: 60_000,
|
||||
})
|
||||
const services = useQuery({
|
||||
@@ -303,6 +303,13 @@ export default function DashboardPage() {
|
||||
return down
|
||||
})()
|
||||
|
||||
// Auf dem keepalived-BACKUP-Node erreicht die lokale HAProxy die Backend-
|
||||
// Subnetze nicht (die VLAN-Gateway-VIPs liegen beim Master) → sie sieht
|
||||
// ALLE Backends als down. Das ist strukturell erwartet, kein Ausfall:
|
||||
// der Master bedient den Traffic. Deshalb den roten Down-Alarm auf dem
|
||||
// Standby durch einen ruhigen Hinweis ersetzen.
|
||||
const isBackup = vipStatus.data?.vrrp_state === 'BACKUP'
|
||||
|
||||
const alerts = recentAlerts.data ?? []
|
||||
const nCritical = alerts.filter(e => e.severity === 'critical' || e.severity === 'error').length
|
||||
const nWarning = alerts.filter(e => e.severity === 'warning').length
|
||||
@@ -361,18 +368,25 @@ export default function DashboardPage() {
|
||||
</span>
|
||||
</Space>
|
||||
}
|
||||
action={<Link to="/alerts" style={{ fontSize: 12 }}>{t('dashboard.alertsCard.viewAll')} →</Link>}
|
||||
action={<Link to="/alerts?tab=events" style={{ fontSize: 12 }}>{t('dashboard.alertsCard.viewAll')} →</Link>}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ─ Operational alerts ─────────────────────────────── */}
|
||||
{downBackends.length > 0 && (
|
||||
{downBackends.length > 0 && !isBackup && (
|
||||
<Alert
|
||||
type="error" showIcon className="mb-12"
|
||||
message={t('dashboard.downBackendsAlert', { count: downBackends.length })}
|
||||
description={<Space wrap size={4}>{downBackends.map(n => <Link key={n} to="/backends">{n}</Link>)}</Space>}
|
||||
/>
|
||||
)}
|
||||
{downBackends.length > 0 && isBackup && (
|
||||
<Alert
|
||||
type="info" showIcon className="mb-12"
|
||||
message={t('dashboard.backupNodeBackends')}
|
||||
description={t('dashboard.backupNodeBackendsDesc')}
|
||||
/>
|
||||
)}
|
||||
{maintenanceDomains.length > 0 && (
|
||||
<Alert
|
||||
type="warning" showIcon className="mb-12"
|
||||
|
||||
@@ -30,6 +30,7 @@ interface Domain {
|
||||
redirect_to: string
|
||||
rate_limit_rps: number; max_body_kb: number
|
||||
disable_h3: boolean
|
||||
crowdsec_trusted: boolean
|
||||
notes?: string | null
|
||||
}
|
||||
|
||||
@@ -44,6 +45,7 @@ interface DomainFormValues {
|
||||
redirect_to: string
|
||||
rate_limit_rps: number; max_body_kb: number
|
||||
disable_h3: boolean
|
||||
crowdsec_trusted: boolean
|
||||
notes?: string
|
||||
}
|
||||
|
||||
@@ -278,6 +280,7 @@ export default function DomainDetailPage() {
|
||||
rate_limit_rps: domain.rate_limit_rps ?? 0,
|
||||
max_body_kb: domain.max_body_kb ?? 0,
|
||||
disable_h3: domain.disable_h3 ?? false,
|
||||
crowdsec_trusted: domain.crowdsec_trusted ?? false,
|
||||
notes: domain.notes ?? '',
|
||||
}}
|
||||
onFinish={(v) => update.mutate(v)}
|
||||
@@ -367,6 +370,11 @@ export default function DomainDetailPage() {
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t('domains.crowdsecTrusted')} name="crowdsec_trusted" valuePropName="checked"
|
||||
extra={t('domains.crowdsecTrustedHint')}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t('domains.notes')} name="notes">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
|
||||
@@ -129,6 +129,12 @@ export default function FirewallLivePage() {
|
||||
pausedRef.current = paused
|
||||
const pendingDuringPauseRef = useRef<Entry[]>([])
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
// tRef statt `t` in den WS-Effect-Deps: `t` wechselt bei jedem
|
||||
// i18n-Store-Update die Identität. Stünde es in den Deps, würde der
|
||||
// Effect neu laufen — inkl. `setEntries([])` und WS-Reconnect, d. h.
|
||||
// der Live-Puffer wäre ohne erkennbaren Grund plötzlich leer.
|
||||
const tRef = useRef(t)
|
||||
useEffect(() => { tRef.current = t }, [t])
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setAppliedFilters(filters), 300)
|
||||
@@ -187,7 +193,7 @@ export default function FirewallLivePage() {
|
||||
if (!cancelled && active) scheduleReconnect()
|
||||
}
|
||||
ws.onerror = () => {
|
||||
setError(t('fwlog.connError'))
|
||||
setError(tRef.current('fwlog.connError'))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,7 +210,7 @@ export default function FirewallLivePage() {
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer)
|
||||
if (wsRef.current) { wsRef.current.close(); wsRef.current = null }
|
||||
}
|
||||
}, [active, query, t])
|
||||
}, [active, query])
|
||||
|
||||
// Resume: gebufferte Events in die Tabelle mergen.
|
||||
useEffect(() => {
|
||||
@@ -412,7 +418,7 @@ export default function FirewallLivePage() {
|
||||
size="small"
|
||||
dataSource={[...entries].reverse()}
|
||||
columns={columns}
|
||||
pagination={{ pageSize: 50, showSizeChanger: true, pageSizeOptions: [25, 50, 100, 200] }}
|
||||
pagination={{ defaultPageSize: 50, showSizeChanger: true, pageSizeOptions: [25, 50, 100, 200] }}
|
||||
locale={{ emptyText: connected ? t('fwlog.empty') : t('fwlog.connecting') }}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -293,7 +293,7 @@ export default function LogsPage() {
|
||||
loading={logsQuery.isFetching}
|
||||
dataSource={entries}
|
||||
columns={columns}
|
||||
pagination={{ pageSize: 50, showSizeChanger: true, pageSizeOptions: [25, 50, 100, 200] }}
|
||||
pagination={{ defaultPageSize: 50, showSizeChanger: true, pageSizeOptions: [25, 50, 100, 200] }}
|
||||
/>
|
||||
|
||||
{entries.length === 0 && !logsQuery.isFetching && (
|
||||
|
||||
@@ -228,7 +228,7 @@ export default function RoutesTab() {
|
||||
size="small"
|
||||
dataSource={live.data ?? []}
|
||||
columns={liveColumns}
|
||||
pagination={{ pageSize: 25, showSizeChanger: true, pageSizeOptions: [25, 50, 100] }}
|
||||
pagination={{ defaultPageSize: 25, showSizeChanger: true, pageSizeOptions: [25, 50, 100] }}
|
||||
locale={{ emptyText: t('routes.liveEmpty') }}
|
||||
style={{ marginTop: 12 }}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Alert, Button, Card, Descriptions, Form, Input, InputNumber, Popconfirm, Select, Space, Spin, Switch, Tooltip, Typography, message } from 'antd'
|
||||
import { ApartmentOutlined, CloudDownloadOutlined, CloudSyncOutlined, CodeOutlined, CopyOutlined, DatabaseOutlined, DownloadOutlined, ExclamationCircleOutlined, FileSearchOutlined, GlobalOutlined, LockOutlined, MailOutlined, ReloadOutlined, SettingOutlined, StopOutlined, ToolOutlined } from '@ant-design/icons'
|
||||
import { ApartmentOutlined, BranchesOutlined, CloudDownloadOutlined, CloudSyncOutlined, CodeOutlined, CopyOutlined, DatabaseOutlined, DownloadOutlined, ExclamationCircleOutlined, FileSearchOutlined, GlobalOutlined, LockOutlined, MailOutlined, ReloadOutlined, SettingOutlined, StopOutlined, ToolOutlined } from '@ant-design/icons'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -342,6 +342,30 @@ export default function SettingsPage() {
|
||||
},
|
||||
})
|
||||
|
||||
const [channelDraft, setChannelDraft] = useState<string | null>(null)
|
||||
const { data: updateChannel } = useQuery({
|
||||
queryKey: ['cluster', 'update-channel'],
|
||||
queryFn: async () => {
|
||||
const r = await apiClient.get('/cluster/update-channel')
|
||||
return isEnvelope(r.data)
|
||||
? (r.data.data as { channel: string; peer_channel?: string; peer_reached: boolean; peer_drifted: boolean })
|
||||
: { channel: 'stable', peer_reached: false, peer_drifted: false }
|
||||
},
|
||||
})
|
||||
const setUpdateChannel = useMutation({
|
||||
mutationFn: async (channel: string) => {
|
||||
const r = await apiClient.post('/cluster/update-channel', { channel })
|
||||
return r.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
msg.success(t('settings.updateChannelSaved'))
|
||||
void qc.invalidateQueries({ queryKey: ['cluster', 'update-channel'] })
|
||||
void qc.invalidateQueries({ queryKey: ['system', 'package-versions'] })
|
||||
},
|
||||
onError: (e: Error) => msg.error(t('settings.updateChannelFailed') + ': ' + e.message),
|
||||
onSettled: () => setChannelDraft(null),
|
||||
})
|
||||
|
||||
const { data: ipv6 } = useQuery({
|
||||
queryKey: ['system', 'ipv6'],
|
||||
queryFn: async () => {
|
||||
@@ -765,6 +789,55 @@ export default function SettingsPage() {
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={<><BranchesOutlined /> {t('settings.updateChannelCardTitle')}</>}
|
||||
className="mb-12"
|
||||
size="small"
|
||||
>
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
<Space>
|
||||
<Select
|
||||
value={channelDraft ?? updateChannel?.channel ?? 'stable'}
|
||||
style={{ width: 160 }}
|
||||
disabled={isViewer}
|
||||
options={[
|
||||
{ value: 'stable', label: t('settings.updateChannelStable') },
|
||||
{ value: 'testing', label: t('settings.updateChannelTesting') },
|
||||
]}
|
||||
onChange={setChannelDraft}
|
||||
/>
|
||||
{channelDraft && channelDraft !== (updateChannel?.channel ?? 'stable') && (
|
||||
<Popconfirm
|
||||
title={t('settings.updateChannelConfirmTitle')}
|
||||
description={
|
||||
channelDraft === 'testing'
|
||||
? t('settings.updateChannelSwitchTestingWarn')
|
||||
: t('settings.updateChannelSwitchStableWarn')
|
||||
}
|
||||
okText={t('settings.updateChannelApply')}
|
||||
cancelText={t('common.cancel')}
|
||||
onConfirm={() => setUpdateChannel.mutate(channelDraft)}
|
||||
onCancel={() => setChannelDraft(null)}
|
||||
>
|
||||
<Button size="small" type="primary" loading={setUpdateChannel.isPending}>
|
||||
{t('settings.updateChannelApply')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
{updateChannel?.peer_drifted && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={t('settings.updateChannelDrift', { peer: updateChannel?.peer_channel ?? '?' })}
|
||||
/>
|
||||
)}
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{t('settings.updateChannelHint')}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={<><GlobalOutlined /> {t('settings.ipv6CardTitle')}</>}
|
||||
className="mb-12"
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
Alert, Button, Card, Col, Drawer, Form, Input, Modal, Popconfirm, Row,
|
||||
Select, Space, Switch, Tabs, Tag, Tooltip, Typography, message,
|
||||
} from 'antd'
|
||||
import {
|
||||
CheckCircleOutlined, CloseCircleOutlined, DeleteOutlined,
|
||||
SafetyCertificateOutlined, SettingOutlined, WarningOutlined,
|
||||
CheckCircleOutlined, CloseCircleOutlined, CopyOutlined, DeleteOutlined,
|
||||
EditOutlined, PlusOutlined, SafetyCertificateOutlined, SettingOutlined,
|
||||
WarningOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
@@ -14,7 +15,7 @@ import apiClient, { isEnvelope } from '../../api/client'
|
||||
import PageHeader from '../../components/PageHeader'
|
||||
import DataTable from '../../components/DataTable'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { getRuleDescription } from './crsRules'
|
||||
import { CRS_RULES, getRuleDescription } from './crsRules'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
@@ -33,11 +34,30 @@ interface WafConfig {
|
||||
mode: 'detection' | 'blocking'
|
||||
paranoia_level: number
|
||||
rule_exclusions: string[]
|
||||
crs_plugins: string[]
|
||||
app_profiles: string[]
|
||||
exclusion_notes: Record<string, string>
|
||||
trusted_proxies: string[]
|
||||
custom_rules: string
|
||||
}
|
||||
|
||||
interface WafProfile {
|
||||
id: number
|
||||
name: string
|
||||
description: string
|
||||
rule_exclusions: string[]
|
||||
builtin: boolean
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
// CRS-App-Exclusion-Plugins — muss zur Backend-Whitelist (KnownCRSPlugins) passen.
|
||||
const CRS_PLUGIN_OPTIONS = [
|
||||
{ value: 'nextcloud', label: 'Nextcloud' },
|
||||
{ value: 'wordpress', label: 'WordPress' },
|
||||
{ value: 'drupal', label: 'Drupal' },
|
||||
]
|
||||
|
||||
// ---------- API helpers -----------------------------------------------------
|
||||
|
||||
async function fetchDomains(): Promise<Domain[]> {
|
||||
@@ -65,16 +85,26 @@ function defaultConfig(domainId: number): WafConfig {
|
||||
mode: 'detection',
|
||||
paranoia_level: 1,
|
||||
rule_exclusions: [],
|
||||
crs_plugins: [],
|
||||
app_profiles: [],
|
||||
exclusion_notes: {},
|
||||
trusted_proxies: [],
|
||||
custom_rules: '',
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProfiles(): Promise<WafProfile[]> {
|
||||
const r = await apiClient.get('/waf/profiles')
|
||||
if (!isEnvelope(r.data)) return []
|
||||
return (r.data.data as { profiles?: WafProfile[] }).profiles ?? []
|
||||
}
|
||||
|
||||
interface WafFormValues {
|
||||
enabled: boolean
|
||||
mode: 'detection' | 'blocking'
|
||||
paranoia_level: number
|
||||
crs_plugins: string[]
|
||||
app_profiles: string[]
|
||||
trusted_proxies_str: string
|
||||
custom_rules: string
|
||||
}
|
||||
@@ -92,6 +122,14 @@ function ConfigDrawer({ domainName, domainId, onClose }: ConfigDrawerProps) {
|
||||
const qc = useQueryClient()
|
||||
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
|
||||
const [form] = Form.useForm<WafFormValues>()
|
||||
const [addRuleId, setAddRuleId] = useState<string>('')
|
||||
const [addNote, setAddNote] = useState<string>('')
|
||||
// Durchsuchbare Optionen aus der CRS-Regel-Liste (ID — Beschreibung).
|
||||
// filterOption unten matcht sowohl ID als auch Beschreibung.
|
||||
const ruleOptions = useMemo(
|
||||
() => Object.entries(CRS_RULES).map(([id, desc]) => ({ value: id, label: `${id} — ${desc}` })),
|
||||
[],
|
||||
)
|
||||
|
||||
const { data: cfg, isLoading } = useQuery({
|
||||
queryKey: ['waf', 'config', domainId],
|
||||
@@ -99,6 +137,13 @@ function ConfigDrawer({ domainName, domainId, onClose }: ConfigDrawerProps) {
|
||||
enabled: domainId !== null,
|
||||
})
|
||||
|
||||
// Eigene App-Profile (nur benutzerdefinierte, nicht built-in) als Optionen.
|
||||
const { data: profiles } = useQuery({ queryKey: ['waf', 'profiles'], queryFn: fetchProfiles })
|
||||
const profileOptions = useMemo(
|
||||
() => (profiles ?? []).filter(p => !p.builtin).map(p => ({ value: p.name, label: p.name })),
|
||||
[profiles],
|
||||
)
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (values: WafConfig) =>
|
||||
apiClient.put(`/waf/configs/${domainId}`, values),
|
||||
@@ -136,6 +181,7 @@ function ConfigDrawer({ domainName, domainId, onClose }: ConfigDrawerProps) {
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
...cfg,
|
||||
app_profiles: cfg.app_profiles ?? [],
|
||||
trusted_proxies_str: (cfg.trusted_proxies ?? []).join(', '),
|
||||
}}
|
||||
onFinish={(vals) => {
|
||||
@@ -147,6 +193,8 @@ function ConfigDrawer({ domainName, domainId, onClose }: ConfigDrawerProps) {
|
||||
mode: vals.mode,
|
||||
paranoia_level: vals.paranoia_level,
|
||||
rule_exclusions: cfg?.rule_exclusions ?? [],
|
||||
crs_plugins: vals.crs_plugins ?? [],
|
||||
app_profiles: vals.app_profiles ?? [],
|
||||
exclusion_notes: cfg?.exclusion_notes ?? {},
|
||||
trusted_proxies: proxies,
|
||||
custom_rules: vals.custom_rules ?? '',
|
||||
@@ -190,6 +238,35 @@ function ConfigDrawer({ domainName, domainId, onClose }: ConfigDrawerProps) {
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('waf.config.crsPlugins')}
|
||||
name="crs_plugins"
|
||||
help={t('waf.config.crsPluginsHint')}
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
disabled={isViewer}
|
||||
placeholder={t('waf.config.crsPluginsPlaceholder')}
|
||||
options={CRS_PLUGIN_OPTIONS}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('waf.config.appProfiles')}
|
||||
name="app_profiles"
|
||||
help={t('waf.config.appProfilesHint')}
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
disabled={isViewer}
|
||||
placeholder={t('waf.config.appProfilesPlaceholder')}
|
||||
options={profileOptions}
|
||||
notFoundContent={t('waf.profiles.empty')}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* Exclusions list — shows existing exclusions with notes + remove button */}
|
||||
<Form.Item label={t('waf.config.exclusions')}>
|
||||
{(cfg?.rule_exclusions ?? []).length === 0 ? (
|
||||
@@ -224,6 +301,8 @@ function ConfigDrawer({ domainName, domainId, onClose }: ConfigDrawerProps) {
|
||||
mode: cfg?.mode ?? 'detection',
|
||||
paranoia_level: cfg?.paranoia_level ?? 1,
|
||||
rule_exclusions: newExclusions,
|
||||
crs_plugins: cfg?.crs_plugins ?? [],
|
||||
app_profiles: cfg?.app_profiles ?? [],
|
||||
exclusion_notes: newNotes,
|
||||
trusted_proxies: cfg?.trusted_proxies ?? [],
|
||||
custom_rules: cfg?.custom_rules ?? '',
|
||||
@@ -235,9 +314,59 @@ function ConfigDrawer({ domainName, domainId, onClose }: ConfigDrawerProps) {
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>{t('waf.config.exclusionsAddHint')}</Text>
|
||||
</div>
|
||||
{!isViewer && (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Select
|
||||
showSearch
|
||||
value={addRuleId || undefined}
|
||||
placeholder={t('waf.config.exclusionAddPlaceholder')}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
options={ruleOptions}
|
||||
optionFilterProp="label"
|
||||
notFoundContent={t('waf.config.exclusionAddNotFound')}
|
||||
onChange={(v) => setAddRuleId(v)}
|
||||
/>
|
||||
<Input
|
||||
placeholder={t('waf.config.exclusionAddNote')}
|
||||
value={addNote}
|
||||
onChange={(e) => setAddNote(e.target.value)}
|
||||
style={{ width: 150 }}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
loading={save.isPending}
|
||||
disabled={!addRuleId}
|
||||
onClick={() => {
|
||||
if (!addRuleId) return
|
||||
const existing = cfg?.rule_exclusions ?? []
|
||||
if (existing.includes(addRuleId)) { setAddRuleId(''); setAddNote(''); return }
|
||||
const newNotes = { ...(cfg?.exclusion_notes ?? {}) }
|
||||
if (addNote.trim()) newNotes[addRuleId] = addNote.trim()
|
||||
save.mutate({
|
||||
domain_id: domainId!,
|
||||
enabled: cfg?.enabled ?? false,
|
||||
mode: cfg?.mode ?? 'detection',
|
||||
paranoia_level: cfg?.paranoia_level ?? 1,
|
||||
rule_exclusions: [...existing, addRuleId],
|
||||
crs_plugins: cfg?.crs_plugins ?? [],
|
||||
app_profiles: cfg?.app_profiles ?? [],
|
||||
exclusion_notes: newNotes,
|
||||
trusted_proxies: cfg?.trusted_proxies ?? [],
|
||||
custom_rules: cfg?.custom_rules ?? '',
|
||||
})
|
||||
setAddRuleId(''); setAddNote('')
|
||||
}}
|
||||
>
|
||||
{t('waf.config.exclusionAddBtn')}
|
||||
</Button>
|
||||
</div>
|
||||
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginTop: 6 }}>
|
||||
{t('waf.config.exclusionsAddHint')}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
@@ -479,6 +608,220 @@ function AlertsTab({ domainId, configMap }: { domainId?: number; configMap: Map<
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- Profiles tab ----------------------------------------------------
|
||||
|
||||
interface ProfileFormValues {
|
||||
name: string
|
||||
description: string
|
||||
rule_exclusions: string[]
|
||||
}
|
||||
|
||||
function ProfileEditor({ profile, onClose }: { profile: WafProfile | null; onClose: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
const qc = useQueryClient()
|
||||
const [form] = Form.useForm<ProfileFormValues>()
|
||||
const isCreate = profile !== null && profile.id === 0
|
||||
const ruleOptions = useMemo(
|
||||
() => Object.entries(CRS_RULES).map(([id, desc]) => ({ value: id, label: `${id} — ${desc}` })),
|
||||
[],
|
||||
)
|
||||
|
||||
// Formular bei jedem Öffnen/Wechsel neu befüllen.
|
||||
useEffect(() => {
|
||||
if (profile) {
|
||||
form.setFieldsValue({
|
||||
name: profile.name,
|
||||
description: profile.description,
|
||||
rule_exclusions: profile.rule_exclusions ?? [],
|
||||
})
|
||||
}
|
||||
}, [profile, form])
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (vals: ProfileFormValues) =>
|
||||
isCreate
|
||||
? apiClient.post('/waf/profiles', vals)
|
||||
: apiClient.put(`/waf/profiles/${profile!.id}`, vals),
|
||||
onSuccess: () => {
|
||||
message.success(t('waf.profiles.saved'))
|
||||
void qc.invalidateQueries({ queryKey: ['waf'] })
|
||||
onClose()
|
||||
},
|
||||
onError: () => message.error(t('waf.profiles.saveFailed')),
|
||||
})
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={isCreate ? t('waf.profiles.createTitle') : t('waf.profiles.editTitle')}
|
||||
open={profile !== null}
|
||||
onClose={onClose}
|
||||
width={520}
|
||||
footer={
|
||||
<Space style={{ justifyContent: 'flex-end', width: '100%' }}>
|
||||
<Button onClick={onClose}>{t('common.cancel')}</Button>
|
||||
<Button type="primary" loading={save.isPending} onClick={() => form.submit()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={(vals) => save.mutate({
|
||||
name: vals.name,
|
||||
description: vals.description ?? '',
|
||||
rule_exclusions: vals.rule_exclusions ?? [],
|
||||
})}
|
||||
>
|
||||
<Form.Item
|
||||
label={t('waf.profiles.name')}
|
||||
name="name"
|
||||
rules={[{ required: true, max: 60 }]}
|
||||
>
|
||||
<Input placeholder={t('waf.profiles.namePlaceholder')} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('waf.profiles.description')} name="description">
|
||||
<Input placeholder={t('waf.profiles.descriptionPlaceholder')} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={t('waf.profiles.rules')}
|
||||
name="rule_exclusions"
|
||||
help={t('waf.profiles.rulesHint')}
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
showSearch
|
||||
allowClear
|
||||
placeholder={t('waf.profiles.rulesPlaceholder')}
|
||||
options={ruleOptions}
|
||||
optionFilterProp="label"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
function ProfilesTab() {
|
||||
const { t } = useTranslation()
|
||||
const qc = useQueryClient()
|
||||
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
|
||||
const [editing, setEditing] = useState<WafProfile | null>(null)
|
||||
|
||||
const { data: profiles, isLoading } = useQuery({ queryKey: ['waf', 'profiles'], queryFn: fetchProfiles })
|
||||
const custom = (profiles ?? []).filter(p => !p.builtin)
|
||||
|
||||
const del = useMutation({
|
||||
mutationFn: (id: number) => apiClient.delete(`/waf/profiles/${id}`),
|
||||
onSuccess: () => {
|
||||
message.success(t('waf.profiles.deleted'))
|
||||
void qc.invalidateQueries({ queryKey: ['waf'] })
|
||||
},
|
||||
onError: () => message.error(t('waf.profiles.deleteFailed')),
|
||||
})
|
||||
|
||||
// openCreate(seed) öffnet den Editor im Create-Modus (id=0) mit Startwerten.
|
||||
const openCreate = (seed?: Partial<WafProfile>) => setEditing({
|
||||
id: 0,
|
||||
name: seed?.name ?? '',
|
||||
description: seed?.description ?? '',
|
||||
rule_exclusions: seed?.rule_exclusions ?? [],
|
||||
builtin: false,
|
||||
})
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: t('waf.profiles.col.name'),
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (v: string) => <Text strong style={{ fontSize: 13 }}>{v}</Text>,
|
||||
},
|
||||
{
|
||||
title: t('waf.profiles.col.description'),
|
||||
dataIndex: 'description',
|
||||
key: 'description',
|
||||
ellipsis: true,
|
||||
render: (v: string) => <Text type="secondary" style={{ fontSize: 12 }}>{v || '—'}</Text>,
|
||||
},
|
||||
{
|
||||
title: t('waf.profiles.col.rules'),
|
||||
key: 'rules',
|
||||
width: 90,
|
||||
render: (_: unknown, row: WafProfile) => <Tag>{(row.rule_exclusions ?? []).length}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'actions',
|
||||
width: 210,
|
||||
render: (_: unknown, row: WafProfile) => (
|
||||
<Space size={4}>
|
||||
<Button size="small" icon={<EditOutlined />} disabled={isViewer} onClick={() => setEditing(row)}>
|
||||
{t('waf.profiles.edit')}
|
||||
</Button>
|
||||
<Tooltip title={t('waf.profiles.clone')}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
disabled={isViewer}
|
||||
onClick={() => openCreate({
|
||||
name: `${row.name} ${t('waf.profiles.cloneSuffix')}`,
|
||||
description: row.description,
|
||||
rule_exclusions: row.rule_exclusions,
|
||||
})}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Popconfirm title={t('waf.profiles.deleteConfirm')} onConfirm={() => del.mutate(row.id)} disabled={isViewer}>
|
||||
<Button size="small" danger icon={<DeleteOutlined />} disabled={isViewer} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
className="mb-16"
|
||||
message={t('waf.profiles.intro')}
|
||||
description={
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Text style={{ fontSize: 12 }}>{t('waf.profiles.builtinInfo')}</Text>
|
||||
<div style={{ marginTop: 8, display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||
{CRS_PLUGIN_OPTIONS.map(p => (
|
||||
<Space key={p.value} size={2}>
|
||||
<Tag icon={<SafetyCertificateOutlined />}>{p.label}</Tag>
|
||||
{!isViewer && (
|
||||
<Button size="small" type="link" onClick={() => openCreate({ name: `${p.value}-custom` })}>
|
||||
{t('waf.profiles.asTemplate')}
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="flex-between mb-12">
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{custom.length} {t('waf.profiles.typeCustom')}
|
||||
</Text>
|
||||
<Button type="primary" icon={<PlusOutlined />} disabled={isViewer} onClick={() => openCreate()}>
|
||||
{t('waf.profiles.new')}
|
||||
</Button>
|
||||
</div>
|
||||
{custom.length === 0 && !isLoading ? (
|
||||
<Alert type="info" showIcon message={t('waf.profiles.empty')} />
|
||||
) : (
|
||||
<DataTable rowKey="id" loading={isLoading} dataSource={custom} columns={columns} />
|
||||
)}
|
||||
<ProfileEditor profile={editing} onClose={() => setEditing(null)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- Page ------------------------------------------------------------
|
||||
|
||||
export default function WAFPage() {
|
||||
@@ -652,6 +995,11 @@ export default function WAFPage() {
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'profiles',
|
||||
label: t('waf.tabs.profiles'),
|
||||
children: <ProfilesTab />,
|
||||
},
|
||||
{
|
||||
key: 'alerts',
|
||||
label: t('waf.tabs.alerts'),
|
||||
|
||||
@@ -1160,6 +1160,9 @@ h1, h2, h3, h4, h5, h6 {
|
||||
.threat-tile.threat-tile--warn { opacity: 1; border-left: 3px solid #F59E0B; }
|
||||
.threat-tile.threat-tile--crit { opacity: 1; border-left: 3px solid #DC2626; }
|
||||
.threat-tile.threat-tile--idle { opacity: 0.55; border-left: 3px solid transparent; }
|
||||
|
||||
/* Quittierte Alert-Events (Alerts → Events-Tab) werden abgedimmt. */
|
||||
tr.eg-row-muted > td { opacity: 0.5; }
|
||||
.threat-tile-value--hit { color: #EF4444; }
|
||||
.threat-tile-value--warn { color: #F59E0B; }
|
||||
.threat-tile-value--crit { color: #DC2626; }
|
||||
|
||||
@@ -152,6 +152,9 @@ edgeguard ALL=(root) NOPASSWD: /usr/bin/apt-get update
|
||||
# NICHT beliebig in /etc/apt/apt.conf.d schreiben darf.
|
||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/tee /etc/apt/apt.conf.d/52edgeguard-auto-updates
|
||||
edgeguard ALL=(root) NOPASSWD: /bin/rm -f /etc/apt/apt.conf.d/52edgeguard-auto-updates
|
||||
# Update-Kanal-Switch (Settings → Update-Kanal) schreibt exakt diese
|
||||
# sources.list-Zeile. Gleiches Restrict-Pattern wie oben.
|
||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/tee /etc/apt/sources.list.d/edgeguard.list
|
||||
# Backup-Pfad: pg_dump als postgres-User. Whitelist exakt mit
|
||||
# --clean --if-exists --no-owner --no-acl + dem festen DB-Namen.
|
||||
edgeguard ALL=(postgres) NOPASSWD: /usr/bin/pg_dump --clean --if-exists --no-owner --no-acl edgeguard
|
||||
@@ -203,7 +206,9 @@ edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl stop edgeguard-waf.service
|
||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl enable edgeguard-waf.service
|
||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl disable edgeguard-waf.service
|
||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl restart edgeguard-waf.service
|
||||
# CrowdSec service toggle (start/stop/enable/disable)
|
||||
# CrowdSec service toggle (start/stop/enable/disable) + reload (Whitelist-Render)
|
||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl reload crowdsec.service
|
||||
edgeguard ALL=(root) NOPASSWD: /bin/systemctl reload crowdsec.service
|
||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl start crowdsec.service
|
||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl stop crowdsec.service
|
||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl enable crowdsec.service
|
||||
@@ -292,6 +297,28 @@ SUDOERS
|
||||
|
||||
chmod 0440 /etc/sudoers.d/edgeguard
|
||||
|
||||
# ── Migration: alte "main"-Komponente → "stable" (Kanal-Modell) ──
|
||||
# Vor diesem Release schrieb der Installer immer Komponente "main"
|
||||
# in die apt-Quelle. Ab jetzt publiziert publish.sh/release.sh nur
|
||||
# noch nach stable/testing — ohne diese Migration würden
|
||||
# Bestandsnodes stumm keine neuen Updates mehr sehen (main bleibt
|
||||
# auf dem letzten main-Stand stehen). Idempotent.
|
||||
#
|
||||
# netcell-edgeguard.list: NOCH ältere Installer-Generation vor dem
|
||||
# Rename auf edgeguard.list (install.sh räumt sie nur bei einem
|
||||
# FRISCHEN Install auf, nie bei einem Upgrade — auf Bestandsnodes,
|
||||
# die nie neu installiert wurden, ist sie ggf. noch die aktive
|
||||
# Datei). Erst konsolidieren, dann main→stable.
|
||||
EG_SOURCES_LIST=/etc/apt/sources.list.d/edgeguard.list
|
||||
EG_LEGACY_LIST=/etc/apt/sources.list.d/netcell-edgeguard.list
|
||||
if [ -f "$EG_LEGACY_LIST" ] && [ ! -f "$EG_SOURCES_LIST" ]; then
|
||||
mv "$EG_LEGACY_LIST" "$EG_SOURCES_LIST"
|
||||
fi
|
||||
if [ -f "$EG_SOURCES_LIST" ] && grep -q ' trixie main$' "$EG_SOURCES_LIST"; then
|
||||
sed -i 's/ trixie main$/ trixie stable/' "$EG_SOURCES_LIST"
|
||||
apt-get update -qq || true
|
||||
fi
|
||||
|
||||
# ── Sysctl-Profil für Edge-Gateway (NAT + HAProxy + Forwarding) ──
|
||||
# Voraussetzung für NAT/DNAT/Masquerade + sinnvolle Defaults
|
||||
# für eine high-throughput Forwarding-Box. Edit nicht von Hand
|
||||
@@ -355,7 +382,14 @@ net.core.default_qdisc = fq
|
||||
net.ipv4.tcp_syncookies = 1
|
||||
net.ipv4.icmp_echo_ignore_broadcasts = 1
|
||||
net.ipv4.icmp_ignore_bogus_error_responses = 1
|
||||
net.ipv4.conf.all.log_martians = 1
|
||||
# log_martians AUS (nicht der rp_filter-Drop selbst!): Ein VRRP-Backup-Node
|
||||
# sieht auf geteilten L2-VLANs dauerhaft Broadcast/Multicast für Gateway-/
|
||||
# VIP-Adressen, die er im Backup-Zustand nicht besitzt — rp_filter=2 verwirft
|
||||
# das korrekt, aber log_martians=1 flutet dmesg/journal damit (>100k Zeilen/
|
||||
# Woche beobachtet). Kernel-OR-Semantik: conf.all=1 überschreibt jeden
|
||||
# Interface-spezifischen Wert, daher nur hier zentral abschaltbar. Security-
|
||||
# Sichtbarkeit läuft ohnehin über nftables-NFLOG/ulogd2 + CrowdSec, nicht dmesg.
|
||||
net.ipv4.conf.all.log_martians = 0
|
||||
kernel.kptr_restrict = 2
|
||||
kernel.dmesg_restrict = 1
|
||||
|
||||
@@ -376,9 +410,25 @@ SYSCTL
|
||||
if [ ! -f /var/log/edgeguard/firewall.jsonl ]; then
|
||||
: > /var/log/edgeguard/firewall.jsonl
|
||||
fi
|
||||
# ulogd2 läuft als root (eigener Daemon); File muss von ihm
|
||||
# schreibbar UND von edgeguard-API lesbar sein.
|
||||
chown root:"$EG_USER" /var/log/edgeguard/firewall.jsonl
|
||||
# WICHTIG (Incident 2026-09-06): ulogd2 läuft NICHT als root —
|
||||
# die Debian-Unit startet `ulogd --daemon --uid ulog`, der Daemon
|
||||
# dropped also nach dem Öffnen seiner Files auf den User `ulog`.
|
||||
# Solange er läuft schreibt er über den offenen fd weiter, egal
|
||||
# wem die Datei gehört. Aber: das Distro-Profil
|
||||
# /etc/logrotate.d/ulogd2 schickt nachts SIGHUP an ulogd, damit
|
||||
# es seine Logfiles neu öffnet — und DAS passiert als `ulog`.
|
||||
# Gehörte die Datei root:edgeguard 0640, scheitert das Reopen mit
|
||||
# "can't open JSON log file: Permission denied", ulogd wertet das
|
||||
# als fatal und BEENDET sich. Ohne Restart= blieb der Dienst dann
|
||||
# tagelang still tot (5 Tage unbemerkt) → Firewall-Live-Log zeigte
|
||||
# nur noch den alten In-Memory-Ring der API, nie neue Events.
|
||||
# Daher: Owner = ulog (Schreiber), Gruppe = edgeguard (Leser).
|
||||
if getent passwd ulog >/dev/null 2>&1; then
|
||||
chown ulog:"$EG_USER" /var/log/edgeguard/firewall.jsonl
|
||||
else
|
||||
# ulogd2 (noch) nicht installiert — Fallback wie bisher.
|
||||
chown root:"$EG_USER" /var/log/edgeguard/firewall.jsonl
|
||||
fi
|
||||
chmod 0640 /var/log/edgeguard/firewall.jsonl
|
||||
|
||||
cat > /etc/ulogd.conf <<'ULOGD'
|
||||
@@ -426,7 +476,9 @@ ULOGD
|
||||
compress
|
||||
delaycompress
|
||||
copytruncate
|
||||
create 0640 root edgeguard
|
||||
# Owner muss ulog sein — siehe Kommentar oben: ulogd re-öffnet die
|
||||
# Datei bei SIGHUP als `ulog` und stirbt sonst an EACCES.
|
||||
create 0640 ulog edgeguard
|
||||
}
|
||||
LOGROTATE
|
||||
chmod 0644 /etc/logrotate.d/edgeguard-firewall
|
||||
@@ -435,6 +487,18 @@ LOGROTATE
|
||||
# da ist (Dependency-Konflikt o.ä.), nur warnen — die Firewall
|
||||
# läuft auch ohne Logger.
|
||||
if systemctl list-unit-files ulogd2.service >/dev/null 2>&1; then
|
||||
# Restart=always als Selbstheilung: ulogd beendet sich bei
|
||||
# einem fehlgeschlagenen Reopen mit Exit-Code 0 (also KEIN
|
||||
# Restart=on-failure — das würde nicht greifen). Ohne das
|
||||
# bleibt Firewall-Logging nach einem Rotate-Hiccup still tot.
|
||||
install -d -m 0755 /etc/systemd/system/ulogd2.service.d
|
||||
cat > /etc/systemd/system/ulogd2.service.d/edgeguard-restart.conf <<'ULOGDUNIT'
|
||||
# Managed by edgeguard — re-installation overwrites this file.
|
||||
[Service]
|
||||
Restart=always
|
||||
RestartSec=5s
|
||||
ULOGDUNIT
|
||||
systemctl daemon-reload
|
||||
systemctl enable ulogd2.service >/dev/null 2>&1 || true
|
||||
systemctl restart ulogd2.service || \
|
||||
echo "postinst: ulogd2.service restart failed (firewall logs disabled until fixed)" >&2
|
||||
@@ -780,24 +844,20 @@ VIPCMD
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ALTER PUBLICATION erfordert den PG-Superuser (edgeguard ist nicht
|
||||
# Owner der Publication). Idempotent — No-Op auf Secondary-Nodes
|
||||
# (wo die Publication nicht existiert) und wenn die Tabellen schon
|
||||
# drin sind.
|
||||
sudo -u postgres psql edgeguard <<'EOSQL' 2>/dev/null || true
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_publication WHERE pubname = 'edgeguard_shared') THEN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_publication_tables
|
||||
WHERE pubname = 'edgeguard_shared' AND tablename = 'network_interfaces'
|
||||
) THEN
|
||||
ALTER PUBLICATION edgeguard_shared ADD TABLE network_interfaces, ip_addresses;
|
||||
END IF;
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
EOSQL
|
||||
# ── Replikation reconcilen (Publication / Grants / Subscription) ──
|
||||
# Ersetzt die frühere feste ADD-TABLE-Logik (die network_interfaces/
|
||||
# ip_addresses bei JEDEM Upgrade fälschlich wieder in die Publication
|
||||
# zog — beide sind node-lokal). edgeguard-ctl erkennt die Rolle selbst
|
||||
# und bringt idempotent den Soll-Zustand:
|
||||
# Publisher : fehlende Shared-Tables ADD, node-lokale (localOnlyTables)
|
||||
# DROP, GRANT SELECT für den Replikations-User (deckt per
|
||||
# Migration neu hinzugekommene Tabellen ab — sonst hängt
|
||||
# deren tablesync in 'd').
|
||||
# Subscriber: neue Tabellen leeren + REFRESH PUBLICATION.
|
||||
# Standalone: No-Op.
|
||||
# Läuft als root → buildPsqlCmd nutzt `sudo -u postgres psql` (Superuser).
|
||||
# Best-effort: ein Reconcile-Fehler darf das Upgrade nie abbrechen.
|
||||
/usr/bin/edgeguard-ctl cluster-reconcile-replication 2>&1 || true
|
||||
|
||||
# ── CrowdSec IDS installation ─────────────────────────────────────────
|
||||
# Install CrowdSec if not present. We use the official CrowdSec APT repo.
|
||||
@@ -823,6 +883,83 @@ EOSQL
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── CrowdSec HAProxy-Acquisition auf journald zeigen ──────────
|
||||
# EdgeGuards HAProxy loggt nach journald (log /dev/log local0/1) —
|
||||
# es gibt KEINE /var/log/haproxy.log. `cscli setup` rät aber auf eine
|
||||
# datei-basierte Quelle → CrowdSec liest nichts → die HTTP-/CVE-
|
||||
# Szenarien laufen leer (WAF-artiger Web-Schutz tot). Wir setzen die
|
||||
# Acquisition deterministisch auf die haproxy.service-Journal-Unit
|
||||
# (analog zur sshd-Quelle). Läuft auf JEDEM configure (self-healing
|
||||
# auch für Bestandsinstalls). Admin-Custom bleibt unangetastet: nur
|
||||
# überschreiben, wenn Datei fehlt / noch die kaputte Datei-Default
|
||||
# (/var/log/haproxy.log) enthält / von uns stammt.
|
||||
if command -v cscli >/dev/null 2>&1; then
|
||||
CS_HAPROXY_ACQUIS="/etc/crowdsec/acquis.d/setup.haproxy.yaml"
|
||||
if [ ! -f "$CS_HAPROXY_ACQUIS" ] \
|
||||
|| grep -q '/var/log/haproxy.log' "$CS_HAPROXY_ACQUIS" 2>/dev/null \
|
||||
|| grep -q 'Managed by EdgeGuard' "$CS_HAPROXY_ACQUIS" 2>/dev/null; then
|
||||
install -d -m 0755 /etc/crowdsec/acquis.d
|
||||
cat > "$CS_HAPROXY_ACQUIS" <<'ACQUIS'
|
||||
# Managed by EdgeGuard. HAProxy loggt nach journald (log /dev/log local0/1),
|
||||
# es gibt keine /var/log/haproxy.log. journalctl-Quelle analog zur sshd-Acquis.
|
||||
source: journalctl
|
||||
journalctl_filter:
|
||||
- "_SYSTEMD_UNIT=haproxy.service"
|
||||
labels:
|
||||
type: haproxy
|
||||
ACQUIS
|
||||
# SIGHUP-Reload lädt die Acquisition neu; Restart als Fallback.
|
||||
systemctl reload crowdsec 2>/dev/null \
|
||||
|| systemctl restart crowdsec 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# CrowdSec-Whitelist: eigenen Management-Traffic (Backend
|
||||
# api_backend = ausschließlich edgeguard-api: Admin-UI + REST-API +
|
||||
# ACME) von ALLEN Scenarios ausnehmen. Die Admin-SPA feuert beim
|
||||
# Laden viele /api/-Requests — normal, wurde aber als http-crawl
|
||||
# gebannt (Incident 2026-08-03, Admin-IP gesperrt). IP-unabhängig.
|
||||
install -d -m 0755 /etc/crowdsec/parsers/s02-enrich
|
||||
# Altlast aus dem manuellen Live-Fix (2026-08-03) entfernen — sonst
|
||||
# lägen zwei inhaltsgleiche Whitelist-Dateien nebeneinander.
|
||||
rm -f /etc/crowdsec/parsers/s02-enrich/netcell-mgmt-whitelist.yaml
|
||||
cat > /etc/crowdsec/parsers/s02-enrich/edgeguard-mgmt-whitelist.yaml <<'WL'
|
||||
name: edgeguard/mgmt-ui-whitelist
|
||||
description: Management-UI/REST-API/ACME (HAProxy-Backend api_backend) von CrowdSec ausnehmen - Admin-SPA-Traffic ist kein Angriff.
|
||||
whitelist:
|
||||
reason: edgeguard management-ui / api (backend api_backend)
|
||||
expression:
|
||||
- "evt.Parsed.backend_name == 'api_backend'"
|
||||
WL
|
||||
systemctl reload crowdsec 2>/dev/null \
|
||||
|| systemctl restart crowdsec 2>/dev/null || true
|
||||
|
||||
# Admin-Hosts-Whitelist: wird vom crowdsec-whitelist-Generator aus
|
||||
# domains.crowdsec_trusted gerendert (UI-Schalter "vertrauenswuerdiges
|
||||
# Admin-Panel"). Das Verzeichnis ist root-owned → der Generator (laeuft
|
||||
# als edgeguard) kann nur eine vorab-chownte Datei ueberschreiben, daher
|
||||
# hier leer + edgeguard-owned anlegen, dann aus der DB befuellen.
|
||||
install -o "$EG_USER" -g "$EG_USER" -m 0644 /dev/null \
|
||||
/etc/crowdsec/parsers/s02-enrich/edgeguard-admin-hosts-whitelist.yaml
|
||||
sudo -n -u "$EG_USER" /usr/bin/edgeguard-ctl render-config --only=crowdsec-whitelist 2>/dev/null || true
|
||||
|
||||
# CrowdSec-Simulations-Default: http-crawl-non_statics ist bei modernen
|
||||
# SPAs/Apps strukturell FP-anfaellig — ein Seiten-Load/Sync feuert 40+
|
||||
# distinkte /api/-URLs, der Leaky-Bucket (capacity=40, leak ~2/s) laeuft
|
||||
# in Sekunden ueber → False-Positive-Ban legitimer Nutzer/Kunden. Daher
|
||||
# per Default in SIMULATION: alarmiert weiter, bannt aber nicht. Echte
|
||||
# Angriffe (ssh-bf, http-cve-*, backdoors, CVE-2021-41773 …) bleiben
|
||||
# scharf. Nur bei ERST-Install setzen (Marker) → ein spaeteres manuelles
|
||||
# 'cscli simulation disable …' des Operators wird bei Updates NICHT
|
||||
# wieder ueberschrieben.
|
||||
EG_CROWDSEC_SIM_MARKER=/var/lib/edgeguard/.crowdsec-crawl-sim-applied
|
||||
if [ ! -f "$EG_CROWDSEC_SIM_MARKER" ]; then
|
||||
cscli simulation enable crowdsecurity/http-crawl-non_statics 2>/dev/null || true
|
||||
install -d -m 0755 /var/lib/edgeguard
|
||||
: > "$EG_CROWDSEC_SIM_MARKER"
|
||||
systemctl reload crowdsec 2>/dev/null || systemctl restart crowdsec 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Render initial service configs ───────────────────────────
|
||||
# Writes /etc/edgeguard/haproxy/haproxy.cfg + nftables.d/
|
||||
# ruleset.nft from the (just-migrated, empty) PG state.
|
||||
@@ -910,6 +1047,43 @@ KEEPALIVEDDROPIN
|
||||
rm -rf "${CRS_TMP}"
|
||||
fi
|
||||
|
||||
# ── CRS App-Exclusion-Plugins (Nextcloud / WordPress / Drupal) ──────
|
||||
# Offizielle OWASP-CRS-Plugins (separate Repos coreruleset/<name>-plugin).
|
||||
# Ihre plugins/*.conf landen in <crs>/plugins/; der WAF-Renderer bindet
|
||||
# je Domain die GEWÄHLTEN ein (waf_configs.crs_plugins). Läuft auf JEDEM
|
||||
# configure (self-healing für Bestandsinstalls), aber nur wenn das
|
||||
# jeweilige Plugin noch fehlt — admin-Anpassungen bleiben unangetastet.
|
||||
if [ -d "$WAF_CRS_DIR/rules" ]; then
|
||||
install -d -m 0755 "$WAF_CRS_DIR/plugins"
|
||||
for plugin in nextcloud-rule-exclusions wordpress-rule-exclusions drupal-rule-exclusions; do
|
||||
if [ -f "$WAF_CRS_DIR/plugins/${plugin}-before.conf" ] \
|
||||
|| [ -f "$WAF_CRS_DIR/plugins/${plugin}-config.conf" ]; then
|
||||
continue
|
||||
fi
|
||||
P_TMP="$(mktemp -d)"
|
||||
P_OK=""
|
||||
# Plugin-Repos nutzen teils 'main', teils 'master' als Default-
|
||||
# Branch. curl -f (statt still einen 404 als Erfolg zu werten).
|
||||
for P_BR in main master; do
|
||||
if curl -fsSL --max-time 45 \
|
||||
"https://github.com/coreruleset/${plugin}-plugin/archive/refs/heads/${P_BR}.tar.gz" \
|
||||
-o "${P_TMP}/p.tgz" 2>/dev/null \
|
||||
&& tar xzf "${P_TMP}/p.tgz" -C "${P_TMP}" 2>/dev/null; then
|
||||
P_OK=1; break
|
||||
fi
|
||||
done
|
||||
if [ -n "$P_OK" ] && ls "${P_TMP}"/*/plugins/${plugin}-*.conf >/dev/null 2>&1; then
|
||||
install -m 0644 "${P_TMP}"/*/plugins/${plugin}-*.conf \
|
||||
"$WAF_CRS_DIR/plugins/" 2>/dev/null \
|
||||
&& echo "postinst: CRS-Plugin ${plugin} installiert"
|
||||
else
|
||||
echo "postinst: CRS-Plugin ${plugin} nicht installiert (Download/Layout)" >&2
|
||||
fi
|
||||
rm -rf "${P_TMP}"
|
||||
done
|
||||
chown -R "$EG_USER":"$EG_USER" "$WAF_CRS_DIR/plugins" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ── systemd: pick up new units + restart haproxy with our cfg
|
||||
systemctl daemon-reload
|
||||
systemctl restart haproxy.service || true
|
||||
|
||||
@@ -1,6 +1,30 @@
|
||||
#!/bin/bash
|
||||
# Keepalived health check: edgeguard-api erreichbar?
|
||||
# Weight -50 → BACKUP gewinnt wenn Primary-API nicht antwortet.
|
||||
# Keepalived-Health-Check (chk_edgeguard).
|
||||
#
|
||||
# WICHTIG (Incident 2026-08-03): Dieser Check entscheidet mit, ob ein Node die
|
||||
# VIP übernehmen bzw. per Preempt zurückholen darf. Er darf NUR dann "gesund"
|
||||
# (exit 0) melden, wenn der Node Traffic WIRKLICH bedienen kann — nicht nur
|
||||
# "edgeguard-api-Prozess up". Sonst reißt ein frisch gebooteter / mitten im
|
||||
# Deploy befindlicher Node (haproxy noch nicht bereit) die VIP an sich, obwohl
|
||||
# er nichts bedient → Cluster-Ausfall, bis der Node hart abgeschaltet wird.
|
||||
#
|
||||
# ALLE Bedingungen müssen erfüllt sein, sonst exit 1 → Instanz geht in FAULT →
|
||||
# kein (Re-)Preempt, der gesunde Peer behält/bekommt die VIP.
|
||||
|
||||
# 1) edgeguard-api antwortet auf den Health-Endpoint (Unix-Socket oder Loopback)?
|
||||
curl -sf --max-time 2 --unix-socket /run/edgeguard/api.sock \
|
||||
http://localhost/api/v1/system/health > /dev/null 2>&1 \
|
||||
|| curl -sf --max-time 2 http://127.0.0.1:9443/api/v1/system/health > /dev/null 2>&1
|
||||
|| curl -sf --max-time 2 http://127.0.0.1:9443/api/v1/system/health > /dev/null 2>&1 \
|
||||
|| exit 1
|
||||
|
||||
# 2) haproxy-Prozess aktiv? Während Boot/Restart kurz false → Node bleibt in dem
|
||||
# Fenster Backup (kann nicht preempten).
|
||||
systemctl is-active --quiet haproxy 2>/dev/null || exit 1
|
||||
|
||||
# 3) haproxy hört wirklich auf dem oeffentlichen TLS-Port :443 (bindet = bedient)?
|
||||
# Faengt "Prozess up, aber Config kaputt / Port nicht gebunden" ab. :443 ist
|
||||
# architektur-bedingt immer gebunden (Public-TLS + ACME-Webroot + Mgmt-FQDN-
|
||||
# Fallback), also ein verlaessliches Ready-Signal.
|
||||
ss -H -ltn 2>/dev/null | awk '{print $4}' | grep -Eq ':443$' || exit 1
|
||||
|
||||
exit 0
|
||||
|
||||
@@ -4,8 +4,20 @@
|
||||
# KEIN Auto-Promote — Split-Brain-Schutz durch manuelle Promotion.
|
||||
# Admin muss "edgeguard-ctl promote" ausführen wenn PG-Failover gewünscht.
|
||||
|
||||
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"
|
||||
# PG-Rolle ZUVERLÄSSIG über die Publication ermitteln — nicht über die evtl.
|
||||
# veraltete/fehlende Datei /var/lib/edgeguard/pg_role (die schrieb nur `promote`;
|
||||
# ein via cluster-init-replication eingerichteter Primary hat sie nie → Log sagte
|
||||
# fälschlich "standby"). Nur der Primary trägt die Publication edgeguard_shared
|
||||
# (Konvention, vgl. cluster_repair.go). Best-effort: bei psql-Fehler neutral.
|
||||
if sudo -u postgres psql -d edgeguard -tAc \
|
||||
"SELECT EXISTS(SELECT 1 FROM pg_publication WHERE pubname='edgeguard_shared')" \
|
||||
2>/dev/null | grep -q '^t$'; then
|
||||
logger -t keepalived -p daemon.warning \
|
||||
"MASTER: VIP übernommen — dieser Node ist bereits PG-Primary (kein promote nötig)."
|
||||
else
|
||||
logger -t keepalived -p daemon.warning \
|
||||
"MASTER: VIP übernommen — PG-Rolle ist 'standby'. Für PG-Failover: edgeguard-ctl promote"
|
||||
fi
|
||||
|
||||
# ── Upstream-ARP/Routing für die Failover-VIP(s) aktualisieren ──
|
||||
# Manche Hoster lernen die neue MAC einer Failover-IP NICHT zuverlässig über
|
||||
|
||||
@@ -1,27 +1,49 @@
|
||||
#!/bin/bash
|
||||
# Cleanup-old: löscht alle EdgeGuard-Versionen im Gitea Package
|
||||
# Registry außer den letzten N (default 10).
|
||||
# Registry außer den letzten N (default 10) — pro Kanal (Komponente).
|
||||
#
|
||||
# Wird vom Makefile direkt nach erfolgreichem Upload aufgerufen. Reihen-
|
||||
# folge ist wichtig: ERST der neue Build hochladen, DANN die ältesten
|
||||
# wegschmeißen — sonst riskieren wir bei Cleanup-vor-Upload eine Lücke.
|
||||
# Wird vom Makefile/release.sh direkt nach erfolgreichem Upload
|
||||
# aufgerufen. Reihenfolge ist wichtig: ERST der neue Build hochladen,
|
||||
# DANN die ältesten wegschmeißen — sonst riskieren wir bei Cleanup-vor-
|
||||
# Upload eine Lücke.
|
||||
#
|
||||
# Stable-Schutz: Versionen, die als Git-Tag `v<version>` im Repo stehen,
|
||||
# werden im stable-Kanal NIE gelöscht (analog enconf STABLE_PROTECT) —
|
||||
# ein Kunde, der genau diese Version installiert hat, muss sie über
|
||||
# apt-get weiterhin ziehen können.
|
||||
#
|
||||
# Voraussetzungen:
|
||||
# - ~/.gitea-token mit write-Package-Scope
|
||||
# - jq + curl
|
||||
#
|
||||
# Aufruf:
|
||||
# ./cleanup-old.sh # KEEP=10 (default)
|
||||
# KEEP=20 ./cleanup-old.sh # mehr behalten
|
||||
# ./cleanup-old.sh # Kanal stable, KEEP=10 (default)
|
||||
# ./cleanup-old.sh testing # Kanal testing
|
||||
# KEEP=20 ./cleanup-old.sh stable # mehr behalten
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
KEEP="${KEEP:-10}"
|
||||
OWNER="projekte"
|
||||
DIST="trixie"
|
||||
COMPONENT="main"
|
||||
COMPONENT="${1:-stable}"
|
||||
case "$COMPONENT" in stable|testing) ;; *) echo "cleanup-old: unknown channel '$COMPONENT' (expected stable or testing)" >&2; exit 2 ;; esac
|
||||
BASE="https://git.netcell-it.de"
|
||||
|
||||
# Geschützte Versionen (nur relevant im stable-Kanal): jeder vorhandene
|
||||
# Git-Tag `v*` im Repo-Root. Leer wenn nicht im Repo ausgeführt oder im
|
||||
# testing-Kanal (dort gibt es keine Tags/keinen Schutz).
|
||||
PROTECTED_VERSIONS=""
|
||||
if [ "$COMPONENT" = "stable" ]; then
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
PROTECTED_VERSIONS="$(git -C "$REPO_ROOT" tag --list 'v*' 2>/dev/null | sed 's/^v//' || true)"
|
||||
fi
|
||||
is_protected() {
|
||||
local v="$1"
|
||||
[ -z "$PROTECTED_VERSIONS" ] && return 1
|
||||
printf '%s\n' "$PROTECTED_VERSIONS" | grep -qx "$v"
|
||||
}
|
||||
|
||||
if [ -z "${GITEA_TOKEN:-}" ]; then
|
||||
if [ -r "$HOME/.gitea-token" ]; then
|
||||
GITEA_TOKEN="$(tr -d '\n' < "$HOME/.gitea-token")"
|
||||
@@ -37,6 +59,7 @@ TOK="$GITEA_TOKEN"
|
||||
PKGS=(
|
||||
"edgeguard:all"
|
||||
"edgeguard-api:amd64"
|
||||
"edgeguard-api:arm64"
|
||||
"edgeguard-ui:all"
|
||||
)
|
||||
|
||||
@@ -44,27 +67,37 @@ cleanup_pkg() {
|
||||
local pkg="$1"
|
||||
local arch="$2"
|
||||
|
||||
# Versionen sammeln. Gitea Package-API liefert flache Liste:
|
||||
# /api/v1/packages/{owner}?type=debian&q={name}
|
||||
# Versionen sammeln. Gitea Package-API liefert flache Liste über ALLE
|
||||
# Kanäle hinweg: /api/v1/packages/{owner}?type=debian&q={name}
|
||||
# Wir filtern client-seitig auf name (exact-match — Gitea-Query ist
|
||||
# leider substring-fuzzy) und sort -V (semver) absteigend.
|
||||
# leider substring-fuzzy) UND auf das Versionsformat des aktuellen
|
||||
# Kanals — stable=Semver (X.Y.Z), testing=datumsbasiert (YYYY.MM.DD.NN).
|
||||
# Die beiden Formate überlappen nie, das hält testing- und stable-
|
||||
# Versionen sauber getrennt, obwohl Gitea (Name,Version) global dedupliziert.
|
||||
local raw
|
||||
raw="$(curl -fsS -H "Authorization: token $TOK" \
|
||||
"$BASE/api/v1/packages/$OWNER?type=debian&q=$pkg&limit=1000")"
|
||||
|
||||
local ver_pattern
|
||||
if [ "$COMPONENT" = "stable" ]; then
|
||||
ver_pattern='^[0-9]+\.[0-9]+\.[0-9]+$'
|
||||
else
|
||||
ver_pattern='^[0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]+$'
|
||||
fi
|
||||
|
||||
local versions
|
||||
versions="$(printf '%s' "$raw" | jq -r --arg n "$pkg" \
|
||||
'.[] | select(.name==$n) | .version' | sort -V -r | awk '!seen[$0]++')"
|
||||
'.[] | select(.name==$n) | .version' | grep -E "$ver_pattern" | sort -V -r | awk '!seen[$0]++')"
|
||||
|
||||
if [ -z "$versions" ]; then
|
||||
echo " $pkg ($arch): no versions found, skip"
|
||||
echo " $pkg ($arch): no $COMPONENT versions found, skip"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local count
|
||||
count="$(printf '%s\n' "$versions" | wc -l)"
|
||||
if [ "$count" -le "$KEEP" ]; then
|
||||
echo " $pkg ($arch): $count versions, ≤ keep=$KEEP, nothing to delete"
|
||||
echo " $pkg ($arch): $count $COMPONENT versions, ≤ keep=$KEEP, nothing to delete"
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -72,10 +105,14 @@ cleanup_pkg() {
|
||||
to_delete="$(printf '%s\n' "$versions" | tail -n +$((KEEP+1)))"
|
||||
|
||||
local kept_count=$((count - $(printf '%s\n' "$to_delete" | wc -l)))
|
||||
echo " $pkg ($arch): $count versions total, keeping $kept_count newest, deleting $(printf '%s\n' "$to_delete" | wc -l)"
|
||||
echo " $pkg ($arch): $count $COMPONENT versions total, keeping $kept_count newest, considering $(printf '%s\n' "$to_delete" | wc -l) for deletion"
|
||||
|
||||
while IFS= read -r v; do
|
||||
[ -z "$v" ] && continue
|
||||
if is_protected "$v"; then
|
||||
echo " skip $pkg $v $arch (protected — git tag v$v exists)"
|
||||
continue
|
||||
fi
|
||||
# DELETE-Endpoint für Debian-Pakete:
|
||||
# /api/packages/{owner}/debian/pool/{dist}/{comp}/{name}/{version}/{arch}
|
||||
local url="$BASE/api/packages/$OWNER/debian/pool/$DIST/$COMPONENT/$pkg/$v/$arch"
|
||||
|
||||
@@ -11,16 +11,22 @@
|
||||
# Befund 2026-05-17 nach Gitea-Disk-Full-Vorfall.
|
||||
#
|
||||
# Aufruf:
|
||||
# ./publish.sh <version> <arch>
|
||||
# arch = amd64 | arm64. arm64 publiziert NUR edgeguard-api (das einzige
|
||||
# arch-spezifische Paket); amd64 publiziert alle drei.
|
||||
# ./publish.sh <version> <arch> [channel]
|
||||
# arch = amd64 | arm64. arm64 publiziert NUR edgeguard-api (das einzige
|
||||
# arch-spezifische Paket); amd64 publiziert alle drei.
|
||||
# channel = stable | testing (Default stable). Kanal-Modell wie enconf:
|
||||
# Suite=trixie fest, Komponente=Kanal. Wird von scripts/release.sh
|
||||
# gesetzt — direkter Aufruf (z.B. aus altem `make publish`) bleibt
|
||||
# ohne 3. Arg unverändert auf stable.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${1:?usage: publish.sh <version> <arch>}"
|
||||
ARCH="${2:?usage: publish.sh <version> <arch>}"
|
||||
VERSION="${1:?usage: publish.sh <version> <arch> [channel]}"
|
||||
ARCH="${2:?usage: publish.sh <version> <arch> [channel]}"
|
||||
CHANNEL="${3:-stable}"
|
||||
case "$CHANNEL" in stable|testing) ;; *) echo "publish: unknown channel '$CHANNEL' (expected stable or testing)" >&2; exit 2 ;; esac
|
||||
|
||||
BASE="https://git.netcell-it.de/api/packages/projekte/debian/pool/trixie/main/upload"
|
||||
BASE="https://git.netcell-it.de/api/packages/projekte/debian/pool/trixie/${CHANNEL}/upload"
|
||||
|
||||
if [ -z "${GITEA_TOKEN:-}" ]; then
|
||||
if [ -r "$HOME/.gitea-token" ]; then
|
||||
@@ -88,4 +94,4 @@ case "$ARCH" in
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "publish: ok ($VERSION/$ARCH)"
|
||||
echo "publish: ok ($VERSION/$ARCH/$CHANNEL)"
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
#
|
||||
# curl -fsSL https://get.netcell-edgeguard.de | sudo bash
|
||||
#
|
||||
# Kanal wählen (Default: stable):
|
||||
# curl -fsSL https://get.netcell-edgeguard.de | EDGEGUARD_CHANNEL=testing sudo -E bash
|
||||
#
|
||||
# Supported: Debian 13 (Trixie), amd64 + arm64.
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -111,12 +114,14 @@ setup_repo() {
|
||||
curl -fsSL "https://git.netcell-it.de/api/packages/projekte/debian/repository.key" \
|
||||
-o /etc/apt/keyrings/nmg.asc
|
||||
fi
|
||||
echo "deb [signed-by=/etc/apt/keyrings/nmg.asc] https://git.netcell-it.de/api/packages/projekte/debian trixie main" \
|
||||
# Kanal-Modell (wie enconf): Suite = OS-Codename, Komponente = Kanal.
|
||||
# Default stable; EDGEGUARD_CHANNEL=testing für Testing-Kanal.
|
||||
echo "deb [signed-by=/etc/apt/keyrings/nmg.asc] https://git.netcell-it.de/api/packages/projekte/debian trixie ${EDGEGUARD_CHANNEL:-stable}" \
|
||||
> /etc/apt/sources.list.d/edgeguard.list
|
||||
|
||||
apt-get update -qq
|
||||
}
|
||||
step "Set up EdgeGuard apt repository" setup_repo
|
||||
step "Set up EdgeGuard apt repository (${EDGEGUARD_CHANNEL:-stable})" setup_repo
|
||||
|
||||
AVAILABLE=$(LC_ALL=C apt-cache policy edgeguard 2>/dev/null | awk '/Candidate:/ {print $2; exit}' || true)
|
||||
if [ -n "$AVAILABLE" ] && [ "$AVAILABLE" != "(none)" ]; then
|
||||
|
||||
193
scripts/release.sh
Executable file
193
scripts/release.sh
Executable file
@@ -0,0 +1,193 @@
|
||||
#!/usr/bin/env bash
|
||||
# EdgeGuard — Release erstellen (Testing-Push oder Stable-Promotion)
|
||||
#
|
||||
# Verwendung:
|
||||
# ./scripts/release.sh # Testing-Push: baut den aktuellen
|
||||
# # Stand, Version = datumsbasiert
|
||||
# # YYYY.MM.DD.NN, Upload → testing
|
||||
# ./scripts/release.sh stable # Stable-Promotion: baut den
|
||||
# # aktuellen Stand unter der
|
||||
# # nächsten Patch-Version (VERSION-
|
||||
# # Datei +1), Upload → stable,
|
||||
# # VERSION-Commit + Git-Tag v<version>
|
||||
# ./scripts/release.sh stable 1.4.0 # Stable mit expliziter Version
|
||||
#
|
||||
# Kanal-Modell 1:1 von enconf (netcell-webpanel) übernommen — Suite=trixie
|
||||
# fest, Komponente=Kanal (stable/testing). An EdgeGuard angepasst:
|
||||
# - Gates laufen über die bestehende Go-Quality-Baseline (`make deb` ruft
|
||||
# `release-check` + `management-ui`-tsc automatisch auf) statt eigener
|
||||
# Preflight-Schritte.
|
||||
# - Kein Docs-/Changelog-/Checksum-/Marketing-Site-Deploy — dafür
|
||||
# existiert bei EdgeGuard keine Infrastruktur (siehe CLAUDE.md).
|
||||
# - Testing-Versionen sind datumsbasiert (wie enconf) DAMIT sie (a) bei
|
||||
# apt immer über jeder Stable-Semver-Version sortieren und (b) nie mit
|
||||
# einer Stable-Versionsnummer kollidieren — Gitea dedupliziert
|
||||
# (Name,Version) global über alle Kanäle hinweg.
|
||||
#
|
||||
# Voraussetzung: GITEA_TOKEN (env oder ~/.gitea-token) mit Package-
|
||||
# Upload/Delete/Query-Scope.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
cd "$REPO_DIR"
|
||||
|
||||
OWNER="projekte"
|
||||
GITEA_URL="https://git.netcell-it.de"
|
||||
KEEP="${KEEP:-10}"
|
||||
|
||||
log() { echo "[release] $*"; }
|
||||
warn() { echo " ⚠ $*" >&2; }
|
||||
abort() { echo "🛑 ABBRUCH: $*" >&2; exit 1; }
|
||||
|
||||
if [ -z "${GITEA_TOKEN:-}" ]; then
|
||||
if [ -r "$HOME/.gitea-token" ]; then
|
||||
GITEA_TOKEN="$(tr -d '\n' < "$HOME/.gitea-token")"
|
||||
else
|
||||
abort "GITEA_TOKEN fehlt (env oder ~/.gitea-token)"
|
||||
fi
|
||||
fi
|
||||
export GITEA_TOKEN
|
||||
|
||||
# `make ui` fällt bei fehlendem bun auf `npm install` zurück (Convenience
|
||||
# für schnelle lokale Builds) — das löst Dependencies gegen package.json
|
||||
# neu auf statt gegen das gepinnte management-ui/bun.lock, und hinterlässt
|
||||
# eine package-lock.json die nichts mit dem committeten Lockfile zu tun
|
||||
# hat. Für einen Release (Testing ODER Stable) ist das nicht akzeptabel —
|
||||
# beide Kanäle müssen reproduzierbar aus dem gepinnten Lockfile bauen.
|
||||
command -v bun >/dev/null 2>&1 || \
|
||||
abort "bun fehlt — Release-Builds müssen aus management-ui/bun.lock bauen, nicht aus dem npm-Fallback. Installieren: https://bun.sh/install"
|
||||
|
||||
# ─── Testing-Version: datumsbasiert YYYY.MM.DD.NN ──────────────────────
|
||||
# NN = laufende Nummer für den Tag, ermittelt aus den bereits im Registry
|
||||
# vorhandenen Versionen des Meta-Pakets (edgeguard) mit dem heutigen
|
||||
# Datumspräfix. Erster Release des Tages -> .01.
|
||||
next_testing_version() {
|
||||
local today
|
||||
today="$(date -u +%Y.%m.%d)"
|
||||
local raw
|
||||
raw="$(curl -fsS -H "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_URL/api/v1/packages/$OWNER?type=debian&q=edgeguard&limit=1000" 2>/dev/null || echo '[]')"
|
||||
local last_n
|
||||
last_n="$(printf '%s' "$raw" | jq -r --arg n "edgeguard" --arg pfx "${today}." \
|
||||
'.[] | select(.name==$n) | .version | select(startswith($pfx))' \
|
||||
| sed "s/^${today}\.//" | sort -n | tail -1)"
|
||||
printf '%s.%02d' "$today" "$(( ${last_n:-0} + 1 ))"
|
||||
}
|
||||
|
||||
# ─── Stable-Version: Patch-Bump der VERSION-Datei (Default) ────────────
|
||||
bump_patch() {
|
||||
local major minor patch
|
||||
IFS='.' read -r major minor patch <<< "$1"
|
||||
echo "${major}.${minor}.$((patch + 1))"
|
||||
}
|
||||
|
||||
# ─── Alte Version im Ziel-Kanal ersetzen — Upload derselben Nummer würde
|
||||
# sonst mit 409 den alten Inhalt behalten (Gitea dedupliziert (Name,
|
||||
# Version,Arch,Kanal-Pool) — best-effort, 404 ist der Normalfall). ──────
|
||||
delete_before_upload() {
|
||||
local channel="$1" version="$2" name="$3" arch="$4"
|
||||
local url="$GITEA_URL/api/packages/$OWNER/debian/pool/trixie/$channel/$name/$version/$arch"
|
||||
curl -sS -o /dev/null -w '' -X DELETE -H "Authorization: token $GITEA_TOKEN" "$url" || true
|
||||
}
|
||||
|
||||
replace_in_channel() {
|
||||
local channel="$1" version="$2"
|
||||
delete_before_upload "$channel" "$version" edgeguard-api amd64
|
||||
delete_before_upload "$channel" "$version" edgeguard-api arm64
|
||||
delete_before_upload "$channel" "$version" edgeguard-ui all
|
||||
delete_before_upload "$channel" "$version" edgeguard all
|
||||
}
|
||||
|
||||
# ─── verify_channel_debs — Health-Gate wie enconf verify_stable_debs ───
|
||||
# Holt den Packages-Index eines Kanals und prüft für JEDE dort
|
||||
# referenzierte .deb, ob sie unter ihrer Pool-URL wirklich mit HTTP 200
|
||||
# abrufbar ist (nicht nur ob der Index existiert — Gitea regeneriert den
|
||||
# Index nicht immer zuverlässig nach einem Delete).
|
||||
verify_channel_debs() {
|
||||
local channel="$1"
|
||||
log "verify_channel_debs($channel): jede indexierte .deb muss abrufbar sein..."
|
||||
local files f code bad=0 checked=0
|
||||
files="$(curl -sk -H "Authorization: token $GITEA_TOKEN" \
|
||||
"$GITEA_URL/api/packages/$OWNER/debian/dists/trixie/$channel/binary-amd64/Packages" 2>/dev/null \
|
||||
| awk '/^Filename:/{print $2}')"
|
||||
if [ -z "$files" ]; then
|
||||
warn "$channel: Packages-Index leer/nicht erreichbar — übersprungen"
|
||||
return 0
|
||||
fi
|
||||
while IFS= read -r f; do
|
||||
[ -z "$f" ] && continue
|
||||
checked=$((checked + 1))
|
||||
code="$(curl -sk -o /dev/null -w '%{http_code}' \
|
||||
-H "Authorization: token $GITEA_TOKEN" "$GITEA_URL/api/packages/$OWNER/debian/$f")"
|
||||
if [ "$code" != "200" ]; then
|
||||
warn "$channel: $f -> HTTP $code"
|
||||
bad=$((bad + 1))
|
||||
fi
|
||||
done <<< "$files"
|
||||
[ "$bad" -eq 0 ] || abort "$channel: $bad von $checked .deb(s) im Index nicht abrufbar — Registry inkonsistent."
|
||||
log "$channel: $checked .deb(s) verifiziert, alle abrufbar."
|
||||
}
|
||||
|
||||
# ─── Testing-Push ───────────────────────────────────────────────────────
|
||||
testing_push() {
|
||||
local new_version
|
||||
new_version="$(next_testing_version)"
|
||||
log "[testing 1/3] Gates + Build $new_version (make deb — release-check, amd64+arm64+ui)..."
|
||||
make VERSION="$new_version" deb
|
||||
|
||||
log "[testing 2/3] Alte Version im testing-Kanal ersetzen + hochladen..."
|
||||
replace_in_channel testing "$new_version"
|
||||
./scripts/apt-repo/publish.sh "$new_version" amd64 testing
|
||||
./scripts/apt-repo/publish.sh "$new_version" arm64 testing
|
||||
|
||||
log "[testing 3/3] Cleanup (keep last $KEEP) + Verify..."
|
||||
KEEP="$KEEP" ./scripts/apt-repo/cleanup-old.sh testing
|
||||
verify_channel_debs testing
|
||||
|
||||
log "✅ Testing-Release $new_version veröffentlicht."
|
||||
}
|
||||
|
||||
# ─── Stable-Promotion ───────────────────────────────────────────────────
|
||||
promote_stable() {
|
||||
local explicit_version="${1:-}"
|
||||
local current_version new_version
|
||||
current_version="$(cat "$REPO_DIR/VERSION")"
|
||||
new_version="${explicit_version:-$(bump_patch "$current_version")}"
|
||||
|
||||
git -C "$REPO_DIR" rev-parse "v$new_version" >/dev/null 2>&1 && \
|
||||
abort "Git-Tag v$new_version existiert bereits."
|
||||
# --untracked-files=no: nur versionierte Änderungen blocken. Das Repo
|
||||
# kann fremde, noch nicht committete Arbeit in eigenen Verzeichnissen
|
||||
# liegen haben (untracked) — die geht ein Stable-Release nichts an.
|
||||
[ -n "$(git -C "$REPO_DIR" status --porcelain --untracked-files=no)" ] && \
|
||||
abort "Uncommittete Änderungen an versionierten Dateien — commit/stash erst, dann Stable-Release."
|
||||
|
||||
log "[stable 1/4] Gates + Build $new_version (make deb — release-check, amd64+arm64+ui)..."
|
||||
make VERSION="$new_version" deb
|
||||
|
||||
log "[stable 2/4] Alte Version im stable-Kanal ersetzen + hochladen..."
|
||||
replace_in_channel stable "$new_version"
|
||||
./scripts/apt-repo/publish.sh "$new_version" amd64 stable
|
||||
./scripts/apt-repo/publish.sh "$new_version" arm64 stable
|
||||
|
||||
log "[stable 3/4] VERSION-Datei setzen + Commit + Git-Tag..."
|
||||
printf '%s' "$new_version" > "$REPO_DIR/VERSION"
|
||||
git -C "$REPO_DIR" add VERSION
|
||||
git -C "$REPO_DIR" commit -m "chore(release): v$new_version stable"
|
||||
git -C "$REPO_DIR" tag "v$new_version"
|
||||
|
||||
log "[stable 4/4] Cleanup (keep last $KEEP) + Verify..."
|
||||
KEEP="$KEEP" ./scripts/apt-repo/cleanup-old.sh stable
|
||||
verify_channel_debs stable
|
||||
|
||||
log "✅ Stable-Release v$new_version fertig."
|
||||
log " Push nicht vergessen: git push origin main --tags"
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
stable) promote_stable "${2:-}" ;;
|
||||
"") testing_push ;;
|
||||
*) abort "unbekanntes Argument '$1' (erwartet: 'stable' oder kein Argument)" ;;
|
||||
esac
|
||||
Reference in New Issue
Block a user