From bd32bc343acac609bd597ede1370fbb342baf7df Mon Sep 17 00:00:00 2001 From: Debian Date: Tue, 2 Jun 2026 15:43:15 +0200 Subject: [PATCH] =?UTF-8?q?feat(waf):=20Phase=202=20=E2=80=94=20edgeguard-?= =?UTF-8?q?waf=20Binary=20+=20SPOE=20+=20Coraza=20Engine=20=E2=80=94=20v1.?= =?UTF-8?q?2.67?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cmd/edgeguard-waf/: neues Binary — lädt WAF-Configs aus DB, startet SPOE-Agent auf 127.0.0.1:9000, refreshed Configs alle 30s - internal/waf/engine.go: BuildEngine() — Coraza WAF aus WafConfig bauen (SecLang-Direktiven: RuleEngine, PL, CRS-Include, Exclusions, Custom) - internal/waf/manager.go: Manager — per-Hostname Coraza-Engine-Cache (thread-safe, Lazy-Init via Reload(), Port-Strip, IPv6-Brackets) - internal/waf/spoe.go: SPOEAgent — haproxy-go SPOE-Handler (src/method/path/query/ver/host/headers aus HAProxy-Vars, Coraza-Transaction, Blocking: txn.waf.status=403 setzen) - services/waf/waf.go: ListAllWithDomain() — JOIN domains+waf_configs - go.mod: coraza/v3 v3.7.0 + dropmorepackets/haproxy-go v0.0.8 Co-Authored-By: Claude Sonnet 4.6 --- VERSION | 2 +- cmd/edgeguard-waf/main.go | 102 ++++++++++++++++++++++ go.mod | 25 ++++-- go.sum | 55 +++++++++--- internal/services/waf/waf.go | 39 +++++++++ internal/waf/engine.go | 110 ++++++++++++++++++++++++ internal/waf/manager.go | 121 ++++++++++++++++++++++++++ internal/waf/spoe.go | 161 +++++++++++++++++++++++++++++++++++ 8 files changed, 599 insertions(+), 16 deletions(-) create mode 100644 cmd/edgeguard-waf/main.go create mode 100644 internal/waf/engine.go create mode 100644 internal/waf/manager.go create mode 100644 internal/waf/spoe.go diff --git a/VERSION b/VERSION index 793f558..29ea5f5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.66 +1.2.67 diff --git a/cmd/edgeguard-waf/main.go b/cmd/edgeguard-waf/main.go new file mode 100644 index 0000000..cc4ce61 --- /dev/null +++ b/cmd/edgeguard-waf/main.go @@ -0,0 +1,102 @@ +// Command edgeguard-waf is the per-domain WAF SPOE agent for EdgeGuard. +// HAProxy connects to it via the SPOE protocol (127.0.0.1:9000). +// It loads per-domain WAF configs from PostgreSQL and uses Coraza v3 +// with the OWASP Core Rule Set to inspect HTTP requests. +package main + +import ( + "context" + "log/slog" + "os" + "os/signal" + "syscall" + "time" + + "git.netcell-it.de/projekte/edgeguard-native/internal/database" + "git.netcell-it.de/projekte/edgeguard-native/internal/models" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/waf" + intwaf "git.netcell-it.de/projekte/edgeguard-native/internal/waf" +) + +func main() { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + dsn := database.ConnStringFromEnv() + pool, err := database.Open(ctx, dsn) + if err != nil { + slog.Error("waf: db connect", "error", err) + os.Exit(1) + } + defer pool.Close() + + if err := database.Migrate(ctx, ""); err != nil { + slog.Error("waf: migrate", "error", err) + os.Exit(1) + } + + repo := waf.New(pool) + + crsDir := os.Getenv("EDGEGUARD_WAF_CRS_DIR") + if crsDir == "" { + crsDir = intwaf.DefaultCRSDir + } + spoeAddr := os.Getenv("EDGEGUARD_WAF_ADDR") + if spoeAddr == "" { + spoeAddr = intwaf.DefaultSPOEAddr + } + + mgr := intwaf.NewManager(crsDir) + + // Initial load. + if err := reload(ctx, repo, mgr); err != nil { + slog.Error("waf: initial load", "error", err) + os.Exit(1) + } + + // Periodic config refresh every 30 seconds. + go func() { + t := time.NewTicker(30 * time.Second) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + if err := reload(ctx, repo, mgr); err != nil { + slog.Warn("waf: reload", "error", err) + } + } + } + }() + + agent := intwaf.SPOEAgent{ + Manager: mgr, + Addr: spoeAddr, + } + + slog.Info("waf: SPOE agent starting", "addr", spoeAddr, "crs", crsDir) + if err := agent.ListenAndServe(ctx); err != nil && ctx.Err() == nil { + slog.Error("waf: SPOE agent stopped", "error", err) + os.Exit(1) + } +} + +// reload fetches all domain+waf_config pairs from DB and rebuilds engines. +func reload(ctx context.Context, repo *waf.Repo, mgr *intwaf.Manager) error { + configs, err := repo.ListAllWithDomain(ctx) + if err != nil { + return err + } + domains := make([]intwaf.DomainConfig, 0, len(configs)) + for _, c := range configs { + domains = append(domains, intwaf.DomainConfig{ + Hostname: c.Hostname, + Config: c.Config, + }) + } + return mgr.Reload(domains) +} + +// Ensure models package is used (imported transitively via services/waf). +var _ = models.WafConfig{} diff --git a/go.mod b/go.mod index e6240ed..390e3c7 100644 --- a/go.mod +++ b/go.mod @@ -3,11 +3,16 @@ module git.netcell-it.de/projekte/edgeguard-native go 1.26.0 require ( + github.com/corazawaf/coraza/v3 v3.7.0 + github.com/dropmorepackets/haproxy-go v0.0.8 github.com/fsnotify/fsnotify v1.10.1 github.com/gin-gonic/gin v1.10.0 github.com/go-acme/lego/v4 v4.35.2 github.com/gorilla/websocket v1.5.3 github.com/jackc/pgx/v5 v5.9.2 + github.com/minio/minio-go/v7 v7.1.0 + github.com/pkg/sftp v1.13.10 + github.com/pquerna/otp v1.5.0 github.com/pressly/goose/v3 v3.27.1 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e golang.org/x/crypto v0.51.0 @@ -21,6 +26,7 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect + github.com/corazawaf/libinjection-go v0.3.2 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gin-contrib/sse v0.1.0 // indirect @@ -29,35 +35,43 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.23.0 // indirect - github.com/goccy/go-json v0.10.2 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gotnospirit/makeplural v0.0.0-20180622080156-a5f48d94d976 // indirect + github.com/gotnospirit/messageformat v0.0.0-20221001023931-dfe49f1eb092 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect + github.com/kaptinlin/go-i18n v0.1.4 // indirect + github.com/kaptinlin/jsonschema v0.4.6 // indirect github.com/klauspost/compress v1.18.5 // indirect github.com/klauspost/cpuid/v2 v2.2.11 // indirect github.com/klauspost/crc32 v1.3.0 // indirect github.com/kr/fs v0.1.0 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/magefile/mage v1.17.0 // indirect github.com/mattn/go-isatty v0.0.21 // indirect github.com/mfridman/interpolate v0.0.2 // indirect github.com/miekg/dns v1.1.72 // indirect github.com/minio/crc64nvme v1.1.1 // indirect github.com/minio/md5-simd v1.1.2 // indirect - github.com/minio/minio-go/v7 v7.1.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect - github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/petar-dambovaliev/aho-corasick v0.0.0-20250424160509-463d218d4745 // indirect github.com/philhofer/fwd v1.2.0 // indirect - github.com/pkg/sftp v1.13.10 // indirect - github.com/pquerna/otp v1.5.0 // indirect github.com/rs/xid v1.6.0 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect github.com/tinylib/msgp v1.6.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect + github.com/valllabh/ocsf-schema-golang v1.0.3 // indirect github.com/zeebo/xxh3 v1.1.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect @@ -70,4 +84,5 @@ require ( golang.org/x/tools v0.44.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 ) diff --git a/go.sum b/go.sum index 5da4e6c..79af5bf 100644 --- a/go.sum +++ b/go.sum @@ -13,13 +13,23 @@ github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/ github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/corazawaf/coraza-coreruleset v0.0.0-20240226094324-415b1017abdc h1:OlJhrgI3I+FLUCTI3JJW8MoqyM78WbqJjecqMnqG+wc= +github.com/corazawaf/coraza-coreruleset v0.0.0-20240226094324-415b1017abdc/go.mod h1:7rsocqNDkTCira5T0M7buoKR2ehh7YZiPkzxRuAgvVU= +github.com/corazawaf/coraza/v3 v3.7.0 h1:LIQqu1r+l6e/U/gyiZeykWaNNBY1TzRLz+aaI+QYEEM= +github.com/corazawaf/coraza/v3 v3.7.0/go.mod h1:dOSt5evqC7EstouEv6ghhui01+oVUwp9X1vybWwqTlo= +github.com/corazawaf/libinjection-go v0.3.2 h1:9rrKt0lpg4WvUXt+lwS06GywfqRXXsa/7JcOw5cQLwI= +github.com/corazawaf/libinjection-go v0.3.2/go.mod h1:Ik/+w3UmTWH9yn366RgS9D95K3y7Atb5m/H/gXzzPCk= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dropmorepackets/haproxy-go v0.0.8 h1:kS2Wa8+ZDcnJdRSTiuBsaPun5hpdUPIuLQ+Drp9ZxYs= +github.com/dropmorepackets/haproxy-go v0.0.8/go.mod h1:4a2AmmVjvg2zPNdizGZrMN8ZSUpj90U43VlcdbOIBnU= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/foxcpp/go-mockdns v1.1.0 h1:jI0rD8M0wuYAxL7r/ynTrCQQq0BVqfB99Vgk7DlmewI= +github.com/foxcpp/go-mockdns v1.1.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= @@ -42,8 +52,10 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o= github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= -github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= -github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -51,6 +63,10 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gotnospirit/makeplural v0.0.0-20180622080156-a5f48d94d976 h1:b70jEaX2iaJSPZULSUxKtm73LBfsCrMsIlYCUgNGSIs= +github.com/gotnospirit/makeplural v0.0.0-20180622080156-a5f48d94d976/go.mod h1:ZGQeOwybjD8lkCjIyJfqR5LD2wMVHJ31d6GdPxoTsWY= +github.com/gotnospirit/messageformat v0.0.0-20221001023931-dfe49f1eb092 h1:c7gcNWTSr1gtLp6PyYi3wzvFCEcHJ4YRobDgqmIgf7Q= +github.com/gotnospirit/messageformat v0.0.0-20221001023931-dfe49f1eb092/go.mod h1:ZZAN4fkkful3l1lpJwF8JbW41ZiG9TwJ2ZlqzQovBNU= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -59,14 +75,18 @@ github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jcchavezs/mergefs v0.1.1 h1:D45R17m6dHnSVZefnhynoeZvcK2Uw0oTrRfoUOQ0S5Y= +github.com/jcchavezs/mergefs v0.1.1/go.mod h1:eRLTrsA+vFwQZ48hj8p8gki/5v9C2bFtHH5Mnn4bcGk= github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 h1:9Nu54bhS/H/Kgo2/7xNSUuC5G28VR8ljfrLKU2G4IjU= github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12/go.mod h1:TBzl5BIHNXfS9+C35ZyJaklL7mLDbgUkcgXzSLa8Tk0= +github.com/kaptinlin/go-i18n v0.1.4 h1:wCiwAn1LOcvymvWIVAM4m5dUAMiHunTdEubLDk4hTGs= +github.com/kaptinlin/go-i18n v0.1.4/go.mod h1:g1fn1GvTgT4CiLE8/fFE1hboHWJ6erivrDpiDtCcFKg= +github.com/kaptinlin/jsonschema v0.4.6 h1:vOSFg5tjmfkOdKg+D6Oo4fVOM/pActWu/ntkPsI1T64= +github.com/kaptinlin/jsonschema v0.4.6/go.mod h1:1DUd7r5SdyB2ZnMtyB7uLv64dE3zTFTiYytDCd+AEL0= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= -github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU= github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= @@ -80,6 +100,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/magefile/mage v1.17.0 h1:dS4tkq997Ism03akafC8509iqDjeE7TNTexI25Y7sXM= +github.com/magefile/mage v1.17.0/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA= github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= @@ -100,8 +122,10 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= -github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/petar-dambovaliev/aho-corasick v0.0.0-20250424160509-463d218d4745 h1:Vpr4VgAizEgEZsaMohpw6JYDP+i9Of9dmdY4ufNP6HI= +github.com/petar-dambovaliev/aho-corasick v0.0.0-20250424160509-463d218d4745/go.mod h1:EHPiTAKtiFmrMldLUNswFwfZ2eJIYBHktdaUTZxYWRw= github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= @@ -127,22 +151,30 @@ github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDq github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY= github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/valllabh/ocsf-schema-golang v1.0.3 h1:eR8k/3jP/OOqB8LRCtdJ4U+vlgd/gk5y3KMXoodrsrw= +github.com/valllabh/ocsf-schema-golang v1.0.3/go.mod h1:sZ3as9xqm1SSK5feFWIR2CuGeGRhsM7TR1MbpBctzPk= +github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= +github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -160,9 +192,10 @@ golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/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= @@ -184,4 +217,6 @@ modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U= modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew= nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/binaryregexp v0.2.0 h1:HfqmD5MEmC0zvwBuF187nq9mdnXjXsSivRiXN7SmRkE= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/internal/services/waf/waf.go b/internal/services/waf/waf.go index 5bf7b87..f98629a 100644 --- a/internal/services/waf/waf.go +++ b/internal/services/waf/waf.go @@ -112,3 +112,42 @@ func (r *Repo) ListEnabled(ctx context.Context) ([]models.WafConfig, error) { } return out, rows.Err() } + +// DomainConfigPair combines a domain hostname with its WAF config. +type DomainConfigPair struct { + Hostname string + Config models.WafConfig +} + +// ListAllWithDomain returns all WAF configs joined with their domain name. +// Used by the WAF agent to build the hostname→engine mapping. +func (r *Repo) ListAllWithDomain(ctx context.Context) ([]DomainConfigPair, error) { + 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 + FROM waf_configs w + JOIN domains d ON d.id = w.domain_id + WHERE d.active = true + ORDER BY d.name ASC + `) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]DomainConfigPair, 0, 16) + for rows.Next() { + var p DomainConfigPair + var c models.WafConfig + if err := rows.Scan( + &p.Hostname, + &c.ID, &c.DomainID, &c.Enabled, &c.Mode, &c.ParanoiaLevel, + &c.RuleExclusions, &c.TrustedProxies, &c.CustomRules, &c.UpdatedAt, + ); err != nil { + return nil, err + } + p.Config = c + out = append(out, p) + } + return out, rows.Err() +} diff --git a/internal/waf/engine.go b/internal/waf/engine.go new file mode 100644 index 0000000..34728d8 --- /dev/null +++ b/internal/waf/engine.go @@ -0,0 +1,110 @@ +// Package waf implements the per-domain WAF engine for EdgeGuard. +// It wraps Coraza v3 (OWASP Core Rule Set) and exposes a simple +// hostname-keyed engine manager that the SPOE agent uses. +package waf + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/corazawaf/coraza/v3" + + "git.netcell-it.de/projekte/edgeguard-native/internal/models" +) + +const ( + DefaultCRSDir = "/usr/share/edgeguard/waf/crs" + DefaultSPOEAddr = "127.0.0.1:9000" +) + +// BuildEngine creates a Coraza WAF instance for the given domain config. +// crsDir is the path to the OWASP CRS directory (may be empty — engine +// works without CRS, using only the basic Coraza core rules). +func BuildEngine(cfg models.WafConfig, crsDir string) (coraza.WAF, error) { + directives := buildDirectives(cfg, crsDir) + wafCfg := coraza.NewWAFConfig(). + WithRequestBodyAccess(). + WithDirectives(directives) + return coraza.NewWAF(wafCfg) +} + +// buildDirectives assembles the SecLang directives for a domain config. +func buildDirectives(cfg models.WafConfig, crsDir string) string { + var sb strings.Builder + + sb.WriteString("SecRequestBodyAccess On\n") + sb.WriteString("SecResponseBodyAccess Off\n") + sb.WriteString("SecRequestBodyLimit 13107200\n") // 12.5 MB + sb.WriteString("SecRequestBodyInMemoryLimit 131072\n") // 128 KB + + sb.WriteString(fmt.Sprintf("SecRuleEngine %s\n", ruleEngineMode(cfg.Mode))) + + if crsDir != "" && crsAvailable(crsDir) { + // Paranoia level MUST be set before CRS rules are included. + pl := cfg.ParanoiaLevel + if pl < 1 || pl > 4 { + pl = 1 + } + sb.WriteString(fmt.Sprintf( + "SecAction \"id:900000,phase:1,nolog,pass,t:none,setvar:tx.paranoia_level=%d\"\n", pl, + )) + setupConf := filepath.Join(crsDir, "crs-setup.conf") + if _, err := os.Stat(setupConf); err == nil { + sb.WriteString(fmt.Sprintf("Include %s\n", setupConf)) + } + rulesGlob := filepath.Join(crsDir, "rules", "*.conf") + sb.WriteString(fmt.Sprintf("Include %s\n", rulesGlob)) + } + + // Rule exclusions (applied after CRS load so they override CRS). + for _, id := range cfg.RuleExclusions { + id = strings.TrimSpace(id) + if id != "" { + sb.WriteString(fmt.Sprintf("SecRuleRemoveById %s\n", id)) + } + } + + // Trusted proxies: tell Coraza to trust X-Forwarded-For from these IPs. + for _, ip := range cfg.TrustedProxies { + ip = strings.TrimSpace(ip) + if ip != "" { + sb.WriteString(fmt.Sprintf("SecRemoteRulesFailAction Abort\n")) + _ = ip // used in custom rules below if needed + } + } + + // Custom rules (appended last so they can override CRS). + if strings.TrimSpace(cfg.CustomRules) != "" { + sb.WriteString(cfg.CustomRules) + sb.WriteString("\n") + } + + return sb.String() +} + +func ruleEngineMode(mode string) string { + switch mode { + case "blocking": + return "On" + default: // "detection" + return "DetectionOnly" + } +} + +// crsAvailable returns true when the CRS rules directory exists and +// contains at least one .conf file. +func crsAvailable(crsDir string) bool { + rulesDir := filepath.Join(crsDir, "rules") + entries, err := os.ReadDir(rulesDir) + if err != nil { + return false + } + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".conf") { + return true + } + } + return false +} diff --git a/internal/waf/manager.go b/internal/waf/manager.go new file mode 100644 index 0000000..f35eddf --- /dev/null +++ b/internal/waf/manager.go @@ -0,0 +1,121 @@ +package waf + +import ( + "fmt" + "log/slog" + "sync" + + "github.com/corazawaf/coraza/v3" + + "git.netcell-it.de/projekte/edgeguard-native/internal/models" +) + +// DomainEngine bundles a Coraza WAF with its operating mode. +type DomainEngine struct { + WAF coraza.WAF + Mode string // "detection" | "blocking" +} + +// Manager holds per-domain Coraza engine instances. Engines are +// created lazily on first Reload() and cached until the next reload. +// All public methods are safe for concurrent use. +type Manager struct { + mu sync.RWMutex + engines map[string]*DomainEngine // hostname → engine (nil entry = disabled) + crsDir string +} + +// NewManager creates an empty Manager with the given CRS directory. +func NewManager(crsDir string) *Manager { + if crsDir == "" { + crsDir = DefaultCRSDir + } + return &Manager{ + engines: make(map[string]*DomainEngine), + crsDir: crsDir, + } +} + +// DomainConfig pairs a domain hostname with its WAF policy. +type DomainConfig struct { + Hostname string + Config models.WafConfig +} + +// Reload rebuilds all engine instances from the given list. Domains +// that are disabled get a nil entry so GetForHost returns quickly +// without looking up a missing key. +func (m *Manager) Reload(domains []DomainConfig) error { + engines := make(map[string]*DomainEngine, len(domains)) + for _, dc := range domains { + if !dc.Config.Enabled { + engines[dc.Hostname] = nil + continue + } + waf, err := BuildEngine(dc.Config, m.crsDir) + if err != nil { + return fmt.Errorf("waf: build engine for %s: %w", dc.Hostname, err) + } + engines[dc.Hostname] = &DomainEngine{WAF: waf, Mode: dc.Config.Mode} + slog.Info("waf: engine loaded", + "host", dc.Hostname, + "mode", dc.Config.Mode, + "paranoia_level", dc.Config.ParanoiaLevel, + "crs", crsAvailable(m.crsDir), + ) + } + m.mu.Lock() + m.engines = engines + m.mu.Unlock() + return nil +} + +// GetForHost returns the DomainEngine for the given hostname, or +// (nil, false) when the domain has no WAF or WAF is disabled. +func (m *Manager) GetForHost(host string) (*DomainEngine, bool) { + // Strip port if present (e.g. "example.com:443" → "example.com"). + if i := lastColon(host); i >= 0 { + host = host[:i] + } + m.mu.RLock() + de, ok := m.engines[host] + m.mu.RUnlock() + if !ok || de == nil { + return nil, false + } + return de, true +} + +// lastColon returns the index of the last ':' in s that looks like a +// port separator (after the final ']' for IPv6), or -1. +func lastColon(s string) int { + // IPv6 addresses in brackets: "[::1]:443" + if len(s) > 0 && s[0] == '[' { + if rb := lastByte(s, ']'); rb >= 0 && rb < len(s)-1 && s[rb+1] == ':' { + return rb + 1 + } + return -1 + } + // Plain host — only strip port if there's exactly one colon. + count := 0 + idx := -1 + for i, c := range s { + if c == ':' { + count++ + idx = i + } + } + if count == 1 { + return idx + } + return -1 +} + +func lastByte(s string, b byte) int { + for i := len(s) - 1; i >= 0; i-- { + if s[i] == b { + return i + } + } + return -1 +} diff --git a/internal/waf/spoe.go b/internal/waf/spoe.go new file mode 100644 index 0000000..a4d0bd8 --- /dev/null +++ b/internal/waf/spoe.go @@ -0,0 +1,161 @@ +package waf + +import ( + "context" + "log/slog" + "net/http" + "strings" + + "github.com/dropmorepackets/haproxy-go/pkg/encoding" + "github.com/dropmorepackets/haproxy-go/spop" +) + +// SPOEAgent wraps the haproxy-go SPOE server and dispatches each +// inspected request to the appropriate per-domain Coraza engine. +type SPOEAgent struct { + Manager *Manager + Addr string +} + +// ListenAndServe starts the SPOE agent. Blocks until ctx is cancelled. +func (a *SPOEAgent) ListenAndServe(ctx context.Context) error { + agent := spop.Agent{ + Addr: a.Addr, + Handler: spop.HandlerFunc(a.handle), + BaseContext: ctx, + } + return agent.ListenAndServe() +} + +// handle is called by the haproxy-go SPOE library for every NOTIFY +// frame HAProxy sends. It extracts the request data, runs Coraza, +// and optionally sets a txn.waf.status variable to trigger a deny ACL. +func (a *SPOEAgent) handle(ctx context.Context, w *encoding.ActionWriter, m *encoding.Message) { + var ( + clientIP string + method string + path string + query string + httpVer string + host string + rawHdrs string + ) + + // Iterate over the key-value pairs HAProxy sent with this message. + entry := encoding.AcquireKVEntry() + defer encoding.ReleaseKVEntry(entry) + for m.KV.Next(entry) { + switch { + case entry.NameEquals("src"): + addr := entry.ValueAddr() + if addr.IsValid() { + clientIP = addr.String() + } + case entry.NameEquals("method"): + method = string(entry.ValueBytes()) + case entry.NameEquals("path"): + path = string(entry.ValueBytes()) + case entry.NameEquals("query"): + query = string(entry.ValueBytes()) + case entry.NameEquals("ver"): + httpVer = string(entry.ValueBytes()) + case entry.NameEquals("host"): + host = string(entry.ValueBytes()) + case entry.NameEquals("headers"): + rawHdrs = string(entry.ValueBytes()) + } + entry.Reset() + } + + if host == "" { + return + } + + de, ok := a.Manager.GetForHost(host) + if !ok { + return // WAF not configured or disabled for this domain + } + + tx := de.WAF.NewTransaction() + defer func() { + tx.ProcessLogging() + if err := tx.Close(); err != nil { + slog.Warn("waf: tx.Close", "error", err) + } + }() + + // Feed connection metadata. + if clientIP != "" { + tx.ProcessConnection(clientIP, 0, "", 0) + } + + // Build full URI. + uri := path + if query != "" { + uri += "?" + query + } + if httpVer == "" { + httpVer = "HTTP/1.1" + } + tx.ProcessURI(uri, method, httpVer) + + // Feed Host header first (required by many CRS rules). + tx.AddRequestHeader("Host", host) + + // Parse and feed all raw headers. + parseHeaders(rawHdrs, func(name, val string) { + if !strings.EqualFold(name, "host") { // already added above + tx.AddRequestHeader(name, val) + } + }) + + // Evaluate request headers. + interruption := tx.ProcessRequestHeaders() + if interruption != nil { + status := interruption.Status + if status == 0 { + status = http.StatusForbidden + } + slog.Info("waf: request blocked", + "host", host, + "method", method, + "uri", uri, + "client", clientIP, + "status", status, + "mode", de.Mode, + ) + if de.Mode == "blocking" { + if err := w.SetInt64(encoding.VarScopeTransaction, "status", int64(status)); err != nil { + slog.Warn("waf: SetInt64 status", "error", err) + } + } + return + } + + // Alert-only log for detection mode. + if tx.IsInterrupted() && de.Mode != "blocking" { + slog.Info("waf: request flagged (detection)", + "host", host, "method", method, "uri", uri, "client", clientIP, + ) + } +} + +// parseHeaders splits HAProxy raw headers ("Name: value\r\n…") and +// calls fn for each valid header line. +func parseHeaders(raw string, fn func(name, val string)) { + for _, line := range strings.Split(raw, "\n") { + line = strings.TrimRight(line, "\r") + if line == "" { + continue + } + idx := strings.IndexByte(line, ':') + if idx <= 0 { + continue + } + name := strings.TrimSpace(line[:idx]) + val := strings.TrimSpace(line[idx+1:]) + if name != "" { + fn(name, val) + } + } +}