diff --git a/.github/images/accessControl.png b/.github/images/accessControl.png deleted file mode 100644 index e815dd8..0000000 Binary files a/.github/images/accessControl.png and /dev/null differ diff --git a/.github/images/aiDetection.png b/.github/images/aiDetection.png deleted file mode 100644 index 81eb510..0000000 Binary files a/.github/images/aiDetection.png and /dev/null differ diff --git a/.github/images/architecture.png b/.github/images/architecture.png new file mode 100644 index 0000000..c92bbfa Binary files /dev/null and b/.github/images/architecture.png differ diff --git a/.github/images/architecture.svg b/.github/images/architecture.svg new file mode 100644 index 0000000..69f5e04 --- /dev/null +++ b/.github/images/architecture.svg @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + NetGuardia architecture + + CONTROL PLANE + + Web UI + REST / WebSocket API + + USER SPACE — DETECTION & RESPONSE + + ML Inference + tract-onnx · BYO model + + Beaconing + temporal CV + + Correlation + graph topology + + Suricata + eve.json ingest + + Detection Orchestrator + • canonicalize attack type + • fuse: 1 − ∏(1 − c_i) + • WORM audit (SHA-256 chained) + + SOAR Engine + • playbook match + • cooldown + dry-run + • actions: block / rate-limit / webhook / email / telegram + + KERNEL SPACE — eBPF / XDP + + ACL + + Rate Limit + + Protocol Filter + + Geo Block + + DNS Filter + + + + + + + + admin & live events + + + + + + + ThreatDetectedEvent + + + enforce (block / rate-limit) + + + AF_XDP mirror + + detection events + + fused event / kernel pipeline + + enforcement action + + AF_XDP mirror + + admin / control + \ No newline at end of file diff --git a/.github/images/dashboard.png b/.github/images/dashboard.png deleted file mode 100644 index 30569c7..0000000 Binary files a/.github/images/dashboard.png and /dev/null differ diff --git a/.github/images/index.png b/.github/images/index.png deleted file mode 100644 index 316c4bd..0000000 Binary files a/.github/images/index.png and /dev/null differ diff --git a/.github/images/map.png b/.github/images/map.png deleted file mode 100644 index 6070b8d..0000000 Binary files a/.github/images/map.png and /dev/null differ diff --git a/.github/images/statistics.png b/.github/images/statistics.png deleted file mode 100644 index 0ac5f6c..0000000 Binary files a/.github/images/statistics.png and /dev/null differ diff --git a/.github/images/ui/access-control.png b/.github/images/ui/access-control.png new file mode 100644 index 0000000..0a0ed01 Binary files /dev/null and b/.github/images/ui/access-control.png differ diff --git a/.github/images/ui/account-management.png b/.github/images/ui/account-management.png new file mode 100644 index 0000000..3519dd5 Binary files /dev/null and b/.github/images/ui/account-management.png differ diff --git a/.github/images/ui/api-keys.png b/.github/images/ui/api-keys.png new file mode 100644 index 0000000..af263da Binary files /dev/null and b/.github/images/ui/api-keys.png differ diff --git a/.github/images/ui/audit-log.png b/.github/images/ui/audit-log.png new file mode 100644 index 0000000..d1f68eb Binary files /dev/null and b/.github/images/ui/audit-log.png differ diff --git a/.github/images/ui/auto-response.png b/.github/images/ui/auto-response.png new file mode 100644 index 0000000..ae43f26 Binary files /dev/null and b/.github/images/ui/auto-response.png differ diff --git a/.github/images/ui/detection.png b/.github/images/ui/detection.png new file mode 100644 index 0000000..35f41e1 Binary files /dev/null and b/.github/images/ui/detection.png differ diff --git a/.github/images/ui/dns-filter.png b/.github/images/ui/dns-filter.png new file mode 100644 index 0000000..ff9ea55 Binary files /dev/null and b/.github/images/ui/dns-filter.png differ diff --git a/.github/images/ui/drop-monitor.png b/.github/images/ui/drop-monitor.png new file mode 100644 index 0000000..43c4c6c Binary files /dev/null and b/.github/images/ui/drop-monitor.png differ diff --git a/.github/images/ui/flow-trace.png b/.github/images/ui/flow-trace.png new file mode 100644 index 0000000..057fad1 Binary files /dev/null and b/.github/images/ui/flow-trace.png differ diff --git a/.github/images/ui/geoip-block.png b/.github/images/ui/geoip-block.png new file mode 100644 index 0000000..7351fa5 Binary files /dev/null and b/.github/images/ui/geoip-block.png differ diff --git a/.github/images/ui/logs.png b/.github/images/ui/logs.png new file mode 100644 index 0000000..a35a3d1 Binary files /dev/null and b/.github/images/ui/logs.png differ diff --git a/.github/images/ui/map.png b/.github/images/ui/map.png new file mode 100644 index 0000000..f3c2759 Binary files /dev/null and b/.github/images/ui/map.png differ diff --git a/.github/images/ui/protocol-filter.png b/.github/images/ui/protocol-filter.png new file mode 100644 index 0000000..ee2db1f Binary files /dev/null and b/.github/images/ui/protocol-filter.png differ diff --git a/.github/images/ui/rate-limit.png b/.github/images/ui/rate-limit.png new file mode 100644 index 0000000..666d0cf Binary files /dev/null and b/.github/images/ui/rate-limit.png differ diff --git a/.github/images/ui/security-report.png b/.github/images/ui/security-report.png new file mode 100644 index 0000000..f2db7a7 Binary files /dev/null and b/.github/images/ui/security-report.png differ diff --git a/.github/images/ui/statistics.png b/.github/images/ui/statistics.png new file mode 100644 index 0000000..6aef05d Binary files /dev/null and b/.github/images/ui/statistics.png differ diff --git a/.github/images/ui/system-settings.png b/.github/images/ui/system-settings.png new file mode 100644 index 0000000..5a0d103 Binary files /dev/null and b/.github/images/ui/system-settings.png differ diff --git a/.github/images/ui/system-status.png b/.github/images/ui/system-status.png new file mode 100644 index 0000000..29847e5 Binary files /dev/null and b/.github/images/ui/system-status.png differ diff --git a/.gitignore b/.gitignore index 342a607..306a301 100644 --- a/.gitignore +++ b/.gitignore @@ -41,7 +41,9 @@ interfaces.txt traffic_log.csv # Project docs (local only) -CLAUDE.md +# CLAUDE.md — tracked on dev branches; MUST be untracked before PR to master +# (see CLAUDE.md "Branch discipline" section) +# CLAUDE.md DESIGN.md TODOS.md VERSION @@ -51,7 +53,9 @@ CHANGELOG.md benchmark/ # Generated docs -docs/ +# docs/ — tracked on dev branches; MUST be untracked before PR to master +# (see CLAUDE.md "Branch discipline" section) +# docs/ # SQLite database files *.db diff --git a/Cargo.lock b/Cargo.lock index ebee79c..cc41b96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,7 +11,7 @@ dependencies = [ "actix-macros", "actix-rt", "actix_derive", - "bitflags", + "bitflags 2.11.0", "bytes", "crossbeam-channel", "futures-core", @@ -33,7 +33,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" dependencies = [ - "bitflags", + "bitflags 2.11.0", "bytes", "futures-core", "futures-sink", @@ -52,13 +52,36 @@ checksum = "daa239b93927be1ff123eebada5a3ff23e89f0124ccb8609234e5103d5a5ae6d" dependencies = [ "actix-utils", "actix-web", - "derive_more", + "derive_more 2.1.1", "futures-util", "log", "once_cell", "smallvec", ] +[[package]] +name = "actix-files" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8c4f30e3272d7c345f88ae0aac3848507ef5ba871f9cc2a41c8085a0f0523b" +dependencies = [ + "actix-http", + "actix-service", + "actix-utils", + "actix-web", + "bitflags 2.11.0", + "bytes", + "derive_more 2.1.1", + "futures-core", + "http-range", + "log", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "v_htmlescape", +] + [[package]] name = "actix-http" version = "3.12.0" @@ -70,11 +93,11 @@ dependencies = [ "actix-service", "actix-utils", "base64", - "bitflags", + "bitflags 2.11.0", "brotli", "bytes", "bytestring", - "derive_more", + "derive_more 2.1.1", "encoding_rs", "flate2", "foldhash 0.1.5", @@ -108,6 +131,44 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "actix-multipart" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5118a26dee7e34e894f7e85aa0ee5080ae4c18bf03c0e30d49a80e418f00a53" +dependencies = [ + "actix-multipart-derive", + "actix-utils", + "actix-web", + "derive_more 0.99.20", + "futures-core", + "futures-util", + "httparse", + "local-waker", + "log", + "memchr", + "mime", + "rand 0.8.5", + "serde", + "serde_json", + "serde_plain", + "tempfile", + "tokio", +] + +[[package]] +name = "actix-multipart-derive" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11eb847f49a700678ea2fa73daeb3208061afa2b9d1a8527c03390f4c4a1c6b" +dependencies = [ + "darling", + "parse-size", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "actix-router" version = "0.5.4" @@ -189,7 +250,7 @@ dependencies = [ "bytestring", "cfg-if", "cookie", - "derive_more", + "derive_more 2.1.1", "encoding_rs", "foldhash 0.1.5", "futures-core", @@ -413,12 +474,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "170433209e817da6aae2c51aa0dd443009a613425dd041ebfb2492d1c4c11a25" [[package]] -name = "ar_archive_writer" -version = "0.5.1" +name = "arc-swap" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" dependencies = [ - "object 0.37.3", + "rustversion", ] [[package]] @@ -470,11 +531,11 @@ checksum = "d18bc4e506fbb85ab7392ed993a7db4d1a452c71b75a246af4a80ab8c9d2dd50" dependencies = [ "assert_matches", "aya-obj", - "bitflags", + "bitflags 2.11.0", "bytes", "libc", "log", - "object 0.36.7", + "object", "once_cell", "thiserror 1.0.69", "tokio", @@ -585,7 +646,7 @@ dependencies = [ "core-error", "hashbrown 0.15.5", "log", - "object 0.36.7", + "object", "thiserror 1.0.69", ] @@ -607,7 +668,7 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags", + "bitflags 2.11.0", "cexpr", "clang-sys", "itertools 0.13.0", @@ -636,6 +697,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.0" @@ -743,9 +810,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.57" +version = "1.2.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" dependencies = [ "find-msvc-tools", "jobserver", @@ -796,16 +863,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "chumsky" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9" -dependencies = [ - "hashbrown 0.14.5", - "stacker", -] - [[package]] name = "cipher" version = "0.4.4" @@ -883,6 +940,12 @@ dependencies = [ "serde", ] +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + [[package]] name = "convert_case" version = "0.10.0" @@ -1027,6 +1090,41 @@ dependencies = [ "cipher", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + [[package]] name = "dashmap" version = "6.1.0" @@ -1067,6 +1165,19 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case 0.4.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -1082,7 +1193,7 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ - "convert_case", + "convert_case 0.10.0", "proc-macro2", "quote", "rustc_version", @@ -1191,7 +1302,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1208,9 +1319,9 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "filetime" @@ -1266,6 +1377,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -1431,7 +1551,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ "ahash", - "allocator-api2", ] [[package]] @@ -1456,6 +1575,12 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + [[package]] name = "hashlink" version = "0.10.0" @@ -1544,6 +1669,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" + [[package]] name = "httparse" version = "1.10.1" @@ -1558,9 +1689,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", @@ -1571,7 +1702,6 @@ dependencies = [ "httparse", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -1611,7 +1741,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2 0.5.10", "tokio", "tower-service", "tracing", @@ -1643,12 +1773,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -1656,9 +1787,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -1669,9 +1800,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1683,15 +1814,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -1703,15 +1834,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -1728,6 +1859,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -1757,12 +1894,12 @@ checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.0", "serde", "serde_core", ] @@ -1778,6 +1915,26 @@ dependencies = [ "which", ] +[[package]] +name = "inotify" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "inout" version = "0.1.4" @@ -1787,6 +1944,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1801,9 +1967,9 @@ checksum = "cf370abdafd54d13e54a620e8c3e1145f28e46cc9d704bc6d94414559df41763" [[package]] name = "iri-string" -version = "0.7.10" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" dependencies = [ "memchr", "serde", @@ -1853,9 +2019,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jobserver" @@ -1869,10 +2035,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.91" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -1892,6 +2060,26 @@ dependencies = [ "simple_asn1", ] +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + [[package]] name = "kstring" version = "2.0.2" @@ -1922,13 +2110,12 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lettre" -version = "0.11.19" +version = "0.11.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e13e10e8818f8b2a60f52cb127041d388b89f3a96a62be9ceaffa22262fef7f" +checksum = "dabda5859ee7c06b995b9d1165aa52c39110e079ef609db97178d86aeb051fa7" dependencies = [ "async-trait", "base64", - "chumsky", "email-encoding", "email_address", "fastrand", @@ -1984,14 +2171,14 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.14" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ - "bitflags", + "bitflags 2.11.0", "libc", "plain", - "redox_syscall 0.7.3", + "redox_syscall 0.7.4", ] [[package]] @@ -2078,9 +2265,9 @@ dependencies = [ [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "local-channel" @@ -2247,9 +2434,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "log", @@ -2257,6 +2444,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + [[package]] name = "ndarray" version = "0.16.1" @@ -2278,9 +2482,12 @@ version = "0.1.0" dependencies = [ "actix", "actix-cors", + "actix-files", + "actix-multipart", "actix-web", "actix-ws", "aes-gcm", + "arc-swap", "argon2", "async-trait", "aya", @@ -2294,6 +2501,7 @@ dependencies = [ "dotenvy", "futures-util", "hkdf", + "hmac", "ipnetwork", "jsonwebtoken", "lettre", @@ -2303,7 +2511,9 @@ dependencies = [ "macros", "maxminddb", "mime_guess", + "moka", "network-types", + "notify", "parking_lot", "r2d2", "r2d2_sqlite", @@ -2314,6 +2524,7 @@ dependencies = [ "sd-notify", "serde", "serde_json", + "serde_yaml_ng", "sha2", "sysinfo", "thiserror 2.0.18", @@ -2325,6 +2536,7 @@ dependencies = [ "tracing-subscriber", "tract-onnx", "url", + "uuid", "which", "xsk-rs", ] @@ -2356,7 +2568,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags", + "bitflags 2.11.0", "cfg-if", "cfg_aliases", "libc", @@ -2390,6 +2602,34 @@ dependencies = [ "nom 8.0.0", ] +[[package]] +name = "notify" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009" +dependencies = [ + "bitflags 2.11.0", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.52.0", +] + +[[package]] +name = "notify-types" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174" +dependencies = [ + "instant", +] + [[package]] name = "ntapi" version = "0.4.3" @@ -2429,9 +2669,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-integer" @@ -2479,7 +2719,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags", + "bitflags 2.11.0", ] [[package]] @@ -2504,15 +2744,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "object" -version = "0.37.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" -dependencies = [ - "memchr", -] - [[package]] name = "once_cell" version = "1.21.4" @@ -2554,6 +2785,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "parse-size" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "487f2ccd1e17ce8c1bfab3a65c89525af41cfad4c8659021a1e9a2aacd73b89b" + [[package]] name = "password-hash" version = "0.5.0" @@ -2636,12 +2873,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - [[package]] name = "pkg-config" version = "0.3.32" @@ -2683,9 +2914,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -2768,16 +2999,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "psm" -version = "0.1.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3852766467df634d74f0b2d7819bf8dc483a0eb2e3b0f50f756f9cfe8b0d18d8" -dependencies = [ - "ar_archive_writer", - "cc", -] - [[package]] name = "quinn" version = "0.11.9" @@ -2791,7 +3012,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.3", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -2828,9 +3049,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.3", + "socket2 0.5.10", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -2844,9 +3065,9 @@ dependencies = [ [[package]] name = "quoted_printable" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73" +checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" [[package]] name = "r-efi" @@ -2980,16 +3201,16 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.0", ] [[package]] name = "redox_syscall" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" dependencies = [ - "bitflags", + "bitflags 2.11.0", ] [[package]] @@ -3085,7 +3306,7 @@ version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37e34486da88d8e051c7c0e23c3f15fd806ea8546260aa2fec247e97242ec143" dependencies = [ - "bitflags", + "bitflags 2.11.0", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -3129,9 +3350,9 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" [[package]] name = "rustc_version" @@ -3162,11 +3383,11 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3271,9 +3492,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" dependencies = [ "serde", "serde_core", @@ -3323,10 +3544,19 @@ dependencies = [ ] [[package]] -name = "serde_spanned" -version = "1.0.4" +name = "serde_plain" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ "serde_core", ] @@ -3343,6 +3573,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sha1" version = "0.10.6" @@ -3392,9 +3635,9 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "simple_asn1" @@ -3446,19 +3689,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" -[[package]] -name = "stacker" -version = "0.1.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d74a23609d509411d10e2176dc2a4346e3b4aea2e7b1869f19fdedbc71c013" -dependencies = [ - "cc", - "cfg-if", - "libc", - "psm", - "windows-sys 0.59.0", -] - [[package]] name = "static_assertions" version = "1.1.0" @@ -3551,16 +3781,35 @@ dependencies = [ ] [[package]] -name = "tar" -version = "0.4.44" +name = "tagptr" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "tar" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" dependencies = [ "filetime", "libc", "xattr", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.52.0", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -3643,9 +3892,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -3732,9 +3981,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.0.7+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd28d57d8a6f6e458bc0b8784f8fdcc4b99a437936056fa122cb234f18656a96" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ "indexmap", "serde_core", @@ -3747,27 +3996,27 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.0.1+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] [[package]] name = "toml_parser" -version = "1.0.10+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ "winnow", ] [[package]] name = "toml_writer" -version = "1.0.7+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17aaa1c6e3dc22b1da4b6bba97d066e354c7945cac2f7852d4e4e7ca7a6b56d" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" [[package]] name = "tower" @@ -3790,7 +4039,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags", + "bitflags 2.11.0", "bytes", "futures-util", "http 1.4.0", @@ -4099,9 +4348,9 @@ dependencies = [ [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-xid" @@ -4119,6 +4368,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" @@ -4167,6 +4422,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "v_htmlescape" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c" + [[package]] name = "valuable" version = "0.1.1" @@ -4230,9 +4491,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.114" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" dependencies = [ "cfg-if", "once_cell", @@ -4243,23 +4504,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.64" +version = "0.4.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" dependencies = [ - "cfg-if", - "futures-util", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.114" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4267,9 +4524,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.114" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" dependencies = [ "bumpalo", "proc-macro2", @@ -4280,9 +4537,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.114" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" dependencies = [ "unicode-ident", ] @@ -4315,7 +4572,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.0", "hashbrown 0.15.5", "indexmap", "semver", @@ -4323,9 +4580,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.91" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" dependencies = [ "js-sys", "wasm-bindgen", @@ -4381,7 +4638,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4500,15 +4757,6 @@ dependencies = [ "windows-targets", ] -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -4593,9 +4841,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" [[package]] name = "wit-bindgen" @@ -4655,7 +4903,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.0", "indexmap", "log", "serde", @@ -4687,9 +4935,9 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "xattr" @@ -4707,7 +4955,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d1fef46e3505c5055082f52ada0a7f8e5dcaebdbb9eccf8e978c32382c159270" dependencies = [ - "bitflags", + "bitflags 2.11.0", "cfg-if", "libc", "libxdp-sys", @@ -4716,9 +4964,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -4727,9 +4975,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", @@ -4739,18 +4987,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.42" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.42" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", @@ -4759,18 +5007,18 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", @@ -4786,9 +5034,9 @@ checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -4797,9 +5045,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -4808,9 +5056,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 23f61fb..3b12232 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,9 +20,10 @@ libxdp-sys = { version = "0.2.4", features = ["use_cc_build", "use_precompiled_b # Serialization serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" +serde_yaml_ng = "0.10.0" # Async runtime -tokio = { version = "1.50.0", features = ["rt-multi-thread", "macros", "sync", "time"] } +tokio = { version = "1.50.0", features = ["rt-multi-thread", "macros", "sync", "time", "process", "io-util", "fs", "signal"] } # Web framework actix = "0.13.5" diff --git a/README.md b/README.md index 7b47c7d..8420fbc 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,76 @@ # NetGuardia -## Project Overview +Inline network security platform built on eBPF/XDP. Runs four independent detectors (per-packet ML, temporal beaconing, graph correlation, Suricata) over the same data plane, fuses their verdicts, drives SOAR playbooks, and writes every decision into a WORM audit chain. -**NetGuardia** is a high-performance network security solution that combines eBPF XDP technology with deep learning models to provide advanced network protection. The system operates as a standalone network appliance that can run on any Ubuntu-based system with compatible network hardware. +## Stack -## Core Technologies +- **Data plane** — eBPF / XDP / AF_XDP (aya, xsk-rs) +- **Detection** — Rust + tract-onnx for ML, custom temporal / graph engines, Suricata `eve.json` ingest +- **Control plane** — actix-web REST + WebSocket, SQLite + SQLCipher, argon2 / JWT / CSRF, per-playbook SOAR +- **Frontend** — Vue 3 + Pinia + Vue-i18n (en / zh-TW / zh-CN / ja) +- **Architecture** — hexagonal: `adapter/` · `core/` · `infrastructure/` · `interface/` · `model/` -- **eBPF XDP Technology** - Provides high-performance packet processing directly at the data link layer -- **Deep Learning Models** - Identifies and predicts potential network attacks with intelligent threat detection -- **Hardware Integration** - Designed to work with Intel i350 T2 and similar enterprise-grade network interface cards +## Screens -## Functional Modules + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Traffic statistics
Traffic statistics (per-IP bytes/packets)
Geo map
Live geographic flow map
Drop monitor
Real-time drop monitor
Threat detection
Fused threat detection + ML status
Access control
IPv4/IPv6 allow + block lists
GeoIP block
GeoIP country block
DNS filter
DNS blacklist
Rate limit
Per-class DDoS rate limits
Protocol filter
HTTP / SSH service rules
SOAR
SOAR playbooks + dry-run
Security report
Security report (PDF / email)
Audit log
WORM-chained audit log
Accounts
Users + groups + RBAC
API keys
API keys
Flow trace
Rotated flow recording
Logs
Live + archived logs
System status
CPU / memory / NIC counters
System settings
Mode / theme / HTTP / engine
-### Resource Overview -![Home](.github/images/index.png) -- Real-time control system occupancy rate +## Architecture -### Dashboard Overview -![Dashboard](.github/images/dashboard.png) -- Real-time network traffic monitoring and visualization -- Recent traffic statistics and trend analysis +![NetGuardia architecture](.github/images/architecture.png) -### Detailed Traffic Statistics -![Statistics](.github/images/statistics.png) -- Detailed traffic usage information per IP address +## Requirements -### Network Access Control -![accessControl](.github/images/accessControl.png) -- IPv4/IPv6 whitelist and blacklist management -- Precise port-level access control +Linux kernel with eBPF **and** a NIC driver that implements AF_XDP on that kernel. No single "minimum kernel" — it depends on the NIC. -[//]: # (### AI Attack Detection) +| Driver | NIC family | Min kernel for AF_XDP | +|---|---|---| +| `mlx5` | Mellanox ConnectX-4/5/6/7 | 5.x | +| `ixgbe` | Intel 82599, X520, X540, X550 | 5.x | +| `i40e` | Intel X710, XL710, XXV710 | 5.x | +| `ice` | Intel E810 | 5.5+ | +| `igb` | Intel i350 T2 (reference HW) | **6.17** | +| `igc` | Intel I225/I226 | 6.x | +| `virtio_net` | QEMU/KVM | varies | -[//]: # (![AI 攻擊偵測介面](.github/images/aiDetection.png)) +Check with `ethtool -i ` before deploying. 8 GB RAM minimum, 16 GB+ for high-traffic. -[//]: # (- AI-based attack detection engine) +## Build -## System Features - -- **High Performance** - Low-latency packet processing with minimal network performance impact -- **User-Friendly** - Cross-platform web management interface with intuitive operation -- **Reliability** - Hardware-accelerated processing ensures stable operation -- **Scalability** - Modular design supports functional expansion - -## System Requirements - -- Ubuntu-based operating system (Ubuntu 24.04 LTS or newer recommended) -- Dual-port network interface card (Intel i350 T2 or compatible XDP-capable NIC) -- Root/sudo access for eBPF program loading - -## Hardware Compatibility -NetGuardia is designed to work on any Ubuntu-based system meeting the following requirements: - -- Network Interface: Any dual-port NIC supporting XDP native or offload mode (Intel i350 T2 recommended) -- CPU: Multi-core processor recommended for optimal performance -- Memory: 8GB RAM minimum, 16GB or more for high-traffic environments - -The system is not limited to embedded platforms and can be deployed on standard server hardware, virtual machines, or dedicated appliances running Ubuntu. +```sh +cargo build --release --package net-guardia +sudo ./target/release/net-guardia +# open http://:8080 — setup wizard issues the admin password on first boot +``` +Systemd unit: [`deploy/netguardia.service`](deploy/netguardia.service). diff --git a/license-generator/Cargo.toml b/license-generator/Cargo.toml deleted file mode 100644 index e4f16d8..0000000 --- a/license-generator/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[package] -name = "license-generator" -version = "0.1.0" -edition = "2024" - -[dependencies] -ed25519-dalek = { version = "2", features = ["std", "rand_core"] } -base64 = "0.22" -serde = { version = "1", features = ["derive"] } -serde_json = "1" -rand = "0.9" -clap = { version = "4", features = ["derive"] } -pnet = "0.36" diff --git a/license-generator/src/main.rs b/license-generator/src/main.rs deleted file mode 100644 index b65ff49..0000000 --- a/license-generator/src/main.rs +++ /dev/null @@ -1,217 +0,0 @@ -use std::fs; - -use base64::Engine; -use base64::engine::general_purpose::STANDARD as BASE64; -use clap::{Parser, Subcommand}; -use ed25519_dalek::{Signer, SigningKey, Verifier, VerifyingKey, Signature}; -use pnet::datalink; -use rand::rngs::OsRng; -use serde::{Deserialize, Serialize}; - -#[derive(Parser)] -#[command(name = "license-generator", about = "NetGuardia license generator")] -struct Cli { - #[command(subcommand)] - command: Commands, -} - -#[derive(Subcommand)] -enum Commands { - /// Generate a new Ed25519 keypair - Keygen { - #[arg(short, long, default_value = "license")] - prefix: String, - }, - /// Issue a signed license bound to NIC MACs - Issue { - #[arg(short = 'k', long)] - private_key: String, - /// Ingress interface name (e.g. ng-ext) - #[arg(long)] - ingress: String, - /// Egress interface name (e.g. ng-int) - #[arg(long)] - egress: String, - /// Expiry date (YYYY-MM-DD) - #[arg(short, long)] - expires: String, - /// Comma-separated list of features - #[arg(short, long, default_value = "")] - features: String, - /// Output license file path - #[arg(short, long, default_value = "license.key")] - output: String, - }, - /// Verify a license file - Verify { - #[arg(short = 'k', long)] - public_key: String, - #[arg(short, long)] - license: String, - }, -} - -#[derive(Serialize, Deserialize, Debug)] -struct LicensePayload { - ingress_mac: String, - egress_mac: String, - expires: String, - features: Vec, -} - -fn get_mac(ifname: &str) -> String { - for iface in datalink::interfaces() { - if iface.name == ifname { - if let Some(mac) = iface.mac { - return format!( - "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", - mac.0, mac.1, mac.2, mac.3, mac.4, mac.5 - ); - } - } - } - eprintln!("Interface '{}' not found or has no MAC address", ifname); - eprintln!("Available interfaces:"); - for iface in datalink::interfaces() { - if let Some(mac) = iface.mac { - eprintln!(" {} — {:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", - iface.name, mac.0, mac.1, mac.2, mac.3, mac.4, mac.5); - } - } - std::process::exit(1); -} - -fn main() { - let cli = Cli::parse(); - - match cli.command { - Commands::Keygen { prefix } => keygen(&prefix), - Commands::Issue { private_key, ingress, egress, expires, features, output } => { - issue(&private_key, &ingress, &egress, &expires, &features, &output) - } - Commands::Verify { public_key, license } => verify(&public_key, &license), - } -} - -fn keygen(prefix: &str) { - let mut csprng = OsRng; - let signing_key = SigningKey::generate(&mut csprng); - let verifying_key = signing_key.verifying_key(); - - let priv_hex = hex_encode(signing_key.as_bytes()); - let pub_hex = hex_encode(verifying_key.as_bytes()); - - let priv_path = format!("{}_priv.key", prefix); - let pub_path = format!("{}_pub.key", prefix); - - fs::write(&priv_path, &priv_hex).expect("Failed to write private key"); - fs::write(&pub_path, &pub_hex).expect("Failed to write public key"); - - println!("Keypair generated:"); - println!(" Private key: {}", priv_path); - println!(" Public key: {}", pub_path); - println!(); - println!("Public key hex (embed in validator.rs):"); - println!(" {}", pub_hex); -} - -fn issue(private_key_path: &str, ingress: &str, egress: &str, expires: &str, features: &str, output: &str) { - let ingress_mac = get_mac(ingress); - let egress_mac = get_mac(egress); - - println!("Detected MACs:"); - println!(" {} — {}", ingress, ingress_mac); - println!(" {} — {}", egress, egress_mac); - - let priv_hex = fs::read_to_string(private_key_path) - .expect("Failed to read private key") - .trim() - .to_string(); - let priv_bytes = hex_decode(&priv_hex).expect("Invalid hex"); - let priv_array: [u8; 32] = priv_bytes.try_into().expect("Key must be 32 bytes"); - let signing_key = SigningKey::from_bytes(&priv_array); - - let feature_list: Vec = if features.is_empty() { - vec![] - } else { - features.split(',').map(|s| s.trim().to_string()).collect() - }; - - let payload = LicensePayload { - ingress_mac: ingress_mac.clone(), - egress_mac: egress_mac.clone(), - expires: expires.to_string(), - features: feature_list, - }; - - let payload_json = serde_json::to_string(&payload).expect("Failed to serialize"); - let payload_b64 = BASE64.encode(payload_json.as_bytes()); - let signature: Signature = signing_key.sign(payload_b64.as_bytes()); - let sig_b64 = BASE64.encode(signature.to_bytes()); - - let license_content = format!("{}.{}", payload_b64, sig_b64); - fs::write(output, &license_content).expect("Failed to write license"); - - println!(); - println!("License issued:"); - println!(" Ingress MAC: {}", ingress_mac); - println!(" Egress MAC: {}", egress_mac); - println!(" Expires: {}", expires); - println!(" Features: {:?}", payload.features); - println!(" Output: {}", output); -} - -fn verify(public_key_path: &str, license_path: &str) { - let pub_hex = fs::read_to_string(public_key_path) - .expect("Failed to read public key") - .trim() - .to_string(); - let pub_bytes = hex_decode(&pub_hex).expect("Invalid hex"); - let pub_array: [u8; 32] = pub_bytes.try_into().expect("Key must be 32 bytes"); - let verifying_key = VerifyingKey::from_bytes(&pub_array).expect("Invalid public key"); - - let contents = fs::read_to_string(license_path) - .expect("Failed to read license") - .trim() - .to_string(); - - let parts: Vec<&str> = contents.splitn(2, '.').collect(); - if parts.len() != 2 { - eprintln!("Invalid license format"); - std::process::exit(1); - } - - let sig_bytes = BASE64.decode(parts[1]).expect("Invalid signature"); - let sig_array: [u8; 64] = sig_bytes.try_into().expect("Signature must be 64 bytes"); - let signature = Signature::from_bytes(&sig_array); - - match verifying_key.verify(parts[0].as_bytes(), &signature) { - Ok(()) => { - let payload_bytes = BASE64.decode(parts[0]).expect("Invalid payload"); - let payload: LicensePayload = serde_json::from_slice(&payload_bytes).expect("Invalid JSON"); - println!("License VALID:"); - println!(" Ingress MAC: {}", payload.ingress_mac); - println!(" Egress MAC: {}", payload.egress_mac); - println!(" Expires: {}", payload.expires); - println!(" Features: {:?}", payload.features); - } - Err(e) => { - eprintln!("License INVALID: {}", e); - std::process::exit(1); - } - } -} - -fn hex_encode(bytes: &[u8]) -> String { - bytes.iter().map(|b| format!("{:02x}", b)).collect() -} - -fn hex_decode(hex: &str) -> Result, String> { - if hex.len() % 2 != 0 { - return Err("Odd-length hex string".to_string()); - } - (0..hex.len()) - .step_by(2) - .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).map_err(|e| e.to_string())) - .collect() -} diff --git a/models/classifier.onnx b/models/classifier.onnx index 43b003c..cc19997 100644 Binary files a/models/classifier.onnx and b/models/classifier.onnx differ diff --git a/models/deep_autoencoder.onnx b/models/deep_autoencoder.onnx index b5ffc06..f25a646 100644 Binary files a/models/deep_autoencoder.onnx and b/models/deep_autoencoder.onnx differ diff --git a/models/full_config.json b/models/full_config.json index b590d5d..381bc0a 100644 --- a/models/full_config.json +++ b/models/full_config.json @@ -1,11 +1,12 @@ { - "created_at": "2026-03-05T14:04:08.736531", + "created_at": "2026-04-07T02:27:17.281069", "framework": "PyTorch", + "model_type": "MultiTaskModel", "model": { "deep_autoencoder": { "file": "deep_autoencoder.onnx", - "input_dim": 27, - "encoding_dim": 16, + "input_dim": 31, + "encoding_dim": 12, "ae_feature_names": [ "flow_duration", "fwd_packets", @@ -33,15 +34,24 @@ "fwd_pkt_len_std", "bwd_pkt_len_std", "fwd_seg_size_min", - "fwd_act_data_pkts" + "fwd_act_data_pkts", + "fwd_iat_std", + "bwd_iat_std", + "fwd_bwd_bytes_ratio", + "iat_cv" ], - "ae_threshold": 0.17166012525558472 + "ae_threshold": 0.23011694848537445 }, "classifier": { "file": "classifier.onnx", - "type": "ResNet MLP", - "n_features": 28, - "n_classes": 5, + "type": "MultiTaskModel", + "n_features": 32, + "n_classes": 10, + "outputs": [ + "anomaly", + "class_probs", + "c2_score" + ], "classifier_feature_names": [ "flow_duration", "fwd_packets", @@ -70,6 +80,10 @@ "bwd_pkt_len_std", "fwd_seg_size_min", "fwd_act_data_pkts", + "fwd_iat_std", + "bwd_iat_std", + "fwd_bwd_bytes_ratio", + "iat_cv", "ae_anomaly_score" ] } @@ -77,68 +91,68 @@ "preprocessing": { "ae_clip_params": { "flow_duration": { - "lower": 1.0, - "upper": 118485085.19000001 + "lower": 0.0, + "upper": 115669365.2 }, "fwd_packets": { "lower": 0.0, - "upper": 54.0 + "upper": 120.0 }, "bwd_packets": { "lower": 0.0, - "upper": 82.0 + "upper": 126.0 }, "fwd_bytes": { "lower": 0.0, - "upper": 6960.0 + "upper": 19557.400390625 }, "bwd_bytes": { "lower": 0.0, - "upper": 89558.6950000003 + "upper": 85164.0 }, "flow_bytes_per_sec": { "lower": 0.0, - "upper": 12500000.0 + "upper": 1627586.8125000005 }, "flow_pkts_per_sec": { - "lower": 0.0226829350855, - "upper": 2000000.0 + "lower": 0.0, + "upper": 23809.5234375 }, "fwd_win_bytes": { - "lower": -1.0, - "upper": 65535.0 + "lower": 0.0, + "upper": 65280.0 }, "bwd_win_bytes": { - "lower": -1.0, - "upper": 64000.0 + "lower": 0.0, + "upper": 65535.0 }, "fwd_pkt_len_mean": { "lower": 0.0, - "upper": 744.7272727272729 + "upper": 1500.0 }, "bwd_pkt_len_mean": { "lower": 0.0, - "upper": 1393.9601153594992 + "upper": 822.0007794189461 }, "fwd_iat_mean": { "lower": 0.0, - "upper": 86366722.035 + "upper": 54051113.24 }, "bwd_iat_mean": { "lower": 0.0, - "upper": 21700000.0 + "upper": 6912790.715000001 }, "flow_iat_mean": { - "lower": 1.0, - "upper": 86353400.64500001 + "lower": 0.0, + "upper": 166521472.0 }, "pkt_len_mean": { "lower": 0.0, - "upper": 945.0294113974604 + "upper": 957.2035284423835 }, "dst_port": { "lower": 0.0, - "upper": 64499.0 + "upper": 63005.0 }, "protocol": { "lower": 0.0, @@ -146,11 +160,11 @@ }, "psh_flag_cnt": { "lower": 0.0, - "upper": 240.0 + "upper": 52.0 }, "ack_flag_cnt": { "lower": 0.0, - "upper": 655.0 + "upper": 107.0 }, "syn_flag_cnt": { "lower": 0.0, @@ -158,91 +172,115 @@ }, "fin_flag_cnt": { "lower": 0.0, - "upper": 2.0 + "upper": 1.0 }, "rst_flag_cnt": { "lower": 0.0, - "upper": 1.0 + "upper": 0.0 }, "pkt_len_std": { "lower": 0.0, - "upper": 943.9221842600019 + "upper": 818.4579974365238 }, "fwd_pkt_len_std": { "lower": 0.0, - "upper": 721.4199648708689 + "upper": 256.8401712036142 }, "bwd_pkt_len_std": { "lower": 0.0, - "upper": 911.0272448420104 + "upper": 676.0667114257812 }, "fwd_seg_size_min": { "lower": 0.0, - "upper": 40.0 + "upper": 1026743.0693750025 }, "fwd_act_data_pkts": { "lower": 0.0, - "upper": 58.0 + "upper": 12.0 + }, + "fwd_iat_std": { + "lower": 0.0, + "upper": 6691987.085000001 + }, + "bwd_iat_std": { + "lower": 0.0, + "upper": 5136363.065000001 + }, + "fwd_bwd_bytes_ratio": { + "lower": 0.0, + "upper": 1.0 + }, + "iat_cv": { + "lower": 0.0, + "upper": 0.0 } }, "ae_scaler": { "mean": [ - 9783470.902880548, - 3.6532605393470496, - 3.2219949045990472, - 311.35281195367173, - 1407.508354811673, - 298997.28705051675, - 32378.36735569276, - 6158.3256759444475, - 6125.023325143747, - 58.1683929498305, - 149.72168204849987, - 2991407.86700246, - 543337.1970583026, - 2727511.340181723, - 105.03829359866097, - 13463.473246959382, - 8.920585011866685, - 5.457814320208867, - 12.214631683524651, - 0.4885976379312313, - 0.246598391240437, - 0.08478201266637703, - 151.71809799252043, - 76.89649795414182, - 143.21014925828936, - 21.447338538177014, - 3.2607463735155715 + 1775639.8280735926, + 5.542641564702501, + 4.873551306800804, + 673.3628917399571, + 1776.7162625946232, + 22595.98254433581, + 457.7205079100132, + 5370.204067202503, + 9979.409366405764, + 310.21111530262294, + 64.0225296706552, + 421007.8046985764, + 74954.24060464761, + 1927814.4699678936, + 18.27810422291583, + 10009.108501605231, + 9.275900254913996, + 0.6270530398000678, + 1.4141691028300247, + 0.08438195832759936, + 0.0413846397252831, + 0.0, + 20.458150398533718, + 3.321724142251631, + 13.033036407393814, + 8569.843223681366, + 0.21485243990919378, + 94146.99057411935, + 68775.38334652747, + 0.47595050130443944, + 0.0 ], "std": [ - 27895851.239715174, - 6.4263677035863696, - 8.326275195918974, - 739.2548064174216, - 7898.711708881367, - 1187804.3239195002, - 190280.1548511611, - 15556.664474188405, - 17012.235322987635, - 98.89458775902833, - 242.79522892109904, - 13146157.964503227, - 2242915.166631539, - 12878527.145353919, - 151.95316919115754, - 21264.915159327567, - 4.943209145195101, - 23.02750953458606, - 57.0633472601567, - 1.2662749497077417, - 0.4553262784517569, - 0.278557037237646, - 211.1373807979287, - 143.47790203327, - 228.24531573376805, - 9.323458649060111, - 7.228913366949205 + 12196313.175317517, + 13.15004546194127, + 13.245688945052057, + 1969.9160973703263, + 8250.018585629565, + 161540.2407042193, + 2518.5714986775442, + 12103.605209582642, + 20308.80085611352, + 476.8868336260097, + 95.72217224515282, + 4199427.656867863, + 616719.9756782106, + 16437665.614047276, + 97.4688620675193, + 18194.33523865822, + 5.182633726939228, + 4.994559084102198, + 10.431975160428792, + 0.558462828085695, + 0.19916731632770637, + 1.0, + 106.06236469581468, + 24.362268530572912, + 85.37603561474889, + 84340.18813365103, + 1.4442281045462682, + 646928.7442307192, + 525550.9737726098, + 0.3431291415218137, + 1.0 ], "feature_names": [ "flow_duration", @@ -271,7 +309,11 @@ "fwd_pkt_len_std", "bwd_pkt_len_std", "fwd_seg_size_min", - "fwd_act_data_pkts" + "fwd_act_data_pkts", + "fwd_iat_std", + "bwd_iat_std", + "fwd_bwd_bytes_ratio", + "iat_cv" ] }, "post_scaling_clip": { @@ -280,10 +322,15 @@ } }, "attack_labels": { - "0": "Brute Force", - "1": "DDoS", - "2": "DoS", - "3": "Exploitation", - "4": "Reconnaissance" + "0": "Bot", + "1": "Brute Force", + "2": "C2 Communication", + "3": "DNS Tunneling", + "4": "DoS\/DDoS", + "5": "Exploitation", + "6": "Malware", + "7": "Normal", + "8": "Reconnaissance", + "9": "Web Attack" } } \ No newline at end of file diff --git a/models/inference_config.json b/models/inference_config.json index 1d42207..9204c8e 100644 --- a/models/inference_config.json +++ b/models/inference_config.json @@ -26,72 +26,76 @@ "fwd_pkt_len_std", "bwd_pkt_len_std", "fwd_seg_size_min", - "fwd_act_data_pkts" + "fwd_act_data_pkts", + "fwd_iat_std", + "bwd_iat_std", + "fwd_bwd_bytes_ratio", + "iat_cv" ], "ae_clip_params": { "flow_duration": { - "lower": 1.0, - "upper": 118485085.19000001 + "lower": 0.0, + "upper": 115669365.2 }, "fwd_packets": { "lower": 0.0, - "upper": 54.0 + "upper": 120.0 }, "bwd_packets": { "lower": 0.0, - "upper": 82.0 + "upper": 126.0 }, "fwd_bytes": { "lower": 0.0, - "upper": 6960.0 + "upper": 19557.400390625 }, "bwd_bytes": { "lower": 0.0, - "upper": 89558.6950000003 + "upper": 85164.0 }, "flow_bytes_per_sec": { "lower": 0.0, - "upper": 12500000.0 + "upper": 1627586.8125000005 }, "flow_pkts_per_sec": { - "lower": 0.0226829350855, - "upper": 2000000.0 + "lower": 0.0, + "upper": 23809.5234375 }, "fwd_win_bytes": { - "lower": -1.0, - "upper": 65535.0 + "lower": 0.0, + "upper": 65280.0 }, "bwd_win_bytes": { - "lower": -1.0, - "upper": 64000.0 + "lower": 0.0, + "upper": 65535.0 }, "fwd_pkt_len_mean": { "lower": 0.0, - "upper": 744.7272727272729 + "upper": 1500.0 }, "bwd_pkt_len_mean": { "lower": 0.0, - "upper": 1393.9601153594992 + "upper": 822.0007794189461 }, "fwd_iat_mean": { "lower": 0.0, - "upper": 86366722.035 + "upper": 54051113.24 }, "bwd_iat_mean": { "lower": 0.0, - "upper": 21700000.0 + "upper": 6912790.715000001 }, "flow_iat_mean": { - "lower": 1.0, - "upper": 86353400.64500001 + "lower": 0.0, + "upper": 166521472.0 }, "pkt_len_mean": { "lower": 0.0, - "upper": 945.0294113974604 + "upper": 957.2035284423835 }, "dst_port": { "lower": 0.0, - "upper": 64499.0 + "upper": 63005.0 }, "protocol": { "lower": 0.0, @@ -99,11 +103,11 @@ }, "psh_flag_cnt": { "lower": 0.0, - "upper": 240.0 + "upper": 52.0 }, "ack_flag_cnt": { "lower": 0.0, - "upper": 655.0 + "upper": 107.0 }, "syn_flag_cnt": { "lower": 0.0, @@ -111,94 +115,118 @@ }, "fin_flag_cnt": { "lower": 0.0, - "upper": 2.0 + "upper": 1.0 }, "rst_flag_cnt": { "lower": 0.0, - "upper": 1.0 + "upper": 0.0 }, "pkt_len_std": { "lower": 0.0, - "upper": 943.9221842600019 + "upper": 818.4579974365238 }, "fwd_pkt_len_std": { "lower": 0.0, - "upper": 721.4199648708689 + "upper": 256.8401712036142 }, "bwd_pkt_len_std": { "lower": 0.0, - "upper": 911.0272448420104 + "upper": 676.0667114257812 }, "fwd_seg_size_min": { "lower": 0.0, - "upper": 40.0 + "upper": 1026743.0693750025 }, "fwd_act_data_pkts": { "lower": 0.0, - "upper": 58.0 + "upper": 12.0 + }, + "fwd_iat_std": { + "lower": 0.0, + "upper": 6691987.085000001 + }, + "bwd_iat_std": { + "lower": 0.0, + "upper": 5136363.065000001 + }, + "fwd_bwd_bytes_ratio": { + "lower": 0.0, + "upper": 1.0 + }, + "iat_cv": { + "lower": 0.0, + "upper": 0.0 } }, "ae_scaler_mean": [ - 9783470.902880548, - 3.6532605393470496, - 3.2219949045990472, - 311.35281195367173, - 1407.508354811673, - 298997.28705051675, - 32378.36735569276, - 6158.3256759444475, - 6125.023325143747, - 58.1683929498305, - 149.72168204849987, - 2991407.86700246, - 543337.1970583026, - 2727511.340181723, - 105.03829359866097, - 13463.473246959382, - 8.920585011866685, - 5.457814320208867, - 12.214631683524651, - 0.4885976379312313, - 0.246598391240437, - 0.08478201266637703, - 151.71809799252043, - 76.89649795414182, - 143.21014925828936, - 21.447338538177014, - 3.2607463735155715 + 1775639.8280735926, + 5.542641564702501, + 4.873551306800804, + 673.3628917399571, + 1776.7162625946232, + 22595.98254433581, + 457.7205079100132, + 5370.204067202503, + 9979.409366405764, + 310.21111530262294, + 64.0225296706552, + 421007.8046985764, + 74954.24060464761, + 1927814.4699678936, + 18.27810422291583, + 10009.108501605231, + 9.275900254913996, + 0.6270530398000678, + 1.4141691028300247, + 0.08438195832759936, + 0.0413846397252831, + 0.0, + 20.458150398533718, + 3.321724142251631, + 13.033036407393814, + 8569.843223681366, + 0.21485243990919378, + 94146.99057411935, + 68775.38334652747, + 0.47595050130443944, + 0.0 ], "ae_scaler_std": [ - 27895851.239715174, - 6.4263677035863696, - 8.326275195918974, - 739.2548064174216, - 7898.711708881367, - 1187804.3239195002, - 190280.1548511611, - 15556.664474188405, - 17012.235322987635, - 98.89458775902833, - 242.79522892109904, - 13146157.964503227, - 2242915.166631539, - 12878527.145353919, - 151.95316919115754, - 21264.915159327567, - 4.943209145195101, - 23.02750953458606, - 57.0633472601567, - 1.2662749497077417, - 0.4553262784517569, - 0.278557037237646, - 211.1373807979287, - 143.47790203327, - 228.24531573376805, - 9.323458649060111, - 7.228913366949205 + 12196313.175317517, + 13.15004546194127, + 13.245688945052057, + 1969.9160973703263, + 8250.018585629565, + 161540.2407042193, + 2518.5714986775442, + 12103.605209582642, + 20308.80085611352, + 476.8868336260097, + 95.72217224515282, + 4199427.656867863, + 616719.9756782106, + 16437665.614047276, + 97.4688620675193, + 18194.33523865822, + 5.182633726939228, + 4.994559084102198, + 10.431975160428792, + 0.558462828085695, + 0.19916731632770637, + 1.0, + 106.06236469581468, + 24.362268530572912, + 85.37603561474889, + 84340.18813365103, + 1.4442281045462682, + 646928.7442307192, + 525550.9737726098, + 0.3431291415218137, + 1.0 ], "ae_post_clip_min": -5.0, "ae_post_clip_max": 5.0, - "ae_threshold": 0.17166012525558472, + "ae_threshold": 0.23011694848537445, "classifier_feature_names": [ "flow_duration", "fwd_packets", @@ -227,13 +255,64 @@ "bwd_pkt_len_std", "fwd_seg_size_min", "fwd_act_data_pkts", + "fwd_iat_std", + "bwd_iat_std", + "fwd_bwd_bytes_ratio", + "iat_cv", "ae_anomaly_score" ], "attack_labels": { - "0": "Brute Force", - "1": "DDoS", - "2": "DoS", - "3": "Exploitation", - "4": "Reconnaissance" - } + "0": "Bot", + "1": "Brute Force", + "2": "C2 Communication", + "3": "DNS Tunneling", + "4": "DoS/DDoS", + "5": "Exploitation", + "6": "Malware", + "7": "Normal", + "8": "Reconnaissance", + "9": "Web Attack" + }, + "anomaly_threshold": 0.9179317355155945, + "c2_threshold": 0.9085615873336792, + "model_type": "MultiTaskModel", + "output_names": [ + "anomaly", + "class_probs", + "c2_score" + ], + "ae_feature_weights": { + "flow_duration": 1.0, + "fwd_packets": 1.0, + "bwd_packets": 1.0, + "fwd_bytes": 1.0, + "bwd_bytes": 1.0, + "flow_bytes_per_sec": 1.0, + "flow_pkts_per_sec": 1.0, + "fwd_win_bytes": 4.0, + "bwd_win_bytes": 4.0, + "fwd_pkt_len_mean": 1.0, + "bwd_pkt_len_mean": 1.0, + "fwd_iat_mean": 1.0, + "bwd_iat_mean": 1.0, + "flow_iat_mean": 1.0, + "pkt_len_mean": 1.0, + "dst_port": 1.0, + "protocol": 1.0, + "psh_flag_cnt": 2.0, + "ack_flag_cnt": 1.0, + "syn_flag_cnt": 2.0, + "fin_flag_cnt": 2.0, + "rst_flag_cnt": 2.0, + "pkt_len_std": 1.0, + "fwd_pkt_len_std": 1.0, + "bwd_pkt_len_std": 1.0, + "fwd_seg_size_min": 1.0, + "fwd_act_data_pkts": 1.0, + "fwd_iat_std": 1.5, + "bwd_iat_std": 1.5, + "fwd_bwd_bytes_ratio": 2.0, + "iat_cv": 2.0 + }, + "class_min_confidence": 0.4 } \ No newline at end of file diff --git a/models/manifest.yaml b/models/manifest.yaml new file mode 100644 index 0000000..0ff4a93 --- /dev/null +++ b/models/manifest.yaml @@ -0,0 +1,74 @@ +# NetGuardia model manifest. Structural/semantic fields live here; +# preprocessing arrays (scaler mean/std, clip params, feature weights) stay +# in the JSON sidecar referenced by `preprocessing.scaler_sidecar`. + +name: netguardia-v10 +adapter: multi_task + +models: + autoencoder: deep_autoencoder.onnx + classifier: classifier.onnx + +# 31 AE-input features. Order matters — must match ONNX input column order +# and inference_config.json `ae_feature_names`. The classifier takes these +# plus `ae_anomaly_score` appended as the 32nd input (handled in code). +features: + - flow_duration + - fwd_packets + - bwd_packets + - fwd_bytes + - bwd_bytes + - flow_bytes_per_sec + - flow_pkts_per_sec + - fwd_win_bytes + - bwd_win_bytes + - fwd_pkt_len_mean + - bwd_pkt_len_mean + - fwd_iat_mean + - bwd_iat_mean + - flow_iat_mean + - pkt_len_mean + - dst_port + - protocol + - psh_flag_cnt + - ack_flag_cnt + - syn_flag_cnt + - fin_flag_cnt + - rst_flag_cnt + - pkt_len_std + - fwd_pkt_len_std + - bwd_pkt_len_std + - fwd_seg_size_min + - fwd_act_data_pkts + - fwd_iat_std + - bwd_iat_std + - fwd_bwd_bytes_ratio + - iat_cv + +# `confirmations` sets the per-class aggregator firing threshold. Classes +# with single-shot semantics (C2 / Bot / DNS tunneling / exploit) use 1 so +# the aggregator alerts on the first detection; noisier classes can raise +# it (DoS/DDoS: 2). Absent entries fall back to the engine default. +labels: + "0": { name: Bot, confirmations: 1 } + "1": { name: Brute Force } + "2": { name: C2 Communication, confirmations: 1 } + "3": { name: DNS Tunneling, confirmations: 1 } + "4": { name: DoS/DDoS, confirmations: 2 } + "5": { name: Exploitation, confirmations: 1 } + "6": { name: Malware } + "7": { name: Normal } + "8": { name: Reconnaissance } + "9": { name: Web Attack } + +thresholds: + anomaly: 0.9179317355155945 + c2: 0.9085615873336792 + class_min_confidence: 0.4 + ae: 0.23011694848537445 + # Average score must exceed `class_min_confidence * alert_multiplier` + # before the aggregator fires. Raising this suppresses borderline hits. + alert_multiplier: 1.2 + +preprocessing: + scaler_sidecar: inference_config.json diff --git a/net-guardia-frontend b/net-guardia-frontend index 71d2d7f..00d347c 160000 --- a/net-guardia-frontend +++ b/net-guardia-frontend @@ -1 +1 @@ -Subproject commit 71d2d7f2d53f4afe6510b3018227aa5e28d97476 +Subproject commit 00d347c5eae3ed32f595b0a3553601f16bedfa7e diff --git a/net-guardia-trainer b/net-guardia-trainer index 1f5cbb8..dea59f2 160000 --- a/net-guardia-trainer +++ b/net-guardia-trainer @@ -1 +1 @@ -Subproject commit 1f5cbb8b9ba69a5bd16cc15055c230715d6bb9ae +Subproject commit dea59f289635445fe63e69db8345df2f444fb9f1 diff --git a/net-guardia/Cargo.toml b/net-guardia/Cargo.toml index 992f18a..33cef24 100644 --- a/net-guardia/Cargo.toml +++ b/net-guardia/Cargo.toml @@ -20,6 +20,9 @@ actix = { workspace = true } actix-web = { workspace = true } actix-cors = { workspace = true } actix-ws = { workspace = true } +actix-multipart = "0.7" +actix-files = "0.6" +uuid = { version = "1", features = ["v4"] } rust-embed = "8.11.0" mime_guess = "2.0.5" url = "2.5.8" @@ -28,6 +31,7 @@ tokio-tungstenite = "0.28.0" # Serialization serde = { workspace = true } serde_json = { workspace = true } +serde_yaml_ng = { workspace = true } toml = "1.0.7" # Async @@ -53,6 +57,9 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus # Architecture async-trait = "0.1" dashmap = "6" +arc-swap = "1" +moka = { version = "0.12", features = ["sync"] } +notify = "7" # Utilities parking_lot = { workspace = true } @@ -67,6 +74,7 @@ r2d2_sqlite = "0.27" jsonwebtoken = { workspace = true } argon2 = { workspace = true } sha2 = "0.10" +hmac = "0.12" aes-gcm = "0.10" hkdf = "0.12" base64 = { workspace = true } diff --git a/net-guardia/src/adapter/access_control_adapter.rs b/net-guardia/src/adapter/access_control_adapter.rs index 4614a67..7bc7968 100644 --- a/net-guardia/src/adapter/access_control_adapter.rs +++ b/net-guardia/src/adapter/access_control_adapter.rs @@ -1,13 +1,12 @@ use std::net::{IpAddr, SocketAddrV4, SocketAddrV6}; use std::sync::Arc; -use async_trait::async_trait; - -use crate::core::ebpf::access_control::AccessControl; +use crate::adapter::ebpf::access_control::AccessControl; use crate::interface::port::access_control::AccessControlPort; -use crate::model::direction::FlowDirection; +use crate::model::access_control::list_type::ListType; use crate::model::error::Error; -use crate::model::list_type::ListType; +use crate::model::error::ebpf::EbpfError; +use crate::model::monitoring::direction::FlowDirection; /// Adapter that implements AccessControlPort by delegating to the eBPF AccessControl. pub struct EbpfAccessControlAdapter { @@ -20,44 +19,39 @@ impl EbpfAccessControlAdapter { } } -#[async_trait] impl AccessControlPort for EbpfAccessControlAdapter { - async fn block_ip(&self, ip: &str) -> Result<(), Error> { + fn block_ip(&self, ip: &str) -> Result<(), Error> { let addr: IpAddr = ip .parse() - .map_err(|_| Error::from(crate::model::error::ebpf::EbpfError::InvalidIpAddress { ip: ip.to_string() }))?; + .map_err(|_| Error::from(EbpfError::InvalidIpAddress(ip.to_string())))?; match addr { IpAddr::V4(v4) => { let socket = SocketAddrV4::new(v4, 0); self.access_control .add_ipv4_list(FlowDirection::Source, ListType::Black, socket) - .await } IpAddr::V6(v6) => { let socket = SocketAddrV6::new(v6, 0, 0, 0); self.access_control .add_ipv6_list(FlowDirection::Source, ListType::Black, socket) - .await } } } - async fn unblock_ip(&self, ip: &str) -> Result<(), Error> { + fn unblock_ip(&self, ip: &str) -> Result<(), Error> { let addr: IpAddr = ip .parse() - .map_err(|_| Error::from(crate::model::error::ebpf::EbpfError::InvalidIpAddress { ip: ip.to_string() }))?; + .map_err(|_| Error::from(EbpfError::InvalidIpAddress(ip.to_string())))?; match addr { IpAddr::V4(v4) => { let socket = SocketAddrV4::new(v4, 0); self.access_control .remove_ipv4_list(FlowDirection::Source, ListType::Black, socket) - .await } IpAddr::V6(v6) => { let socket = SocketAddrV6::new(v6, 0, 0, 0); self.access_control .remove_ipv6_list(FlowDirection::Source, ListType::Black, socket) - .await } } } diff --git a/net-guardia/src/core/ebpf/access_control.rs b/net-guardia/src/adapter/ebpf/access_control.rs similarity index 57% rename from net-guardia/src/core/ebpf/access_control.rs rename to net-guardia/src/adapter/ebpf/access_control.rs index f9f9844..0cc07c1 100644 --- a/net-guardia/src/core/ebpf/access_control.rs +++ b/net-guardia/src/adapter/ebpf/access_control.rs @@ -5,13 +5,14 @@ use aya::maps::{HashMap as AyaHashMap, MapData}; use aya::{Ebpf, Pod}; use common::model::ip_address::{IPv4, IPv6, Port}; use common::model::port_rule::PortRule; -use tokio::sync::RwLock; +use parking_lot::RwLock; -use crate::model::direction::FlowDirection; +use crate::interface::port::access_control_admin::AccessControlAdminPort; +use crate::model::access_control::ip_address::NativeConvert; +use crate::model::access_control::list_type::ListType; use crate::model::error::Error; use crate::model::error::ebpf::EbpfError; -use crate::model::ip_address::NativeConvert; -use crate::model::list_type::ListType; +use crate::model::monitoring::direction::FlowDirection; pub struct AccessControl { ipv4_src_whitelist: RwLock>, @@ -39,27 +40,43 @@ impl AccessControl { Ok(access_control) } - pub async fn get_ipv4_list(&self, direction: FlowDirection, list_type: ListType) -> HashMap> { + /// Construct an AccessControl backed by no eBPF maps. Used when eBPF + /// failed to load at startup; every mutating call returns `EbpfError::NotLoaded`, + /// and list queries return empty maps. + pub fn unavailable() -> Self { + Self { + ipv4_src_whitelist: RwLock::new(MapWrapper::unavailable()), + ipv4_src_blacklist: RwLock::new(MapWrapper::unavailable()), + ipv4_dst_whitelist: RwLock::new(MapWrapper::unavailable()), + ipv4_dst_blacklist: RwLock::new(MapWrapper::unavailable()), + ipv6_src_whitelist: RwLock::new(MapWrapper::unavailable()), + ipv6_src_blacklist: RwLock::new(MapWrapper::unavailable()), + ipv6_dst_whitelist: RwLock::new(MapWrapper::unavailable()), + ipv6_dst_blacklist: RwLock::new(MapWrapper::unavailable()), + } + } + + pub fn get_ipv4_list(&self, direction: FlowDirection, list_type: ListType) -> HashMap> { let map_wrapper = match (direction, list_type) { - (FlowDirection::Source, ListType::White) => self.ipv4_src_whitelist.read().await, - (FlowDirection::Source, ListType::Black) => self.ipv4_src_blacklist.read().await, - (FlowDirection::Destination, ListType::White) => self.ipv4_dst_whitelist.read().await, - (FlowDirection::Destination, ListType::Black) => self.ipv4_dst_blacklist.read().await, + (FlowDirection::Source, ListType::White) => self.ipv4_src_whitelist.read(), + (FlowDirection::Source, ListType::Black) => self.ipv4_src_blacklist.read(), + (FlowDirection::Destination, ListType::White) => self.ipv4_dst_whitelist.read(), + (FlowDirection::Destination, ListType::Black) => self.ipv4_dst_blacklist.read(), }; map_wrapper.get_list() } - pub async fn get_ipv6_list(&self, direction: FlowDirection, list_type: ListType) -> HashMap> { + pub fn get_ipv6_list(&self, direction: FlowDirection, list_type: ListType) -> HashMap> { let map_wrapper = match (direction, list_type) { - (FlowDirection::Source, ListType::White) => self.ipv6_src_whitelist.read().await, - (FlowDirection::Source, ListType::Black) => self.ipv6_src_blacklist.read().await, - (FlowDirection::Destination, ListType::White) => self.ipv6_dst_whitelist.read().await, - (FlowDirection::Destination, ListType::Black) => self.ipv6_dst_blacklist.read().await, + (FlowDirection::Source, ListType::White) => self.ipv6_src_whitelist.read(), + (FlowDirection::Source, ListType::Black) => self.ipv6_src_blacklist.read(), + (FlowDirection::Destination, ListType::White) => self.ipv6_dst_whitelist.read(), + (FlowDirection::Destination, ListType::Black) => self.ipv6_dst_blacklist.read(), }; map_wrapper.get_list() } - pub async fn add_ipv4_list( + pub fn add_ipv4_list( &self, direction: FlowDirection, list_type: ListType, @@ -68,15 +85,15 @@ impl AccessControl { let ip: u32 = (*address.ip()).to_bits().to_be(); let port = address.port(); let mut map_wrapper = match (direction, list_type) { - (FlowDirection::Source, ListType::White) => self.ipv4_src_whitelist.write().await, - (FlowDirection::Source, ListType::Black) => self.ipv4_src_blacklist.write().await, - (FlowDirection::Destination, ListType::White) => self.ipv4_dst_whitelist.write().await, - (FlowDirection::Destination, ListType::Black) => self.ipv4_dst_blacklist.write().await, + (FlowDirection::Source, ListType::White) => self.ipv4_src_whitelist.write(), + (FlowDirection::Source, ListType::Black) => self.ipv4_src_blacklist.write(), + (FlowDirection::Destination, ListType::White) => self.ipv4_dst_whitelist.write(), + (FlowDirection::Destination, ListType::Black) => self.ipv4_dst_blacklist.write(), }; map_wrapper.add(ip, port) } - pub async fn add_ipv6_list( + pub fn add_ipv6_list( &self, direction: FlowDirection, list_type: ListType, @@ -85,15 +102,15 @@ impl AccessControl { let ip: u128 = (*address.ip()).to_bits().to_be(); let port = address.port(); let mut map_wrapper = match (direction, list_type) { - (FlowDirection::Source, ListType::White) => self.ipv6_src_whitelist.write().await, - (FlowDirection::Source, ListType::Black) => self.ipv6_src_blacklist.write().await, - (FlowDirection::Destination, ListType::White) => self.ipv6_dst_whitelist.write().await, - (FlowDirection::Destination, ListType::Black) => self.ipv6_dst_blacklist.write().await, + (FlowDirection::Source, ListType::White) => self.ipv6_src_whitelist.write(), + (FlowDirection::Source, ListType::Black) => self.ipv6_src_blacklist.write(), + (FlowDirection::Destination, ListType::White) => self.ipv6_dst_whitelist.write(), + (FlowDirection::Destination, ListType::Black) => self.ipv6_dst_blacklist.write(), }; map_wrapper.add(ip, port) } - pub async fn remove_ipv4_list( + pub fn remove_ipv4_list( &self, direction: FlowDirection, list_type: ListType, @@ -102,15 +119,15 @@ impl AccessControl { let ip: u32 = (*address.ip()).to_bits().to_be(); let port = address.port(); let mut map_wrapper = match (direction, list_type) { - (FlowDirection::Source, ListType::White) => self.ipv4_src_whitelist.write().await, - (FlowDirection::Source, ListType::Black) => self.ipv4_src_blacklist.write().await, - (FlowDirection::Destination, ListType::White) => self.ipv4_dst_whitelist.write().await, - (FlowDirection::Destination, ListType::Black) => self.ipv4_dst_blacklist.write().await, + (FlowDirection::Source, ListType::White) => self.ipv4_src_whitelist.write(), + (FlowDirection::Source, ListType::Black) => self.ipv4_src_blacklist.write(), + (FlowDirection::Destination, ListType::White) => self.ipv4_dst_whitelist.write(), + (FlowDirection::Destination, ListType::Black) => self.ipv4_dst_blacklist.write(), }; map_wrapper.remove(ip, port) } - pub async fn remove_ipv6_list( + pub fn remove_ipv6_list( &self, direction: FlowDirection, list_type: ListType, @@ -119,43 +136,82 @@ impl AccessControl { let ip: u128 = (*address.ip()).to_bits().to_be(); let port = address.port(); let mut map_wrapper = match (direction, list_type) { - (FlowDirection::Source, ListType::White) => self.ipv6_src_whitelist.write().await, - (FlowDirection::Source, ListType::Black) => self.ipv6_src_blacklist.write().await, - (FlowDirection::Destination, ListType::White) => self.ipv6_dst_whitelist.write().await, - (FlowDirection::Destination, ListType::Black) => self.ipv6_dst_blacklist.write().await, + (FlowDirection::Source, ListType::White) => self.ipv6_src_whitelist.write(), + (FlowDirection::Source, ListType::Black) => self.ipv6_src_blacklist.write(), + (FlowDirection::Destination, ListType::White) => self.ipv6_dst_whitelist.write(), + (FlowDirection::Destination, ListType::Black) => self.ipv6_dst_blacklist.write(), }; map_wrapper.remove(ip, port) } } +impl AccessControlAdminPort for AccessControl { + fn add_ipv4_list(&self, direction: FlowDirection, list_type: ListType, address: SocketAddrV4) -> Result<(), Error> { + self.add_ipv4_list(direction, list_type, address) + } + fn add_ipv6_list(&self, direction: FlowDirection, list_type: ListType, address: SocketAddrV6) -> Result<(), Error> { + self.add_ipv6_list(direction, list_type, address) + } + fn remove_ipv4_list( + &self, + direction: FlowDirection, + list_type: ListType, + address: SocketAddrV4, + ) -> Result<(), Error> { + self.remove_ipv4_list(direction, list_type, address) + } + fn remove_ipv6_list( + &self, + direction: FlowDirection, + list_type: ListType, + address: SocketAddrV6, + ) -> Result<(), Error> { + self.remove_ipv6_list(direction, list_type, address) + } + fn get_ipv4_list(&self, direction: FlowDirection, list_type: ListType) -> HashMap> { + self.get_ipv4_list(direction, list_type) + } + fn get_ipv6_list(&self, direction: FlowDirection, list_type: ListType) -> HashMap> { + self.get_ipv6_list(direction, list_type) + } +} + struct MapWrapper { - map: AyaHashMap, + map: Option>, } impl MapWrapper { fn new(ebpf: &mut Ebpf, map_name: &str) -> Result { let map = ebpf.take_map(map_name).ok_or(EbpfError::MapNotFound)?; let map = AyaHashMap::try_from(map).map_err(EbpfError::MapOperationError)?; - Ok(Self { map }) + Ok(Self { map: Some(map) }) + } + + fn unavailable() -> Self { + Self { map: None } } fn get_list(&self) -> HashMap> { - self.map - .iter() + let Some(map) = self.map.as_ref() else { + return HashMap::new(); + }; + map.iter() .filter_map(Result::ok) .map(|(key, rule)| (key.into_native(), rule.to_port_vec())) .collect() } fn add(&mut self, ip: T, port: Port) -> Result<(), Error> { + let Some(map) = self.map.as_mut() else { + return Err(EbpfError::NotLoaded.into()); + }; if port == 0 { - self.map - .insert(ip, PortRule::new_match_all(), 0) + map.insert(ip, PortRule::new_match_all(), 0) .map_err(EbpfError::MapOperationError)?; return Ok(()); } - let mut rule = self.map.get(&ip, 0).unwrap_or_else(|_| PortRule::new_empty()); + let mut rule = map.get(&ip, 0).unwrap_or_else(|_| PortRule::new_empty()); if rule.is_match_all() { return Ok(()); @@ -165,29 +221,32 @@ impl MapWrapper { Err(EbpfError::RuleReachLimit)?; } - self.map.insert(ip, rule, 0).map_err(EbpfError::MapOperationError)?; + map.insert(ip, rule, 0).map_err(EbpfError::MapOperationError)?; Ok(()) } fn remove(&mut self, ip: T, port: Port) -> Result<(), Error> { + let Some(map) = self.map.as_mut() else { + return Err(EbpfError::NotLoaded.into()); + }; if port == 0 { - self.map.remove(&ip).map_err(EbpfError::MapOperationError)?; + map.remove(&ip).map_err(EbpfError::MapOperationError)?; return Ok(()); } - let mut rule = self.map.get(&ip, 0).map_err(|_| EbpfError::IpDoesNotExist)?; + let mut rule = map.get(&ip, 0).map_err(|_| EbpfError::IpDoesNotExist)?; if rule.is_match_all() { - self.map.remove(&ip).map_err(EbpfError::MapOperationError)?; + map.remove(&ip).map_err(EbpfError::MapOperationError)?; return Ok(()); } rule.remove_port(port); if rule.is_empty() { - self.map.remove(&ip).map_err(EbpfError::MapOperationError)?; + map.remove(&ip).map_err(EbpfError::MapOperationError)?; } else { - self.map.insert(ip, rule, 0).map_err(EbpfError::MapOperationError)?; + map.insert(ip, rule, 0).map_err(EbpfError::MapOperationError)?; } Ok(()) } diff --git a/net-guardia/src/core/ebpf/dns_filter.rs b/net-guardia/src/adapter/ebpf/dns_filter.rs similarity index 79% rename from net-guardia/src/core/ebpf/dns_filter.rs rename to net-guardia/src/adapter/ebpf/dns_filter.rs index 0567aaa..80f3245 100644 --- a/net-guardia/src/core/ebpf/dns_filter.rs +++ b/net-guardia/src/adapter/ebpf/dns_filter.rs @@ -1,46 +1,59 @@ -use std::collections::HashSet; +use core::str; use common::model::dns_name::DnsName; -use parking_lot::RwLock; +use dashmap::DashSet; +use crate::interface::port::dns_filter_api::DnsFilterPort; +use crate::interface::port::dns_query_filter::DnsQueryFilter; use crate::model::error::Error; use crate::model::error::misc::MiscError; pub struct DnsFilter { - blacklist: RwLock>, + blacklist: DashSet, } impl DnsFilter { pub fn new() -> Self { Self { - blacklist: RwLock::new(HashSet::new()), + blacklist: DashSet::new(), } } pub fn add_domain(&self, domain: &str) -> Result<(), Error> { let name = domain_to_wire_format(domain)?; - self.blacklist.write().insert(name); + self.blacklist.insert(name); Ok(()) } pub fn remove_domain(&self, domain: &str) -> Result<(), Error> { let name = domain_to_wire_format(domain)?; - self.blacklist.write().remove(&name); + self.blacklist.remove(&name); Ok(()) } pub fn list_domains(&self) -> Vec { - self.blacklist.read().iter().filter_map(wire_format_to_domain).collect() + self.blacklist + .iter() + .filter_map(|entry| wire_format_to_domain(&entry)) + .collect() + } + + /// Fast-path helper combining `parse_query_name` + `is_blacklisted` — used + /// by the AF_XDP RX loop. + pub fn is_query_blacklisted(&self, raw: &[u8]) -> bool { + match Self::parse_query_name(raw) { + Some((name, name_len)) => self.is_blacklisted(&name, name_len), + None => false, + } } /// Check if a DNS query name (in wire format) or any of its parent domains is blacklisted. pub fn is_blacklisted(&self, name: &DnsName, name_len: usize) -> bool { - let bl = self.blacklist.read(); - if bl.is_empty() { + if self.blacklist.is_empty() { return false; } // Check exact match - if bl.contains(name) { + if self.blacklist.contains(name) { return true; } // Check parent domains @@ -64,7 +77,7 @@ impl DnsFilter { let mut parent = DnsName::zeroed(); let remaining = name_len - offset; parent.data[..remaining.min(128)].copy_from_slice(&name.data[offset..offset + remaining.min(128)]); - if bl.contains(&parent) { + if self.blacklist.contains(&parent) { return true; } } @@ -176,6 +189,24 @@ impl DnsFilter { } } +impl DnsFilterPort for DnsFilter { + fn add_domain(&self, domain: &str) -> Result<(), Error> { + self.add_domain(domain) + } + fn remove_domain(&self, domain: &str) -> Result<(), Error> { + self.remove_domain(domain) + } + fn list_domains(&self) -> Vec { + self.list_domains() + } +} + +impl DnsQueryFilter for DnsFilter { + fn is_query_blacklisted(&self, raw: &[u8]) -> bool { + self.is_query_blacklisted(raw) + } +} + /// Convert a human-readable domain name (e.g., "example.com") to DNS wire format. /// The result is a DnsName with lowercase, length-prefixed labels, zero-terminated and zero-padded. fn domain_to_wire_format(domain: &str) -> Result { @@ -187,16 +218,10 @@ fn domain_to_wire_format(domain: &str) -> Result { let label_bytes = label.as_bytes(); let label_len = label_bytes.len(); if label_len == 0 || label_len >= 64 { - return Err(MiscError::InvalidDnsName { - reason: format!("invalid label length: {}", label_len), - } - .into()); + return Err(MiscError::DnsLabelOutOfRange(label_len).into()); } if pos + 1 + label_len >= 128 { - return Err(MiscError::InvalidDnsName { - reason: format!("domain name too long: {}", domain), - } - .into()); + return Err(MiscError::DnsDomainTooLong(domain).into()); } name.data[pos] = label_len as u8; pos += 1; @@ -229,7 +254,7 @@ fn wire_format_to_domain(name: &DnsName) -> Option { return None; } pos += 1; - let label = core::str::from_utf8(&name.data[pos..pos + label_len]).ok()?; + let label = str::from_utf8(&name.data[pos..pos + label_len]).ok()?; labels.push(label.to_string()); pos += label_len; } diff --git a/net-guardia/src/core/ebpf/drop_monitor.rs b/net-guardia/src/adapter/ebpf/drop_monitor.rs similarity index 57% rename from net-guardia/src/core/ebpf/drop_monitor.rs rename to net-guardia/src/adapter/ebpf/drop_monitor.rs index f6644ef..4277c5c 100644 --- a/net-guardia/src/core/ebpf/drop_monitor.rs +++ b/net-guardia/src/adapter/ebpf/drop_monitor.rs @@ -1,20 +1,22 @@ use std::mem; +use std::net::Ipv6Addr; use std::sync::Arc; +use std::sync::atomic::Ordering; use std::time::Duration; use aya::maps::{MapData, RingBuf}; use tokio::sync::{broadcast, oneshot}; +use tokio::time::interval; use common::define::drop_reason::*; use common::model::drop_event::DropEvent as RawDropEvent; -use parking_lot::Mutex; use crate::model::config::constants::DROP_CHANNEL_CAPACITY; -use crate::model::drop_event::{DropCounters, DropEventMessage}; +use crate::model::monitoring::drop_event::{DropCounters, DropCountersAtomic, DropEventMessage}; pub struct DropMonitor { broadcast_tx: broadcast::Sender, - counters: Mutex, + counters: DropCountersAtomic, } impl DropMonitor { @@ -22,7 +24,7 @@ impl DropMonitor { let (tx, _) = broadcast::channel(DROP_CHANNEL_CAPACITY); Self { broadcast_tx: tx, - counters: Mutex::new(DropCounters::default()), + counters: DropCountersAtomic::default(), } } @@ -31,25 +33,47 @@ impl DropMonitor { } pub fn get_counters(&self) -> DropCounters { - self.counters.lock().clone() + self.counters.snapshot() + } + + /// Record a userspace drop decision (XSK worker's DNS filter) by the + /// per-reason counter. Callers at this layer haven't parsed src/dst yet, + /// so no broadcast event is emitted — `/api/stats/drops` stays correct, + /// `/ws/drops` simply does not surface the individual packet. Parse the + /// packet upstream if you need a structured event. + pub fn record_userspace_drop_count_only(&self, reason: u8) { + self.counters.total.fetch_add(1, Ordering::Relaxed); + let bucket = match reason { + DROP_REASON_ACL_BLACKLIST => Some(&self.counters.acl_blacklist), + DROP_REASON_RATE_LIMIT_PKT => Some(&self.counters.rate_limit_pkt), + DROP_REASON_RATE_LIMIT_SYN => Some(&self.counters.rate_limit_syn), + DROP_REASON_RATE_LIMIT_UDP => Some(&self.counters.rate_limit_udp), + DROP_REASON_RATE_LIMIT_DNS => Some(&self.counters.rate_limit_dns), + DROP_REASON_PROTOCOL_FILTER => Some(&self.counters.protocol_filter), + DROP_REASON_DNS_BLACKLIST => Some(&self.counters.dns_blacklist), + DROP_REASON_GEO_BLOCK => Some(&self.counters.geo_block), + _ => None, + }; + if let Some(counter) = bucket { + counter.fetch_add(1, Ordering::Relaxed); + } } fn process_event(&self, raw: &RawDropEvent) { - // Update counters - { - let mut c = self.counters.lock(); - c.total += 1; - match raw.reason { - DROP_REASON_ACL_BLACKLIST => c.acl_blacklist += 1, - DROP_REASON_RATE_LIMIT_PKT => c.rate_limit_pkt += 1, - DROP_REASON_RATE_LIMIT_SYN => c.rate_limit_syn += 1, - DROP_REASON_RATE_LIMIT_UDP => c.rate_limit_udp += 1, - DROP_REASON_RATE_LIMIT_DNS => c.rate_limit_dns += 1, - DROP_REASON_PROTOCOL_FILTER => c.protocol_filter += 1, - DROP_REASON_DNS_BLACKLIST => c.dns_blacklist += 1, - DROP_REASON_GEO_BLOCK => c.geo_block += 1, - _ => {} - } + self.counters.total.fetch_add(1, Ordering::Relaxed); + let bucket = match raw.reason { + DROP_REASON_ACL_BLACKLIST => Some(&self.counters.acl_blacklist), + DROP_REASON_RATE_LIMIT_PKT => Some(&self.counters.rate_limit_pkt), + DROP_REASON_RATE_LIMIT_SYN => Some(&self.counters.rate_limit_syn), + DROP_REASON_RATE_LIMIT_UDP => Some(&self.counters.rate_limit_udp), + DROP_REASON_RATE_LIMIT_DNS => Some(&self.counters.rate_limit_dns), + DROP_REASON_PROTOCOL_FILTER => Some(&self.counters.protocol_filter), + DROP_REASON_DNS_BLACKLIST => Some(&self.counters.dns_blacklist), + DROP_REASON_GEO_BLOCK => Some(&self.counters.geo_block), + _ => None, + }; + if let Some(counter) = bucket { + counter.fetch_add(1, Ordering::Relaxed); } let reason_str = reason_to_str(raw.reason); @@ -101,7 +125,7 @@ fn format_ips(raw: &RawDropEvent) -> (String, String) { } fn format_ipv6(bytes: &[u8; 16]) -> String { - std::net::Ipv6Addr::from(*bytes).to_string() + Ipv6Addr::from(*bytes).to_string() } fn reason_to_str(reason: u8) -> &'static str { @@ -124,7 +148,7 @@ pub async fn start_consumer(ring_buf: RingBuf, monitor: Arc>, - geo_block_v6: RwLock>, - blocked_countries: RwLock>, + geo_block_v4: RwLock>>, + geo_block_v6: RwLock>>, + blocked_countries: ArcSwap>, index: Arc, } @@ -35,26 +37,44 @@ impl GeoBlock { let v6_trie = LpmTrie::try_from(v6_map).map_err(EbpfError::MapOperationError)?; let db_path = &app_config.misc.geoip_db_name; - let reader = Reader::open_readfile(db_path).map_err(|e| MiscError::GeoIPDatabaseError { - path: db_path.clone(), - reason: e.to_string(), - })?; + let reader = Reader::open_readfile(db_path).map_err(|e| MiscError::GeoIPDatabaseError(db_path.clone(), e))?; let index = Self::build_index(&reader)?; Ok(Self { - geo_block_v4: RwLock::new(v4_trie), - geo_block_v6: RwLock::new(v6_trie), - blocked_countries: RwLock::new(HashSet::new()), + geo_block_v4: RwLock::new(Some(v4_trie)), + geo_block_v6: RwLock::new(Some(v6_trie)), + blocked_countries: ArcSwap::from_pointee(HashSet::new()), index: Arc::new(index), }) } + /// Construct a GeoBlock with no eBPF trie backing. Attempts to still load + /// the GeoIP index so the frontend can list what *would* be enforced; + /// mutating calls (`block_countries`, `unblock_countries`) return + /// `EbpfError::NotLoaded`. + pub fn unavailable(app_config: &AppConfig) -> Self { + let index = Reader::open_readfile(&app_config.misc.geoip_db_name) + .ok() + .and_then(|reader| Self::build_index(&reader).ok()) + .unwrap_or(GeoIndex { + v4: StdHashMap::new(), + v6: StdHashMap::new(), + }); + Self { + geo_block_v4: RwLock::new(None), + geo_block_v6: RwLock::new(None), + blocked_countries: ArcSwap::from_pointee(HashSet::new()), + index: Arc::new(index), + } + } + /// Build index from MaxMind DB at startup. One-time cost. fn build_index(reader: &Reader>) -> Result { let mut v4: StdHashMap> = StdHashMap::new(); let mut v6: StdHashMap> = StdHashMap::new(); + // SAFETY: "0.0.0.0/0" is a valid IPv4 CIDR literal, parse is infallible. let ipv4_all: IpNetwork = "0.0.0.0/0".parse().unwrap(); if let Ok(iter) = reader.within(ipv4_all, Default::default()) { for result in iter { @@ -73,6 +93,7 @@ impl GeoBlock { } } + // SAFETY: "::/0" is a valid IPv6 CIDR literal, parse is infallible. let ipv6_all: IpNetwork = "::/0".parse().unwrap(); if let Ok(iter) = reader.within(ipv6_all, Default::default()) { for result in iter { @@ -96,42 +117,44 @@ impl GeoBlock { /// Block multiple countries at once, rebuilding tries only once. pub fn block_countries(&self, country_codes: &[String]) -> Result { - { - let mut countries = self.blocked_countries.write(); + self.blocked_countries.rcu(|cur| { + let mut next: HashSet = (**cur).clone(); for code in country_codes { let upper = code.trim().to_uppercase(); if upper.len() == 2 && upper.chars().all(|c| c.is_ascii_alphabetic()) { - countries.insert(upper); + next.insert(upper); } } - } + next + }); self.rebuild_tries() } /// Unblock multiple countries at once, rebuilding tries only once. pub fn unblock_countries(&self, country_codes: &[String]) -> Result { - { - let mut countries = self.blocked_countries.write(); + self.blocked_countries.rcu(|cur| { + let mut next: HashSet = (**cur).clone(); for code in country_codes { - countries.remove(&code.trim().to_uppercase()); + next.remove(&code.trim().to_uppercase()); } - } + next + }); self.rebuild_tries() } pub fn get_blocked_countries(&self) -> Vec { - self.blocked_countries.read().iter().cloned().collect() + self.blocked_countries.load().iter().cloned().collect() } /// Rebuild LPM tries from pre-indexed data. Fast — no DB scan. fn rebuild_tries(&self) -> Result { - let countries = self.blocked_countries.read().clone(); + let countries = self.blocked_countries.load_full(); // Collect entries from index (no DB scan) let mut v4_entries: Vec<(Key, u8)> = Vec::new(); let mut v6_entries: Vec<(Key, u8)> = Vec::new(); - for code in &countries { + for code in countries.iter() { if let Some(prefixes) = self.index.v4.get(code) { for &(ip_be, prefix_len) in prefixes { v4_entries.push((Key::new(prefix_len, ip_be), 1u8)); @@ -145,10 +168,14 @@ impl GeoBlock { } // Lock, clear, insert - let mut v4_trie = self.geo_block_v4.write(); - let mut v6_trie = self.geo_block_v6.write(); - Self::clear_trie_v4(&mut v4_trie); - Self::clear_trie_v6(&mut v6_trie); + let mut v4_guard = self.geo_block_v4.write(); + let mut v6_guard = self.geo_block_v6.write(); + let (v4_trie, v6_trie) = match (v4_guard.as_mut(), v6_guard.as_mut()) { + (Some(v4), Some(v6)) => (v4, v6), + _ => Err(EbpfError::NotLoaded)?, + }; + Self::clear_trie_v4(v4_trie); + Self::clear_trie_v6(v6_trie); let mut count = 0u64; for (key, val) in &v4_entries { @@ -179,3 +206,15 @@ impl GeoBlock { } } } + +impl GeoBlockPort for GeoBlock { + fn block_countries(&self, codes: &[String]) -> Result { + self.block_countries(codes) + } + fn unblock_countries(&self, codes: &[String]) -> Result { + self.unblock_countries(codes) + } + fn list_blocked(&self) -> Vec { + self.get_blocked_countries() + } +} diff --git a/net-guardia/src/core/ebpf/mod.rs b/net-guardia/src/adapter/ebpf/mod.rs similarity index 61% rename from net-guardia/src/core/ebpf/mod.rs rename to net-guardia/src/adapter/ebpf/mod.rs index 98b51d9..d7c3aa8 100644 --- a/net-guardia/src/core/ebpf/mod.rs +++ b/net-guardia/src/adapter/ebpf/mod.rs @@ -15,15 +15,16 @@ use macros::log; use parking_lot::Mutex; use tokio::sync::oneshot; -use crate::core::ebpf::access_control::AccessControl; -use crate::core::ebpf::dns_filter::DnsFilter; -use crate::core::ebpf::drop_monitor::DropMonitor; -use crate::core::ebpf::geo_block::GeoBlock; -use crate::core::ebpf::protocol_filter::ProtocolFilter; -use crate::core::ebpf::rate_limit::RateLimitConfig; -use crate::core::ebpf::xsk_manager::XskManager; -use crate::core::ml::engine::Engine; +use crate::adapter::ebpf::access_control::AccessControl; +use crate::adapter::ebpf::dns_filter::DnsFilter; +use crate::adapter::ebpf::drop_monitor::DropMonitor; +use crate::adapter::ebpf::geo_block::GeoBlock; +use crate::adapter::ebpf::protocol_filter::ProtocolFilter; +use crate::adapter::ebpf::rate_limit::RateLimitConfig; +use crate::adapter::ebpf::xsk_manager::XskManager; use crate::infrastructure::app_config::AppConfig; +use crate::interface::port::dns_query_filter::DnsQueryFilter; +use crate::interface::port::packet_sink::PacketSinkFactory; use crate::model::error::Error; use crate::model::error::ebpf::EbpfError; use crate::model::error::system::SystemError; @@ -66,9 +67,32 @@ impl EbpfServices { }) } - pub async fn run(self: Arc, ml_engine: Arc) -> Result<(), Error> { + /// Build an EbpfServices with every eBPF-backed subservice in the + /// "unavailable" state. Used when eBPF failed to load at startup. + /// Queries return empty results; mutating calls return `EbpfError::NotLoaded`. + pub fn unavailable(app_config: Arc) -> Self { + Self { + xsk_manager: Arc::new(XskManager::unavailable(app_config.clone())), + access_control: Arc::new(AccessControl::unavailable()), + protocol_filter: Arc::new(ProtocolFilter::unavailable()), + dns_filter: Arc::new(DnsFilter::new()), + geo_block: Arc::new(GeoBlock::unavailable(&app_config)), + rate_limit: Arc::new(RateLimitConfig::unavailable()), + drop_monitor: Arc::new(DropMonitor::new()), + drop_ring_buf: Mutex::new(None), + shutdowns: SegQueue::new(), + } + } + + pub async fn run(self: Arc, sink_factory: Arc) -> Result<(), Error> { let xsk_manager = self.xsk_manager.clone(); - xsk_manager.run(Some(ml_engine), Some(self.dns_filter.clone()), &self.shutdowns)?; + let dns: Arc = self.dns_filter.clone(); + xsk_manager.run( + Some(sink_factory), + Some(dns), + Some(self.drop_monitor.clone()), + &self.shutdowns, + )?; let ring_buf = self.drop_ring_buf.lock().take(); if let Some(ring_buf) = ring_buf { diff --git a/net-guardia/src/adapter/ebpf/protocol_filter.rs b/net-guardia/src/adapter/ebpf/protocol_filter.rs new file mode 100644 index 0000000..0ec0808 --- /dev/null +++ b/net-guardia/src/adapter/ebpf/protocol_filter.rs @@ -0,0 +1,309 @@ +use std::collections::HashMap; +use std::net::{Ipv4Addr, Ipv6Addr, SocketAddrV4, SocketAddrV6}; + +use aya::maps::{Array as AyaArray, HashMap as AyaHashMap, MapData}; +use aya::{Ebpf, Pod}; +use common::model::http_method::{HttpMethod, HttpMethodBitmap}; +use common::model::ip_address::{AddrPortV4, AddrPortV6, IPv4, IPv6}; +use common::model::placeholder::PlaceHolder; +use parking_lot::RwLock; + +use crate::model::access_control::ip_address::NativeConvert; +use crate::model::error::Error; +use crate::model::error::ebpf::EbpfError; + +pub struct ProtocolFilter { + ipv4_http_service: RwLock>, + ipv6_http_service: RwLock>, + ssh_white_list_enable: RwLock, + ipv4_ssh_service: RwLock>, + ipv6_ssh_service: RwLock>, + ipv4_ssh_white_list: RwLock>, + ipv6_ssh_white_list: RwLock>, + ipv4_ssh_black_list: RwLock>, + ipv6_ssh_black_list: RwLock>, +} + +impl ProtocolFilter { + pub fn new(ebpf: &mut Ebpf) -> Result { + let service = Self { + ipv4_http_service: RwLock::new(HttpServiceWrapper::new(ebpf, "IPV4_HTTP_SERVICE")?), + ipv6_http_service: RwLock::new(HttpServiceWrapper::new(ebpf, "IPV6_HTTP_SERVICE")?), + ssh_white_list_enable: RwLock::new(WhiteListControl::new(ebpf, "SSH_WHITE_LIST_ENABLE")?), + ipv4_ssh_service: RwLock::new(EntryMap::new(ebpf, "IPV4_SSH_SERVICE")?), + ipv6_ssh_service: RwLock::new(EntryMap::new(ebpf, "IPV6_SSH_SERVICE")?), + ipv4_ssh_white_list: RwLock::new(EntryMap::new(ebpf, "IPV4_SSH_WHITE_LIST")?), + ipv6_ssh_white_list: RwLock::new(EntryMap::new(ebpf, "IPV6_SSH_WHITE_LIST")?), + ipv4_ssh_black_list: RwLock::new(EntryMap::new(ebpf, "IPV4_SSH_BLACK_LIST")?), + ipv6_ssh_black_list: RwLock::new(EntryMap::new(ebpf, "IPV6_SSH_BLACK_LIST")?), + }; + Ok(service) + } + + /// Construct a ProtocolFilter backed by no eBPF maps. + pub fn unavailable() -> Self { + Self { + ipv4_http_service: RwLock::new(HttpServiceWrapper::unavailable()), + ipv6_http_service: RwLock::new(HttpServiceWrapper::unavailable()), + ssh_white_list_enable: RwLock::new(WhiteListControl::unavailable()), + ipv4_ssh_service: RwLock::new(EntryMap::unavailable()), + ipv6_ssh_service: RwLock::new(EntryMap::unavailable()), + ipv4_ssh_white_list: RwLock::new(EntryMap::unavailable()), + ipv6_ssh_white_list: RwLock::new(EntryMap::unavailable()), + ipv4_ssh_black_list: RwLock::new(EntryMap::unavailable()), + ipv6_ssh_black_list: RwLock::new(EntryMap::unavailable()), + } + } + + pub fn get_ipv4_http_service(&self) -> HashMap> { + self.ipv4_http_service.read().get_http_method() + } + + pub fn get_ipv6_http_service(&self) -> HashMap> { + self.ipv6_http_service.read().get_http_method() + } + + pub fn add_ipv4_http_service(&self, address: SocketAddrV4, http_method: Vec) -> Result<(), Error> { + self.ipv4_http_service.write().add_http_service(address, http_method) + } + + pub fn add_ipv6_http_service(&self, address: SocketAddrV6, http_method: Vec) -> Result<(), Error> { + self.ipv6_http_service.write().add_http_service(address, http_method) + } + + pub fn remove_ipv4_http_service( + &self, + address: SocketAddrV4, + removed_http_method: Vec, + ) -> Result<(), Error> { + self.ipv4_http_service + .write() + .remove_http_service(address, removed_http_method) + } + + pub fn remove_ipv6_http_service( + &self, + address: SocketAddrV6, + removed_http_method: Vec, + ) -> Result<(), Error> { + self.ipv6_http_service + .write() + .remove_http_service(address, removed_http_method) + } + + pub fn is_ssh_white_list_enable(&self) -> bool { + self.ssh_white_list_enable.read().is_white_list_enable() + } + + pub fn enable_ssh_white_list(&self) -> Result<(), Error> { + self.ssh_white_list_enable.write().enable_white_list() + } + + pub fn disable_ssh_white_list(&self) -> Result<(), Error> { + self.ssh_white_list_enable.write().disable_white_list() + } + + pub fn get_ipv4_ssh_service(&self) -> Vec { + self.ipv4_ssh_service.read().get_all() + } + + pub fn get_ipv6_ssh_service(&self) -> Vec { + self.ipv6_ssh_service.read().get_all() + } + + pub fn add_ipv4_ssh_service(&self, address: SocketAddrV4) -> Result<(), Error> { + self.ipv4_ssh_service.write().add(address) + } + + pub fn add_ipv6_ssh_service(&self, address: SocketAddrV6) -> Result<(), Error> { + self.ipv6_ssh_service.write().add(address) + } + + pub fn remove_ipv4_ssh_service(&self, address: SocketAddrV4) -> Result<(), Error> { + self.ipv4_ssh_service.write().remove(address) + } + + pub fn remove_ipv6_ssh_service(&self, address: SocketAddrV6) -> Result<(), Error> { + self.ipv6_ssh_service.write().remove(address) + } + + pub fn get_ipv4_ssh_white_list(&self) -> Vec { + self.ipv4_ssh_white_list.read().get_all() + } + + pub fn get_ipv6_ssh_white_list(&self) -> Vec { + self.ipv6_ssh_white_list.read().get_all() + } + + pub fn add_ipv4_ssh_white_list(&self, ip: Ipv4Addr) -> Result<(), Error> { + self.ipv4_ssh_white_list.write().add(ip) + } + + pub fn add_ipv6_ssh_white_list(&self, ip: Ipv6Addr) -> Result<(), Error> { + self.ipv6_ssh_white_list.write().add(ip) + } + + pub fn remove_ipv4_ssh_white_list(&self, ip: Ipv4Addr) -> Result<(), Error> { + self.ipv4_ssh_white_list.write().remove(ip) + } + + pub fn remove_ipv6_ssh_white_list(&self, ip: Ipv6Addr) -> Result<(), Error> { + self.ipv6_ssh_white_list.write().remove(ip) + } + + pub fn get_ipv4_ssh_black_list(&self) -> Vec { + self.ipv4_ssh_black_list.read().get_all() + } + + pub fn get_ipv6_ssh_black_list(&self) -> Vec { + self.ipv6_ssh_black_list.read().get_all() + } + + pub fn add_ipv4_ssh_black_list(&self, ip: Ipv4Addr) -> Result<(), Error> { + self.ipv4_ssh_black_list.write().add(ip) + } + + pub fn add_ipv6_ssh_black_list(&self, ip: Ipv6Addr) -> Result<(), Error> { + self.ipv6_ssh_black_list.write().add(ip) + } + + pub fn remove_ipv4_ssh_black_list(&self, ip: Ipv4Addr) -> Result<(), Error> { + self.ipv4_ssh_black_list.write().remove(ip) + } + + pub fn remove_ipv6_ssh_black_list(&self, ip: Ipv6Addr) -> Result<(), Error> { + self.ipv6_ssh_black_list.write().remove(ip) + } +} + +struct WhiteListControl { + map: Option>, +} + +impl WhiteListControl { + fn new(ebpf: &mut Ebpf, map_name: &str) -> Result { + let map = ebpf.take_map(map_name).ok_or(EbpfError::MapNotFound)?; + let map = AyaArray::try_from(map).map_err(EbpfError::MapOperationError)?; + Ok(Self { map: Some(map) }) + } + + fn unavailable() -> Self { + Self { map: None } + } + + fn is_white_list_enable(&self) -> bool { + let Some(map) = self.map.as_ref() else { + return false; + }; + match map.get(&0, 0) { + Ok(status) => status != 0, + Err(_) => false, + } + } + + fn enable_white_list(&mut self) -> Result<(), Error> { + let map = self.map.as_mut().ok_or(EbpfError::NotLoaded)?; + map.set(0, 1_u8, 0).map_err(EbpfError::MapOperationError)?; + Ok(()) + } + + fn disable_white_list(&mut self) -> Result<(), Error> { + let map = self.map.as_mut().ok_or(EbpfError::NotLoaded)?; + map.set(0, 0_u8, 0).map_err(EbpfError::MapOperationError)?; + Ok(()) + } +} + +struct HttpServiceWrapper { + map: Option>, +} + +impl HttpServiceWrapper { + fn new(ebpf: &mut Ebpf, map_name: &str) -> Result { + let map = ebpf.take_map(map_name).ok_or(EbpfError::MapNotFound)?; + let map = AyaHashMap::try_from(map).map_err(EbpfError::MapOperationError)?; + Ok(Self { map: Some(map) }) + } + + fn unavailable() -> Self { + Self { map: None } + } + + fn get_http_method(&self) -> HashMap> { + let Some(map) = self.map.as_ref() else { + return HashMap::new(); + }; + map.iter() + .filter_map(Result::ok) + .map(|(key, value)| { + let address = key.into_native(); + (address, HttpMethod::convert_from_bitmap(value)) + }) + .collect() + } + + fn add_http_service(&mut self, address: T::Native, http_method: Vec) -> Result<(), Error> { + let map = self.map.as_mut().ok_or(EbpfError::NotLoaded)?; + let address = T::from_native(address); + let ebpf_method = HttpMethod::convert_to_bitmap(http_method); + map.insert(address, ebpf_method, 0) + .map_err(EbpfError::MapOperationError)?; + Ok(()) + } + + fn remove_http_service(&mut self, address: T::Native, removed_http_method: Vec) -> Result<(), Error> { + let map = self.map.as_mut().ok_or(EbpfError::NotLoaded)?; + let address = T::from_native(address); + if let Ok(current_http_method) = map.get(&address, 0) { + let mut http_method = HttpMethod::convert_from_bitmap(current_http_method); + http_method.retain(|method| !removed_http_method.contains(method)); + if http_method.is_empty() { + map.remove(&address).map_err(EbpfError::MapOperationError)?; + } else { + let new_http_method = HttpMethod::convert_to_bitmap(http_method); + map.insert(address, new_http_method, 0) + .map_err(EbpfError::MapOperationError)?; + } + Ok(()) + } else { + Err(EbpfError::IpDoesNotExist)? + } + } +} + +struct EntryMap { + map: Option>, +} + +impl EntryMap { + fn new(ebpf: &mut Ebpf, map_name: &str) -> Result { + let map = ebpf.take_map(map_name).ok_or(EbpfError::MapNotFound)?; + let map = AyaHashMap::try_from(map).map_err(EbpfError::MapOperationError)?; + Ok(Self { map: Some(map) }) + } + + fn unavailable() -> Self { + Self { map: None } + } + + fn get_all(&self) -> Vec { + let Some(map) = self.map.as_ref() else { + return Vec::new(); + }; + map.keys().filter_map(Result::ok).map(|key| key.into_native()).collect() + } + + fn add(&mut self, key: T::Native) -> Result<(), Error> { + let map = self.map.as_mut().ok_or(EbpfError::NotLoaded)?; + let key = T::from_native(key); + map.insert(key, 0_u8, 0).map_err(EbpfError::MapOperationError)?; + Ok(()) + } + + fn remove(&mut self, key: T::Native) -> Result<(), Error> { + let map = self.map.as_mut().ok_or(EbpfError::NotLoaded)?; + let key = T::from_native(key); + map.remove(&key).map_err(EbpfError::MapOperationError)?; + Ok(()) + } +} diff --git a/net-guardia/src/adapter/ebpf/rate_limit.rs b/net-guardia/src/adapter/ebpf/rate_limit.rs new file mode 100644 index 0000000..50cf8d9 --- /dev/null +++ b/net-guardia/src/adapter/ebpf/rate_limit.rs @@ -0,0 +1,113 @@ +use aya::Ebpf; +use aya::maps::{Array, MapData}; +use parking_lot::Mutex; + +use crate::interface::port::rate_limit_api::RateLimitPort; +use crate::model::error::Error; +use crate::model::error::ebpf::EbpfError; + +pub struct RateLimitConfig { + config_map: Mutex>>, +} + +impl RateLimitConfig { + pub fn new(ebpf: &mut Ebpf) -> Result { + let map = ebpf.take_map("RATE_LIMIT_CONFIG").ok_or(EbpfError::MapNotFound)?; + let config_map = Array::try_from(map).map_err(EbpfError::MapOperationError)?; + Ok(Self { + config_map: Mutex::new(Some(config_map)), + }) + } + + pub fn unavailable() -> Self { + Self { + config_map: Mutex::new(None), + } + } + + fn set_at(&self, index: u32, value: u64) -> Result<(), Error> { + let mut guard = self.config_map.lock(); + let map = guard.as_mut().ok_or(EbpfError::NotLoaded)?; + map.set(index, value, 0).map_err(EbpfError::MapOperationError)?; + Ok(()) + } + + fn get_at(&self, index: u32) -> Result { + let guard = self.config_map.lock(); + let map = guard.as_ref().ok_or(EbpfError::NotLoaded)?; + map.get(&index, 0).map_err(|e| EbpfError::MapOperationError(e).into()) + } + + pub fn set_packet_rate(&self, rate: u64) -> Result<(), Error> { + self.set_at(0, rate) + } + + pub fn set_syn_rate(&self, rate: u64) -> Result<(), Error> { + self.set_at(1, rate) + } + + pub fn set_udp_rate(&self, rate: u64) -> Result<(), Error> { + self.set_at(2, rate) + } + + pub fn set_dns_rate(&self, rate: u64) -> Result<(), Error> { + self.set_at(3, rate) + } + + pub fn set_window_ns(&self, ns: u64) -> Result<(), Error> { + self.set_at(4, ns) + } + + pub fn get_packet_rate(&self) -> Result { + self.get_at(0) + } + + pub fn get_syn_rate(&self) -> Result { + self.get_at(1) + } + + pub fn get_udp_rate(&self) -> Result { + self.get_at(2) + } + + pub fn get_dns_rate(&self) -> Result { + self.get_at(3) + } + + pub fn get_window_ns(&self) -> Result { + self.get_at(4) + } +} + +impl RateLimitPort for RateLimitConfig { + fn set_packet_rate(&self, rate: u64) -> Result<(), Error> { + self.set_packet_rate(rate) + } + fn set_syn_rate(&self, rate: u64) -> Result<(), Error> { + self.set_syn_rate(rate) + } + fn set_udp_rate(&self, rate: u64) -> Result<(), Error> { + self.set_udp_rate(rate) + } + fn set_dns_rate(&self, rate: u64) -> Result<(), Error> { + self.set_dns_rate(rate) + } + fn set_window_ns(&self, ns: u64) -> Result<(), Error> { + self.set_window_ns(ns) + } + fn get_packet_rate(&self) -> Result { + self.get_packet_rate() + } + fn get_syn_rate(&self) -> Result { + self.get_syn_rate() + } + fn get_udp_rate(&self) -> Result { + self.get_udp_rate() + } + fn get_dns_rate(&self) -> Result { + self.get_dns_rate() + } + fn get_window_ns(&self) -> Result { + self.get_window_ns() + } +} diff --git a/net-guardia/src/core/ebpf/xsk_manager.rs b/net-guardia/src/adapter/ebpf/xsk_manager.rs similarity index 79% rename from net-guardia/src/core/ebpf/xsk_manager.rs rename to net-guardia/src/adapter/ebpf/xsk_manager.rs index a62ba96..0a2a40b 100644 --- a/net-guardia/src/core/ebpf/xsk_manager.rs +++ b/net-guardia/src/adapter/ebpf/xsk_manager.rs @@ -1,5 +1,5 @@ use std::ffi::CString; -use std::io::Write; +use std::io::{ErrorKind, Write}; use std::num::NonZero; use std::os::fd::AsRawFd; use std::sync::Arc; @@ -8,24 +8,26 @@ use std::time::Duration; use aya::Ebpf; use aya::maps::{MapData, XskMap}; -use crossbeam::channel::{Receiver, Sender, bounded}; +use crossbeam::channel::{Receiver, Sender, TrySendError, bounded}; use crossbeam::queue::SegQueue; use macros::log; use parking_lot::Mutex; -use tokio::sync::oneshot; +use tokio::sync::oneshot::{self, error::TryRecvError}; use xsk_rs::config::{BindFlags, FrameSize, Interface, LibxdpFlags, QueueSize, SocketConfig, UmemConfig}; use xsk_rs::{CompQueue, FillQueue, FrameDesc, RxQueue, Socket, TxQueue, Umem}; -use crate::core::ebpf::dns_filter::DnsFilter; -use crate::core::ml::engine::Engine; -use crate::core::ml::flow_tracker::FlowTracker; +use common::define::drop_reason::DROP_REASON_DNS_BLACKLIST; + +use crate::adapter::ebpf::drop_monitor::DropMonitor; use crate::infrastructure::app_config::AppConfig; -use crate::model::config::NetworkConfig; -use crate::model::direction::Direction; +use crate::interface::port::dns_query_filter::DnsQueryFilter; +use crate::interface::port::packet_sink::{PacketSink, PacketSinkFactory}; use crate::model::error::Error; use crate::model::error::ebpf::EbpfError; use crate::model::error::system::SystemError; use crate::model::log::ebpf::EbpfLog; +use crate::model::monitoring::direction::Direction; +use crate::model::system::config::NetworkConfig; use crate::utils::packet_parser::parse_packet; /// Pre-allocated buffer pool to avoid per-packet malloc. @@ -61,8 +63,8 @@ impl BufferPool { pub struct XskManager { app_config: Arc, - xsk_map: Mutex>, - egress_xsk_map: Mutex>, + xsk_map: Mutex>>, + egress_xsk_map: Mutex>>, } impl XskManager { @@ -77,17 +79,33 @@ impl XskManager { Ok(Self { app_config, - xsk_map: Mutex::new(xsk_map), - egress_xsk_map: Mutex::new(egress_xsk_map), + xsk_map: Mutex::new(Some(xsk_map)), + egress_xsk_map: Mutex::new(Some(egress_xsk_map)), }) } + pub fn unavailable(app_config: Arc) -> Self { + Self { + app_config, + xsk_map: Mutex::new(None), + egress_xsk_map: Mutex::new(None), + } + } + pub fn run( &self, - ml_engine: Option>, - dns_filter: Option>, + sinks: Option>, + dns_filter: Option>, + drop_monitor: Option>, shutdowns: &SegQueue>, ) -> Result<(), Error> { + // If eBPF failed to load, there are no XSK maps to bind and no queues + // to start — skip silently. AF_XDP would have no maps to attach sockets + // to, and ML sees no packets, which is the designed behaviour. + if self.xsk_map.lock().is_none() || self.egress_xsk_map.lock().is_none() { + return Ok(()); + } + let network = self.app_config.network.clone(); let combined_queue_count = network.combined_queue_count; @@ -95,7 +113,7 @@ impl XskManager { let (ingress_to_egress_tx, ingress_to_egress_rx) = bounded(network.channel_size); let (egress_to_ingress_tx, egress_to_ingress_rx) = bounded(network.channel_size); - let tracker = ml_engine.as_ref().map(|engine| engine.tracker(queue_id).clone()); + let sink = sinks.as_ref().and_then(|f| f.sink_for_queue(queue_id)); let ingress_xsk = XskPair::new( network.clone(), @@ -103,8 +121,9 @@ impl XskManager { &network.ingress_ifname, &network.egress_ifname, Direction::Ingress, - tracker.clone(), + sink.clone(), dns_filter.clone(), + drop_monitor.clone(), )?; let egress_xsk = XskPair::new( @@ -113,12 +132,15 @@ impl XskManager { &network.egress_ifname, &network.ingress_ifname, Direction::Egress, - tracker, + sink, None, + drop_monitor.clone(), )?; - let mut xsk_map = self.xsk_map.lock(); - let mut egress_xsk_map = self.egress_xsk_map.lock(); + let mut xsk_guard = self.xsk_map.lock(); + let mut egress_guard = self.egress_xsk_map.lock(); + let xsk_map = xsk_guard.as_mut().ok_or(EbpfError::NotLoaded)?; + let egress_xsk_map = egress_guard.as_mut().ok_or(EbpfError::NotLoaded)?; let ingress_fd = ingress_xsk.rx.fd().as_raw_fd(); xsk_map @@ -130,8 +152,8 @@ impl XskManager { .set(queue_id, egress_fd, 0) .map_err(EbpfError::AfXdpSetFailed)?; - drop(xsk_map); - drop(egress_xsk_map); + drop(xsk_guard); + drop(egress_guard); let ingress_shutdown = ingress_xsk.run(ingress_to_egress_tx, egress_to_ingress_rx)?; shutdowns.push(ingress_shutdown); @@ -154,21 +176,24 @@ pub struct XskPair { tx: TxQueue, rx: RxQueue, frame_pool: Vec, - tracker: Option>>, - dns_filter: Option>, + sink: Option>, + dns_filter: Option>, + drop_monitor: Option>, packet_buffer_size: usize, buffer_pool_capacity: usize, } impl XskPair { + #[allow(clippy::too_many_arguments)] pub fn new( config: NetworkConfig, queue_id: u32, rx_ifname: &str, _tx_ifname: &str, direction: Direction, - tracker: Option>>, - dns_filter: Option>, + sink: Option>, + dns_filter: Option>, + drop_monitor: Option>, ) -> Result { let rx_ifname_c = CString::new(rx_ifname).map_err(|_| SystemError::InvalidConfig)?; @@ -210,7 +235,7 @@ impl XskPair { let submitted = unsafe { fill_queue.produce(&fill_frames) }; if submitted != fill_frames.len() { - return Err(EbpfError::FillQueueInitFailed.into()); + Err(EbpfError::FillQueueInitFailed)?; } let pool_frames: Vec = frame_descs.iter().skip(fill_frames_count).copied().collect(); @@ -223,8 +248,9 @@ impl XskPair { tx, rx, frame_pool: pool_frames, - tracker, + sink, dns_filter, + drop_monitor, packet_buffer_size: config.packet_buffer_size, buffer_pool_capacity: config.buffer_pool_capacity, }; @@ -253,10 +279,10 @@ impl XskPair { loop { if let Some(ref mut rx) = shutdown_rx { match rx.try_recv() { - Ok(_) | Err(oneshot::error::TryRecvError::Closed) => { + Ok(_) | Err(TryRecvError::Closed) => { break; } - Err(oneshot::error::TryRecvError::Empty) => {} + Err(TryRecvError::Empty) => {} } } @@ -337,20 +363,25 @@ impl XskPair { let raw = &contents[..packet_len]; - // DNS blacklist check — drop blacklisted DNS queries before forwarding + // DNS blacklist check — drop blacklisted DNS queries before forwarding. + // Report to DropMonitor so `/api/stats/drops` and `/ws/drops` + // reflect userspace-decided drops (the kernel eBPF never saw + // this packet's DNS payload, so it emits no DROP_EVENTS entry). if let Some(ref dns) = self.dns_filter - && let Some((dns_name, name_len)) = DnsFilter::parse_query_name(raw) - && dns.is_blacklisted(&dns_name, name_len) + && dns.is_query_blacklisted(raw) { + if let Some(ref monitor) = self.drop_monitor { + monitor.record_userspace_drop_count_only(DROP_REASON_DNS_BLACKLIST); + } continue; } // Parse directly from UMEM (zero-copy for ML path). // Only clone for the forwarding path afterwards. - if let Some(ref tracker) = self.tracker + if let Some(ref sink) = self.sink && let Some((packet_info, _)) = parse_packet(raw) { - tracker.lock().process_packet(packet_info, is_ingress); + sink.process_packet(packet_info, is_ingress); } // Clone into pooled buffer for forwarding @@ -358,11 +389,11 @@ impl XskPair { buf.extend_from_slice(raw); if let Err(e) = forward_tx.try_send(buf) { match e { - crossbeam::channel::TrySendError::Full(returned) => { + TrySendError::Full(returned) => { buffer_pool.put(returned); log!(EbpfLog::ForwardChannelFull); } - crossbeam::channel::TrySendError::Disconnected(returned) => { + TrySendError::Disconnected(returned) => { buffer_pool.put(returned); log!(EbpfLog::ForwardChannelDisconnected); } @@ -424,7 +455,6 @@ impl XskPair { return Ok(0); } - let sent_count = frames.len(); for (frame, packet) in frames.iter_mut().zip(packets_to_send.iter()) { unsafe { self.umem @@ -445,13 +475,17 @@ impl XskPair { } if let Err(e) = self.tx.wakeup() - && e.kind() != std::io::ErrorKind::WouldBlock + && e.kind() != ErrorKind::WouldBlock { log!(EbpfLog::TXWakeupFailed(e.to_string())); } - // Log dropped packets when frames < packets - let dropped = total_packets - sent_count; + // Drop accounting: a packet is dropped whenever we couldn't put it + // on the TX ring. That includes both the frame-pool-exhausted path + // (frames.len() < total_packets) and the TX-ring backpressure path + // (nb_submitted < frames.len()). Using `nb_submitted` as the sent + // count covers both. + let dropped = total_packets - nb_submitted; if dropped > 0 { log!(EbpfLog::FramePoolExhausted(dropped)); } diff --git a/net-guardia/src/adapter/http/acl.rs b/net-guardia/src/adapter/http/acl.rs index f667bbf..0f09b1f 100644 --- a/net-guardia/src/adapter/http/acl.rs +++ b/net-guardia/src/adapter/http/acl.rs @@ -4,8 +4,8 @@ use actix_web::{HttpResponse, Responder, Scope, web}; use serde::Deserialize; use crate::core::acl_service::AclService; -use crate::model::direction::FlowDirection; -use crate::model::list_type::ListType; +use crate::model::access_control::list_type::ListType; +use crate::model::monitoring::direction::FlowDirection; #[derive(Deserialize)] struct CountryCodesRequest { @@ -27,13 +27,13 @@ pub fn initialize() -> Scope { async fn get_ipv4_list(path: web::Path<(FlowDirection, ListType)>, acl: web::Data) -> impl Responder { let (direction, list_type) = path.into_inner(); - let list = acl.access_control().get_ipv4_list(direction, list_type).await; + let list = acl.access_control().get_ipv4_list(direction, list_type); HttpResponse::Ok().json(list) } async fn get_ipv6_list(path: web::Path<(FlowDirection, ListType)>, acl: web::Data) -> impl Responder { let (direction, list_type) = path.into_inner(); - let list = acl.access_control().get_ipv6_list(direction, list_type).await; + let list = acl.access_control().get_ipv6_list(direction, list_type); HttpResponse::Ok().json(list) } @@ -43,7 +43,7 @@ async fn add_ipv4_list( acl: web::Data, ) -> impl Responder { let (direction, list_type) = path.into_inner(); - match acl.add_ipv4(direction, list_type, address.into_inner()).await { + match acl.add_ipv4(direction, list_type, address.into_inner()) { Ok(_) => HttpResponse::Ok().finish(), Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), } @@ -55,7 +55,7 @@ async fn add_ipv6_list( acl: web::Data, ) -> impl Responder { let (direction, list_type) = path.into_inner(); - match acl.add_ipv6(direction, list_type, address.into_inner()).await { + match acl.add_ipv6(direction, list_type, address.into_inner()) { Ok(_) => HttpResponse::Ok().finish(), Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), } @@ -67,7 +67,7 @@ async fn remove_ipv4_list( acl: web::Data, ) -> impl Responder { let (direction, list_type) = path.into_inner(); - match acl.remove_ipv4(direction, list_type, address.into_inner()).await { + match acl.remove_ipv4(direction, list_type, address.into_inner()) { Ok(_) => HttpResponse::Ok().finish(), Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), } @@ -79,7 +79,7 @@ async fn remove_ipv6_list( acl: web::Data, ) -> impl Responder { let (direction, list_type) = path.into_inner(); - match acl.remove_ipv6(direction, list_type, address.into_inner()).await { + match acl.remove_ipv6(direction, list_type, address.into_inner()) { Ok(_) => HttpResponse::Ok().finish(), Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), } diff --git a/net-guardia/src/adapter/http/api_keys.rs b/net-guardia/src/adapter/http/api_keys.rs index cc7a4ea..e3cf309 100644 --- a/net-guardia/src/adapter/http/api_keys.rs +++ b/net-guardia/src/adapter/http/api_keys.rs @@ -2,7 +2,7 @@ use actix_web::{HttpResponse, Scope, web}; use serde::Deserialize; use crate::core::auth::extractor::AuthClaims; -use crate::interface::port::api_key::ApiKeyPort; +use crate::interface::port::api_key::ApiKeyRepo; pub fn initialize() -> Scope { web::scope("/api-keys") @@ -11,7 +11,7 @@ pub fn initialize() -> Scope { .route("/{id}", web::delete().to(delete_key)) } -async fn list_keys(_auth: AuthClaims, db: web::Data) -> HttpResponse { +async fn list_keys(_auth: AuthClaims, db: web::Data) -> HttpResponse { match db.list_api_keys() { Ok(keys) => { let responses: Vec = keys @@ -40,7 +40,7 @@ struct GenerateKeyRequest { async fn generate_key( _auth: AuthClaims, - db: web::Data, + db: web::Data, body: web::Json, ) -> HttpResponse { use rand::Rng; @@ -52,12 +52,7 @@ async fn generate_key( .map(char::from) .collect(); - use sha2::{Digest, Sha256}; - let key_hash = { - let mut hasher = Sha256::new(); - hasher.update(raw_key.as_bytes()); - format!("{:x}", hasher.finalize()) - }; + let key_hash = db.hmac_api_key(&raw_key); let level = body.level.as_deref().unwrap_or("read_only"); if !matches!(level, "read_only" | "read_write" | "full_access") { @@ -77,7 +72,7 @@ async fn generate_key( } } -async fn delete_key(_auth: AuthClaims, db: web::Data, path: web::Path) -> HttpResponse { +async fn delete_key(_auth: AuthClaims, db: web::Data, path: web::Path) -> HttpResponse { let id = path.into_inner(); match db.delete_api_key(id) { Ok(true) => HttpResponse::Ok().json(serde_json::json!({"deleted": true})), diff --git a/net-guardia/src/adapter/http/audit.rs b/net-guardia/src/adapter/http/audit.rs index ee617b1..51ca729 100644 --- a/net-guardia/src/adapter/http/audit.rs +++ b/net-guardia/src/adapter/http/audit.rs @@ -2,9 +2,14 @@ use actix_web::{HttpResponse, Scope, web}; use crate::adapter::persistence::Database; use crate::core::auth::extractor::AuthClaims; +use crate::interface::port::audit::AuditRepo; +use crate::model::error::Error; +use crate::model::error::database::DatabaseError; pub fn initialize() -> Scope { - web::scope("/audit").route("", web::get().to(list_audit_logs)) + web::scope("/audit") + .route("", web::get().to(list_audit_logs)) + .route("/verify", web::get().to(verify_chain)) } async fn list_audit_logs(_auth: AuthClaims, db: web::Data) -> HttpResponse { @@ -27,3 +32,46 @@ async fn list_audit_logs(_auth: AuthClaims, db: web::Data) -> HttpResp Err(_) => HttpResponse::Ok().json(serde_json::json!([])), } } + +/// `GET /api/audit/verify` — walk the WORM hash chain and report whether +/// every row_hash still matches `H(ts || actor || action || detail || +/// prev_hash)`. Surfaces over HTTP the same verification the CLI's +/// `--verify-audit-log` flag performs, so auditors can check chain +/// integrity without shell access. Any mismatch returns the offending +/// row id inside `error` so the dashboard can link straight to it. +async fn verify_chain(_auth: AuthClaims, audit: web::Data) -> HttpResponse { + match audit.verify_audit_log_chain() { + Ok(count) => HttpResponse::Ok().json(serde_json::json!({ + "chain_intact": true, + "verified": count, + })), + Err(e) => { + // Tamper detection is a successful verify outcome, not a server + // failure — return 200 with `chain_intact: false` so frontend + // retry/error handling treats real chain corruption as a + // distinct condition from transient DB connectivity issues. + // Reserve 500 for actual DB/IO failures. + let prev_mismatch = matches!(&e, Error::Database(DatabaseError::AuditPrevHashMismatch { .. })); + let row_mismatch = matches!(&e, Error::Database(DatabaseError::AuditRowHashMismatch { .. })); + if prev_mismatch || row_mismatch { + let kind = if prev_mismatch { + "prev_hash_mismatch" + } else { + "row_hash_mismatch" + }; + HttpResponse::Ok().json(serde_json::json!({ + "chain_intact": false, + "verified": 0, + "kind": kind, + "detail": e.to_string(), + })) + } else { + HttpResponse::InternalServerError().json(serde_json::json!({ + "chain_intact": null, + "verified": 0, + "error": e.to_string(), + })) + } + } + } +} diff --git a/net-guardia/src/adapter/http/auth.rs b/net-guardia/src/adapter/http/auth.rs index e1a72b8..ee6420a 100644 --- a/net-guardia/src/adapter/http/auth.rs +++ b/net-guardia/src/adapter/http/auth.rs @@ -5,10 +5,10 @@ use serde::Deserialize; use crate::core::auth::extractor::AuthClaims; use crate::core::auth::jwt::JwtService; use crate::core::auth::password; -use crate::interface::port::repository::RepositoryPort; +use crate::interface::port::app_repo::AppRepo; use crate::model::error::auth::AuthError; -type Repo = dyn RepositoryPort; +type Repo = dyn AppRepo; #[derive(Deserialize)] struct LoginRequest { @@ -116,7 +116,6 @@ async fn login(body: web::Json, db: web::Data, jwt: web::Dat // Permissions come exclusively from groups — no role-based fallback let permissions = db.get_user_permissions(id).unwrap_or_default(); - // Derive role from groups for backwards compat in JWT let groups = db.get_user_groups(id).unwrap_or_default(); let role = if groups.iter().any(|(_id, name, _desc, _perms)| name == "Administrator") { "admin".to_string() @@ -257,7 +256,6 @@ async fn list_users(_auth: AuthClaims, db: web::Data) -> impl Responder { .iter() .map(|(gid, name)| serde_json::json!({"id": gid, "name": name})) .collect(); - // Derive role from groups for backwards compat let role = if user_groups.iter().any(|(_id, name)| name == "Administrator") { "admin" } else { diff --git a/net-guardia/src/adapter/http/byo.rs b/net-guardia/src/adapter/http/byo.rs new file mode 100644 index 0000000..7fa09bd --- /dev/null +++ b/net-guardia/src/adapter/http/byo.rs @@ -0,0 +1,26 @@ +//! HTTP surface for the BYO (bring-your-own-model) Quickstart flow. +//! Exposes read-only metadata that helps an administrator author a +//! valid `manifest.yaml` — principally the `FEATURE_REGISTRY` list, +//! which is the authoritative set of feature names the system will +//! extract and feed to a user-supplied ONNX model. + +use actix_web::{HttpResponse, Scope, web}; + +use crate::core::auth::extractor::AuthClaims; +use crate::core::ml::feature_extractor::feature_registry_names; + +pub fn initialize() -> Scope { + web::scope("/byo").route("/feature-registry", web::get().to(get_feature_registry)) +} + +/// `GET /api/byo/feature-registry` — list every feature name the +/// manifest validator accepts. Returning this over HTTP lets the +/// BYO Quickstart panel show the authoritative set without shipping +/// duplicated documentation that would drift from the Rust constants. +async fn get_feature_registry(_auth: AuthClaims) -> HttpResponse { + let names = feature_registry_names(); + HttpResponse::Ok().json(serde_json::json!({ + "count": names.len(), + "features": names, + })) +} diff --git a/net-guardia/src/adapter/http/filter.rs b/net-guardia/src/adapter/http/filter.rs index f2fb8a2..c9eb1f2 100644 --- a/net-guardia/src/adapter/http/filter.rs +++ b/net-guardia/src/adapter/http/filter.rs @@ -5,8 +5,8 @@ use actix_web::{HttpResponse, Responder, Scope, web}; use common::model::http_method::HttpMethod; use serde::Deserialize; +use crate::adapter::ebpf::protocol_filter::ProtocolFilter; use crate::core::dns_filter_service::DnsFilterService; -use crate::core::ebpf::protocol_filter::ProtocolFilter; /// Convert a fallible result into an Ok (200) or InternalServerError (500) response. fn ok_or_error(result: Result) -> HttpResponse { @@ -111,11 +111,11 @@ fn ssh_blacklist_scope() -> Scope { // --- HTTP service handlers --- async fn get_ipv4_http_service(service: web::Data) -> impl Responder { - HttpResponse::Ok().json(service.get_ipv4_http_service().await) + HttpResponse::Ok().json(service.get_ipv4_http_service()) } async fn get_ipv6_http_service(service: web::Data) -> impl Responder { - HttpResponse::Ok().json(service.get_ipv6_http_service().await) + HttpResponse::Ok().json(service.get_ipv6_http_service()) } async fn add_ipv4_http_service( @@ -123,7 +123,7 @@ async fn add_ipv4_http_service( service: web::Data, ) -> impl Responder { let (addr, methods) = payload.into_inner(); - ok_or_error(service.add_ipv4_http_service(addr, methods).await) + ok_or_error(service.add_ipv4_http_service(addr, methods)) } async fn add_ipv6_http_service( @@ -131,7 +131,7 @@ async fn add_ipv6_http_service( service: web::Data, ) -> impl Responder { let (addr, methods) = payload.into_inner(); - ok_or_error(service.add_ipv6_http_service(addr, methods).await) + ok_or_error(service.add_ipv6_http_service(addr, methods)) } async fn remove_ipv4_http_service( @@ -139,7 +139,7 @@ async fn remove_ipv4_http_service( service: web::Data, ) -> impl Responder { let (addr, methods) = payload.into_inner(); - ok_or_error(service.remove_ipv4_http_service(addr, methods).await) + ok_or_error(service.remove_ipv4_http_service(addr, methods)) } async fn remove_ipv6_http_service( @@ -147,113 +147,113 @@ async fn remove_ipv6_http_service( service: web::Data, ) -> impl Responder { let (addr, methods) = payload.into_inner(); - ok_or_error(service.remove_ipv6_http_service(addr, methods).await) + ok_or_error(service.remove_ipv6_http_service(addr, methods)) } // --- SSH service handlers --- async fn get_ipv4_ssh_service(service: web::Data) -> impl Responder { - HttpResponse::Ok().json(service.get_ipv4_ssh_service().await) + HttpResponse::Ok().json(service.get_ipv4_ssh_service()) } async fn get_ipv6_ssh_service(service: web::Data) -> impl Responder { - HttpResponse::Ok().json(service.get_ipv6_ssh_service().await) + HttpResponse::Ok().json(service.get_ipv6_ssh_service()) } async fn add_ipv4_ssh_service(ip_addr: web::Json, service: web::Data) -> impl Responder { - ok_or_error(service.add_ipv4_ssh_service(ip_addr.into_inner()).await) + ok_or_error(service.add_ipv4_ssh_service(ip_addr.into_inner())) } async fn add_ipv6_ssh_service(ip_addr: web::Json, service: web::Data) -> impl Responder { - ok_or_error(service.add_ipv6_ssh_service(ip_addr.into_inner()).await) + ok_or_error(service.add_ipv6_ssh_service(ip_addr.into_inner())) } async fn remove_ipv4_ssh_service( ip_addr: web::Json, service: web::Data, ) -> impl Responder { - ok_or_error(service.remove_ipv4_ssh_service(ip_addr.into_inner()).await) + ok_or_error(service.remove_ipv4_ssh_service(ip_addr.into_inner())) } async fn remove_ipv6_ssh_service( ip_addr: web::Json, service: web::Data, ) -> impl Responder { - ok_or_error(service.remove_ipv6_ssh_service(ip_addr.into_inner()).await) + ok_or_error(service.remove_ipv6_ssh_service(ip_addr.into_inner())) } // --- SSH whitelist handlers --- async fn is_ssh_white_list_enable(service: web::Data) -> impl Responder { - HttpResponse::Ok().json(service.is_ssh_white_list_enable().await) + HttpResponse::Ok().json(service.is_ssh_white_list_enable()) } async fn enable_ssh_white_list(service: web::Data) -> impl Responder { - ok_or_error(service.enable_ssh_white_list().await) + ok_or_error(service.enable_ssh_white_list()) } async fn disable_ssh_white_list(service: web::Data) -> impl Responder { - ok_or_error(service.disable_ssh_white_list().await) + ok_or_error(service.disable_ssh_white_list()) } async fn get_ipv4_ssh_white_list(service: web::Data) -> impl Responder { - HttpResponse::Ok().json(service.get_ipv4_ssh_white_list().await) + HttpResponse::Ok().json(service.get_ipv4_ssh_white_list()) } async fn get_ipv6_ssh_white_list(service: web::Data) -> impl Responder { - HttpResponse::Ok().json(service.get_ipv6_ssh_white_list().await) + HttpResponse::Ok().json(service.get_ipv6_ssh_white_list()) } async fn add_ipv4_ssh_white_list(ip_addr: web::Json, service: web::Data) -> impl Responder { - ok_or_error(service.add_ipv4_ssh_white_list(ip_addr.into_inner()).await) + ok_or_error(service.add_ipv4_ssh_white_list(ip_addr.into_inner())) } async fn add_ipv6_ssh_white_list(ip_addr: web::Json, service: web::Data) -> impl Responder { - ok_or_error(service.add_ipv6_ssh_white_list(ip_addr.into_inner()).await) + ok_or_error(service.add_ipv6_ssh_white_list(ip_addr.into_inner())) } async fn remove_ipv4_ssh_white_list( ip_addr: web::Json, service: web::Data, ) -> impl Responder { - ok_or_error(service.remove_ipv4_ssh_white_list(ip_addr.into_inner()).await) + ok_or_error(service.remove_ipv4_ssh_white_list(ip_addr.into_inner())) } async fn remove_ipv6_ssh_white_list( ip_addr: web::Json, service: web::Data, ) -> impl Responder { - ok_or_error(service.remove_ipv6_ssh_white_list(ip_addr.into_inner()).await) + ok_or_error(service.remove_ipv6_ssh_white_list(ip_addr.into_inner())) } // --- SSH blacklist handlers --- async fn get_ipv4_ssh_black_list(service: web::Data) -> impl Responder { - HttpResponse::Ok().json(service.get_ipv4_ssh_black_list().await) + HttpResponse::Ok().json(service.get_ipv4_ssh_black_list()) } async fn get_ipv6_ssh_black_list(service: web::Data) -> impl Responder { - HttpResponse::Ok().json(service.get_ipv6_ssh_black_list().await) + HttpResponse::Ok().json(service.get_ipv6_ssh_black_list()) } async fn add_ipv4_ssh_black_list(ip_addr: web::Json, service: web::Data) -> impl Responder { - ok_or_error(service.add_ipv4_ssh_black_list(ip_addr.into_inner()).await) + ok_or_error(service.add_ipv4_ssh_black_list(ip_addr.into_inner())) } async fn add_ipv6_ssh_black_list(ip_addr: web::Json, service: web::Data) -> impl Responder { - ok_or_error(service.add_ipv6_ssh_black_list(ip_addr.into_inner()).await) + ok_or_error(service.add_ipv6_ssh_black_list(ip_addr.into_inner())) } async fn remove_ipv4_ssh_black_list( ip_addr: web::Json, service: web::Data, ) -> impl Responder { - ok_or_error(service.remove_ipv4_ssh_black_list(ip_addr.into_inner()).await) + ok_or_error(service.remove_ipv4_ssh_black_list(ip_addr.into_inner())) } async fn remove_ipv6_ssh_black_list( ip_addr: web::Json, service: web::Data, ) -> impl Responder { - ok_or_error(service.remove_ipv6_ssh_black_list(ip_addr.into_inner()).await) + ok_or_error(service.remove_ipv6_ssh_black_list(ip_addr.into_inner())) } diff --git a/net-guardia/src/adapter/http/flow_trace.rs b/net-guardia/src/adapter/http/flow_trace.rs new file mode 100644 index 0000000..c9cf9c9 --- /dev/null +++ b/net-guardia/src/adapter/http/flow_trace.rs @@ -0,0 +1,143 @@ +//! HTTP surface for Flow Trace recording. Exposes the rotated CSV +//! shards the writer thread produces so analysts can pull them for +//! offline training / audit. +//! +//! Range support via `actix_files::NamedFile` — the frontend's download +//! progress bar needs `Content-Range` to show % complete on large files. + +use std::path::{Path, PathBuf}; + +use actix_files::NamedFile; +use actix_web::{HttpRequest, HttpResponse, Responder, Scope, web}; + +use crate::core::ml::engine::Engine; +use crate::core::ml::traffic_logger::{FLOW_TRACE_FILE_EXT, FLOW_TRACE_FILE_MARKER, list_flow_trace_files}; + +pub fn initialize() -> Scope { + web::scope("/flow-trace") + .route("/files", web::get().to(list_files)) + .route("/download/{name}", web::get().to(download)) +} + +/// `GET /api/flow-trace/files` — JSON summary of every rotated CSV in +/// the recording directory. Sorted oldest-first so clients showing a +/// retention list get a stable order. +async fn list_files(engine: web::Data) -> impl Responder { + let Some(directory) = flow_trace_directory(&engine) else { + return HttpResponse::Ok().json(serde_json::json!({ "files": [], "enabled": false })); + }; + + match list_flow_trace_files(&directory) { + Ok(files) => { + let json_files: Vec = files + .into_iter() + .map(|f| { + serde_json::json!({ + "name": f.name, + "size_bytes": f.size_bytes, + "modified_unix_secs": f.modified_unix_secs, + }) + }) + .collect(); + HttpResponse::Ok().json(serde_json::json!({ + "files": json_files, + "enabled": true, + })) + } + Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ + "error": format!("failed to list flow-trace directory: {e}"), + })), + } +} + +/// `GET /api/flow-trace/download/{name}` — streams a single rotated +/// shard with range support. +async fn download(req: HttpRequest, engine: web::Data) -> actix_web::Result { + let name = match req.match_info().get("name") { + Some(n) => n.to_string(), + None => { + return Ok(HttpResponse::BadRequest().json(serde_json::json!({ + "error": "missing filename path segment", + }))); + } + }; + + if !is_safe_flow_trace_name(&name) { + return Ok(HttpResponse::BadRequest().json(serde_json::json!({ + "error": "invalid flow-trace filename", + }))); + } + + let Some(directory) = flow_trace_directory(&engine) else { + return Ok(HttpResponse::NotFound().json(serde_json::json!({ + "error": "Flow Trace recording is not enabled", + }))); + }; + + let file_path = directory.join(&name); + if !file_path.is_file() { + return Ok(HttpResponse::NotFound().json(serde_json::json!({ + "error": "flow-trace file not found", + }))); + } + + let named = NamedFile::open_async(&file_path).await?; + Ok(named.into_response(&req)) +} + +/// Reject anything that isn't a plain `flow-trace-.csv` entry. +/// Traversal sequences and empty / renamed files get zero chance to +/// escape the recording directory. +pub fn is_safe_flow_trace_name(name: &str) -> bool { + if name.is_empty() || name.contains('/') || name.contains('\\') || name.contains("..") { + return false; + } + let Some(stripped) = name.strip_prefix(FLOW_TRACE_FILE_MARKER) else { + return false; + }; + let Some(suffix) = stripped.strip_suffix(FLOW_TRACE_FILE_EXT) else { + return false; + }; + !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit()) +} + +/// Resolve the Flow Trace recording directory from the shared +/// `Engine` if the logger is active. Returns `None` when Flow Trace +/// isn't enabled (Dormant state). +fn flow_trace_directory(engine: &web::Data) -> Option { + engine.traffic_logger_directory().map(Path::to_path_buf) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn safe_name_accepts_canonical_flow_trace_file() { + assert!(is_safe_flow_trace_name("flow-trace-00000000000000000042.csv")); + assert!(is_safe_flow_trace_name("flow-trace-17150000000000000000.csv")); + } + + #[test] + fn safe_name_rejects_traversal_sequences() { + assert!(!is_safe_flow_trace_name("../etc/passwd")); + assert!(!is_safe_flow_trace_name("flow-trace-../x.csv")); + assert!(!is_safe_flow_trace_name("../flow-trace-1.csv")); + assert!(!is_safe_flow_trace_name("flow-trace-1/.csv")); + assert!(!is_safe_flow_trace_name("flow-trace-1\\.csv")); + } + + #[test] + fn safe_name_rejects_unrelated_prefixes_and_suffixes() { + assert!(!is_safe_flow_trace_name("config.csv")); + assert!(!is_safe_flow_trace_name("flow-trace-42.txt")); + assert!(!is_safe_flow_trace_name("")); + } + + #[test] + fn safe_name_rejects_non_numeric_suffix() { + assert!(!is_safe_flow_trace_name("flow-trace-.csv")); + assert!(!is_safe_flow_trace_name("flow-trace-abc.csv")); + assert!(!is_safe_flow_trace_name("flow-trace-12abc.csv")); + } +} diff --git a/net-guardia/src/adapter/http/fusion.rs b/net-guardia/src/adapter/http/fusion.rs new file mode 100644 index 0000000..1751f5d --- /dev/null +++ b/net-guardia/src/adapter/http/fusion.rs @@ -0,0 +1,226 @@ +//! HTTP surface for fusion-layer observability + incident explain. +//! Metrics handlers read shared atomic counters maintained by the +//! detection orchestrator — they never touch orchestrator state, so a +//! hung dashboard cannot stall the detection pipeline. The explain +//! handler reads the WORM audit chain populated by +//! `publish_fusion_audit` and surfaces a per-IP evidence timeline so +//! analysts can answer "why was this IP blocked?" without parsing +//! logs by hand. + +use actix_web::{HttpRequest, HttpResponse, Responder, Scope, web}; + +use crate::core::detection::metrics::FusionMetrics; +use crate::interface::port::audit::{AuditLogEntry, AuditRepo}; + +/// Maximum audit rows scanned per explain request. Caps DB work in +/// case the audit chain grows large enough that a naive full-table +/// scan would be noticeable. +const FUSION_EXPLAIN_SCAN_LIMIT: i64 = 5_000; + +/// Upper cap on entries returned to the client per explain request. +/// Guards against a UI rendering path that chokes on enormous JSON. +const FUSION_EXPLAIN_RESPONSE_CAP: usize = 200; + +/// Stable audit action string the fusion engine emits — kept in sync +/// with `core::detection::orchestrator::FUSION_AUDIT_ACTION`. If that +/// constant changes, the explain endpoint silently returns nothing, so +/// keep this updated at the same time. +const FUSION_AUDIT_ACTION: &str = "fused_threat_emitted"; + +pub fn initialize() -> Scope { + web::scope("/fusion") + .route("/metrics", web::get().to(get_metrics)) + .route("/explain/{src_ip}", web::get().to(explain_ip)) +} + +/// `GET /api/fusion/metrics` — lock-free snapshot of fusion counters and +/// derived rates. Drives the operator dashboard's "how well is fusion +/// working on my network?" view. +async fn get_metrics(metrics: web::Data) -> impl Responder { + HttpResponse::Ok().json(metrics.snapshot()) +} + +/// `GET /api/fusion/explain/{src_ip}` — per-IP fusion evidence timeline. +/// Scans the WORM audit chain for `fused_threat_emitted` entries that +/// match `src_ip`, returning them oldest-first so the UI can render a +/// chronological "why was this IP blocked" view. +async fn explain_ip(req: HttpRequest, audit: web::Data) -> impl Responder { + let src_ip = match req.match_info().get("src_ip") { + Some(ip) => ip.to_string(), + None => { + return HttpResponse::BadRequest().json(serde_json::json!({ + "error": "missing src_ip path segment", + })); + } + }; + + let entries = match audit.list_audit_logs_by_action(FUSION_AUDIT_ACTION, FUSION_EXPLAIN_SCAN_LIMIT) { + Ok(e) => e, + Err(e) => { + return HttpResponse::InternalServerError().json(serde_json::json!({ + "error": format!("audit store unavailable: {e}"), + })); + } + }; + + let (matches, truncated) = filter_fusion_evidence_for_ip(&entries, &src_ip, FUSION_EXPLAIN_RESPONSE_CAP); + HttpResponse::Ok().json(serde_json::json!({ + "src_ip": src_ip, + "match_count": matches.len(), + "truncated": truncated, + "entries": matches, + })) +} + +/// Filter audit entries down to the ones whose JSON detail's `src_ip` +/// matches `target_ip`, ordered oldest-first (ascending id). Entries +/// with unparseable detail are dropped silently — the chain is +/// append-only, so a malformed row is an integrity concern for the +/// audit-verify endpoint to surface, not this handler. +/// +/// Returns `(entries_up_to_cap, truncated)`. `truncated` is `true` when +/// at least one matching entry was dropped — `matches.len() == cap` does +/// NOT imply truncation, so we look at `cap + 1` candidates and set the +/// flag only when the overflow entry exists. +/// +/// Extracted as a free function so tests can cover the filter / +/// ordering / cap behaviour without an in-memory DB. +pub fn filter_fusion_evidence_for_ip( + entries: &[AuditLogEntry], + target_ip: &str, + cap: usize, +) -> (Vec, bool) { + let mut filtered: Vec<&AuditLogEntry> = entries + .iter() + .filter(|entry| detail_matches_src_ip(&entry.detail, target_ip)) + .collect(); + filtered.sort_by_key(|entry| entry.id); + let truncated = filtered.len() > cap; + if truncated { + filtered.truncate(cap); + } + let rendered = filtered + .into_iter() + .map(|entry| { + let detail: serde_json::Value = serde_json::from_str(&entry.detail).unwrap_or(serde_json::Value::Null); + serde_json::json!({ + "id": entry.id, + "actor": entry.actor, + "action": entry.action, + "created_at": entry.created_at, + "detail": detail, + }) + }) + .collect(); + (rendered, truncated) +} + +fn detail_matches_src_ip(detail_json: &str, target_ip: &str) -> bool { + let parsed: serde_json::Value = match serde_json::from_str(detail_json) { + Ok(v) => v, + Err(_) => return false, + }; + parsed.get("src_ip").and_then(|v| v.as_str()) == Some(target_ip) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(id: i64, src_ip: &str, attack: &str) -> AuditLogEntry { + let detail = serde_json::json!({ + "src_ip": src_ip, + "attack_type": attack, + "fused_confidence": 0.9, + "per_source": [{"source": "Suricata", "confidence": 0.9, "local_attack_type": "brute-force"}], + }) + .to_string(); + AuditLogEntry { + id, + actor: "FusionEngine".to_string(), + action: "fused_threat_emitted".to_string(), + detail, + created_at: format!("2026-04-18T10:00:{:02}Z", id), + } + } + + #[test] + fn filter_returns_only_matching_src_ip() { + let entries = [ + entry(1, "1.2.3.4", "brute_force"), + entry(2, "10.0.0.5", "port_scan"), + entry(3, "1.2.3.4", "exploit"), + ]; + let (got, truncated) = filter_fusion_evidence_for_ip(&entries, "1.2.3.4", 100); + assert_eq!(got.len(), 2); + assert!(!truncated); + assert_eq!(got[0]["id"], 1); + assert_eq!(got[1]["id"], 3); + } + + #[test] + fn filter_sorts_oldest_first_even_when_input_is_reversed() { + // Real repo query returns DESC; filter must still hand back ASC. + let entries = [ + entry(30, "1.1.1.1", "a"), + entry(10, "1.1.1.1", "b"), + entry(20, "1.1.1.1", "c"), + ]; + let (got, _) = filter_fusion_evidence_for_ip(&entries, "1.1.1.1", 100); + let ids: Vec = got.iter().map(|v| v["id"].as_i64().unwrap()).collect(); + assert_eq!(ids, vec![10, 20, 30]); + } + + #[test] + fn filter_applies_response_cap() { + let entries: Vec = (1..=10).map(|i| entry(i, "9.9.9.9", "x")).collect(); + let (got, truncated) = filter_fusion_evidence_for_ip(&entries, "9.9.9.9", 3); + assert_eq!(got.len(), 3); + assert!(truncated, "10 matching rows with cap=3 must set truncated"); + let ids: Vec = got.iter().map(|v| v["id"].as_i64().unwrap()).collect(); + assert_eq!(ids, vec![1, 2, 3], "cap takes oldest, not newest"); + } + + #[test] + fn filter_exactly_cap_is_not_truncated() { + // Regression guard: `matches.len() == cap` with no overflow row must + // return `truncated = false`. Earlier `>=` check mis-flagged this. + let entries: Vec = (1..=3).map(|i| entry(i, "9.9.9.9", "x")).collect(); + let (got, truncated) = filter_fusion_evidence_for_ip(&entries, "9.9.9.9", 3); + assert_eq!(got.len(), 3); + assert!(!truncated, "exactly cap matches must NOT report truncated"); + } + + #[test] + fn filter_drops_rows_with_unparseable_detail() { + let good = entry(1, "1.2.3.4", "brute_force"); + let bad = AuditLogEntry { + id: 2, + actor: "FusionEngine".into(), + action: "fused_threat_emitted".into(), + detail: "{{not json".into(), + created_at: "2026-04-18T10:00:02Z".into(), + }; + let (got, _) = filter_fusion_evidence_for_ip(&[good, bad], "1.2.3.4", 100); + assert_eq!(got.len(), 1); + assert_eq!(got[0]["id"], 1); + } + + #[test] + fn filter_nonmatching_ip_returns_empty() { + let entries = [entry(1, "1.2.3.4", "brute_force")]; + let (got, truncated) = filter_fusion_evidence_for_ip(&entries, "5.6.7.8", 100); + assert!(got.is_empty()); + assert!(!truncated); + } + + #[test] + fn filter_preserves_detail_structure_in_response() { + let entries = [entry(1, "1.2.3.4", "brute_force")]; + let (got, _) = filter_fusion_evidence_for_ip(&entries, "1.2.3.4", 100); + assert_eq!(got.len(), 1); + let detail = &got[0]["detail"]; + assert_eq!(detail["attack_type"], "brute_force"); + assert_eq!(detail["per_source"][0]["source"], "Suricata"); + } +} diff --git a/net-guardia/src/adapter/http/health.rs b/net-guardia/src/adapter/http/health.rs index 76a438c..f506538 100644 --- a/net-guardia/src/adapter/http/health.rs +++ b/net-guardia/src/adapter/http/health.rs @@ -1,19 +1,32 @@ use actix_web::{HttpResponse, Responder, Scope, web}; use crate::infrastructure::health::SystemHealth; +use crate::infrastructure::suricata_manager::SuricataManager; pub fn initialize() -> Scope { web::scope("/health") .route("/metrics", web::get().to(get_current_metrics)) .route("/status", web::get().to(get_health_status)) + .route("/ebpf", web::get().to(get_ebpf_health)) + .route("/suricata", web::get().to(get_suricata_health)) } async fn get_current_metrics(health: web::Data) -> impl Responder { - let metrics = health.get_current_metrics().await; + let metrics = health.get_current_metrics(); HttpResponse::Ok().json(metrics) } async fn get_health_status(health: web::Data) -> impl Responder { - let status = health.is_system_healthy().await; + let status = health.is_system_healthy(); HttpResponse::Ok().json(status) } + +async fn get_ebpf_health(health: web::Data) -> impl Responder { + let ebpf = (**health.ebpf_health().load()).clone(); + HttpResponse::Ok().json(ebpf) +} + +async fn get_suricata_health(manager: web::Data) -> impl Responder { + let state = (**manager.health().load()).clone(); + HttpResponse::Ok().json(state) +} diff --git a/net-guardia/src/adapter/http/logs.rs b/net-guardia/src/adapter/http/logs.rs index 1bdcda3..fdce5a3 100644 --- a/net-guardia/src/adapter/http/logs.rs +++ b/net-guardia/src/adapter/http/logs.rs @@ -1,5 +1,12 @@ +use std::fs; +use std::io::ErrorKind; +use std::path::Path; +use std::time::UNIX_EPOCH; + use actix_web::{HttpResponse, Scope, web}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; + +use crate::core::observability::log_buffer::{self, LogEntry}; /// Hardcoded log directory — not configurable via API to prevent directory traversal. const LOG_DIR: &str = "logs"; @@ -7,6 +14,15 @@ const LOG_DIR: &str = "logs"; /// Maximum downloadable log file size (50 MB). Prevents OOM from reading huge files. const MAX_DOWNLOAD_SIZE: u64 = 50 * 1024 * 1024; +/// Default page size for `/live` when the client does not specify `limit`. +/// Chosen so a 2 s poll against a DEBUG-chatty deployment catches up in +/// one round-trip without being absurd payload-wise. +const LIVE_DEFAULT_LIMIT: usize = 500; + +/// Hard cap on `/live?limit=` — prevents pathological clients from asking +/// for the entire buffer at once. +const LIVE_MAX_LIMIT: usize = 2_000; + /// Validate log filename: only alphanumeric, dots, underscores, hyphens. /// Prevents path traversal. fn is_valid_log_filename(name: &str) -> bool { @@ -20,9 +36,52 @@ fn is_valid_log_filename(name: &str) -> bool { pub fn initialize() -> Scope { web::scope("/logs") .route("", web::get().to(list_logs)) + .route("/live", web::get().to(live_logs)) .route("/{filename}", web::get().to(download_log)) } +#[derive(Deserialize)] +struct LiveQuery { + #[serde(default)] + since_id: Option, + #[serde(default)] + limit: Option, + #[serde(default)] + min_level: Option, +} + +#[derive(Serialize)] +struct LiveResponse { + entries: Vec, + next_id: u64, + total_buffered: usize, + dropped_oldest: bool, +} + +async fn live_logs(query: web::Query) -> HttpResponse { + let since_id = query.since_id.unwrap_or(0); + let limit = query.limit.unwrap_or(LIVE_DEFAULT_LIMIT).clamp(1, LIVE_MAX_LIMIT); + let min_severity = query + .min_level + .as_deref() + .map(|s| log_buffer::level_severity(&s.to_ascii_uppercase())) + .unwrap_or(log_buffer::level_severity("TRACE")); + + let snap = log_buffer::snapshot(since_id, min_severity, limit); + // Signal to the UI that it lagged enough for the ring to evict rows + // between polls. Frontend can warn "older entries dropped" without + // silently skipping a gap. + let dropped_oldest = since_id > 0 && snap.entries.first().is_some_and(|e| e.id > since_id + 1); + let next_id = snap.entries.last().map(|e| e.id).unwrap_or(snap.latest_id); + + HttpResponse::Ok().json(LiveResponse { + entries: snap.entries, + next_id, + total_buffered: snap.total, + dropped_oldest, + }) +} + #[derive(Serialize)] struct LogFileEntry { name: String, @@ -32,7 +91,7 @@ struct LogFileEntry { async fn list_logs() -> HttpResponse { let log_dir = LOG_DIR; - let entries = match std::fs::read_dir(log_dir) { + let entries = match fs::read_dir(log_dir) { Ok(dir) => dir .filter_map(|e| e.ok()) .filter_map(|e| { @@ -44,7 +103,7 @@ async fn list_logs() -> HttpResponse { let modified = meta .modified() .ok() - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) .map(|d| d.as_secs()); Some(LogFileEntry { name, @@ -68,10 +127,10 @@ async fn download_log(path: web::Path) -> HttpResponse { })); } - let file_path = std::path::Path::new(LOG_DIR).join(&filename); + let file_path = Path::new(LOG_DIR).join(&filename); // Canonicalize to prevent symlink traversal - let canonical = match std::fs::canonicalize(&file_path) { + let canonical = match fs::canonicalize(&file_path) { Ok(p) => p, Err(_) => { return HttpResponse::NotFound().json(serde_json::json!({ @@ -79,7 +138,7 @@ async fn download_log(path: web::Path) -> HttpResponse { })); } }; - if let Ok(log_dir_canonical) = std::fs::canonicalize(LOG_DIR) + if let Ok(log_dir_canonical) = fs::canonicalize(LOG_DIR) && !canonical.starts_with(&log_dir_canonical) { return HttpResponse::Forbidden().json(serde_json::json!({ @@ -88,13 +147,13 @@ async fn download_log(path: web::Path) -> HttpResponse { } // Check file size before reading to prevent OOM on large logs - match std::fs::metadata(&canonical) { + match fs::metadata(&canonical) { Ok(meta) if meta.len() > MAX_DOWNLOAD_SIZE => { return HttpResponse::PayloadTooLarge().json(serde_json::json!({ "error": format!("Log file exceeds maximum download size ({}MB)", MAX_DOWNLOAD_SIZE / 1024 / 1024) })); } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + Err(e) if e.kind() == ErrorKind::NotFound => { return HttpResponse::NotFound().json(serde_json::json!({ "error": format!("Log file '{}' not found", filename) })); @@ -107,7 +166,7 @@ async fn download_log(path: web::Path) -> HttpResponse { Ok(_) => {} } - let content = match std::fs::read(&canonical) { + let content = match fs::read(&canonical) { Ok(bytes) => bytes, Err(e) => { return HttpResponse::InternalServerError().json(serde_json::json!({ diff --git a/net-guardia/src/adapter/http/ml.rs b/net-guardia/src/adapter/http/ml.rs index be52b8d..f23f9fa 100644 --- a/net-guardia/src/adapter/http/ml.rs +++ b/net-guardia/src/adapter/http/ml.rs @@ -1,15 +1,39 @@ use actix_web::{HttpResponse, Responder, Scope, web}; +use crate::core::auth::extractor::AuthClaims; +use crate::core::ml::adapter::ModelSourceState; use crate::core::ml::engine::Engine; +use crate::core::ml::inference::Inference; +use crate::infrastructure::communication_manager::CommunicationManager; +use crate::model::event::AuditEvent; + +/// Permission required to forcibly revert the active ML source to dormant. +/// Mirrors the upload handler's gate so swap-out and revert are symmetric: +/// without this, anyone holding `ai_detection:write` could disable the +/// detector silently while the upload path required `users:admin`. +const DORMANT_REQUIRED_PERMISSION: &str = "users:admin"; + +/// Actor prefix recorded on the WORM chain when an admin reverts the ML +/// source. Matches the prefix used by `model_swap` so downstream filters +/// see both events in the same admin-action stream. +const AUDIT_ACTOR_SECURITY_ADMIN_PREFIX: &str = "SecurityAdmin"; + +/// Action recorded on the WORM chain when the ML source is forced +/// dormant via this endpoint. Stable wire string — UI/audit tooling +/// filters on it, paired with `model_swap` from the upload path. +const AUDIT_ACTION_MODEL_DORMANT: &str = "model_dormant"; pub fn initialize() -> Scope { - web::scope("/ml").route("/status", web::get().to(get_status)) + web::scope("/ml") + .route("/status", web::get().to(get_status)) + .route("/models/current", web::get().to(get_current_model)) + .route("/models/current", web::delete().to(delete_current_model)) } async fn get_status(engine: web::Data) -> impl Responder { let trackers = engine.trackers(); let num_trackers = trackers.len(); - let total_flows: usize = trackers.iter().map(|t| t.lock().flow_count()).sum(); + let total_flows: usize = trackers.iter().map(|t| t.flow_count()).sum(); let has_traffic_logger = engine.has_traffic_logger(); HttpResponse::Ok().json(serde_json::json!({ @@ -20,3 +44,62 @@ async fn get_status(engine: web::Data) -> impl Responder { "inference_interval_secs": engine.inference_interval_secs(), })) } + +/// `GET /api/ml/models/current` — wire-format snapshot of the ML source +/// state the dashboard's ML Status panel renders. +async fn get_current_model(inference: web::Data) -> impl Responder { + let status = inference.current_status(); + let label = if status.is_active() { + "active" + } else if status.is_dormant() { + "dormant" + } else { + "error" + }; + HttpResponse::Ok().json(serde_json::json!({ + "label": label, + "status": status, + })) +} + +/// `DELETE /api/ml/models/current` — admin action: force the ML source back +/// to dormant. No-op when already dormant so the client can retry idempotently. +/// Requires `users:admin` (see `DORMANT_REQUIRED_PERMISSION`) and emits a +/// WORM `model_dormant` audit entry capturing the pre-revert state, mirroring +/// the upload path's `model_swap` so both swap-in and revert are auditable. +async fn delete_current_model( + inference: web::Data, + comm: web::Data, + claims: AuthClaims, +) -> impl Responder { + if !claims.permissions.iter().any(|p| p == DORMANT_REQUIRED_PERMISSION) { + return HttpResponse::Forbidden().json(serde_json::json!({ + "error": format!("model dormant requires the {DORMANT_REQUIRED_PERMISSION} permission"), + })); + } + + let before_status = inference.current_status(); + if before_status.is_dormant() { + return HttpResponse::Ok().json(serde_json::json!({ + "already_dormant": true, + })); + } + + inference.swap_state(ModelSourceState::Dormant); + + let audit_detail = serde_json::json!({ + "before": serde_json::to_value(&before_status).unwrap_or(serde_json::Value::Null), + }) + .to_string(); + let _ = comm + .publish_event(AuditEvent { + actor: format!("{AUDIT_ACTOR_SECURITY_ADMIN_PREFIX}@{}", claims.username), + action: AUDIT_ACTION_MODEL_DORMANT.to_string(), + detail: audit_detail, + }) + .await; + + HttpResponse::Ok().json(serde_json::json!({ + "already_dormant": false, + })) +} diff --git a/net-guardia/src/adapter/http/mod.rs b/net-guardia/src/adapter/http/mod.rs index 4c87808..11813d8 100644 --- a/net-guardia/src/adapter/http/mod.rs +++ b/net-guardia/src/adapter/http/mod.rs @@ -2,11 +2,15 @@ pub mod acl; pub mod api_keys; pub mod audit; pub mod auth; +pub mod byo; pub mod default; pub mod filter; +pub mod flow_trace; +pub mod fusion; pub mod health; pub mod logs; pub mod ml; +pub mod model_upload; pub mod notification; pub mod rate_limit; pub mod report; diff --git a/net-guardia/src/adapter/http/model_upload.rs b/net-guardia/src/adapter/http/model_upload.rs new file mode 100644 index 0000000..1def6c0 --- /dev/null +++ b/net-guardia/src/adapter/http/model_upload.rs @@ -0,0 +1,890 @@ +//! Multipart upload surface for BYO model files. Accepts a `manifest` +//! YAML field, an `onnx` binary field, and an optional `scaler` JSON +//! sidecar; streams each to `models/.staging//` with enforced +//! size caps, runs structural + ONNX shape validation, then atomically +//! renames into `models/` under a process-wide gate (AtomicBool, not a +//! mutex — see `PromoteGate`): a second concurrent promote is rejected +//! with 409 Conflict rather than queued. A WORM `model_swap` audit +//! entry records the SHA-256 of both committed files plus a snapshot +//! of the pre-swap state. +//! +//! Body-size caps come from `InferenceConfig::model_upload_max_*_bytes` +//! so admins can tune them from the settings DB without a rebuild. +//! Defaults: 100MB ONNX, 64KB manifest, 64KB scaler. Streaming writes +//! never buffer the full file in RAM, and staged directories are torn +//! down on any error path so failed uploads don't pile up in +//! `models/.staging/`. + +use std::fs as std_fs; +use std::fs::File as StdFile; +use std::io; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, SystemTime}; + +use actix_multipart::Multipart; +use actix_web::{HttpResponse, Responder, Scope, web}; +use futures_util::TryStreamExt; +use serde_json::Value as JsonValue; +use sha2::{Digest, Sha256}; +use tokio::fs; +use tokio::io::AsyncWriteExt; +use tokio::task; +use uuid::Uuid; + +use crate::core::auth::extractor::AuthClaims; +use crate::core::ml::inference::Inference; +use crate::core::ml::manifest::{AdapterKind, ModelManifest}; +use crate::core::ml::model_loader::build_adapter; +use crate::infrastructure::app_config::AppConfig; +use crate::infrastructure::communication_manager::CommunicationManager; +use crate::model::config::constants::{MANIFEST_FILENAME, MODELS_DIR, STAGING_SUBDIR}; +use crate::model::event::AuditEvent; +use crate::model::system::config::MLInferenceConfig; + +/// Multipart field names the client must use. Stable wire contract — +/// the frontend form generator depends on these exact strings. +const FIELD_MANIFEST: &str = "manifest"; +const FIELD_ONNX: &str = "onnx"; +const FIELD_SCALER: &str = "scaler"; + +/// Number of bytes of the ONNX body we inspect up-front for an obvious +/// non-Protobuf header. A fuller structural check (shape vs manifest +/// declared `features`) runs during `build_adapter` in the promote path. +const ONNX_SNIFF_BYTES: usize = 16; + +/// Permission required to drive the model-upload endpoint. The full +/// RBAC middleware lets anyone with `ai_detection:write` reach +/// `/api/ml/*`, but model promotion can replace the active detector — +/// gate it tighter at the handler layer so only administrators can +/// swap the ML source. +const PROMOTE_REQUIRED_PERMISSION: &str = "users:admin"; + +/// Actor prefix on the WORM `model_swap` audit entry. Administrators +/// that trigger the upload endpoint land on the chain as +/// `SecurityAdmin@` so downstream filters can separate +/// system-driven entries (actor="system") from human-driven ones +/// without parsing free-form text. Stable across releases — renaming +/// breaks downstream audit tooling that filters on this prefix. +const AUDIT_ACTOR_SECURITY_ADMIN_PREFIX: &str = "SecurityAdmin"; + +/// Action recorded on the WORM chain when a promote succeeds. Stable +/// wire string — fusion-explain tooling and future "who swapped the +/// model" views filter on it, so the rename must go through the audit +/// chain too. +const AUDIT_ACTION_MODEL_SWAP: &str = "model_swap"; + +/// Process-wide gate that ensures only one promote ever runs the rename +/// section at a time. The critical section is tiny (three `tokio::fs::rename` +/// syscalls) but must never interleave: a concurrent promote mid-rename could +/// leave `models/` pointing at a manifest whose ONNX hasn't landed yet. +/// +/// Unlike a mutex, the gate does not queue. A second concurrent promote sees +/// the gate held and gets `PromoteError::ConcurrentPromote` immediately — +/// administrators wanting to swap models should know another swap is in flight +/// rather than silently waiting behind it. +#[derive(Default)] +pub struct PromoteGate { + in_progress: AtomicBool, +} + +impl PromoteGate { + pub fn new() -> Self { + Self::default() + } + + /// Try to claim the gate. Returns `Some(guard)` on success; `None` when + /// another promote is already inside the rename section. The guard + /// releases the gate when dropped, including on panic. + fn try_acquire(&self) -> Option> { + if self + .in_progress + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + Some(PromoteGuard { gate: self }) + } else { + None + } + } +} + +struct PromoteGuard<'a> { + gate: &'a PromoteGate, +} + +impl Drop for PromoteGuard<'_> { + fn drop(&mut self) { + self.gate.in_progress.store(false, Ordering::Release); + } +} + +pub fn initialize() -> Scope { + // Mounted under `/ml/models/upload` so the entire ML model lifecycle + // (status, dormant, upload) lives under one URL subtree. The peer + // `/ml/models/current` GET/DELETE routes live in `ml::initialize()`; + // actix dispatches each path to whichever scope owns it. + web::scope("/ml/models").route("/upload", web::post().to(upload)) +} + +/// `POST /api/ml/models/upload` — multipart with `manifest` (YAML text), +/// `onnx` (binary), and optional `scaler` (JSON). Streams fields into +/// `models/.staging//`, validates the manifest + ONNX shape, and +/// atomically renames the triple into `models/` on success. A WORM +/// `model_swap` audit entry captures the SHA-256 pair plus the +/// pre-swap ML source state. The staging directory is always torn +/// down on the way out, even on success (post-promote it's empty). +async fn upload( + app_config: web::Data, + inference: web::Data, + comm: web::Data, + promote_lock: web::Data, + claims: AuthClaims, + payload: Multipart, +) -> impl Responder { + if !claims.permissions.iter().any(|p| p == PROMOTE_REQUIRED_PERMISSION) { + return HttpResponse::Forbidden().json(serde_json::json!({ + "error": format!("model upload requires the {PROMOTE_REQUIRED_PERMISSION} permission"), + })); + } + + let staging_root = PathBuf::from(MODELS_DIR).join(STAGING_SUBDIR); + let staging_id = Uuid::new_v4().to_string(); + let staging_dir = staging_root.join(&staging_id); + + let caps = UploadCaps { + manifest: app_config.inference.model_upload_max_manifest_bytes, + onnx: app_config.inference.model_upload_max_onnx_bytes, + scaler: app_config.inference.model_upload_max_scaler_bytes, + }; + let summary = match ingest_multipart(payload, &staging_dir, caps).await { + Ok(s) => s, + Err(e) => { + let _ = fs::remove_dir_all(&staging_dir).await; + return e.into_response(); + } + }; + + let batch_size = app_config.inference.inference_batch_size; + let outcome = validate_and_promote( + &staging_dir, + &summary, + inference.get_ref(), + comm.get_ref(), + promote_lock.get_ref(), + &claims.username, + batch_size, + ) + .await; + + // Always sweep staging — successful promote renames the files out, + // leaving a now-empty directory; failures leave partial state we + // don't want orbiting forever. + let _ = fs::remove_dir_all(&staging_dir).await; + + match outcome { + Ok(report) => HttpResponse::Ok().json(serde_json::json!({ + "promoted": true, + "staging_id": staging_id, + "manifest_bytes": summary.manifest_bytes, + "onnx_bytes": summary.onnx_bytes, + "scaler_bytes": summary.scaler_bytes, + "manifest_name": report.manifest_name, + "adapter_kind": report.adapter_kind, + "manifest_sha256": report.manifest_sha256, + "onnx_sha256": report.onnx_sha256, + })), + Err(e) => e.into_response(), + } +} + +/// Successful-path metadata the handler surfaces to the client. +#[derive(Debug)] +struct UploadSummary { + manifest_bytes: usize, + onnx_bytes: usize, + onnx_filename: String, + /// Bytes written for the optional scaler sidecar. `None` when the + /// field wasn't submitted at all. + scaler_bytes: Option, +} + +/// Per-field byte caps. Plumbed from `InferenceConfig` through the +/// handler so admins can tune caps from the DB without a code change. +#[derive(Debug, Clone, Copy)] +struct UploadCaps { + manifest: usize, + onnx: usize, + scaler: usize, +} + +/// Errors that can surface a specific HTTP response. Kept in-module +/// because none of these have callers outside this handler. +/// +/// The `*TooLarge(usize)` variants carry the admin-configured cap so +/// the response can tell the client which ceiling they hit without +/// having to query `/api/config` separately. +#[derive(Debug)] +enum UploadError { + MissingField(&'static str), + DuplicateField(&'static str), + UnknownField(String), + ManifestTooLarge(usize), + OnnxTooLarge(usize), + ScalerTooLarge(usize), + OnnxNotBinary, + StreamFailure(String), + StagingSetupFailure(String), +} + +impl UploadError { + fn into_response(self) -> HttpResponse { + let (status, message) = match self { + Self::MissingField(name) => (400, format!("missing required multipart field: {name}")), + Self::DuplicateField(name) => (400, format!("multipart field sent twice: {name}")), + Self::UnknownField(name) => (400, format!("unexpected multipart field: {name}")), + Self::ManifestTooLarge(max_bytes) => (413, format!("manifest exceeds {max_bytes} bytes")), + Self::OnnxTooLarge(max_bytes) => (413, format!("onnx exceeds {max_bytes} bytes")), + Self::ScalerTooLarge(max_bytes) => (413, format!("scaler exceeds {max_bytes} bytes")), + Self::OnnxNotBinary => ( + 400, + "onnx field does not look like a protobuf-encoded ONNX model".to_string(), + ), + Self::StreamFailure(err) => (400, format!("upload stream error: {err}")), + Self::StagingSetupFailure(err) => (500, format!("staging directory error: {err}")), + }; + let body = serde_json::json!({ "error": message }); + match status { + 400 => HttpResponse::BadRequest().json(body), + 413 => HttpResponse::PayloadTooLarge().json(body), + _ => HttpResponse::InternalServerError().json(body), + } + } +} + +async fn ingest_multipart( + mut payload: Multipart, + staging_dir: &Path, + caps: UploadCaps, +) -> Result { + fs::create_dir_all(staging_dir) + .await + .map_err(|e| UploadError::StagingSetupFailure(e.to_string()))?; + + let mut manifest_written: Option = None; + let mut onnx_summary: Option<(String, usize)> = None; + let mut scaler_summary: Option<(String, usize)> = None; + + while let Some(mut field) = payload + .try_next() + .await + .map_err(|e| UploadError::StreamFailure(e.to_string()))? + { + let field_name = field + .content_disposition() + .and_then(|cd| cd.get_name()) + .unwrap_or("") + .to_string(); + match field_name.as_str() { + FIELD_MANIFEST => { + if manifest_written.is_some() { + return Err(UploadError::DuplicateField(FIELD_MANIFEST)); + } + let dest = staging_dir.join(MANIFEST_FILENAME); + let written = stream_field_to_file(&mut field, &dest, caps.manifest, FieldKind::Manifest).await?; + manifest_written = Some(written); + } + FIELD_ONNX => { + if onnx_summary.is_some() { + return Err(UploadError::DuplicateField(FIELD_ONNX)); + } + let onnx_filename = field + .content_disposition() + .and_then(|cd| cd.get_filename()) + .map(sanitize_filename) + .unwrap_or_else(|| "model.onnx".to_string()); + let dest = staging_dir.join(&onnx_filename); + let written = stream_field_to_file(&mut field, &dest, caps.onnx, FieldKind::Onnx).await?; + onnx_summary = Some((onnx_filename, written)); + } + FIELD_SCALER => { + if scaler_summary.is_some() { + return Err(UploadError::DuplicateField(FIELD_SCALER)); + } + let scaler_filename = field + .content_disposition() + .and_then(|cd| cd.get_filename()) + .map(sanitize_filename) + .unwrap_or_else(|| "inference_config.json".to_string()); + let dest = staging_dir.join(&scaler_filename); + let written = stream_field_to_file(&mut field, &dest, caps.scaler, FieldKind::Scaler).await?; + scaler_summary = Some((scaler_filename, written)); + } + other => { + return Err(UploadError::UnknownField(other.to_string())); + } + } + } + + let manifest_bytes = manifest_written.ok_or(UploadError::MissingField(FIELD_MANIFEST))?; + let (onnx_filename, onnx_bytes) = onnx_summary.ok_or(UploadError::MissingField(FIELD_ONNX))?; + let scaler_bytes = scaler_summary.map(|(_, n)| n); + + Ok(UploadSummary { + manifest_bytes, + onnx_bytes, + onnx_filename, + scaler_bytes, + }) +} + +/// Discriminator for which size cap / sniff rule applies to a given +/// multipart field. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FieldKind { + Manifest, + Onnx, + Scaler, +} + +/// Stream a multipart field directly to disk. Aborts (and leaves the +/// caller to clean up) when the declared byte cap is exceeded or when +/// the binary sniff rejects the first chunk. +async fn stream_field_to_file( + field: &mut actix_multipart::Field, + dest: &Path, + max_bytes: usize, + kind: FieldKind, +) -> Result { + let mut file = fs::File::create(dest) + .await + .map_err(|e| UploadError::StagingSetupFailure(e.to_string()))?; + let mut total = 0usize; + let mut sniffed = kind != FieldKind::Onnx; + + while let Some(chunk) = field + .try_next() + .await + .map_err(|e| UploadError::StreamFailure(e.to_string()))? + { + if !sniffed { + // Cheap up-front validation: reject obvious non-ONNX blobs + // (empty first chunk, all-zero header, all-printable text). + if !looks_like_onnx(&chunk) { + return Err(UploadError::OnnxNotBinary); + } + sniffed = true; + } + total = total.saturating_add(chunk.len()); + if total > max_bytes { + return Err(match kind { + FieldKind::Manifest => UploadError::ManifestTooLarge(max_bytes), + FieldKind::Onnx => UploadError::OnnxTooLarge(max_bytes), + FieldKind::Scaler => UploadError::ScalerTooLarge(max_bytes), + }); + } + file.write_all(&chunk) + .await + .map_err(|e| UploadError::StreamFailure(e.to_string()))?; + } + file.flush() + .await + .map_err(|e| UploadError::StreamFailure(e.to_string()))?; + Ok(total) +} + +/// Shape-preserving filename sanitizer: keep the extension the client +/// sent (it may be `.onnx`, `.bin`, whatever), but strip any directory +/// traversal so the staging dir can never escape. +pub fn sanitize_filename(raw: impl AsRef) -> String { + let raw = raw.as_ref(); + let trimmed = Path::new(raw) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("model.onnx"); + if trimmed.is_empty() || trimmed == "." || trimmed == ".." { + "model.onnx".to_string() + } else { + trimmed.to_string() + } +} + +/// Reject manifest-declared filenames that aren't single-segment basenames. +/// The multipart layer sanitizes client-sent part filenames (silent rewrite), +/// but manifest fields like `models.model` and `preprocessing.scaler_sidecar` +/// are user-controlled YAML — a value such as `../../../etc/cron.d/evil` would +/// otherwise flow into `staging_dir.join(..)` / `models_dir.join(..)` and let +/// `users:admin` write outside the models tree. Reject loudly rather than +/// silently rewriting so an operator who fat-fingered a path sees the failure. +fn validate_manifest_basename(field: &str, value: &str) -> Result<(), PromoteError> { + if value.is_empty() { + return Err(PromoteError::ManifestInvalid(format!( + "manifest field {field} is empty" + ))); + } + if value.contains('/') || value.contains('\\') { + return Err(PromoteError::ManifestInvalid(format!( + "manifest field {field} must be a basename, not a path: {value:?}" + ))); + } + if value == "." || value == ".." || value.contains("..") { + return Err(PromoteError::ManifestInvalid(format!( + "manifest field {field} must not contain path-traversal segments: {value:?}" + ))); + } + if Path::new(value).is_absolute() { + return Err(PromoteError::ManifestInvalid(format!( + "manifest field {field} must be relative, not absolute: {value:?}" + ))); + } + if Path::new(value).file_name().and_then(|s| s.to_str()) != Some(value) { + return Err(PromoteError::ManifestInvalid(format!( + "manifest field {field} must be a plain basename: {value:?}" + ))); + } + Ok(()) +} + +/// Loose first-chunk heuristic. An ONNX protobuf starts with a varint +/// tag byte — the field=1 wire=varint (`0x08` for `ir_version`) and +/// field=1 wire=length-delimited (`0x0a`) patterns both occur in real +/// models — but enumerating positive accept patterns is fragile because +/// tract accepts several tag orderings. We instead: +/// +/// 1. Reject magic bytes of container formats that users routinely +/// upload by mistake (ZIP, PNG, PDF, ELF). +/// 2. Reject all-zero and all-printable-ASCII prefixes (buffers and +/// text files). +/// +/// The authoritative structural validation happens during +/// `build_adapter`; this heuristic's job is catching the obvious wrong +/// upload before the bytes hit disk. +pub fn looks_like_onnx(first_chunk: &[u8]) -> bool { + if first_chunk.is_empty() { + return false; + } + // Container formats that users commonly confuse with ONNX. + const BLOCKED_MAGICS: &[&[u8]] = &[ + b"PK\x03\x04", // ZIP / JAR / DOCX — some pipelines ship ONNX weights this way, + // but our upload path expects a single standalone .onnx file. + b"\x89PNG", + b"%PDF", + b"\x7fELF", + ]; + for magic in BLOCKED_MAGICS { + if first_chunk.starts_with(magic) { + return false; + } + } + let prefix = &first_chunk[..first_chunk.len().min(ONNX_SNIFF_BYTES)]; + if prefix.iter().all(|&b| b == 0) { + return false; + } + let mostly_ascii = prefix.iter().filter(|&&b| b.is_ascii_graphic() || b == b' ').count() >= prefix.len() - 1; + if mostly_ascii { + return false; + } + true +} + +/// Validate the staged manifest + ONNX + optional sidecar, then +/// atomically promote them into `models/`. The sequence is: +/// +/// 1. Parse and structurally validate `manifest.yaml` against the +/// `FEATURE_REGISTRY` plus manifest-level invariants. +/// 2. Reject `multi_task` adapters — v1 upload supports single-ONNX +/// models only; multi-task manifests reference two ONNX files and +/// need a different multipart shape. +/// 3. Rename the uploaded `.onnx` to the filename the manifest +/// declares in `models.model`. The client is free to ship the +/// binary with any user-facing name; the manifest is the canonical +/// layout the watcher rebuilds from. +/// 4. Run `from_manifest_with_sidecar` + `build_adapter` — this exercises +/// the same loader the hot-reload watcher will use after promote, +/// including the 5-second wall-clock budget around `tract`. If +/// anything fails here, nothing in `models/` has changed yet. +/// 5. SHA-256 both files and snapshot the `Inference` state for the +/// audit detail body before we mutate anything shared. +/// 6. Under `PromoteLock`, rename ONNX first, optional sidecar second, +/// manifest last. The manifest is the watcher's commit marker — +/// by landing it last we avoid the window where the watcher reads +/// a manifest that points at a not-yet-renamed ONNX. +/// 7. Publish a WORM `model_swap` audit event. Failure to publish is +/// logged but does not roll back the rename; the chain prefers a +/// missing audit entry to a rolled-back promote that a downstream +/// subscriber may already have reacted to. +async fn validate_and_promote( + staging_dir: &Path, + summary: &UploadSummary, + inference: &Inference, + comm: &CommunicationManager, + promote_lock: &PromoteGate, + actor_username: &str, + batch_size: usize, +) -> Result { + let staging_manifest = staging_dir.join(MANIFEST_FILENAME); + + // Structural manifest validation. The full `build_adapter` pipeline + // below will revisit this via `from_manifest_with_sidecar`, but a + // cheap up-front `load` surfaces manifest-only problems (bad YAML, + // unknown feature, missing `models.model`) before we rename anything. + let manifest_preview = + ModelManifest::load(&staging_manifest).map_err(|e| PromoteError::ManifestInvalid(e.to_string()))?; + + if matches!(manifest_preview.adapter, AdapterKind::MultiTask) { + return Err(PromoteError::UnsupportedAdapter); + } + + let declared_onnx = manifest_preview + .models + .model + .clone() + .ok_or_else(|| PromoteError::ManifestInvalid("single-onnx adapters require models.model".to_string()))?; + validate_manifest_basename("models.model", &declared_onnx)?; + if let Some(ref pp) = manifest_preview.preprocessing { + validate_manifest_basename("preprocessing.scaler_sidecar", &pp.scaler_sidecar)?; + } + + let uploaded_onnx = staging_dir.join(&summary.onnx_filename); + let staged_onnx = staging_dir.join(&declared_onnx); + if uploaded_onnx != staged_onnx { + fs::rename(&uploaded_onnx, &staged_onnx) + .await + .map_err(|e| PromoteError::StagingIo(format!("rename staged onnx: {e}")))?; + } + + // Full validate — sidecar reconciliation, ONNX shape vs manifest + // features, tract optimize+runnable under the 5s load budget. + let (config, manifest) = MLInferenceConfig::from_manifest_with_sidecar(&staging_manifest) + .map_err(|e| PromoteError::ValidationFailed(e.to_string()))?; + let _adapter = build_adapter(&manifest, Some(&staging_manifest), &config, batch_size) + .map_err(|e| PromoteError::ValidationFailed(e.to_string()))?; + + let manifest_sha256 = sha256_file(&staging_manifest) + .await + .map_err(|e| PromoteError::StagingIo(format!("sha256 manifest: {e}")))?; + let onnx_sha256 = sha256_file(&staged_onnx) + .await + .map_err(|e| PromoteError::StagingIo(format!("sha256 onnx: {e}")))?; + + let before_status = inference.current_status(); + + let _guard = promote_lock.try_acquire().ok_or(PromoteError::ConcurrentPromote)?; + let models_dir = PathBuf::from(MODELS_DIR); + let target_onnx = models_dir.join(&declared_onnx); + fs::rename(&staged_onnx, &target_onnx) + .await + .map_err(|e| PromoteError::PromoteIo(format!("rename onnx into models/: {e}")))?; + + if let Some(ref pp) = manifest.preprocessing { + validate_manifest_basename("preprocessing.scaler_sidecar", &pp.scaler_sidecar)?; + let src = staging_dir.join(&pp.scaler_sidecar); + let dst = models_dir.join(&pp.scaler_sidecar); + fs::rename(&src, &dst) + .await + .map_err(|e| PromoteError::PromoteIo(format!("rename sidecar into models/: {e}")))?; + } + + let target_manifest = models_dir.join(MANIFEST_FILENAME); + fs::rename(&staging_manifest, &target_manifest) + .await + .map_err(|e| PromoteError::PromoteIo(format!("rename manifest into models/: {e}")))?; + drop(_guard); + + let audit_detail = serde_json::json!({ + "manifest_name": manifest.name, + "adapter_kind": manifest.adapter.as_str(), + "manifest_sha256": manifest_sha256, + "onnx_sha256": onnx_sha256, + "before": serde_json::to_value(&before_status).unwrap_or(JsonValue::Null), + }) + .to_string(); + let _ = comm + .publish_event(AuditEvent { + actor: format!("{AUDIT_ACTOR_SECURITY_ADMIN_PREFIX}@{actor_username}"), + action: AUDIT_ACTION_MODEL_SWAP.to_string(), + detail: audit_detail, + }) + .await; + + Ok(PromoteReport { + manifest_name: manifest.name, + adapter_kind: manifest.adapter.as_str().to_string(), + manifest_sha256, + onnx_sha256, + }) +} + +/// Metadata surfaced back to the client when the promote succeeds. +#[derive(Debug)] +struct PromoteReport { + manifest_name: String, + adapter_kind: String, + manifest_sha256: String, + onnx_sha256: String, +} + +/// Validation / promote error taxonomy. Distinct from `UploadError` so +/// the two stages produce different HTTP status codes: staging-ingest +/// failures are typically client-facing (400/413), while validation +/// and rename failures are server-side (422/500). +#[derive(Debug)] +enum PromoteError { + ManifestInvalid(String), + ValidationFailed(String), + UnsupportedAdapter, + StagingIo(String), + PromoteIo(String), + ConcurrentPromote, +} + +impl PromoteError { + fn into_response(self) -> HttpResponse { + let (status, message) = match self { + Self::ManifestInvalid(err) => (422, format!("manifest invalid: {err}")), + Self::ValidationFailed(err) => (422, format!("model failed validation: {err}")), + Self::UnsupportedAdapter => ( + 422, + "multi_task adapter is not supported by the v1 upload flow — \ + submit an autoencoder_only or classifier_only manifest" + .to_string(), + ), + Self::StagingIo(err) => (500, format!("staging io error: {err}")), + Self::PromoteIo(err) => (500, format!("promote io error: {err}")), + Self::ConcurrentPromote => (409, "another model promote is already in progress".to_string()), + }; + let body = serde_json::json!({ "error": message }); + match status { + 422 => HttpResponse::UnprocessableEntity().json(body), + 409 => HttpResponse::Conflict().json(body), + _ => HttpResponse::InternalServerError().json(body), + } + } +} + +/// Read `path` in 64KB chunks and return its SHA-256 hex digest. +/// Offloaded to `spawn_blocking` so a large ONNX can't stall the +/// actix worker while the hash computes. +async fn sha256_file(path: &Path) -> io::Result { + let path = path.to_path_buf(); + task::spawn_blocking(move || -> io::Result { + let mut file = StdFile::open(&path)?; + let mut hasher = Sha256::new(); + let mut buf = [0u8; 64 * 1024]; + loop { + let n = file.read(&mut buf)?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + } + let out = hasher.finalize(); + let mut hex = String::with_capacity(64); + for byte in out { + use std::fmt::Write; + // SAFETY: write! on a String is infallible. + let _ = write!(&mut hex, "{byte:02x}"); + } + Ok(hex) + }) + .await + .unwrap_or_else(|e| Err(io::Error::other(format!("sha256 join: {e}")))) +} + +/// Remove staging subdirectories older than `max_age`. Runs on startup +/// and on a periodic timer so failed uploads don't accumulate. +pub fn clean_staging_orphans(staging_root: &Path, max_age: Duration) -> io::Result { + if !staging_root.exists() { + return Ok(0); + } + let now = SystemTime::now(); + let mut cleaned = 0usize; + for entry in std_fs::read_dir(staging_root)? { + let entry = entry?; + let path = entry.path(); + if !path.is_dir() { + continue; + } + let metadata = entry.metadata()?; + let mtime = metadata.modified()?; + let age = now.duration_since(mtime).unwrap_or_default(); + if age >= max_age { + std_fs::remove_dir_all(&path)?; + cleaned += 1; + } + } + Ok(cleaned) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + + #[test] + fn onnx_sniff_rejects_empty() { + assert!(!looks_like_onnx(&[])); + } + + #[test] + fn onnx_sniff_rejects_zero_padded_prefix() { + assert!(!looks_like_onnx(&[0u8; 32])); + } + + #[test] + fn onnx_sniff_rejects_plain_text() { + // A YAML or plain-text payload that ended up in the wrong field. + assert!(!looks_like_onnx(b"name: wrong-file\nkind: yaml\n")); + assert!(!looks_like_onnx(b"PK\x03\x04")); + } + + #[test] + fn onnx_sniff_accepts_varint_tag_prefix() { + // `0x08` = tag field 1, wire-type varint (ir_version). Real ONNX + // files commonly open with this. + let buf = [0x08u8, 0x07, 0x12, 0x0a, 0x70, 0x79, 0x74, 0x6f, 0x72, 0x63, 0x68, 0x00]; + assert!(looks_like_onnx(&buf)); + } + + #[test] + fn onnx_sniff_accepts_length_delimited_tag() { + // `0x0a` = tag field 1, wire-type length-delimited. Also valid. + let buf = [0x0au8, 0x10, 0x80, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07]; + assert!(looks_like_onnx(&buf)); + } + + #[test] + fn sanitize_filename_strips_directory_components() { + assert_eq!(sanitize_filename("../../etc/passwd"), "passwd"); + assert_eq!(sanitize_filename("subdir/model.onnx"), "model.onnx"); + assert_eq!(sanitize_filename("/abs/path/classifier.onnx"), "classifier.onnx"); + } + + #[test] + fn sanitize_filename_rejects_degenerate_values() { + assert_eq!(sanitize_filename(""), "model.onnx"); + assert_eq!(sanitize_filename("."), "model.onnx"); + assert_eq!(sanitize_filename(".."), "model.onnx"); + } + + #[test] + fn orphan_cleanup_removes_every_dir_when_max_age_is_zero() { + // A zero-length max age declares every existing entry stale, so + // the helper must sweep all of them. Portable without touching + // filesystem mtime APIs. + let tmp = std::env::temp_dir().join(format!("nguardia-staging-test-{}", Uuid::new_v4())); + std::fs::create_dir_all(&tmp).unwrap(); + std::fs::create_dir_all(tmp.join("abandoned-1")).unwrap(); + std::fs::create_dir_all(tmp.join("abandoned-2")).unwrap(); + // A file (not a dir) should be ignored by the sweep. + std::fs::write(tmp.join("sidecar.log"), b"noise").unwrap(); + + let cleaned = clean_staging_orphans(&tmp, Duration::ZERO).unwrap(); + assert_eq!(cleaned, 2); + assert!(!tmp.join("abandoned-1").exists()); + assert!(!tmp.join("abandoned-2").exists()); + assert!(tmp.join("sidecar.log").exists(), "non-directory entries must survive"); + + std::fs::remove_dir_all(&tmp).ok(); + } + + #[test] + fn orphan_cleanup_preserves_fresh_directories() { + // With a generous max_age, a freshly-created directory must not + // be touched — the positive case of the time-guard. + let tmp = std::env::temp_dir().join(format!("nguardia-staging-fresh-{}", Uuid::new_v4())); + std::fs::create_dir_all(&tmp).unwrap(); + std::fs::create_dir_all(tmp.join("recent")).unwrap(); + + let cleaned = clean_staging_orphans(&tmp, Duration::from_secs(3600)).unwrap(); + assert_eq!(cleaned, 0); + assert!(tmp.join("recent").exists()); + + std::fs::remove_dir_all(&tmp).ok(); + } + + #[test] + fn orphan_cleanup_is_noop_when_staging_root_missing() { + let missing = PathBuf::from("/nonexistent/staging/path/for/test"); + let cleaned = clean_staging_orphans(&missing, Duration::from_secs(60)).unwrap(); + assert_eq!(cleaned, 0); + } + + #[tokio::test] + async fn sha256_file_produces_known_hex_digest() { + // Canonical NIST-style empty-string vector: the SHA-256 of the + // empty byte sequence is the hex digest below. Asserting the + // concrete value guards against a silently-swapped hash impl. + let tmp = std::env::temp_dir().join(format!("nguardia-sha256-empty-{}", Uuid::new_v4())); + std::fs::write(&tmp, b"").unwrap(); + let hex = sha256_file(&tmp).await.expect("hash empty file"); + assert_eq!(hex, "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"); + std::fs::remove_file(&tmp).ok(); + } + + #[tokio::test] + async fn sha256_file_hex_matches_multi_chunk_content() { + // A payload larger than the 64KB internal read buffer so the + // chunk-loop path actually executes; "abc" repeated until > 64KB. + let tmp = std::env::temp_dir().join(format!("nguardia-sha256-bulk-{}", Uuid::new_v4())); + let payload = "abc".repeat(30_000); + std::fs::write(&tmp, payload.as_bytes()).unwrap(); + let hex = sha256_file(&tmp).await.expect("hash large file"); + let mut hasher = Sha256::new(); + hasher.update(payload.as_bytes()); + let expected = hasher.finalize(); + let expected_hex: String = expected.iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!(hex, expected_hex); + std::fs::remove_file(&tmp).ok(); + } + + #[test] + fn promote_error_validation_failure_maps_to_422() { + let resp = PromoteError::ValidationFailed("shape mismatch".into()).into_response(); + assert_eq!(resp.status().as_u16(), 422); + } + + #[test] + fn promote_error_manifest_invalid_maps_to_422() { + let resp = PromoteError::ManifestInvalid("bad yaml".into()).into_response(); + assert_eq!(resp.status().as_u16(), 422); + } + + #[test] + fn promote_error_unsupported_adapter_maps_to_422() { + let resp = PromoteError::UnsupportedAdapter.into_response(); + assert_eq!(resp.status().as_u16(), 422); + } + + #[test] + fn promote_error_staging_io_maps_to_500() { + let resp = PromoteError::StagingIo("disk full".into()).into_response(); + assert_eq!(resp.status().as_u16(), 500); + } + + #[test] + fn promote_error_promote_io_maps_to_500() { + let resp = PromoteError::PromoteIo("rename failed".into()).into_response(); + assert_eq!(resp.status().as_u16(), 500); + } + + #[test] + fn upload_error_scaler_too_large_maps_to_413_and_echoes_cap() { + let resp = UploadError::ScalerTooLarge(1234).into_response(); + assert_eq!(resp.status().as_u16(), 413); + } + + #[test] + fn upload_error_onnx_too_large_echoes_configured_cap_in_message() { + // Dynamic cap from config must reach the client verbatim — this + // guards against a future refactor that silently drops the cap + // from the format string. + let rendered = format!("{:?}", UploadError::OnnxTooLarge(7_000_000)); + assert!( + rendered.contains("7000000"), + "rendered error must include the cap: {rendered}" + ); + } +} diff --git a/net-guardia/src/adapter/http/report.rs b/net-guardia/src/adapter/http/report.rs index 9ff06e3..3c50057 100644 --- a/net-guardia/src/adapter/http/report.rs +++ b/net-guardia/src/adapter/http/report.rs @@ -1,12 +1,17 @@ +use std::fs; + use actix_web::{HttpResponse, Scope, web}; +use chrono::Local; +use tokio::task::spawn_blocking; use crate::adapter::persistence::Database; use crate::core::auth::extractor::AuthClaims; +use crate::core::email::report::generate_weekly_report; use crate::core::email::scheduler::SmtpClient; use crate::core::report::engine; use crate::infrastructure::secret_store::SecretStore; -use crate::interface::port::repository::RepositoryPort; use crate::interface::port::secret_store::SecretStorePort; +use crate::interface::port::setting::SettingRepo; pub fn initialize() -> Scope { web::scope("/report") .route("/generate", web::post().to(generate_report)) @@ -20,14 +25,14 @@ async fn generate_report(_auth: AuthClaims, db: web::Data) -> HttpResp .ok() .flatten() .unwrap_or_else(|| "/var/lib/netguardia/reports".to_string()); - if let Err(e) = std::fs::create_dir_all(&report_dir) { + if let Err(e) = fs::create_dir_all(&report_dir) { return HttpResponse::InternalServerError().json(serde_json::json!({ "error": format!("Failed to create report directory: {}", e) })); } let db_ref = db.get_ref(); - match engine::generate_html_report(db_ref as &dyn RepositoryPort, &report_dir) { - Ok(path) => match std::fs::read(&path) { + match engine::generate_html_report(db_ref as &dyn SettingRepo, &report_dir) { + Ok(path) => match fs::read(&path) { Ok(content) => HttpResponse::Ok() .content_type("text/html; charset=utf-8") .insert_header(( @@ -52,7 +57,7 @@ async fn generate_report(_auth: AuthClaims, db: web::Data) -> HttpResp async fn report_data(_auth: AuthClaims, db: web::Data) -> HttpResponse { let db_ref = db.get_ref(); - match engine::generate_report_json(db_ref as &dyn RepositoryPort) { + match engine::generate_report_json(db_ref as &dyn SettingRepo) { Ok(data) => HttpResponse::Ok().json(data), Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), } @@ -60,7 +65,7 @@ async fn report_data(_auth: AuthClaims, db: web::Data) -> HttpResponse /// Manually trigger: generate the weekly report and send it via SMTP now. async fn send_report(_auth: AuthClaims, db: web::Data, secrets: web::Data) -> HttpResponse { - let db_ref = db.get_ref() as &dyn RepositoryPort; + let db_ref = db.get_ref() as &dyn SettingRepo; let secrets_ref = secrets.get_ref() as &dyn SecretStorePort; let smtp = match SmtpClient::from_database(db_ref, Some(secrets_ref)) { @@ -89,7 +94,7 @@ async fn send_report(_auth: AuthClaims, db: web::Data, secrets: web::D } }; - let html = match crate::core::email::report::generate_weekly_report(db_ref) { + let html = match generate_weekly_report(db_ref) { Ok(h) => h, Err(e) => { return HttpResponse::InternalServerError().json(serde_json::json!({ @@ -99,9 +104,9 @@ async fn send_report(_auth: AuthClaims, db: web::Data, secrets: web::D } }; - let subject = format!("NetGuardia Weekly Report — {}", chrono::Local::now().format("%Y-%m-%d")); + let subject = format!("NetGuardia Weekly Report — {}", Local::now().format("%Y-%m-%d")); - let send_result = tokio::task::spawn_blocking(move || smtp.send(&recipient, &subject, &html)).await; + let send_result = spawn_blocking(move || smtp.send(&recipient, &subject, &html)).await; match send_result { Ok(Ok(())) => HttpResponse::Ok().json(serde_json::json!({ diff --git a/net-guardia/src/adapter/http/setup.rs b/net-guardia/src/adapter/http/setup.rs index 7959be6..4f39bcb 100644 --- a/net-guardia/src/adapter/http/setup.rs +++ b/net-guardia/src/adapter/http/setup.rs @@ -1,14 +1,18 @@ -use actix_web::{HttpResponse, Scope, web}; -use serde::Deserialize; +use std::fs; +use std::path::Path; use std::sync::atomic::Ordering; +use actix_web::{HttpResponse, Scope, web}; use macros::log; +use serde::Deserialize; +use serde_json::Value; use crate::adapter::persistence::Database; use crate::core::auth::password; use crate::core::auth::setup_guard::SetupCompleteFlag; use crate::infrastructure::secret_store::SecretStore; use crate::interface::port::secret_store::SecretStorePort; +use crate::model::error::Error; use crate::model::error::system::SystemError; pub fn initialize() -> Scope { @@ -27,7 +31,7 @@ async fn setup_status(setup_flag: web::Data) -> HttpResponse async fn list_interfaces() -> HttpResponse { // List available network interfaces - let interfaces: Vec = match std::fs::read_dir("/sys/class/net") { + let interfaces: Vec = match fs::read_dir("/sys/class/net") { Ok(entries) => entries .filter_map(|e| e.ok()) .map(|e| { @@ -98,7 +102,7 @@ async fn complete_setup( })); } let iface_path = format!("/sys/class/net/{}", iface); - if !std::path::Path::new(&iface_path).exists() { + if !Path::new(&iface_path).exists() { return HttpResponse::BadRequest().json(serde_json::json!({ "error": format!("Network interface '{}' not found", iface) })); @@ -172,52 +176,7 @@ async fn complete_setup( })) } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_valid_interface_names() { - assert!(is_valid_interface_name("eth0")); - assert!(is_valid_interface_name("ens33")); - assert!(is_valid_interface_name("br-lan")); - assert!(is_valid_interface_name("wlan0.1")); - } - - #[test] - fn test_invalid_interface_empty() { - assert!(!is_valid_interface_name("")); - } - - #[test] - fn test_invalid_interface_too_long() { - let long = "a".repeat(17); - assert!(!is_valid_interface_name(&long)); - // Exactly 16 should be valid - let exact = "a".repeat(16); - assert!(is_valid_interface_name(&exact)); - } - - #[test] - fn test_invalid_interface_path_traversal() { - assert!(!is_valid_interface_name("../etc")); - assert!(!is_valid_interface_name("../../shadow")); - assert!(!is_valid_interface_name("/sys/class")); - } - - #[test] - fn test_invalid_interface_special_chars() { - assert!(!is_valid_interface_name("eth0;rm")); - assert!(!is_valid_interface_name("lo&&cat")); - assert!(!is_valid_interface_name("eth0 space")); - } -} - -fn save_config( - db: &Database, - secrets: &dyn SecretStorePort, - req: &SetupRequest, -) -> Result<(), crate::model::error::Error> { +fn save_config(db: &Database, secrets: &dyn SecretStorePort, req: &SetupRequest) -> Result<(), Error> { // Save network config db.set_setting("ingress_interface", &req.ingress_interface)?; db.set_setting("egress_interface", &req.egress_interface)?; @@ -258,3 +217,44 @@ fn save_config( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_valid_interface_names() { + assert!(is_valid_interface_name("eth0")); + assert!(is_valid_interface_name("ens33")); + assert!(is_valid_interface_name("br-lan")); + assert!(is_valid_interface_name("wlan0.1")); + } + + #[test] + fn test_invalid_interface_empty() { + assert!(!is_valid_interface_name("")); + } + + #[test] + fn test_invalid_interface_too_long() { + let long = "a".repeat(17); + assert!(!is_valid_interface_name(&long)); + // Exactly 16 should be valid + let exact = "a".repeat(16); + assert!(is_valid_interface_name(&exact)); + } + + #[test] + fn test_invalid_interface_path_traversal() { + assert!(!is_valid_interface_name("../etc")); + assert!(!is_valid_interface_name("../../shadow")); + assert!(!is_valid_interface_name("/sys/class")); + } + + #[test] + fn test_invalid_interface_special_chars() { + assert!(!is_valid_interface_name("eth0;rm")); + assert!(!is_valid_interface_name("lo&&cat")); + assert!(!is_valid_interface_name("eth0 space")); + } +} diff --git a/net-guardia/src/adapter/http/soar.rs b/net-guardia/src/adapter/http/soar.rs index c98e709..e495696 100644 --- a/net-guardia/src/adapter/http/soar.rs +++ b/net-guardia/src/adapter/http/soar.rs @@ -1,8 +1,12 @@ +use std::str::FromStr; + use actix_web::{HttpResponse, Scope, web}; use serde::Deserialize; use crate::core::auth::extractor::AuthClaims; use crate::core::playbook_service::PlaybookService; +use crate::core::soar::engine::SoarEngine; +use crate::model::event::{DetectionSource, ThreatDetectedEvent}; use crate::model::soar::playbook_data::{CreateConditionInput, CreatePlaybookInput}; #[derive(Deserialize)] @@ -44,6 +48,7 @@ pub fn initialize() -> Scope { .route("/whitelist", web::get().to(list_whitelist)) .route("/whitelist", web::post().to(add_whitelist)) .route("/whitelist/{ip}", web::delete().to(remove_whitelist)) + .route("/dry-run", web::post().to(dry_run)) } async fn list_playbooks(_auth: AuthClaims, svc: web::Data) -> HttpResponse { @@ -317,3 +322,83 @@ async fn remove_whitelist(_auth: AuthClaims, svc: web::Data, pa Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), } } + +/// Client shape for `POST /api/soar/dry-run`. Only the fields a SOAR +/// matcher actually reads are carried — `dest_ip`, `protocol`, +/// `packet_rate`, `flow_count` participate in neither trigger-matching +/// nor condition evaluation, so accepting them would just invite +/// confusion. Sensible defaults fill in the rest of the synthetic +/// `ThreatDetectedEvent` body. +#[derive(Deserialize)] +struct DryRunRequest { + attack_type: String, + confidence: f32, + source_ip: String, + #[serde(default)] + sources: Option>, + #[serde(default)] + active_source_count: Option, + #[serde(default)] + fused_confidence: Option, + #[serde(default)] + geoip_country: Option, + #[serde(default)] + is_repeat_offender: Option, +} + +/// `POST /api/soar/dry-run` — simulate every enabled playbook against +/// a synthetic event. No actions execute, no cooldown or frequency +/// state gets recorded. Useful for an admin who just edited a +/// playbook's conditions and wants to sanity-check the match logic +/// before enabling it. +async fn dry_run(_auth: AuthClaims, engine: web::Data, body: web::Json) -> HttpResponse { + let event = match build_event(body.into_inner()) { + Ok(e) => e, + Err(msg) => { + return HttpResponse::BadRequest().json(serde_json::json!({ "error": msg })); + } + }; + let matches = engine.dry_run(&event); + HttpResponse::Ok().json(serde_json::json!({ + "match_count": matches.iter().filter(|m| m.would_fire).count(), + "playbooks_evaluated": matches.len(), + "results": matches, + })) +} + +/// Translate a wire `DryRunRequest` into a synthetic `ThreatDetectedEvent`. +/// Errors on typo'd `DetectionSource` names so an admin dry-running a +/// `SingleSourceHigh` condition doesn't silently get an empty sources +/// vector and a "doesn't match" result they misread as the playbook +/// being broken. +fn build_event(req: DryRunRequest) -> Result { + let sources: Vec = match req.sources { + Some(names) => names + .iter() + .map(|n| DetectionSource::from_str(n).map_err(|_| format!("unknown DetectionSource: {n}"))) + .collect::, _>>()?, + None => vec![DetectionSource::ML], + }; + if sources.is_empty() { + return Err("sources[] must contain at least one DetectionSource (send null to default to [ML])".to_string()); + } + let active_source_count = req.active_source_count.unwrap_or(sources.len()); + let fused_confidence = req.fused_confidence.unwrap_or(req.confidence); + Ok(ThreatDetectedEvent { + attack_type: req.attack_type, + confidence: req.confidence, + source_ip: req.source_ip, + dest_ip: "0.0.0.0".to_string(), + flow_count: 1, + packet_rate: 0.0, + protocol: 6, + geoip_country: req.geoip_country, + is_repeat_offender: req.is_repeat_offender.unwrap_or(false), + sources, + active_source_count, + fused_confidence, + ae_score: 0.0, + anomaly_score: 0.0, + c2_score: 0.0, + }) +} diff --git a/net-guardia/src/adapter/http/stats.rs b/net-guardia/src/adapter/http/stats.rs index e7f3892..8728d2e 100644 --- a/net-guardia/src/adapter/http/stats.rs +++ b/net-guardia/src/adapter/http/stats.rs @@ -1,6 +1,6 @@ use actix_web::{HttpResponse, Responder, Scope, web}; -use crate::core::ebpf::drop_monitor::DropMonitor; +use crate::adapter::ebpf::drop_monitor::DropMonitor; use crate::infrastructure::statistics::FlowStatistics; pub fn initialize() -> Scope { diff --git a/net-guardia/src/adapter/http/system.rs b/net-guardia/src/adapter/http/system.rs index d649809..7fc0ce4 100644 --- a/net-guardia/src/adapter/http/system.rs +++ b/net-guardia/src/adapter/http/system.rs @@ -1,14 +1,17 @@ use actix_web::{HttpResponse, Responder, Scope, web}; use serde::Deserialize; +use crate::core::auth::extractor::AuthClaims; use crate::core::config_service::ConfigService; -use crate::core::system::{ShutdownHandle, ShutdownMode}; use crate::infrastructure::communication_manager::CommunicationManager; +use crate::infrastructure::system::{ShutdownHandle, ShutdownMode}; use crate::interface::communication::command_types::ChangeEnforceModeCommand; use crate::interface::communication::query_types::GetEnforceModeQuery; -use crate::interface::port::repository::RepositoryPort; +use crate::interface::port::app_repo::AppRepo; +use crate::utils::boot_time; +use crate::utils::logging::Logging; -type Repo = dyn RepositoryPort; +type Repo = dyn AppRepo; #[derive(Deserialize)] struct EnforceModeRequest { @@ -30,7 +33,7 @@ pub fn initialize() -> Scope { } async fn get_boot_time() -> impl Responder { - HttpResponse::Ok().json(crate::utils::boot_time::boot_time()) + HttpResponse::Ok().json(boot_time::boot_time()) } async fn get_enforce_mode(comm: web::Data) -> impl Responder { @@ -80,7 +83,7 @@ async fn get_config(svc: web::Data) -> impl Responder { async fn get_log_level() -> impl Responder { HttpResponse::Ok().json(serde_json::json!({ - "level": crate::utils::logging::Logging::current_level(), + "level": Logging::current_level(), })) } @@ -90,7 +93,7 @@ struct LogLevelRequest { } async fn set_log_level(body: web::Json) -> impl Responder { - match crate::utils::logging::Logging::set_level(&body.level) { + match Logging::set_level(&body.level) { Ok(new_level) => HttpResponse::Ok().json(serde_json::json!({ "level": new_level, "message": "Log level updated", @@ -133,7 +136,10 @@ async fn update_config( } } -async fn shutdown(handle: web::Data) -> impl Responder { +async fn shutdown(auth: AuthClaims, handle: web::Data) -> impl Responder { + if !auth.permissions.iter().any(|p| p == "system:admin") { + return HttpResponse::Forbidden().json(serde_json::json!({"error": "Requires system:admin permission"})); + } if handle.trigger(ShutdownMode::Shutdown) { HttpResponse::Ok().json(serde_json::json!({"message": "Shutdown initiated"})) } else { @@ -141,7 +147,10 @@ async fn shutdown(handle: web::Data) -> impl Responder { } } -async fn restart(handle: web::Data) -> impl Responder { +async fn restart(auth: AuthClaims, handle: web::Data) -> impl Responder { + if !auth.permissions.iter().any(|p| p == "system:admin") { + return HttpResponse::Forbidden().json(serde_json::json!({"error": "Requires system:admin permission"})); + } if handle.trigger(ShutdownMode::Restart) { HttpResponse::Ok().json(serde_json::json!({"message": "Restart initiated"})) } else { diff --git a/net-guardia/src/adapter/mod.rs b/net-guardia/src/adapter/mod.rs index c0d2ab8..0670e9f 100644 --- a/net-guardia/src/adapter/mod.rs +++ b/net-guardia/src/adapter/mod.rs @@ -1,4 +1,5 @@ pub mod access_control_adapter; +pub mod ebpf; pub mod http; pub mod persistence; pub mod telegram; diff --git a/net-guardia/src/adapter/persistence/repository.rs b/net-guardia/src/adapter/persistence/repository.rs index c06d702..c32d895 100644 --- a/net-guardia/src/adapter/persistence/repository.rs +++ b/net-guardia/src/adapter/persistence/repository.rs @@ -1,18 +1,32 @@ +use std::collections::{HashMap, HashSet}; +use std::env; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use chrono::Utc; +use macros::log; use r2d2::Pool; use r2d2_sqlite::SqliteConnectionManager; -use rusqlite::params; -use std::collections::HashMap; - -use macros::log; +use rusqlite::{self, Connection, Error as RusqliteError, params}; +use crate::interface::port::acl::{AclRepo, AclRuleTuple}; +use crate::interface::port::api_key::{ApiKeyListItem, ApiKeyRepo}; +use crate::interface::port::audit::{AuditLogEntry, AuditRepo}; +use crate::interface::port::db_admin::DbAdminRepo; +use crate::interface::port::enforcement::EnforcementRepo; +use crate::interface::port::identity::{IdentityRepo, UserGroupTuple, UserTuple, UserWithGroups}; +use crate::interface::port::setting::SettingRepo; +use crate::interface::port::soar::{PlaybookRow, SoarExecutionRow, SoarRepo}; +use crate::interface::port::stats::StatsRepo; use crate::model::error::Error; use crate::model::error::database::DatabaseError; +use crate::model::identity::auth::Claims; use crate::model::log::misc::MiscLog; +use crate::model::soar::playbook_data::UpdatePlaybookRow; /// Reads the SQLCipher encryption key from the environment variable `NETGUARDIA_DB_KEY`. /// Returns `Some(key)` if set and non-empty, `None` otherwise (dev / unencrypted mode). fn db_encryption_key() -> Option { - match std::env::var("NETGUARDIA_DB_KEY") { + match env::var("NETGUARDIA_DB_KEY") { Ok(k) if !k.is_empty() => Some(k), _ => None, } @@ -33,35 +47,41 @@ impl r2d2::CustomizeConnection for Sqlite // Use a parameterised query to avoid SQL-injection via the key value. conn.pragma_update(None, "key", key)?; } - conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?; + // PRAGMA tuning notes: + // - `journal_mode=WAL`: many concurrent readers + one writer; the only + // journal mode that survives crashes without losing committed rows. + // - `synchronous=NORMAL`: canonical pairing with WAL — `FULL` adds an + // extra fsync per commit that buys no durability guarantees beyond + // what WAL already provides for a power-loss event. + // - `busy_timeout=5000`: WAL still serializes writers (SOAR, audit, + // drift, SQL hooks all share one DB), and the default 0ms returns + // SQLITE_BUSY immediately on any contention. 5s gives the loser + // enough time to wait out a normal commit (sub-ms) without masking + // genuine deadlocks. + // - `foreign_keys=ON`: enforce FK constraints at the connection + // level (SQLite's default is OFF for backwards compatibility). + conn.execute_batch( + "PRAGMA journal_mode=WAL; \ + PRAGMA synchronous=NORMAL; \ + PRAGMA busy_timeout=5000; \ + PRAGMA foreign_keys=ON;", + )?; Ok(()) } } -pub struct AuditLogEntry { - pub id: i64, - pub actor: String, - pub action: String, - pub detail: String, - pub created_at: String, -} - pub struct Database { pool: Pool, + /// HMAC-SHA256 key for API key hashing, derived from NETGUARDIA_SECRETS_KEY. + api_key_hmac: [u8; 32], } impl Database { pub fn new(path: &str) -> Result { let encryption_key = db_encryption_key(); - // For on-disk databases with an encryption key, attempt transparent migration - // from a plaintext SQLite database to an encrypted SQLCipher database. - if path != ":memory:" { - if let Some(ref key) = encryption_key { - Self::migrate_plaintext_to_encrypted(path, key)?; - } else { - log!(MiscLog::DbEncryptionDisabled); - } + if path != ":memory:" && encryption_key.is_none() { + log!(MiscLog::DbEncryptionDisabled); } let manager = if path == ":memory:" { @@ -78,161 +98,106 @@ impl Database { .max_size(if path == ":memory:" { 1 } else { 6 }) .connection_customizer(Box::new(customizer)) .build(manager) - .map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?; + .map_err(DatabaseError::QueryFailed)?; // Verify the pool is actually usable (catches wrong key / corrupt DB early). { - let test_conn = pool - .get() - .map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?; + let test_conn = pool.get().map_err(DatabaseError::QueryFailed)?; test_conn .query_row("SELECT count(*) FROM sqlite_master", [], |_| Ok(())) - .map_err(|_| DatabaseError::QueryFailed { - reason: "Database encryption key is incorrect or database is corrupted".to_string(), - })?; + .map_err(|_| DatabaseError::EncryptionKeyInvalid)?; } - let db = Self { pool }; + let api_key_hmac = Self::derive_api_key_hmac(); + let db = Self { pool, api_key_hmac }; db.create_tables()?; Ok(db) } - /// One-time migration: if the DB file exists and is a *plaintext* SQLite database - /// (i.e. opening it with the encryption key fails, but opening without a key - /// succeeds), export it to a new encrypted file and atomically replace the original. - fn migrate_plaintext_to_encrypted(path: &str, key: &str) -> Result<(), Error> { - use std::path::Path; + /// Derive HMAC-SHA256 key for API key hashing from NETGUARDIA_SECRETS_KEY. + /// Falls back to a static dev key if the env var is unset. + fn derive_api_key_hmac() -> [u8; 32] { + use hkdf::Hkdf; + use sha2::Sha256; - let db_path = Path::new(path); - if !db_path.exists() { - return Ok(()); // brand-new DB — nothing to migrate - } - - // Try opening with the key — if it works, the DB is already encrypted. - { - let conn = - rusqlite::Connection::open(path).map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?; - conn.pragma_update(None, "key", key) - .map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?; - if conn - .query_row("SELECT count(*) FROM sqlite_master", [], |_| Ok(())) - .is_ok() - { - return Ok(()); // already encrypted — nothing to do - } - } - - // Try opening *without* a key — if this also fails the file is corrupted. - { - let conn = - rusqlite::Connection::open(path).map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?; - if conn - .query_row("SELECT count(*) FROM sqlite_master", [], |_| Ok(())) - .is_err() - { - log!(MiscLog::DbMigrationSkipped); - return Err(DatabaseError::QueryFailed { - reason: "Database encryption key is incorrect or database is corrupted".to_string(), - } - .into()); - } - } - - // The DB is plaintext and we have a key → migrate via temp file. - let tmp_path = format!("{path}.migrating"); - log!(MiscLog::DbMigrationStarted); - - let result = (|| -> Result<(), Error> { - let conn = - rusqlite::Connection::open(path).map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?; - - // Attach a new encrypted database. - conn.execute_batch(&format!( - "ATTACH DATABASE '{}' AS encrypted KEY '{}';", - tmp_path.replace('\'', "''"), - key.replace('\'', "''"), - )) - .map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?; - - // Export everything from the plaintext DB into the encrypted one. - conn.query_row("SELECT sqlcipher_export('encrypted')", [], |_| Ok(())) - .map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?; - - conn.execute_batch("DETACH DATABASE encrypted;") - .map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?; - Ok(()) - })(); - - match result { - Ok(()) => { - // Atomic replace. - std::fs::rename(&tmp_path, path).map_err(|e| DatabaseError::QueryFailed { - reason: format!("Failed to replace DB file after migration: {e}"), - })?; - log!(MiscLog::DbMigrationCompleted); - Ok(()) - } - Err(e) => { - // Clean up temp file; leave original untouched. - let _ = std::fs::remove_file(&tmp_path); - log!(MiscLog::DbMigrationFailed { error: e.to_string() }); - Err(e) - } + let root_key = env::var("NETGUARDIA_SECRETS_KEY") + .ok() + .filter(|k| !k.is_empty()) + .or_else(|| env::var("NETGUARDIA_DB_KEY").ok().filter(|k| !k.is_empty())) + .unwrap_or_else(|| "netguardia-dev-api-key-secret".to_string()); + + let hk = Hkdf::::new(Some(b"netguardia-v1-salt"), root_key.as_bytes()); + let mut okm = [0u8; 32]; + // SAFETY: 32 bytes is a valid output length for HKDF-SHA256 + hk.expand(b"netguardia-apikey-hmac-v1", &mut okm).unwrap(); + okm + } + + /// Compute HMAC-SHA256 of an API key using the derived secret. + pub fn hmac_api_key(&self, raw_key: &str) -> String { + use hmac::{Hmac, Mac}; + use sha2::Sha256; + use std::fmt::Write; + + type HmacSha256 = Hmac; + // SAFETY: HMAC-SHA256 accepts keys of any length; the only error + // `new_from_slice` returns (`InvalidLength`) is unreachable for this + // algorithm. The unreachable!() is the correct sentinel. + let mut mac = HmacSha256::new_from_slice(&self.api_key_hmac).unwrap_or_else(|_| unreachable!()); + mac.update(raw_key.as_bytes()); + let result = mac.finalize().into_bytes(); + + let mut hex = String::with_capacity(64); + for byte in result { + let _ = write!(&mut hex, "{:02x}", byte); } + hex } /// Export an encrypted database to a plaintext copy. /// The original file is NOT modified. pub fn decrypt_to_file(src_path: &str, key: &str, dest_path: &str) -> Result<(), Error> { - let conn = - rusqlite::Connection::open(src_path).map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?; + let conn = Connection::open(src_path).map_err(DatabaseError::QueryFailed)?; conn.pragma_update(None, "key", key) - .map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?; + .map_err(DatabaseError::QueryFailed)?; // Verify we can read the encrypted DB conn.query_row("SELECT count(*) FROM sqlite_master", [], |_| Ok(())) - .map_err(|_| DatabaseError::QueryFailed { - reason: "Cannot read database with provided key — wrong key or not encrypted".to_string(), - })?; + .map_err(|_| DatabaseError::DatabaseNotReadable)?; // Attach a plaintext destination (empty key = no encryption) conn.execute_batch(&format!( "ATTACH DATABASE '{}' AS plaintext KEY '';", dest_path.replace('\'', "''"), )) - .map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?; + .map_err(DatabaseError::QueryFailed)?; conn.query_row("SELECT sqlcipher_export('plaintext')", [], |_| Ok(())) - .map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?; + .map_err(DatabaseError::QueryFailed)?; conn.execute_batch("DETACH DATABASE plaintext;") - .map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?; + .map_err(DatabaseError::QueryFailed)?; Ok(()) } /// Encrypt a plaintext database to a new encrypted copy. /// The original file is NOT modified. pub fn encrypt_to_file(src_path: &str, key: &str, dest_path: &str) -> Result<(), Error> { - let conn = - rusqlite::Connection::open(src_path).map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?; + let conn = Connection::open(src_path).map_err(DatabaseError::QueryFailed)?; // Verify it's readable as plaintext conn.query_row("SELECT count(*) FROM sqlite_master", [], |_| Ok(())) - .map_err(|_| DatabaseError::QueryFailed { - reason: "Cannot read source database — may already be encrypted".to_string(), - })?; + .map_err(|_| DatabaseError::SourceDatabaseNotReadable)?; conn.execute_batch(&format!( "ATTACH DATABASE '{}' AS encrypted KEY '{}';", dest_path.replace('\'', "''"), key.replace('\'', "''"), )) - .map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?; + .map_err(DatabaseError::QueryFailed)?; conn.query_row("SELECT sqlcipher_export('encrypted')", [], |_| Ok(())) - .map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?; + .map_err(DatabaseError::QueryFailed)?; conn.execute_batch("DETACH DATABASE encrypted;") - .map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?; + .map_err(DatabaseError::QueryFailed)?; Ok(()) } fn conn(&self) -> Result, Error> { - self.pool - .get() - .map_err(|e| -> Error { DatabaseError::QueryFailed { reason: e.to_string() }.into() }) + self.pool.get().map_err(|e| DatabaseError::QueryFailed(e).into()) } fn create_tables(&self) -> Result<(), Error> { @@ -372,27 +337,32 @@ impl Database { retry_count INTEGER NOT NULL DEFAULT 0 ); - -- Audit trail + -- Audit trail (WORM: hash-chained, triggers block UPDATE/DELETE) CREATE TABLE IF NOT EXISTS audit_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, - ts TEXT NOT NULL DEFAULT (datetime('now')), + ts TEXT NOT NULL, actor TEXT NOT NULL, action TEXT NOT NULL, - detail TEXT NOT NULL DEFAULT '{}' + detail TEXT NOT NULL DEFAULT '{}', + prev_hash TEXT NOT NULL DEFAULT '', + row_hash TEXT NOT NULL DEFAULT '' ); + + CREATE TRIGGER IF NOT EXISTS audit_log_no_update + BEFORE UPDATE ON audit_log BEGIN + SELECT RAISE(ABORT, 'audit_log is append-only (WORM)'); + END; + + CREATE TRIGGER IF NOT EXISTS audit_log_no_delete + BEFORE DELETE ON audit_log BEGIN + SELECT RAISE(ABORT, 'audit_log is append-only (WORM)'); + END; ", )?; - // Migration: add force_password_change column if missing (for existing DBs) let conn_ref = &*conn; - let has_column: bool = conn_ref - .prepare("SELECT force_password_change FROM users LIMIT 0") - .is_ok(); - if !has_column { - conn_ref.execute_batch("ALTER TABLE users ADD COLUMN force_password_change INTEGER NOT NULL DEFAULT 0;")?; - } - // Migration: seed default user groups if table is empty + // Seed default user groups on first install (empty table) let group_count: i64 = conn_ref.query_row("SELECT COUNT(*) FROM user_groups", [], |row| row.get(0))?; if group_count == 0 { let all_permissions = serde_json::json!([ @@ -414,9 +384,14 @@ impl Database { "protocol_filter:write", "system:read", "system:write", + "system:admin", "users:read", "users:write", - "users:admin" + "users:admin", + "fusion:read", + "fusion:write", + "flow_trace:read", + "flow_trace:write" ]) .to_string(); let viewer_permissions = serde_json::json!([ @@ -430,7 +405,9 @@ impl Database { "dns_filter:read", "rate_limit:read", "protocol_filter:read", - "system:read" + "system:read", + "fusion:read", + "flow_trace:read" ]) .to_string(); @@ -448,41 +425,6 @@ impl Database { )?; } - // Migration: assign existing users to default groups if user_group_members is empty - let member_count: i64 = conn_ref.query_row("SELECT COUNT(*) FROM user_group_members", [], |row| row.get(0))?; - if member_count == 0 { - // Get admin group id and viewer group id - let admin_group_id: Option = conn_ref - .query_row("SELECT id FROM user_groups WHERE name = 'Administrator'", [], |row| { - row.get(0) - }) - .ok(); - let viewer_group_id: Option = conn_ref - .query_row("SELECT id FROM user_groups WHERE name = 'Viewer'", [], |row| row.get(0)) - .ok(); - - if let Some(ag_id) = admin_group_id { - let mut stmt = conn_ref.prepare("SELECT id FROM users WHERE role = 'admin'")?; - let admin_ids: Vec = stmt.query_map([], |row| row.get(0))?.filter_map(|r| r.ok()).collect(); - for uid in admin_ids { - conn_ref.execute( - "INSERT OR IGNORE INTO user_group_members (user_id, group_id) VALUES (?1, ?2)", - params![uid, ag_id], - )?; - } - } - if let Some(vg_id) = viewer_group_id { - let mut stmt = conn_ref.prepare("SELECT id FROM users WHERE role = 'viewer'")?; - let viewer_ids: Vec = stmt.query_map([], |row| row.get(0))?.filter_map(|r| r.ok()).collect(); - for uid in viewer_ids { - conn_ref.execute( - "INSERT OR IGNORE INTO user_group_members (user_id, group_id) VALUES (?1, ?2)", - params![uid, vg_id], - )?; - } - } - } - Ok(()) } @@ -519,7 +461,7 @@ impl Database { Ok(()) } - pub fn load_acl_rules(&self) -> Result, Error> { + pub fn load_acl_rules(&self) -> Result, Error> { let conn = self.conn()?; let mut stmt = conn.prepare("SELECT ip_version, direction, list_type, ip_address, port FROM acl_rules")?; let rows = stmt.query_map([], |row| { @@ -624,8 +566,8 @@ impl Database { }); match result { Ok(val) => Ok(Some(val)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(e.into()), + Err(RusqliteError::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e)?, } } @@ -647,8 +589,8 @@ impl Database { }); match result { Ok(val) => Ok(Some(val)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(e.into()), + Err(RusqliteError::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e)?, } } @@ -662,7 +604,7 @@ impl Database { } // --- Users --- - pub fn find_user(&self, username: &str) -> Result, Error> { + pub fn find_user(&self, username: &str) -> Result, Error> { let conn = self.conn()?; let result = conn.query_row( "SELECT id, username, password_hash, role, force_password_change FROM users WHERE username = ?1", @@ -679,8 +621,8 @@ impl Database { ); match result { Ok(user) => Ok(Some(user)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(e.into()), + Err(RusqliteError::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e)?, } } @@ -698,10 +640,7 @@ impl Database { ) .map_err(|e| -> Error { if e.to_string().contains("UNIQUE constraint") { - DatabaseError::UserAlreadyExists { - username: username.to_string(), - } - .into() + DatabaseError::UserAlreadyExists(username.to_string()).into() } else { e.into() } @@ -723,27 +662,7 @@ impl Database { Ok(conn.query_row("SELECT COUNT(*) FROM users", [], |row| row.get(0))?) } - pub fn list_users(&self) -> Result, Error> { - let conn = self.conn()?; - let mut stmt = - conn.prepare("SELECT id, username, role, force_password_change, created_at FROM users ORDER BY id")?; - let rows = stmt.query_map([], |row| { - Ok(( - row.get::<_, i64>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - row.get::<_, i64>(3)? != 0, - row.get::<_, String>(4)?, - )) - })?; - let mut results = Vec::new(); - for row in rows { - results.push(row?); - } - Ok(results) - } - - pub fn list_users_with_groups(&self) -> Result, Error> { + pub fn list_users_with_groups(&self) -> Result, Error> { let conn = self.conn()?; let mut stmt = conn.prepare( "SELECT u.id, u.username, u.role, u.force_password_change, u.created_at, \ @@ -765,7 +684,7 @@ impl Database { )) })?; - let mut user_map: HashMap = HashMap::new(); + let mut user_map: HashMap = HashMap::new(); let mut order: Vec = Vec::new(); for row in rows { @@ -804,10 +723,7 @@ impl Database { Ok(()) } - pub fn find_user_by_id( - &self, - user_id: i64, - ) -> Result, Error> { + pub fn find_user_by_id(&self, user_id: i64) -> Result, Error> { let conn = self.conn()?; let result = conn.query_row( "SELECT id, username, password_hash, role, force_password_change FROM users WHERE id = ?1", @@ -824,13 +740,13 @@ impl Database { ); match result { Ok(user) => Ok(Some(user)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(e.into()), + Err(RusqliteError::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e)?, } } // --- User Groups --- - pub fn list_user_groups(&self) -> Result, Error> { + pub fn list_user_groups(&self) -> Result, Error> { let conn = self.conn()?; let mut stmt = conn.prepare("SELECT id, name, description, permissions, created_at FROM user_groups ORDER BY id")?; @@ -858,10 +774,7 @@ impl Database { ) .map_err(|e| -> Error { if e.to_string().contains("UNIQUE constraint") { - DatabaseError::QueryFailed { - reason: format!("Group '{}' already exists", name), - } - .into() + DatabaseError::GroupAlreadyExists(name).into() } else { e.into() } @@ -885,7 +798,7 @@ impl Database { Ok(affected > 0) } - pub fn get_user_group(&self, id: i64) -> Result, Error> { + pub fn get_user_group(&self, id: i64) -> Result, Error> { let conn = self.conn()?; let result = conn.query_row( "SELECT id, name, description, permissions, created_at FROM user_groups WHERE id = ?1", @@ -902,8 +815,8 @@ impl Database { ); match result { Ok(group) => Ok(Some(group)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(e.into()), + Err(RusqliteError::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e)?, } } @@ -944,7 +857,7 @@ impl Database { pub fn get_user_permissions(&self, user_id: i64) -> Result, Error> { let groups = self.get_user_groups(user_id)?; - let mut all_perms = std::collections::HashSet::new(); + let mut all_perms = HashSet::new(); for (_id, _name, _desc, perms_json) in groups { if let Ok(perms) = serde_json::from_str::>(&perms_json) { for p in perms { @@ -1001,9 +914,9 @@ impl Database { self.set_setting(&key_count, &count.to_string())?; if count >= 5 { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or(std::time::Duration::ZERO) + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or(Duration::ZERO) .as_secs(); let locked_until = now + 900; // 15 minutes self.set_setting(&key_locked, &locked_until.to_string())?; @@ -1018,9 +931,9 @@ impl Database { if let Some(locked_str) = self.get_setting(&key_locked)? && let Ok(locked_until) = locked_str.parse::() { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or(std::time::Duration::ZERO) + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or(Duration::ZERO) .as_secs(); if now < locked_until { return Ok(Some(locked_until - now)); @@ -1047,22 +960,9 @@ impl Database { // --- MCP API Keys --- /// Validate an API key and return Claims if valid. - /// Computes SHA-256 hash of the key and looks it up in api_keys table. - pub fn validate_api_key(&self, api_key: &str) -> Result, Error> { - use std::fmt::Write; - - // SHA-256 hash the key - let digest = { - use sha2::{Digest, Sha256}; - let mut hasher = Sha256::new(); - hasher.update(api_key.as_bytes()); - let result = hasher.finalize(); - let mut hex = String::with_capacity(64); - for byte in result { - write!(&mut hex, "{:02x}", byte).unwrap(); - } - hex - }; + /// Computes HMAC-SHA256 of the key and looks it up in api_keys table. + pub fn validate_api_key(&self, api_key: &str) -> Result, Error> { + let digest = self.hmac_api_key(api_key); let conn = self.conn()?; let result = conn.query_row( @@ -1115,7 +1015,7 @@ impl Database { ], }; - Ok(Some(crate::model::auth::Claims { + Ok(Some(Claims { sub: -id, // negative ID to distinguish from user IDs username: format!("api:{}", name), role: level, @@ -1123,8 +1023,8 @@ impl Database { exp: usize::MAX, // API keys don't expire (revocation via DB deletion) })) } - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(e.into()), + Err(RusqliteError::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e)?, } } @@ -1162,6 +1062,10 @@ impl Database { Ok(conn.last_insert_rowid()) } + /// Test-only helper: direct insert of a SOAR block rule row. Production + /// code goes through `commit_soar_block_to_db` which atomically writes + /// both `soar_block_rules` and `acl_rules` under a transaction. + #[cfg(test)] pub fn insert_soar_block_rule(&self, source_ip: &str, playbook_id: i64, expires_at: &str) -> Result { let conn = self.conn()?; conn.execute( @@ -1351,29 +1255,6 @@ impl Database { Ok(conn.last_insert_rowid()) } - pub fn update_playbook( - &self, - id: i64, - row: &crate::model::soar::playbook_data::UpdatePlaybookRow, - ) -> Result { - let conn = self.conn()?; - let rows = conn.execute( - "UPDATE playbooks SET name = ?2, trigger_event = ?3, condition_threshold = ?4, \ - condition_count = ?5, condition_window_secs = ?6, cooldown_secs = ?7, \ - updated_at = datetime('now') WHERE id = ?1", - params![ - id, - row.name, - row.trigger_event, - row.condition_threshold, - row.condition_count, - row.condition_window_secs, - row.cooldown_secs - ], - )?; - Ok(rows > 0) - } - pub fn update_playbook_enabled(&self, id: i64, enabled: bool) -> Result { let conn = self.conn()?; let rows = conn.execute( @@ -1389,24 +1270,6 @@ impl Database { Ok(rows > 0) } - pub fn delete_playbook_actions(&self, playbook_id: i64) -> Result<(), Error> { - let conn = self.conn()?; - conn.execute( - "DELETE FROM playbook_actions WHERE playbook_id = ?1", - params![playbook_id], - )?; - Ok(()) - } - - pub fn delete_playbook_conditions(&self, playbook_id: i64) -> Result<(), Error> { - let conn = self.conn()?; - conn.execute( - "DELETE FROM playbook_conditions WHERE playbook_id = ?1", - params![playbook_id], - )?; - Ok(()) - } - pub fn insert_playbook_condition( &self, playbook_id: i64, @@ -1521,8 +1384,8 @@ impl Database { |row| row.get::<_, String>(0), ) { Ok(json) => Ok(Some(json)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(e.into()), + Err(RusqliteError::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e)?, } } @@ -1657,37 +1520,83 @@ impl Database { } drop(conn); - // 1. default_block: threat_detected, threshold 0.85 → block_ip(1800s) + log - let pb1 = self.insert_playbook("default_block", "threat_detected", Some(0.85), None, None, 300)?; - self.insert_playbook_action(pb1, 1, "block_ip", r#"{"ttl_secs": 1800}"#)?; - self.insert_playbook_action(pb1, 2, "log", r#"{"level": "warn"}"#)?; - self.insert_playbook_condition(pb1, "threshold", ">=", "0.85", None)?; + // 1. brute_force_block: brute_force, count 5 in 60s → block_ip(3600s) + send_telegram + log + let pb1 = self.insert_playbook("brute_force_block", "brute_force", None, Some(5), Some(60), 600)?; + self.insert_playbook_action(pb1, 1, "block_ip", r#"{"ttl_secs": 3600}"#)?; + self.insert_playbook_action(pb1, 2, "send_telegram", "{}")?; + self.insert_playbook_action(pb1, 3, "log", r#"{"level": "warn"}"#)?; + self.insert_playbook_condition(pb1, "frequency", ">=", "5", Some("60"))?; - // 2. brute_force_block: brute_force, count 5 in 60s → block_ip(3600s) + send_telegram + log - let pb2 = self.insert_playbook("brute_force_block", "brute_force", None, Some(5), Some(60), 600)?; - self.insert_playbook_action(pb2, 1, "block_ip", r#"{"ttl_secs": 3600}"#)?; - self.insert_playbook_action(pb2, 2, "send_telegram", "{}")?; - self.insert_playbook_action(pb2, 3, "log", r#"{"level": "warn"}"#)?; - self.insert_playbook_condition(pb2, "frequency", ">=", "5", Some("60"))?; + // 2. port_scan_alert: port_scan, threshold 0.7 → send_telegram + log (no block) + let pb2 = self.insert_playbook("port_scan_alert", "port_scan", Some(0.7), None, None, 300)?; + self.insert_playbook_action(pb2, 1, "send_telegram", "{}")?; + self.insert_playbook_action(pb2, 2, "log", r#"{"level": "warn"}"#)?; + self.insert_playbook_condition(pb2, "threshold", ">=", "0.7", None)?; - // 3. port_scan_alert: port_scan, threshold 0.7 → send_telegram + log (no block) - let pb3 = self.insert_playbook("port_scan_alert", "port_scan", Some(0.7), None, None, 300)?; - self.insert_playbook_action(pb3, 1, "send_telegram", "{}")?; - self.insert_playbook_action(pb3, 2, "log", r#"{"level": "warn"}"#)?; - self.insert_playbook_condition(pb3, "threshold", ">=", "0.7", None)?; + // 3. fusion_c2_multi_source_block — C2 beacon observed by ≥2 sources + // (e.g. Suricata trojan-activity + Beaconing CV + ML c2 class) is + // the highest-precision fusion signal we ship. Block for 1h and + // notify, no solo-source threshold so single-source C2 hits still + // require the solo playbook below to act. + let pb3 = self.insert_playbook("fusion_c2_multi_source_block", "c2_beacon", None, None, None, 600)?; + self.insert_playbook_action(pb3, 1, "block_ip", r#"{"ttl_secs": 3600}"#)?; + self.insert_playbook_action(pb3, 2, "send_telegram", "{}")?; + self.insert_playbook_action(pb3, 3, "log", r#"{"level": "warn"}"#)?; + self.insert_playbook_condition(pb3, "multi_source_min", ">=", "2", None)?; + + // 4. fusion_c2_suricata_solo_high_block — the escape hatch for + // Suricata signature hits with very high confidence (>=0.95). + // Lets known-good rules fire without waiting for agreement from a + // second source, matching how analysts intuitively treat a + // signature "dead-on" match. + let pb4 = self.insert_playbook("fusion_c2_suricata_solo_high_block", "c2_beacon", None, None, None, 600)?; + self.insert_playbook_action(pb4, 1, "block_ip", r#"{"ttl_secs": 3600}"#)?; + self.insert_playbook_action(pb4, 2, "send_telegram", "{}")?; + self.insert_playbook_action(pb4, 3, "log", r#"{"level": "warn"}"#)?; + self.insert_playbook_condition(pb4, "single_source_high", "==", "Suricata", Some("0.95"))?; Ok(()) } - // --- Audit Log --- + // --- Audit Log (WORM, hash-chained) --- - /// Insert an audit trail entry. + /// Compute the row hash for an audit_log entry. + /// Formula: sha256_hex(ts || 0x00 || actor || 0x00 || action || 0x00 || detail || 0x00 || prev_hash) + fn audit_row_hash(ts: &str, actor: &str, action: &str, detail: &str, prev_hash: &str) -> String { + use sha2::{Digest, Sha256}; + let mut h = Sha256::new(); + for part in [ts, actor, action, detail, prev_hash] { + h.update(part.as_bytes()); + h.update([0u8]); + } + let out = h.finalize(); + let mut hex = String::with_capacity(64); + for byte in out { + use std::fmt::Write; + let _ = write!(&mut hex, "{:02x}", byte); + } + hex + } + + /// Insert an audit trail entry. Runs in a transaction so the (prev_hash + /// lookup, row_hash compute, insert) sequence is atomic and serializable. pub fn insert_audit_log(&self, actor: &str, action: &str, detail: &str) -> Result<(), Error> { - let conn = self.conn()?; - conn.execute( - "INSERT INTO audit_log (actor, action, detail) VALUES (?1, ?2, ?3)", - params![actor, action, detail], + let mut conn = self.conn()?; + let ts = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); + + let tx = conn.transaction()?; + let prev_hash: String = tx + .query_row("SELECT row_hash FROM audit_log ORDER BY id DESC LIMIT 1", [], |row| { + row.get(0) + }) + .unwrap_or_default(); + + let row_hash = Self::audit_row_hash(&ts, actor, action, detail, &prev_hash); + tx.execute( + "INSERT INTO audit_log (ts, actor, action, detail, prev_hash, row_hash) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ts, actor, action, detail, prev_hash, row_hash], )?; + tx.commit()?; Ok(()) } @@ -1695,7 +1604,7 @@ impl Database { pub fn list_audit_logs(&self) -> Result, Error> { let conn = self.conn()?; let mut stmt = - conn.prepare("SELECT id, actor, action, detail, created_at FROM audit_log ORDER BY id DESC LIMIT 200")?; + conn.prepare("SELECT id, actor, action, detail, ts FROM audit_log ORDER BY id DESC LIMIT 200")?; let rows = stmt .query_map([], |row| { Ok(AuditLogEntry { @@ -1710,11 +1619,71 @@ impl Database { .collect(); Ok(rows) } + + /// Read audit entries whose `action` matches exactly, newest-first, + /// capped at `limit`. Drives the fusion explain endpoint. + pub fn list_audit_logs_by_action(&self, action: &str, limit: i64) -> Result, Error> { + let conn = self.conn()?; + let mut stmt = conn.prepare( + "SELECT id, actor, action, detail, ts FROM audit_log WHERE action = ?1 ORDER BY id DESC LIMIT ?2", + )?; + let rows = stmt + .query_map(params![action, limit], |row| { + Ok(AuditLogEntry { + id: row.get(0)?, + actor: row.get(1)?, + action: row.get(2)?, + detail: row.get(3)?, + created_at: row.get(4)?, + }) + })? + .filter_map(|r| r.ok()) + .collect(); + Ok(rows) + } + + /// Walk the entire audit_log in id order and verify the hash chain. + /// Returns `Ok(count)` on success; returns `Err` at the first mismatch, + /// naming the offending row id and the kind of mismatch. + pub fn verify_audit_log_chain(&self) -> Result { + let conn = self.conn()?; + let mut stmt = + conn.prepare("SELECT id, ts, actor, action, detail, prev_hash, row_hash FROM audit_log ORDER BY id ASC")?; + let mut rows = stmt.query([])?; + + let mut expected_prev = String::new(); + let mut count = 0usize; + while let Some(row) = rows.next()? { + let id: i64 = row.get(0)?; + let ts: String = row.get(1)?; + let actor: String = row.get(2)?; + let action: String = row.get(3)?; + let detail: String = row.get(4)?; + let prev_hash: String = row.get(5)?; + let row_hash: String = row.get(6)?; + + if prev_hash != expected_prev { + return Err(DatabaseError::AuditPrevHashMismatch(id, expected_prev, prev_hash).into()); + } + let computed = Self::audit_row_hash(&ts, &actor, &action, &detail, &prev_hash); + if computed != row_hash { + return Err(DatabaseError::AuditRowHashMismatch(id, computed, row_hash).into()); + } + expected_prev = row_hash; + count += 1; + } + Ok(count) + } } -/// Implement the RepositoryPort trait, proving Database satisfies the port contract. -/// This enables adapter-level testing with mock implementations. -impl crate::interface::port::repository::RepositoryPort for Database { +// --- Aggregate repository trait implementations --- +// +// All trait methods are forward-only wrappers to the inherent impl above. +// The traits exist to enforce aggregate boundaries: callers take +// `Arc` instead of `Arc` so they see only the +// methods of their own aggregate. See `docs/strategy/DOMAIN_MAP.md` §2. + +impl AclRepo for Database { fn insert_acl_rule( &self, ip_version: u8, @@ -1735,42 +1704,66 @@ impl crate::interface::port::repository::RepositoryPort for Database { ) -> Result<(), Error> { self.delete_acl_rule(ip_version, direction, list_type, ip_address, port) } - fn load_acl_rules(&self) -> Result, Error> { - self.load_acl_rules() + fn has_manual_acl_rule(&self, ip_address: &str) -> Result { + self.has_manual_acl_rule(ip_address) } + fn load_admin_whitelist(&self) -> Result, Error> { + self.load_admin_whitelist() + } + fn insert_admin_whitelist(&self, ip: &str) -> Result<(), Error> { + self.insert_admin_whitelist(ip) + } + fn delete_admin_whitelist(&self, ip: &str) -> Result<(), Error> { + self.delete_admin_whitelist(ip) + } +} + +impl EnforcementRepo for Database { fn set_rate_limit(&self, key: &str, value: u64) -> Result<(), Error> { self.set_rate_limit(key, value) } - fn load_rate_limit_config(&self) -> Result, Error> { - self.load_rate_limit_config() - } fn insert_dns_domain(&self, domain: &str) -> Result<(), Error> { self.insert_dns_domain(domain) } fn delete_dns_domain(&self, domain: &str) -> Result<(), Error> { self.delete_dns_domain(domain) } - fn load_dns_domains(&self) -> Result, Error> { - self.load_dns_domains() - } fn insert_geo_country(&self, code: &str) -> Result<(), Error> { self.insert_geo_country(code) } fn delete_geo_country(&self, code: &str) -> Result<(), Error> { self.delete_geo_country(code) } - fn load_geo_countries(&self) -> Result, Error> { - self.load_geo_countries() - } +} + +impl SettingRepo for Database { fn get_setting(&self, key: &str) -> Result, Error> { self.get_setting(key) } fn set_setting(&self, key: &str, value: &str) -> Result<(), Error> { self.set_setting(key, value) } - fn find_user(&self, username: &str) -> Result, Error> { + fn get_app_secret(&self, key: &str) -> Result, Error> { + self.get_app_secret(key) + } + fn set_app_secret(&self, key: &str, plaintext: &str) -> Result<(), Error> { + self.set_app_secret(key, plaintext) + } + fn get_notification_config(&self, channel: &str) -> Result, Error> { + self.get_notification_config(channel) + } + fn set_notification_config(&self, channel: &str, config_json: &str) -> Result<(), Error> { + self.set_notification_config(channel, config_json) + } +} + +impl IdentityRepo for Database { + fn find_user(&self, username: &str) -> Result, Error> { self.find_user(username) } + fn find_user_by_id(&self, user_id: i64) -> Result, Error> { + self.find_user_by_id(user_id) + } fn insert_user( &self, username: &str, @@ -1783,13 +1776,7 @@ impl crate::interface::port::repository::RepositoryPort for Database { fn update_user_password(&self, user_id: i64, password_hash: &str) -> Result<(), Error> { self.update_user_password(user_id, password_hash) } - fn user_count(&self) -> Result { - self.user_count() - } - fn list_users(&self) -> Result, Error> { - self.list_users() - } - fn list_users_with_groups(&self) -> Result, Error> { + fn list_users_with_groups(&self) -> Result, Error> { self.list_users_with_groups() } fn delete_user(&self, user_id: i64) -> Result { @@ -1801,10 +1788,7 @@ impl crate::interface::port::repository::RepositoryPort for Database { fn reset_user_password(&self, user_id: i64, password_hash: &str) -> Result<(), Error> { self.reset_user_password(user_id, password_hash) } - fn find_user_by_id(&self, user_id: i64) -> Result, Error> { - self.find_user_by_id(user_id) - } - fn list_user_groups(&self) -> Result, Error> { + fn list_user_groups(&self) -> Result, Error> { self.list_user_groups() } fn create_user_group(&self, name: &str, description: &str, permissions: &str) -> Result { @@ -1816,7 +1800,7 @@ impl crate::interface::port::repository::RepositoryPort for Database { fn delete_user_group(&self, id: i64) -> Result { self.delete_user_group(id) } - fn get_user_group(&self, id: i64) -> Result, Error> { + fn get_user_group(&self, id: i64) -> Result, Error> { self.get_user_group(id) } fn get_user_groups(&self, user_id: i64) -> Result, Error> { @@ -1828,9 +1812,6 @@ impl crate::interface::port::repository::RepositoryPort for Database { fn get_user_permissions(&self, user_id: i64) -> Result, Error> { self.get_user_permissions(user_id) } - fn cleanup_user_memberships(&self, user_id: i64) -> Result<(), Error> { - self.cleanup_user_memberships(user_id) - } fn get_group_member_ids(&self, group_id: i64) -> Result, Error> { self.get_group_member_ids(group_id) } @@ -1848,94 +1829,22 @@ impl crate::interface::port::repository::RepositoryPort for Database { } } -impl crate::interface::port::soar::SoarPort for Database { - fn get_setting(&self, key: &str) -> Result, Error> { - self.get_setting(key) - } - fn set_setting(&self, key: &str, value: &str) -> Result<(), Error> { - self.set_setting(key, value) - } - fn insert_acl_rule( - &self, - ip_version: u8, - direction: &str, - list_type: &str, - ip_address: &str, - port: u16, - ) -> Result<(), Error> { - self.insert_acl_rule(ip_version, direction, list_type, ip_address, port) - } - fn delete_acl_rule( - &self, - ip_version: u8, - direction: &str, - list_type: &str, - ip_address: &str, - port: u16, - ) -> Result<(), Error> { - self.delete_acl_rule(ip_version, direction, list_type, ip_address, port) - } - fn insert_playbook( - &self, - name: &str, - trigger_event: &str, - threshold: Option, - count: Option, - window: Option, - cooldown: i64, - ) -> Result { - self.insert_playbook(name, trigger_event, threshold, count, window, cooldown) - } - fn insert_playbook_action( - &self, - playbook_id: i64, - action_order: i64, - action_type: &str, - params_json: &str, - ) -> Result { - self.insert_playbook_action(playbook_id, action_order, action_type, params_json) - } - fn load_playbooks_with_actions(&self) -> Result, Error> { +impl SoarRepo for Database { + fn load_playbooks_with_actions(&self) -> Result, Error> { self.load_playbooks_with_actions() } - fn update_playbook( - &self, - id: i64, - row: &crate::model::soar::playbook_data::UpdatePlaybookRow, - ) -> Result { - self.update_playbook(id, row) - } fn update_playbook_enabled(&self, id: i64, enabled: bool) -> Result { self.update_playbook_enabled(id, enabled) } fn delete_playbook(&self, id: i64) -> Result { self.delete_playbook(id) } - fn delete_playbook_actions(&self, playbook_id: i64) -> Result<(), Error> { - self.delete_playbook_actions(playbook_id) - } - fn delete_playbook_conditions(&self, playbook_id: i64) -> Result<(), Error> { - self.delete_playbook_conditions(playbook_id) - } fn seed_default_playbooks(&self) -> Result<(), Error> { self.seed_default_playbooks() } - fn insert_playbook_condition( - &self, - playbook_id: i64, - condition_type: &str, - operator: &str, - value: &str, - value2: Option<&str>, - ) -> Result { - self.insert_playbook_condition(playbook_id, condition_type, operator, value, value2) - } fn load_all_playbook_conditions(&self) -> Result)>, Error> { self.load_all_playbook_conditions() } - fn insert_soar_block_rule(&self, source_ip: &str, playbook_id: i64, expires_at: &str) -> Result { - self.insert_soar_block_rule(source_ip, playbook_id, expires_at) - } fn count_active_soar_blocks(&self) -> Result { self.count_active_soar_blocks() } @@ -1951,9 +1860,6 @@ impl crate::interface::port::soar::SoarPort for Database { fn mark_soar_block_unblocked(&self, id: i64) -> Result<(), Error> { self.mark_soar_block_unblocked(id) } - fn has_manual_acl_rule(&self, ip_address: &str) -> Result { - self.has_manual_acl_rule(ip_address) - } fn insert_pending_unblock(&self, source_ip: &str) -> Result { self.insert_pending_unblock(source_ip) } @@ -1975,21 +1881,92 @@ impl crate::interface::port::soar::SoarPort for Database { ) -> Result { self.insert_soar_execution(playbook_id, source_ip, trigger_event, actions_json) } - fn list_soar_executions(&self, limit: i64) -> Result, Error> { + fn list_soar_executions(&self, limit: i64) -> Result, Error> { self.list_soar_executions(limit) } - fn load_admin_whitelist(&self) -> Result, Error> { - self.load_admin_whitelist() + + // --- intra-aggregate atomic operations --- + + fn insert_playbook_atomic( + &self, + name: &str, + trigger_event: &str, + threshold: Option, + count: Option, + window: Option, + cooldown: i64, + actions: &[(i64, String, String)], + conditions: &[(String, String, String, Option)], + ) -> Result { + let mut conn = self.conn()?; + let tx = conn.transaction()?; + tx.execute( + "INSERT INTO playbooks (name, trigger_event, condition_threshold, condition_count, condition_window_secs, cooldown_secs) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![name, trigger_event, threshold, count, window, cooldown], + )?; + let playbook_id = tx.last_insert_rowid(); + for (action_order, action_type, params_json) in actions { + tx.execute( + "INSERT INTO playbook_actions (playbook_id, action_order, action_type, params) VALUES (?1, ?2, ?3, ?4)", + params![playbook_id, action_order, action_type, params_json], + )?; + } + for (condition_type, operator, value, value2) in conditions { + tx.execute( + "INSERT INTO playbook_conditions (playbook_id, condition_type, operator, value, value2) VALUES (?1, ?2, ?3, ?4, ?5)", + params![playbook_id, condition_type, operator, value, value2.as_deref()], + )?; + } + tx.commit()?; + Ok(playbook_id) } - fn insert_admin_whitelist(&self, ip: &str) -> Result<(), Error> { - self.insert_admin_whitelist(ip) - } - fn delete_admin_whitelist(&self, ip: &str) -> Result<(), Error> { - self.delete_admin_whitelist(ip) + + fn update_playbook_atomic( + &self, + id: i64, + row: &UpdatePlaybookRow, + actions: &[(i64, String, String)], + conditions: &[(String, String, String, Option)], + ) -> Result { + let mut conn = self.conn()?; + let tx = conn.transaction()?; + let rows_updated = tx.execute( + "UPDATE playbooks SET name = ?2, trigger_event = ?3, condition_threshold = ?4, \ + condition_count = ?5, condition_window_secs = ?6, cooldown_secs = ?7, \ + updated_at = datetime('now') WHERE id = ?1", + params![ + id, + row.name, + row.trigger_event, + row.condition_threshold, + row.condition_count, + row.condition_window_secs, + row.cooldown_secs + ], + )?; + if rows_updated == 0 { + return Ok(false); + } + tx.execute("DELETE FROM playbook_actions WHERE playbook_id = ?1", params![id])?; + tx.execute("DELETE FROM playbook_conditions WHERE playbook_id = ?1", params![id])?; + for (action_order, action_type, params_json) in actions { + tx.execute( + "INSERT INTO playbook_actions (playbook_id, action_order, action_type, params) VALUES (?1, ?2, ?3, ?4)", + params![id, action_order, action_type, params_json], + )?; + } + for (condition_type, operator, value, value2) in conditions { + tx.execute( + "INSERT INTO playbook_conditions (playbook_id, condition_type, operator, value, value2) VALUES (?1, ?2, ?3, ?4, ?5)", + params![id, condition_type, operator, value, value2.as_deref()], + )?; + } + tx.commit()?; + Ok(true) } } -impl crate::interface::port::stats::StatsPort for Database { +impl StatsRepo for Database { fn count_weekly_executions(&self, days: i64) -> Result { self.count_weekly_executions(days) } @@ -2010,29 +1987,29 @@ impl crate::interface::port::stats::StatsPort for Database { } } -impl crate::interface::port::notification::NotificationConfigPort for Database { - fn get_notification_config(&self, channel: &str) -> Result, Error> { - self.get_notification_config(channel) - } - fn set_notification_config(&self, channel: &str, config_json: &str) -> Result<(), Error> { - self.set_notification_config(channel, config_json) - } -} - -impl crate::interface::port::audit::AuditPort for Database { +impl AuditRepo for Database { fn insert_audit_log(&self, actor: &str, action: &str, detail: &str) -> Result<(), Error> { self.insert_audit_log(actor, action, detail) } + fn list_audit_logs_by_action(&self, action: &str, limit: i64) -> Result, Error> { + self.list_audit_logs_by_action(action, limit) + } + fn verify_audit_log_chain(&self) -> Result { + self.verify_audit_log_chain() + } } -impl crate::interface::port::api_key::ApiKeyPort for Database { - fn validate_api_key(&self, api_key: &str) -> Result, Error> { +impl ApiKeyRepo for Database { + fn validate_api_key(&self, api_key: &str) -> Result, Error> { self.validate_api_key(api_key) } + fn hmac_api_key(&self, raw_key: &str) -> String { + self.hmac_api_key(raw_key) + } fn insert_api_key(&self, key_hash: &str, name: &str, permission_level: &str) -> Result { self.insert_api_key(key_hash, name, permission_level) } - fn list_api_keys(&self) -> Result, Error> { + fn list_api_keys(&self) -> Result, Error> { self.list_api_keys() } fn delete_api_key(&self, id: i64) -> Result { @@ -2040,6 +2017,52 @@ impl crate::interface::port::api_key::ApiKeyPort for Database { } } +impl DbAdminRepo for Database { + /// Commit a SOAR-driven block to both `soar_block_rules` and + /// `acl_rules` in one transaction. Callers must have already installed + /// the eBPF block before calling this, and are responsible for removing + /// the eBPF block if this returns Err. + fn commit_soar_block_to_db( + &self, + source_ip: &str, + ip_version: u8, + playbook_id: i64, + expires_at: &str, + ) -> Result { + let mut conn = self.conn()?; + let tx = conn.transaction()?; + tx.execute( + "INSERT INTO soar_block_rules (source_ip, playbook_id, expires_at) VALUES (?1, ?2, ?3)", + params![source_ip, playbook_id, expires_at], + )?; + let soar_block_id = tx.last_insert_rowid(); + tx.execute( + "INSERT OR IGNORE INTO acl_rules (ip_version, direction, list_type, ip_address, port) VALUES (?1, ?2, ?3, ?4, ?5)", + params![ip_version, "source", "blacklist", source_ip, 0i64], + )?; + tx.commit()?; + Ok(soar_block_id) + } + + /// Clear a SOAR-driven block: remove the `acl_rules` entry and mark + /// the `soar_block_rules` row as unblocked in one transaction. + /// Callers handle eBPF unblock separately. + fn commit_soar_unblock_to_db(&self, soar_block_id: i64, ip_version: u8, source_ip: &str) -> Result<(), Error> { + let mut conn = self.conn()?; + let tx = conn.transaction()?; + tx.execute( + "DELETE FROM acl_rules WHERE ip_version = ?1 AND direction = ?2 AND list_type = ?3 AND ip_address = ?4 AND port = ?5", + params![ip_version, "source", "blacklist", source_ip, 0i64], + )?; + tx.execute( + "UPDATE soar_block_rules SET unblocked_at = datetime('now') WHERE id = ?1", + params![soar_block_id], + )?; + tx.commit()?; + Ok(()) + } +} + #[cfg(test)] mod tests { use super::*; @@ -2190,25 +2213,93 @@ mod tests { assert!(db.find_user("nobody").unwrap().is_none()); } - /// Verify that Database satisfies the RepositoryPort trait contract. - /// This test ensures the trait impl compiles and can be used via trait object. + /// Verify that Database satisfies each aggregate Repo trait contract + /// (AclRepo / SettingRepo / IdentityRepo). Exercises the trait-object + /// path so callers that take `Arc` compile end-to-end. #[test] - fn test_repository_port_trait_object() { - use crate::interface::port::repository::RepositoryPort; - + fn test_aggregate_repo_trait_objects() { let db = test_db(); - let repo: &dyn RepositoryPort = &db; - // Use via trait object — proves the abstraction works - repo.set_setting("test_key", "test_value").unwrap(); - assert_eq!(repo.get_setting("test_key").unwrap(), Some("test_value".to_string())); + let setting: &dyn SettingRepo = &db; + setting.set_setting("test_key", "test_value").unwrap(); + assert_eq!(setting.get_setting("test_key").unwrap(), Some("test_value".to_string())); - repo.insert_acl_rule(4, "source", "blacklist", "10.0.0.1", 443).unwrap(); - let rules = repo.load_acl_rules().unwrap(); + let acl: &dyn AclRepo = &db; + acl.insert_acl_rule(4, "source", "blacklist", "10.0.0.1", 443).unwrap(); + // load_acl_rules is an inherent Database method (not on AclRepo), + // so go through `&db` directly for this read-back assertion. + let rules = db.load_acl_rules().unwrap(); assert_eq!(rules.len(), 1); - assert_eq!(repo.user_count().unwrap(), 0); - repo.insert_user("test", "hash", "viewer", false).unwrap(); - assert_eq!(repo.user_count().unwrap(), 1); + let identity: &dyn IdentityRepo = &db; + // user_count is inherent — inserts still go through the trait so + // the vtable has something to exercise. + assert_eq!(db.user_count().unwrap(), 0); + identity.insert_user("test", "hash", "viewer", false).unwrap(); + assert_eq!(db.user_count().unwrap(), 1); + } + + /// Happy path. Verifies `commit_soar_block_to_db` writes both + /// `soar_block_rules` and `acl_rules` atomically. + #[test] + fn test_commit_soar_block_happy_path() { + let db = test_db(); + + // Seed a playbook so the foreign-key-ish playbook_id refers to something real. + let pb_id = db + .insert_playbook("test_pb", "threat_detected", Some(0.9), None, None, 300) + .unwrap(); + + let soar_block_id = db + .commit_soar_block_to_db("10.0.0.99", 4, pb_id, "2099-01-01 00:00:00") + .unwrap(); + assert!(soar_block_id > 0); + + // soar_block_rules has the row + let active = db.get_active_soar_blocks().unwrap(); + assert_eq!(active.len(), 1); + assert_eq!(active[0].1, "10.0.0.99"); + + // acl_rules has the matching row + let rules = db.load_acl_rules().unwrap(); + assert_eq!(rules.len(), 1); + assert_eq!(rules[0].3, "10.0.0.99"); + } + + /// Verifies `commit_soar_unblock_to_db` removes the ACL row and marks + /// the SOAR row as unblocked in one transaction. + #[test] + fn test_commit_soar_unblock_clears_both_tables() { + let db = test_db(); + let pb_id = db + .insert_playbook("test_pb", "threat_detected", Some(0.9), None, None, 300) + .unwrap(); + let soar_block_id = db + .commit_soar_block_to_db("10.0.0.99", 4, pb_id, "2099-01-01 00:00:00") + .unwrap(); + + db.commit_soar_unblock_to_db(soar_block_id, 4, "10.0.0.99").unwrap(); + + // acl_rules row gone + assert!(db.load_acl_rules().unwrap().is_empty()); + // soar_block_rules row no longer in "active" view (unblocked_at is set) + assert!(db.get_active_soar_blocks().unwrap().is_empty()); + } + + /// Verifies `insert_playbook_atomic` writes playbook + actions + conditions + /// atomically. + #[test] + fn test_insert_playbook_atomic_writes_all_three_tables() { + let db = test_db(); + let actions = vec![(1i64, "block_ip".to_string(), "{}".to_string())]; + let conditions = vec![("threshold".to_string(), ">=".to_string(), "0.8".to_string(), None)]; + let id = db + .insert_playbook_atomic("atom_pb", "threat", Some(0.8), None, None, 300, &actions, &conditions) + .unwrap(); + assert!(id > 0); + let loaded = db.load_playbooks_with_actions().unwrap(); + assert!(!loaded.is_empty()); + let cond_rows = db.load_all_playbook_conditions().unwrap(); + assert_eq!(cond_rows.len(), 1); } } diff --git a/net-guardia/src/adapter/telegram/mod.rs b/net-guardia/src/adapter/telegram/mod.rs index b46365f..ab56748 100644 --- a/net-guardia/src/adapter/telegram/mod.rs +++ b/net-guardia/src/adapter/telegram/mod.rs @@ -1,47 +1,50 @@ use std::sync::Arc; -use std::time::{Duration, Instant}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use async_trait::async_trait; -use parking_lot::Mutex; +use macros::log; use reqwest::Client; -use tracing::{debug, warn}; +use tokio::time::sleep; -use crate::interface::port::notification::{AlertNotifier, AlertPayload, NotificationConfigPort}; -use crate::interface::port::repository::RepositoryPort; +use crate::interface::port::app_repo::AppRepo; +use crate::interface::port::notification::{AlertNotifier, AlertNotifierFactory, AlertPayload}; use crate::interface::port::secret_store::SecretStorePort; +use crate::interface::port::setting::SettingRepo; use crate::model::config::constants::TELEGRAM_MAX_RETRIES; use crate::model::error::Error; use crate::model::error::notification::NotificationError; +use crate::model::log::system::SystemLog; /// Telegram Bot API adapter implementing AlertNotifier. pub struct TelegramAdapter { client: Client, - notif: Arc, - repo: Arc, + notif: Arc, + repo: Arc, secrets: Option>, - /// Rate limiter: (count, window_start) - rate_state: Mutex<(u32, Instant)>, + /// Packed rate-limit state: high 32 bits = window-start unix seconds, + /// low 32 bits = count consumed in this window. Updated via CAS so the + /// hot path stays lock-free. + rate_state: AtomicU64, } impl TelegramAdapter { pub fn new( - notif: Arc, - repo: Arc, + notif: Arc, + repo: Arc, secrets: Option>, ) -> Result { let client = Client::builder() .timeout(Duration::from_secs(10)) .build() - .map_err(|e| NotificationError::TelegramApiError { - reason: format!("Failed to create HTTP client: {}", e), - })?; + .map_err(|e| NotificationError::TelegramRequestFailed(e.without_url()))?; Ok(Self { client, notif, repo, secrets, - rate_state: Mutex::new((0, Instant::now())), + rate_state: AtomicU64::new(0), }) } @@ -51,9 +54,7 @@ impl TelegramAdapter { match self.notif.get_notification_config("telegram")? { Some(json_str) => { let config: serde_json::Value = - serde_json::from_str(&json_str).map_err(|e| NotificationError::TelegramApiError { - reason: format!("Invalid telegram config JSON: {}", e), - })?; + serde_json::from_str(&json_str).map_err(NotificationError::TelegramRequestFailed)?; let mut token = config.get("bot_token").and_then(|v| v.as_str()).map(|s| s.to_string()); let chat_id = config.get("chat_id").and_then(|v| v.as_str()).map(|s| s.to_string()); @@ -74,30 +75,60 @@ impl TelegramAdapter { } } - /// Check rate limit. Returns true if send is allowed. - fn check_rate_limit(&self) -> bool { - let mut state = self.rate_state.lock(); - let (count, window_start) = &mut *state; - - // Reset window if >60s has passed - if window_start.elapsed() > Duration::from_secs(60) { - *count = 0; - *window_start = Instant::now(); - } - - let max_per_min: u32 = self - .repo - .get_setting("telegram_max_messages_per_minute") + /// Read the configured per-window message cap from settings. + fn rate_limit_max_messages(&self) -> u32 { + self.repo + .get_setting("telegram_rate_limit_max_messages") .ok() .flatten() .and_then(|v| v.parse().ok()) - .unwrap_or(20); - if *count >= max_per_min { + .unwrap_or(20) + } + + /// Read the configured window length (seconds) from settings. + fn rate_limit_window_secs(&self) -> u32 { + self.repo + .get_setting("telegram_rate_limit_window_secs") + .ok() + .flatten() + .and_then(|v| v.parse().ok()) + .unwrap_or(60) + } + + /// Check rate limit. Returns true if send is allowed. + fn check_rate_limit(&self) -> bool { + let max_messages = self.rate_limit_max_messages(); + if max_messages == 0 { return false; } + let window_secs = self.rate_limit_window_secs().max(1); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as u32) + .unwrap_or(0); - *count += 1; - true + loop { + let cur = self.rate_state.load(Ordering::Acquire); + let count = cur as u32; + let window = (cur >> 32) as u32; + + let (next_count, next_window) = if now.saturating_sub(window) >= window_secs { + (1u32, now) + } else if count >= max_messages { + return false; + } else { + (count + 1, window) + }; + + let new = ((next_window as u64) << 32) | next_count as u64; + if self + .rate_state + .compare_exchange_weak(cur, new, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + return true; + } + } } /// Send a message via Telegram Bot API with retry on 429. @@ -119,7 +150,11 @@ impl TelegramAdapter { if e.is_timeout() { NotificationError::Timeout } else { - NotificationError::TelegramApiError { reason: e.to_string() } + // Strip URL — it embeds the bot token in the path + // (`/bot/sendMessage`) and reqwest::Error's + // Display includes the full URL by default, which + // would leak the token into journal/error logs. + NotificationError::TelegramRequestFailed(e.without_url()) } })?; @@ -132,10 +167,7 @@ impl TelegramAdapter { if status.as_u16() == 401 || status.as_u16() == 403 { let body = resp.text().await.unwrap_or_default(); if body.contains("chat not found") || body.contains("CHAT_NOT_FOUND") { - return Err(NotificationError::TelegramChatNotFound { - chat_id: chat_id.to_string(), - } - .into()); + return Err(NotificationError::TelegramChatNotFound(chat_id.to_string()).into()); } return Err(NotificationError::TelegramAuthError.into()); } @@ -150,28 +182,21 @@ impl TelegramAdapter { .unwrap_or(5); if attempt < TELEGRAM_MAX_RETRIES { - warn!( - "Telegram rate limited, retrying after {}s (attempt {}/{})", + log!(SystemLog::TelegramRateLimitedRetry( retry_after, attempt + 1, - TELEGRAM_MAX_RETRIES - ); - tokio::time::sleep(Duration::from_secs(retry_after)).await; + TELEGRAM_MAX_RETRIES, + )); + sleep(Duration::from_secs(retry_after)).await; continue; } else { - return Err(NotificationError::TelegramRateLimited { - retry_after_secs: retry_after, - } - .into()); + return Err(NotificationError::TelegramRateLimited(retry_after).into()); } } // Other error let body = resp.text().await.unwrap_or_default(); - return Err(NotificationError::TelegramApiError { - reason: format!("HTTP {}: {}", status, body), - } - .into()); + Err(NotificationError::TelegramHttpError(status.as_u16(), body))?; } unreachable!() @@ -205,23 +230,17 @@ impl AlertNotifier for TelegramAdapter { let (bot_token, chat_id) = match self.get_config()? { Some(config) => config, None => { - debug!("Telegram not configured, skipping alert"); + log!(SystemLog::TelegramNotConfiguredSkipped); return Ok(()); } }; if !self.check_rate_limit() { - let max_per_min: u32 = self - .repo - .get_setting("telegram_max_messages_per_minute") - .ok() - .flatten() - .and_then(|v| v.parse().ok()) - .unwrap_or(20); - warn!( - "Telegram rate limit reached ({}/min), dropping alert for IP {}", - max_per_min, payload.source_ip - ); + log!(SystemLog::TelegramLocalRateLimitDropped( + self.rate_limit_max_messages(), + self.rate_limit_window_secs(), + payload.source_ip.clone(), + )); return Ok(()); } @@ -232,12 +251,7 @@ impl AlertNotifier for TelegramAdapter { async fn send_test_message(&self) -> Result<(), Error> { let (bot_token, chat_id) = match self.get_config()? { Some(config) => config, - None => { - return Err(NotificationError::NotConfigured { - channel: "telegram".to_string(), - } - .into()); - } + None => Err(NotificationError::NotConfigured("telegram"))?, }; self.send_message( @@ -248,3 +262,26 @@ impl AlertNotifier for TelegramAdapter { .await } } + +/// Adapter-side factory that satisfies the `AlertNotifierFactory` port. Holds +/// the same shared dependencies the long-lived adapter uses; each `create()` +/// call instantiates a fresh `TelegramAdapter` so the test path observes +/// whatever config the user just saved. +pub struct TelegramAdapterFactory { + notif: Arc, + repo: Arc, + secrets: Option>, +} + +impl TelegramAdapterFactory { + pub fn new(notif: Arc, repo: Arc, secrets: Option>) -> Self { + Self { notif, repo, secrets } + } +} + +impl AlertNotifierFactory for TelegramAdapterFactory { + fn create(&self) -> Result, Error> { + let adapter = TelegramAdapter::new(self.notif.clone(), self.repo.clone(), self.secrets.clone())?; + Ok(Arc::new(adapter)) + } +} diff --git a/net-guardia/src/adapter/websocket/alert_websocket.rs b/net-guardia/src/adapter/websocket/alert_websocket.rs index 3c328ff..c5aaa61 100644 --- a/net-guardia/src/adapter/websocket/alert_websocket.rs +++ b/net-guardia/src/adapter/websocket/alert_websocket.rs @@ -1,21 +1,23 @@ +use actix_web::rt::spawn; use actix_web::{HttpRequest, HttpResponse, Result, web}; use actix_ws::{Message, MessageStream, Session, handle}; use futures_util::StreamExt; use macros::log; use tokio::sync::broadcast; +use tokio::sync::broadcast::error::RecvError; use crate::core::ml::alert::MLAlert; +use crate::model::detection::ml_detection::AlertMessage; use crate::model::error::http::HttpError; use crate::model::error::misc::MiscError; use crate::model::log::http::HttpLog; -use crate::model::ml_detection::AlertMessage; pub async fn websocket_alert(req: HttpRequest, body: web::Payload, ai: web::Data) -> Result { let (response, session, msg_stream) = handle(&req, body)?; let broadcast_rx = ai.subscribe_to_alerts(); - actix_web::rt::spawn(async move { + spawn(async move { handle_alert_connection(session, msg_stream, broadcast_rx).await; }); @@ -41,11 +43,11 @@ async fn handle_alert_connection( break; } } - Err(broadcast::error::RecvError::Lagged(skipped)) => { + Err(RecvError::Lagged(skipped)) => { log!(HttpLog::WebSocketLagged(skipped)); continue; } - Err(broadcast::error::RecvError::Closed) => { + Err(RecvError::Closed) => { break; } } diff --git a/net-guardia/src/adapter/websocket/drop_websocket.rs b/net-guardia/src/adapter/websocket/drop_websocket.rs index e212408..cb662ef 100644 --- a/net-guardia/src/adapter/websocket/drop_websocket.rs +++ b/net-guardia/src/adapter/websocket/drop_websocket.rs @@ -1,14 +1,16 @@ +use actix_web::rt::spawn; use actix_web::{HttpRequest, HttpResponse, Result, web}; use actix_ws::{Message, MessageStream, Session, handle}; use futures_util::StreamExt; use macros::log; use tokio::sync::broadcast; +use tokio::sync::broadcast::error::RecvError; -use crate::core::ebpf::drop_monitor::DropMonitor; -use crate::model::drop_event::DropEventMessage; +use crate::adapter::ebpf::drop_monitor::DropMonitor; use crate::model::error::http::HttpError; use crate::model::error::misc::MiscError; use crate::model::log::http::HttpLog; +use crate::model::monitoring::drop_event::DropEventMessage; pub async fn websocket_drops( req: HttpRequest, @@ -19,7 +21,7 @@ pub async fn websocket_drops( let broadcast_rx = monitor.subscribe(); - actix_web::rt::spawn(async move { + spawn(async move { handle_drop_connection(session, msg_stream, broadcast_rx).await; }); @@ -45,11 +47,11 @@ async fn handle_drop_connection( break; } } - Err(broadcast::error::RecvError::Lagged(skipped)) => { + Err(RecvError::Lagged(skipped)) => { log!(HttpLog::WebSocketLagged(skipped)); continue; } - Err(broadcast::error::RecvError::Closed) => { + Err(RecvError::Closed) => { break; } } diff --git a/net-guardia/src/adapter/websocket/flow_websocket.rs b/net-guardia/src/adapter/websocket/flow_websocket.rs index a81e984..2888725 100644 --- a/net-guardia/src/adapter/websocket/flow_websocket.rs +++ b/net-guardia/src/adapter/websocket/flow_websocket.rs @@ -1,12 +1,13 @@ use std::time::Duration; +use actix_web::rt::spawn; use actix_web::{HttpRequest, HttpResponse, web}; use actix_ws::Message; use futures_util::StreamExt; use tokio::time::interval; use crate::infrastructure::statistics::FlowStatistics; -use crate::model::flow_stats::FlowSubscription; +use crate::model::monitoring::flow_stats::FlowSubscription; /// Default subscription: all flows, no filter, 5 second interval fn default_subscription() -> FlowSubscription { @@ -31,7 +32,7 @@ pub async fn flow_stats_ws( ) -> Result { let (response, mut session, mut msg_stream) = actix_ws::handle(&req, body)?; - actix_web::rt::spawn(async move { + spawn(async move { let mut subscription = default_subscription(); let mut ticker = interval(Duration::from_secs(subscription.interval_secs.unwrap_or(5))); @@ -66,11 +67,7 @@ pub async fn flow_stats_ws( } } } - Some(Ok(Message::Ping(bytes))) => { - if session.pong(&bytes).await.is_err() { - break; - } - } + Some(Ok(Message::Ping(bytes))) if session.pong(&bytes).await.is_err() => break, Some(Ok(Message::Close(_))) | None => break, _ => {} } diff --git a/net-guardia/src/adapter/websocket/fusion_websocket.rs b/net-guardia/src/adapter/websocket/fusion_websocket.rs new file mode 100644 index 0000000..3ead7c7 --- /dev/null +++ b/net-guardia/src/adapter/websocket/fusion_websocket.rs @@ -0,0 +1,192 @@ +//! WebSocket bridge for post-fusion threat events. +//! +//! `/ws/fusion` subscribes to the `ThreatDetectedEvent` broadcast that the +//! `DetectionOrchestrator` already publishes through `CommunicationManager` +//! (the same stream SOAR consumes). Each event is wrapped with a server-side +//! `ts` (unix seconds) so the dashboard can render relative timestamps +//! without doing the conversion itself. +//! +//! Distinct from `/ws/alerts` (flow-level ML detections via `MLAlert`): +//! this stream is the **fused, per-IP, multi-source** view that drives the +//! Overview "Recent Threats" card and the sources-agreed chip. Treating +//! them as one channel would conflate two bounded contexts — see +//! `docs/strategy/DOMAIN_MAP.md` for the BC split rationale. + +use std::time::{SystemTime, UNIX_EPOCH}; + +use actix_web::rt::spawn; +use actix_web::{HttpRequest, HttpResponse, Result, web}; +use actix_ws::{Message, MessageStream, Session, handle}; +use futures_util::StreamExt; +use macros::log; +use tokio::sync::broadcast; +use tokio::sync::broadcast::error::RecvError; + +use crate::infrastructure::communication_manager::CommunicationManager; +use crate::model::error::http::HttpError; +use crate::model::error::misc::MiscError; +use crate::model::event::ThreatDetectedEvent; +use crate::model::log::http::HttpLog; + +pub async fn websocket_fusion( + req: HttpRequest, + body: web::Payload, + comm: web::Data, +) -> Result { + let (response, session, msg_stream) = handle(&req, body)?; + + let broadcast_rx = match comm.subscribe_event::() { + Ok(rx) => rx, + Err(e) => { + log!(HttpLog::FusionSubscribeFailed(e.to_string())); + return Ok(HttpResponse::InternalServerError().json(serde_json::json!({ + "error": "fusion event channel not registered", + }))); + } + }; + + spawn(async move { + handle_fusion_connection(session, msg_stream, broadcast_rx).await; + }); + + Ok(response) +} + +async fn handle_fusion_connection( + mut session: Session, + mut msg_stream: MessageStream, + mut broadcast_rx: broadcast::Receiver, +) { + loop { + tokio::select! { + msg_result = msg_stream.next() => { + if !handle_client_message(&mut session, msg_result).await { + break; + } + }, + broadcast_result = broadcast_rx.recv() => { + match broadcast_result { + Ok(event) => { + if !send_event(&mut session, &event).await { + break; + } + } + Err(RecvError::Lagged(skipped)) => { + log!(HttpLog::WebSocketLagged(skipped)); + continue; + } + Err(RecvError::Closed) => { + break; + } + } + }, + } + } + + let _ = session.close(None).await; +} + +async fn handle_client_message( + session: &mut Session, + msg_result: Option>, +) -> bool { + match msg_result { + Some(Ok(Message::Text(_))) => true, + Some(Ok(Message::Ping(bytes))) => session.pong(&bytes).await.is_ok(), + Some(Ok(Message::Close(reason))) => { + let _ = (session.clone()).close(reason).await; + false + } + Some(Err(err)) => { + log!(HttpError::WebSocketError(err)); + false + } + None => false, + _ => true, + } +} + +/// Wrap each event in `{ts, ...event_fields}`. The `ts` is a server-stamped +/// unix-seconds value so the client can render "5s ago" without inferring +/// the time from the audit chain. All declared fields of +/// `ThreatDetectedEvent` flow through verbatim via the event's own +/// `Serialize` derive — no field whitelist to drift out of date. +async fn send_event(session: &mut Session, event: &ThreatDetectedEvent) -> bool { + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + let payload = match serde_json::to_value(event) { + Ok(serde_json::Value::Object(mut map)) => { + map.insert("ts".to_string(), serde_json::Value::from(ts)); + serde_json::Value::Object(map) + } + // The derived Serialize on a struct always produces an Object — + // this branch only fires if the type changes shape in a future + // refactor. Falling back to the raw value keeps the stream alive. + Ok(other) => other, + Err(err) => { + log!(MiscError::SerializeError(err)); + return false; + } + }; + + match serde_json::to_string(&payload) { + Ok(json) => session.text(json).await.is_ok(), + Err(err) => { + log!(MiscError::SerializeError(err)); + false + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::event::DetectionSource; + + fn sample_event() -> ThreatDetectedEvent { + ThreatDetectedEvent { + attack_type: "brute_force".to_string(), + confidence: 0.92, + source_ip: "203.0.113.10".to_string(), + dest_ip: "10.0.0.1".to_string(), + flow_count: 3, + packet_rate: 12.5, + protocol: 6, + geoip_country: Some("CN".to_string()), + is_repeat_offender: true, + sources: vec![DetectionSource::ML, DetectionSource::Suricata], + active_source_count: 2, + fused_confidence: 0.99, + ae_score: 0.0, + anomaly_score: 0.0, + c2_score: 0.0, + } + } + + #[test] + fn event_serializes_with_canonical_source_strings() { + let event = sample_event(); + let json = serde_json::to_value(&event).expect("serialize event"); + let sources = json["sources"].as_array().expect("sources array"); + assert_eq!(sources[0], "ML"); + assert_eq!(sources[1], "Suricata"); + assert_eq!(json["active_source_count"], 2); + assert_eq!(json["geoip_country"], "CN"); + } + + #[test] + fn envelope_adds_ts_field_to_event_object() { + // The `send_event` wire path inserts `ts` into the event's own + // serde object; mirror that here without an actix session so the + // wrapping logic stays covered when the orchestrator schema evolves. + let event = sample_event(); + let mut value = serde_json::to_value(&event).expect("serialize event"); + let object = value.as_object_mut().expect("expected object shape"); + object.insert("ts".to_string(), serde_json::Value::from(1_700_000_000_u64)); + assert_eq!(value["ts"], 1_700_000_000_u64); + assert_eq!(value["attack_type"], "brute_force"); + } +} diff --git a/net-guardia/src/adapter/websocket/health_websocket.rs b/net-guardia/src/adapter/websocket/health_websocket.rs index c8a6c3a..b2d7876 100644 --- a/net-guardia/src/adapter/websocket/health_websocket.rs +++ b/net-guardia/src/adapter/websocket/health_websocket.rs @@ -1,14 +1,16 @@ +use actix_web::rt::spawn; use actix_web::{HttpRequest, HttpResponse, Result, web}; use actix_ws::{Message, MessageStream, Session, handle}; use futures_util::StreamExt; use macros::log; use tokio::sync::broadcast; +use tokio::sync::broadcast::error::RecvError; use crate::infrastructure::health::SystemHealth; use crate::model::error::http::HttpError; use crate::model::error::misc::MiscError; -use crate::model::health::SystemHealthMetrics; use crate::model::log::http::HttpLog; +use crate::model::system::health::SystemHealthMetrics; pub async fn websocket_system_health( req: HttpRequest, @@ -19,7 +21,7 @@ pub async fn websocket_system_health( let broadcast_rx = health.subscribe_to_metrics(); - actix_web::rt::spawn(async move { + spawn(async move { handle_health_connection(session, msg_stream, broadcast_rx).await; }); @@ -45,11 +47,11 @@ async fn handle_health_connection( break; } } - Err(broadcast::error::RecvError::Lagged(skipped)) => { + Err(RecvError::Lagged(skipped)) => { log!(HttpLog::WebSocketLagged(skipped)); continue; } - Err(broadcast::error::RecvError::Closed) => { + Err(RecvError::Closed) => { break; } } diff --git a/net-guardia/src/adapter/websocket/mod.rs b/net-guardia/src/adapter/websocket/mod.rs index a488f84..d435fd2 100644 --- a/net-guardia/src/adapter/websocket/mod.rs +++ b/net-guardia/src/adapter/websocket/mod.rs @@ -1,5 +1,6 @@ pub mod alert_websocket; pub mod drop_websocket; pub mod flow_websocket; +pub mod fusion_websocket; pub mod health_websocket; pub mod routes; diff --git a/net-guardia/src/adapter/websocket/routes.rs b/net-guardia/src/adapter/websocket/routes.rs index 51732e5..a97ab45 100644 --- a/net-guardia/src/adapter/websocket/routes.rs +++ b/net-guardia/src/adapter/websocket/routes.rs @@ -1,10 +1,11 @@ use actix_web::{HttpRequest, HttpResponse, Responder, Scope, web}; use serde::Deserialize; -use super::{alert_websocket, drop_websocket, flow_websocket, health_websocket}; +use super::{alert_websocket, drop_websocket, flow_websocket, fusion_websocket, health_websocket}; +use crate::adapter::ebpf::drop_monitor::DropMonitor; use crate::core::auth::jwt::JwtService; -use crate::core::ebpf::drop_monitor::DropMonitor; use crate::core::ml::alert::MLAlert; +use crate::infrastructure::communication_manager::CommunicationManager; use crate::infrastructure::health::SystemHealth; use crate::infrastructure::statistics::FlowStatistics; @@ -17,6 +18,7 @@ pub fn initialize() -> Scope { web::scope("/ws") .route("/health", web::get().to(health_ws)) .route("/alerts", web::get().to(alerts_ws)) + .route("/fusion", web::get().to(fusion_ws)) .route("/flows", web::get().to(flows_ws)) .route("/drops", web::get().to(drops_ws)) } @@ -84,6 +86,24 @@ async fn alerts_ws( } } +async fn fusion_ws( + req: HttpRequest, + stream: web::Payload, + comm: web::Data, + query: web::Query, + jwt: web::Data, +) -> impl Responder { + if let Err(resp) = validate_ws_token(&req, &query, &jwt) { + return resp; + } + match fusion_websocket::websocket_fusion(req, stream, comm).await { + Ok(response) => response, + Err(err) => { + HttpResponse::InternalServerError().json(serde_json::json!({"error": format!("WebSocket error: {}", err)})) + } + } +} + async fn flows_ws( req: HttpRequest, stream: web::Payload, diff --git a/net-guardia/src/core/acl_service.rs b/net-guardia/src/core/acl_service.rs index 74e4d82..be56df0 100644 --- a/net-guardia/src/core/acl_service.rs +++ b/net-guardia/src/core/acl_service.rs @@ -1,26 +1,30 @@ use std::net::{SocketAddrV4, SocketAddrV6}; use std::sync::Arc; -use crate::core::ebpf::access_control::AccessControl; -use crate::core::ebpf::geo_block::GeoBlock; -use crate::interface::port::repository::RepositoryPort; -use crate::model::direction::FlowDirection; +use crate::interface::port::access_control_admin::AccessControlAdminPort; +use crate::interface::port::app_repo::AppRepo; +use crate::interface::port::geo_block_api::GeoBlockPort; +use crate::model::monitoring::direction::FlowDirection; use macros::log; +use crate::model::access_control::list_type::ListType; use crate::model::error::Error; use crate::model::error::ebpf::EbpfError; -use crate::model::list_type::ListType; /// Domain service that coordinates ACL changes between DB persistence and eBPF data plane. /// Atomic write: eBPF first, then DB. If DB fails, rollback eBPF. pub struct AclService { - db: Arc, - access_control: Arc, - geo_block: Arc, + db: Arc, + access_control: Arc, + geo_block: Arc, } impl AclService { - pub fn new(db: Arc, access_control: Arc, geo_block: Arc) -> Self { + pub fn new( + db: Arc, + access_control: Arc, + geo_block: Arc, + ) -> Self { Self { db, access_control, @@ -28,13 +32,8 @@ impl AclService { } } - pub async fn add_ipv4( - &self, - direction: FlowDirection, - list_type: ListType, - address: SocketAddrV4, - ) -> Result<(), Error> { - self.access_control.add_ipv4_list(direction, list_type, address).await?; + pub fn add_ipv4(&self, direction: FlowDirection, list_type: ListType, address: SocketAddrV4) -> Result<(), Error> { + self.access_control.add_ipv4_list(direction, list_type, address)?; if let Err(e) = self.db.insert_acl_rule( 4, direction_str(direction), @@ -42,11 +41,7 @@ impl AclService { &address.ip().to_string(), address.port(), ) { - if let Err(rollback_err) = self - .access_control - .remove_ipv4_list(direction, list_type, address) - .await - { + if let Err(rollback_err) = self.access_control.remove_ipv4_list(direction, list_type, address) { log!(EbpfError::RollbackFailed(rollback_err)); } return Err(e); @@ -54,13 +49,8 @@ impl AclService { Ok(()) } - pub async fn add_ipv6( - &self, - direction: FlowDirection, - list_type: ListType, - address: SocketAddrV6, - ) -> Result<(), Error> { - self.access_control.add_ipv6_list(direction, list_type, address).await?; + pub fn add_ipv6(&self, direction: FlowDirection, list_type: ListType, address: SocketAddrV6) -> Result<(), Error> { + self.access_control.add_ipv6_list(direction, list_type, address)?; if let Err(e) = self.db.insert_acl_rule( 6, direction_str(direction), @@ -68,11 +58,7 @@ impl AclService { &address.ip().to_string(), address.port(), ) { - if let Err(rollback_err) = self - .access_control - .remove_ipv6_list(direction, list_type, address) - .await - { + if let Err(rollback_err) = self.access_control.remove_ipv6_list(direction, list_type, address) { log!(EbpfError::RollbackFailed(rollback_err)); } return Err(e); @@ -80,15 +66,13 @@ impl AclService { Ok(()) } - pub async fn remove_ipv4( + pub fn remove_ipv4( &self, direction: FlowDirection, list_type: ListType, address: SocketAddrV4, ) -> Result<(), Error> { - self.access_control - .remove_ipv4_list(direction, list_type, address) - .await?; + self.access_control.remove_ipv4_list(direction, list_type, address)?; if let Err(e) = self.db.delete_acl_rule( 4, direction_str(direction), @@ -96,7 +80,7 @@ impl AclService { &address.ip().to_string(), address.port(), ) { - if let Err(rollback_err) = self.access_control.add_ipv4_list(direction, list_type, address).await { + if let Err(rollback_err) = self.access_control.add_ipv4_list(direction, list_type, address) { log!(EbpfError::RollbackFailed(rollback_err)); } return Err(e); @@ -104,15 +88,13 @@ impl AclService { Ok(()) } - pub async fn remove_ipv6( + pub fn remove_ipv6( &self, direction: FlowDirection, list_type: ListType, address: SocketAddrV6, ) -> Result<(), Error> { - self.access_control - .remove_ipv6_list(direction, list_type, address) - .await?; + self.access_control.remove_ipv6_list(direction, list_type, address)?; if let Err(e) = self.db.delete_acl_rule( 6, direction_str(direction), @@ -120,7 +102,7 @@ impl AclService { &address.ip().to_string(), address.port(), ) { - if let Err(rollback_err) = self.access_control.add_ipv6_list(direction, list_type, address).await { + if let Err(rollback_err) = self.access_control.add_ipv6_list(direction, list_type, address) { log!(EbpfError::RollbackFailed(rollback_err)); } return Err(e); @@ -147,11 +129,11 @@ impl AclService { } pub fn get_blocked_countries(&self) -> Vec { - self.geo_block.get_blocked_countries() + self.geo_block.list_blocked() } - pub fn access_control(&self) -> &AccessControl { - &self.access_control + pub fn access_control(&self) -> &dyn AccessControlAdminPort { + self.access_control.as_ref() } } diff --git a/net-guardia/src/core/auth/csrf.rs b/net-guardia/src/core/auth/csrf.rs new file mode 100644 index 0000000..87b1b1d --- /dev/null +++ b/net-guardia/src/core/auth/csrf.rs @@ -0,0 +1,195 @@ +//! CSRF defense-in-depth middleware. +//! +//! The primary auth path uses `Authorization: Bearer ` — a scheme +//! the browser never auto-attaches — so classical CSRF against a +//! malicious same-origin form POST is already neutralized. This +//! middleware adds a belt-and-suspenders layer on top: +//! +//! - State-changing requests (anything that isn't `GET`/`HEAD`/`OPTIONS`) +//! must carry an `X-CSRF-Token` header. +//! - The header's presence alone is the check. Cross-origin attackers +//! cannot set custom request headers on simple requests (browsers +//! block that via the CORS preflight), so a successful request from +//! a third-party page would need to run JS inside our origin, at +//! which point CSRF is the wrong threat label anyway. +//! - Exempt: auth / setup bootstrap endpoints (no session yet), +//! WebSocket upgrade (no body to forge), and `X-API-Key` +//! authentication (sealed credential — the request isn't a browser +//! navigation at all). +//! +//! The decision lives in `should_require_csrf_token` so unit tests can +//! cover the path without spinning up an Actix test harness. + +use std::future::{Future, Ready, ready}; +use std::pin::Pin; +use std::rc::Rc; +use std::task::{Context, Poll}; + +use actix_web::body::EitherBody; +use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform}; +use actix_web::http::Method; +use actix_web::{Error as ActixError, HttpResponse}; + +/// Request header carrying the CSRF token. Clients (frontend fetch / +/// axios wrappers) set this on every state-changing request; its value +/// is whatever the client produced (we don't validate content). +pub const CSRF_HEADER: &str = "X-CSRF-Token"; + +/// Header used by non-browser clients for API-key authentication. Such +/// clients are exempt from the CSRF requirement. +const API_KEY_HEADER: &str = "X-API-Key"; + +pub struct CsrfMiddleware; + +impl Transform for CsrfMiddleware +where + S: Service, Error = ActixError> + 'static, + B: 'static, +{ + type Response = ServiceResponse>; + type Error = ActixError; + type Transform = CsrfMiddlewareService; + type InitError = (); + type Future = Ready>; + + fn new_transform(&self, service: S) -> Self::Future { + ready(Ok(CsrfMiddlewareService { + service: Rc::new(service), + })) + } +} + +pub struct CsrfMiddlewareService { + service: Rc, +} + +impl Service for CsrfMiddlewareService +where + S: Service, Error = ActixError> + 'static, + B: 'static, +{ + type Response = ServiceResponse>; + type Error = ActixError; + type Future = Pin>>>; + + fn poll_ready(&self, ctx: &mut Context<'_>) -> Poll> { + self.service.poll_ready(ctx) + } + + fn call(&self, req: ServiceRequest) -> Self::Future { + let service = Rc::clone(&self.service); + let path = req.path().to_string(); + let method = req.method().clone(); + let has_api_key = req.headers().contains_key(API_KEY_HEADER); + let has_csrf_token = req.headers().contains_key(CSRF_HEADER); + + Box::pin(async move { + if should_require_csrf_token(&path, &method, has_api_key) && !has_csrf_token { + let resp = HttpResponse::Forbidden().json(serde_json::json!({ + "error": "Missing CSRF token", + "header": CSRF_HEADER, + })); + return Ok(req.into_response(resp).map_into_right_body()); + } + let res = service.call(req).await?.map_into_left_body(); + Ok(res) + }) + } +} + +/// Decide whether a request must present a CSRF token. The rules are +/// extracted as a free function so the middleware is a thin shim and +/// the policy can be unit-tested without an HTTP harness. +pub fn should_require_csrf_token(path: &str, method: &Method, has_api_key: bool) -> bool { + if has_api_key { + return false; + } + if !is_state_changing(method) { + return false; + } + if is_csrf_exempt_path(path) { + return false; + } + true +} + +fn is_state_changing(method: &Method) -> bool { + !matches!(*method, Method::GET | Method::HEAD | Method::OPTIONS) +} + +/// Paths that cannot meaningfully carry a CSRF token because the +/// session that would mint one hasn't been established yet, or because +/// the route uses a protocol outside the CSRF threat model. +fn is_csrf_exempt_path(path: &str) -> bool { + // Login / setup bootstrap: no session yet, so no token to match. + if path == "/api/auth/login" || path.starts_with("/api/setup/") { + return true; + } + // WebSocket upgrade happens over a GET anyway, but list the prefix + // explicitly so the intent is visible when someone reads the file. + if path.starts_with("/ws/") { + return true; + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn get_requests_do_not_need_csrf_token() { + assert!(!should_require_csrf_token("/api/acl/rules", &Method::GET, false)); + assert!(!should_require_csrf_token("/api/stats/summary", &Method::HEAD, false)); + assert!(!should_require_csrf_token("/api/ml/status", &Method::OPTIONS, false)); + } + + #[test] + fn state_changing_requests_on_admin_routes_require_csrf_token() { + assert!(should_require_csrf_token("/api/acl/rules", &Method::POST, false)); + assert!(should_require_csrf_token( + "/api/ml/models/current", + &Method::DELETE, + false + )); + assert!(should_require_csrf_token("/api/soar/playbooks/1", &Method::PUT, false)); + assert!(should_require_csrf_token( + "/api/notifications/telegram", + &Method::PATCH, + false + )); + } + + #[test] + fn login_and_setup_are_exempt() { + // Login hasn't yet issued a session, so there's no token to carry. + assert!(!should_require_csrf_token("/api/auth/login", &Method::POST, false)); + assert!(!should_require_csrf_token( + "/api/setup/initialize", + &Method::POST, + false + )); + } + + #[test] + fn websocket_upgrade_is_exempt() { + // WS upgrade is a GET anyway but stays exempt under any verb. + assert!(!should_require_csrf_token("/ws/events", &Method::GET, false)); + assert!(!should_require_csrf_token("/ws/events", &Method::POST, false)); + } + + #[test] + fn api_key_clients_are_exempt_even_on_state_changing_routes() { + // Non-browser clients present a sealed credential; CSRF is a + // browser threat model. + assert!(!should_require_csrf_token("/api/acl/rules", &Method::POST, true)); + assert!(!should_require_csrf_token("/api/soar/playbooks", &Method::DELETE, true)); + } + + #[test] + fn api_key_exemption_takes_precedence_over_path_rules() { + // Even if the path is a state-changing admin route, the API-key + // header flips the requirement off before the path check runs. + assert!(!should_require_csrf_token("/api/system/reload", &Method::POST, true)); + } +} diff --git a/net-guardia/src/core/auth/extractor.rs b/net-guardia/src/core/auth/extractor.rs index e2f7ce0..e1efe53 100644 --- a/net-guardia/src/core/auth/extractor.rs +++ b/net-guardia/src/core/auth/extractor.rs @@ -1,9 +1,11 @@ use std::future::{Ready, ready}; +use std::ops::Deref; use actix_web::dev::Payload; -use actix_web::{FromRequest, HttpMessage, HttpRequest}; +use actix_web::error::ErrorUnauthorized; +use actix_web::{Error as ActixError, FromRequest, HttpMessage, HttpRequest}; -use crate::model::auth::Claims; +use crate::model::identity::auth::Claims; /// Actix-web extractor that pulls `Claims` from request extensions. /// @@ -19,7 +21,7 @@ use crate::model::auth::Claims; /// ``` pub struct AuthClaims(pub Claims); -impl std::ops::Deref for AuthClaims { +impl Deref for AuthClaims { type Target = Claims; fn deref(&self) -> &Self::Target { &self.0 @@ -27,15 +29,13 @@ impl std::ops::Deref for AuthClaims { } impl FromRequest for AuthClaims { - type Error = actix_web::Error; + type Error = ActixError; type Future = Ready>; fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future { match req.extensions().get::().cloned() { Some(claims) => ready(Ok(AuthClaims(claims))), - None => ready(Err(actix_web::error::ErrorUnauthorized( - serde_json::json!({"error": "Unauthorized"}), - ))), + None => ready(Err(ErrorUnauthorized(serde_json::json!({"error": "Unauthorized"})))), } } } diff --git a/net-guardia/src/core/auth/https_redirect.rs b/net-guardia/src/core/auth/https_redirect.rs index 20a05bc..06ab469 100644 --- a/net-guardia/src/core/auth/https_redirect.rs +++ b/net-guardia/src/core/auth/https_redirect.rs @@ -1,7 +1,10 @@ use std::future::{Future, Ready, ready}; +use std::net::IpAddr; use std::pin::Pin; +use std::rc::Rc; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; +use std::task::{Context, Poll}; use actix_web::body::EitherBody; use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform}; @@ -40,13 +43,13 @@ fn is_safe_redirect_host(host: &str) -> bool { } // Try parsing as IP — allow private ranges only - if let Ok(ip) = hostname.parse::() { + if let Ok(ip) = hostname.parse::() { return match ip { - std::net::IpAddr::V4(v4) => { + IpAddr::V4(v4) => { let o = v4.octets(); o[0] == 10 || (o[0] == 172 && (16..=31).contains(&o[1])) || (o[0] == 192 && o[1] == 168) || o[0] == 127 } - std::net::IpAddr::V6(v6) => v6.is_loopback() || (v6.segments()[0] & 0xfe00) == 0xfc00, + IpAddr::V6(v6) => v6.is_loopback() || (v6.segments()[0] & 0xfe00) == 0xfc00, }; } @@ -68,13 +71,13 @@ where fn new_transform(&self, service: S) -> Self::Future { ready(Ok(HttpsRedirectService { - service: std::rc::Rc::new(service), + service: Rc::new(service), })) } } pub struct HttpsRedirectService { - service: std::rc::Rc, + service: Rc, } impl Service for HttpsRedirectService @@ -86,12 +89,12 @@ where type Error = ActixError; type Future = Pin>>>; - fn poll_ready(&self, ctx: &mut core::task::Context<'_>) -> std::task::Poll> { + fn poll_ready(&self, ctx: &mut Context<'_>) -> Poll> { self.service.poll_ready(ctx) } fn call(&self, req: ServiceRequest) -> Self::Future { - let service = std::rc::Rc::clone(&self.service); + let service = Rc::clone(&self.service); Box::pin(async move { // Check if force_https is enabled diff --git a/net-guardia/src/core/auth/jwt.rs b/net-guardia/src/core/auth/jwt.rs index 7b62106..f069456 100644 --- a/net-guardia/src/core/auth/jwt.rs +++ b/net-guardia/src/core/auth/jwt.rs @@ -1,11 +1,12 @@ use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode, errors::ErrorKind}; use crate::interface::port::secret_store::SecretStorePort; -use crate::model::auth::Claims; use crate::model::error::Error; use crate::model::error::auth::AuthError; +use crate::model::identity::auth::Claims; pub struct JwtService { encoding_key: EncodingKey, @@ -14,20 +15,15 @@ pub struct JwtService { } impl JwtService { - pub fn new(secrets: &Arc, expiry_hours: u64) -> Result { - let raw_bytes = match secrets.get_secret("jwt_secret")? { - Some(hex_str) => hex_decode(&hex_str).map_err(|_| AuthError::InvalidToken)?, - None => { - use rand::Rng; - let secret: [u8; 32] = rand::rng().random(); - secrets.set_secret("jwt_secret", &hex_encode(&secret))?; - secret.to_vec() - } - }; + /// Generate a fresh random JWT signing secret on every boot. + /// This intentionally invalidates all existing tokens on restart. + pub fn new(_secrets: &Arc, expiry_hours: u64) -> Result { + use rand::Rng; + let secret: [u8; 32] = rand::rng().random(); Ok(Self { - encoding_key: EncodingKey::from_secret(&raw_bytes), - decoding_key: DecodingKey::from_secret(&raw_bytes), + encoding_key: EncodingKey::from_secret(&secret), + decoding_key: DecodingKey::from_secret(&secret), expiry_hours, }) } @@ -39,9 +35,9 @@ impl JwtService { role: &str, permissions: Vec, ) -> Result { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or(std::time::Duration::ZERO) + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or(Duration::ZERO) .as_secs(); let claims = Claims { @@ -67,25 +63,6 @@ impl JwtService { } } -fn hex_encode(data: &[u8]) -> String { - use std::fmt::Write; - let mut s = String::with_capacity(data.len() * 2); - for b in data { - write!(s, "{:02x}", b).unwrap(); - } - s -} - -fn hex_decode(hex: &str) -> Result, &'static str> { - if !hex.len().is_multiple_of(2) { - return Err("odd-length hex string"); - } - (0..hex.len()) - .step_by(2) - .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).map_err(|_| "invalid hex")) - .collect() -} - #[cfg(test)] mod tests { use super::*; @@ -137,18 +114,17 @@ mod tests { } #[test] - fn test_jwt_secret_persistence() { + fn test_jwt_secret_changes_on_new_instance() { let db = Arc::new(Database::new(":memory:").unwrap()); let secrets: Arc = Arc::new(SecretStore::new(db)); - // First creation generates and stores secret let jwt1 = JwtService::new(&secrets, 24).unwrap(); let token = jwt1.create_token(1, "admin", "admin", vec![]).unwrap(); - // Second creation reuses stored secret + // New instance = new secret = old token invalid (simulates restart) let jwt2 = JwtService::new(&secrets, 24).unwrap(); - let claims = jwt2.validate_token(&token).unwrap(); - assert_eq!(claims.username, "admin"); + let result = jwt2.validate_token(&token); + assert!(result.is_err()); } #[test] @@ -160,36 +136,4 @@ mod tests { let result = jwt2.validate_token(&token); assert!(result.is_err()); } - - #[test] - fn test_hex_decode_valid() { - let result = hex_decode("48656c6c6f").unwrap(); - assert_eq!(result, b"Hello"); - } - - #[test] - fn test_hex_decode_empty() { - let result = hex_decode("").unwrap(); - assert_eq!(result, Vec::::new()); - } - - #[test] - fn test_hex_decode_odd_length() { - let result = hex_decode("abc"); - assert!(result.is_err()); - } - - #[test] - fn test_hex_decode_invalid_chars() { - let result = hex_decode("gg"); - assert!(result.is_err()); - } - - #[test] - fn test_hex_roundtrip() { - let data = b"NetGuardia\x00\xff"; - let encoded = hex_encode(data); - let decoded = hex_decode(&encoded).unwrap(); - assert_eq!(decoded, data); - } } diff --git a/net-guardia/src/core/auth/middleware.rs b/net-guardia/src/core/auth/middleware.rs index 6cb53e6..3b497be 100644 --- a/net-guardia/src/core/auth/middleware.rs +++ b/net-guardia/src/core/auth/middleware.rs @@ -1,16 +1,18 @@ use std::future::{Future, Ready, ready}; use std::pin::Pin; use std::rc::Rc; +use std::task::{Context, Poll}; use actix_web::body::EitherBody; use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform}; +use actix_web::http::Method; use actix_web::{Error as ActixError, HttpMessage, HttpResponse, web}; use macros::log; use crate::core::auth::jwt::JwtService; -use crate::interface::port::api_key::ApiKeyPort; -use crate::interface::port::repository::RepositoryPort; +use crate::interface::port::api_key::ApiKeyRepo; +use crate::interface::port::app_repo::AppRepo; use crate::model::error::auth::AuthError; pub struct AuthMiddleware; @@ -37,7 +39,7 @@ pub struct AuthMiddlewareService { service: Rc, } -fn required_permission(path: &str, method: &actix_web::http::Method) -> Option { +fn required_permission(path: &str, method: &Method) -> Option { let resource = if path == "/api/auth/login" || path == "/api/auth/me" || path == "/api/auth/change-password" { return None; // Public auth endpoints: login (no auth), me/change-password (auth-only, no RBAC) } else if path.starts_with("/api/auth/") { @@ -45,8 +47,12 @@ fn required_permission(path: &str, method: &actix_web::http::Method) -> Option Option "read", + Method::GET => "read", _ => "write", }; @@ -91,7 +97,7 @@ where type Error = ActixError; type Future = Pin>>>; - fn poll_ready(&self, ctx: &mut core::task::Context<'_>) -> std::task::Poll> { + fn poll_ready(&self, ctx: &mut Context<'_>) -> Poll> { self.service.poll_ready(ctx) } @@ -140,19 +146,19 @@ where } else if let Some(api_key_header) = req.headers().get("X-API-Key") { // API key auth with rate limiting let api_key = api_key_header.to_str().unwrap_or(""); - let api_key_port = match req.app_data::>() { + let api_key_port = match req.app_data::>() { Some(d) => d.clone(), None => { let resp = HttpResponse::InternalServerError() - .json(serde_json::json!({"error": "ApiKeyPort not configured"})); + .json(serde_json::json!({"error": "ApiKeyRepo not configured"})); return Ok(req.into_response(resp).map_into_right_body()); } }; - let repo = match req.app_data::>() { + let repo = match req.app_data::>() { Some(d) => d.clone(), None => { let resp = HttpResponse::InternalServerError() - .json(serde_json::json!({"error": "RepositoryPort not configured"})); + .json(serde_json::json!({"error": "AppRepo not configured"})); return Ok(req.into_response(resp).map_into_right_body()); } }; diff --git a/net-guardia/src/core/auth/mod.rs b/net-guardia/src/core/auth/mod.rs index e3f4c74..294ac44 100644 --- a/net-guardia/src/core/auth/mod.rs +++ b/net-guardia/src/core/auth/mod.rs @@ -1,3 +1,4 @@ +pub mod csrf; pub mod extractor; pub mod https_redirect; pub mod jwt; diff --git a/net-guardia/src/core/auth/setup_guard.rs b/net-guardia/src/core/auth/setup_guard.rs index 1767cb0..e280823 100644 --- a/net-guardia/src/core/auth/setup_guard.rs +++ b/net-guardia/src/core/auth/setup_guard.rs @@ -1,7 +1,9 @@ use std::future::{Future, Ready, ready}; use std::pin::Pin; +use std::rc::Rc; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; +use std::task::{Context, Poll}; use actix_web::body::EitherBody; use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform}; @@ -26,13 +28,13 @@ where fn new_transform(&self, service: S) -> Self::Future { ready(Ok(SetupGuardService { - service: std::rc::Rc::new(service), + service: Rc::new(service), })) } } pub struct SetupGuardService { - service: std::rc::Rc, + service: Rc, } impl Service for SetupGuardService @@ -44,12 +46,12 @@ where type Error = ActixError; type Future = Pin>>>; - fn poll_ready(&self, ctx: &mut core::task::Context<'_>) -> std::task::Poll> { + fn poll_ready(&self, ctx: &mut Context<'_>) -> Poll> { self.service.poll_ready(ctx) } fn call(&self, req: ServiceRequest) -> Self::Future { - let service = std::rc::Rc::clone(&self.service); + let service = Rc::clone(&self.service); Box::pin(async move { let path = req.path().to_string(); diff --git a/net-guardia/src/core/config_service.rs b/net-guardia/src/core/config_service.rs index 599b0da..8c4052d 100644 --- a/net-guardia/src/core/config_service.rs +++ b/net-guardia/src/core/config_service.rs @@ -1,6 +1,8 @@ use std::sync::Arc; -use crate::interface::port::repository::RepositoryPort; +use serde_json::Value; + +use crate::interface::port::app_repo::AppRepo; use crate::interface::port::secret_store::SecretStorePort; use crate::model::error::Error; use crate::model::error::misc::MiscError; @@ -54,19 +56,22 @@ const SETTINGS_MAP: &[(&str, &[&str])] = &[ ("misc", &["geoip_db_name"]), ("soar", &["soar_max_auto_block_cap", "soar_max_ttl_secs"]), ("ml", &["ml_drift_window_secs"]), - ("telegram", &["telegram_max_messages_per_minute"]), + ( + "telegram", + &["telegram_rate_limit_max_messages", "telegram_rate_limit_window_secs"], + ), ("dns", &["dns_max_domains_per_request"]), ("smtp", &["smtp_host", "smtp_port", "smtp_username", "smtp_recipient"]), ]; /// Domain service for system configuration read/write. pub struct ConfigService { - db: Arc, + db: Arc, secrets: Option>, } impl ConfigService { - pub fn new(db: Arc) -> Self { + pub fn new(db: Arc) -> Self { Self { db, secrets: None } } @@ -127,7 +132,8 @@ impl ConfigService { "ml_drift_window_secs": get("ml_drift_window_secs"), }, "telegram": { - "telegram_max_messages_per_minute": get("telegram_max_messages_per_minute"), + "telegram_rate_limit_max_messages": get("telegram_rate_limit_max_messages"), + "telegram_rate_limit_window_secs": get("telegram_rate_limit_window_secs"), }, "dns": { "dns_max_domains_per_request": get("dns_max_domains_per_request"), @@ -162,7 +168,11 @@ impl ConfigService { } } - // Route secret keys through SecretStore (encrypted storage) + // Route secret keys through SecretStore (encrypted storage). + // After writing to SecretStore, scrub the plaintext row in `settings` + // so a legacy plaintext value from pre-SecretStore deployments cannot + // linger — readers fall back to SecretStore when the plaintext row + // is empty. if let Some(ref secrets) = self.secrets { for key in SECRET_KEYS { // Secret keys live under their parent section (e.g., smtp_password under smtp) @@ -174,9 +184,7 @@ impl ConfigService { .and_then(json_value_as_string) { secrets.set_secret(key, &val)?; - // Clear plaintext residue from settings table to prevent - // pre-migration plaintext passwords from persisting. - let _ = self.db.set_setting(key, ""); + self.db.set_setting(key, "")?; updated.push(key.to_string()); } } @@ -190,14 +198,11 @@ impl ConfigService { let stages: Vec<&str> = val.split(',').map(|s| s.trim()).collect(); for stage in &stages { if !stage.is_empty() && !VALID_PIPELINE_STAGES.contains(stage) { - return Err(MiscError::ValidationError { - message: format!( - "Invalid pipeline stage '{}'. Valid stages: {}", - stage, - VALID_PIPELINE_STAGES.join(", ") - ), - } - .into()); + Err(MiscError::ValidationError(format!( + "Invalid pipeline stage '{}'. Valid stages: {}", + stage, + VALID_PIPELINE_STAGES.join(", ") + )))?; } } } @@ -214,9 +219,9 @@ impl ConfigService { /// Extract a JSON value as a non-empty string, handling string, boolean, and number types. fn json_value_as_string(v: &serde_json::Value) -> Option { match v { - serde_json::Value::String(s) if !s.is_empty() => Some(s.clone()), - serde_json::Value::Bool(b) => Some(b.to_string()), - serde_json::Value::Number(n) => Some(n.to_string()), + Value::String(s) if !s.is_empty() => Some(s.clone()), + Value::Bool(b) => Some(b.to_string()), + Value::Number(n) => Some(n.to_string()), _ => None, } } diff --git a/net-guardia/src/core/correlation/botnet.rs b/net-guardia/src/core/correlation/botnet.rs index 8607380..ff732df 100644 --- a/net-guardia/src/core/correlation/botnet.rs +++ b/net-guardia/src/core/correlation/botnet.rs @@ -74,11 +74,11 @@ impl BotnetDetector { }; if let Some(unique_sources) = should_alert { - log!(DetectionLog::BotnetDetected { - dst_ip: key.clone(), + log!(DetectionLog::BotnetDetected( + key.clone(), unique_sources, - window_secs: BOTNET_WINDOW_SECS, - }); + BOTNET_WINDOW_SECS, + )); // source_ip = the latest attacker; dest_ip = the victim being targeted. // SOAR blocks source_ip, so we must NOT put the victim here. @@ -91,6 +91,9 @@ impl BotnetDetector { protocol: alert.protocol, packet_count: 0, flow_duration_us: 0, + ae_score: 0.0, + anomaly_score: 0.0, + c2_score: 0.0, }; let _ = detection_tx.try_send(event); @@ -127,6 +130,8 @@ impl BotnetDetector { #[cfg(test)] mod tests { + use std::thread; + use super::*; fn make_alert(src_ip: &str, dst_ip: &str) -> AlertMessage { @@ -142,6 +147,8 @@ mod tests { attack_type: Some("DDoS".to_string()), confidence: 0.9, ae_score: 0.5, + anomaly_score: 0.0, + c2_score: 0.0, packet_count: 100, flow_duration_us: 1_000_000, } @@ -182,7 +189,7 @@ mod tests { detector.process(&alert, &tx); assert_eq!(detector.state.len(), 1); - std::thread::sleep(Duration::from_millis(20)); + thread::sleep(Duration::from_millis(20)); let removed = detector.cleanup(); assert_eq!(removed, 1); assert_eq!(detector.state.len(), 0); diff --git a/net-guardia/src/core/correlation/engine.rs b/net-guardia/src/core/correlation/engine.rs index fc6a34a..98c4aeb 100644 --- a/net-guardia/src/core/correlation/engine.rs +++ b/net-guardia/src/core/correlation/engine.rs @@ -1,7 +1,9 @@ use std::time::Duration; use macros::log; +use tokio::sync::broadcast::error::RecvError; use tokio::sync::{broadcast, mpsc}; +use tokio::time::interval; use crate::core::correlation::botnet::BotnetDetector; use crate::core::correlation::lateral::LateralMovementDetector; @@ -43,15 +45,15 @@ impl CorrelationEngine { async fn run(mut self) { log!(DetectionLog::CorrelationEngineStarted); - let mut cleanup_interval = tokio::time::interval(Duration::from_secs(CLEANUP_INTERVAL_SECS)); + let mut cleanup_interval = interval(Duration::from_secs(CLEANUP_INTERVAL_SECS)); loop { tokio::select! { result = self.alert_rx.recv() => { match result { Ok(alert) => self.process_alert(&alert), - Err(broadcast::error::RecvError::Lagged(_)) => continue, - Err(broadcast::error::RecvError::Closed) => break, + Err(RecvError::Lagged(_)) => continue, + Err(RecvError::Closed) => break, } } _ = cleanup_interval.tick() => { @@ -70,7 +72,7 @@ impl CorrelationEngine { fn cleanup(&self) { let removed = self.botnet.cleanup() + self.scan.cleanup() + self.lateral.cleanup(); if removed > 0 { - log!(DetectionLog::CorrelationCleanup { removed }); + log!(DetectionLog::CorrelationCleanup(removed)); } } } diff --git a/net-guardia/src/core/correlation/lateral.rs b/net-guardia/src/core/correlation/lateral.rs index 168de0b..572fec6 100644 --- a/net-guardia/src/core/correlation/lateral.rs +++ b/net-guardia/src/core/correlation/lateral.rs @@ -74,11 +74,11 @@ impl LateralMovementDetector { }; if let Some(unique_dests) = should_alert { - log!(DetectionLog::LateralMovementDetected { - src_ip: key.clone(), + log!(DetectionLog::LateralMovementDetected( + key.clone(), unique_dests, - window_secs: LATERAL_WINDOW_SECS, - }); + LATERAL_WINDOW_SECS, + )); let event = DetectionEvent { source: DetectionSource::Correlation, @@ -89,6 +89,9 @@ impl LateralMovementDetector { protocol: alert.protocol, packet_count: 0, flow_duration_us: 0, + ae_score: 0.0, + anomaly_score: 0.0, + c2_score: 0.0, }; let _ = detection_tx.try_send(event); @@ -201,6 +204,8 @@ mod tests { attack_type: Some("Exploitation".to_string()), confidence: 0.8, ae_score: 0.4, + anomaly_score: 0.0, + c2_score: 0.0, packet_count: 50, flow_duration_us: 500_000, } diff --git a/net-guardia/src/core/correlation/scan.rs b/net-guardia/src/core/correlation/scan.rs index 8fcdf84..b9ec5b0 100644 --- a/net-guardia/src/core/correlation/scan.rs +++ b/net-guardia/src/core/correlation/scan.rs @@ -72,11 +72,7 @@ impl ScanDetector { }; if let Some((unique_ports, last_dst_ip)) = should_alert { - log!(DetectionLog::ScanDetected { - src_ip: key.clone(), - unique_ports, - window_secs: SCAN_WINDOW_SECS, - }); + log!(DetectionLog::ScanDetected(key.clone(), unique_ports, SCAN_WINDOW_SECS,)); let event = DetectionEvent { source: DetectionSource::Correlation, @@ -87,6 +83,9 @@ impl ScanDetector { protocol: alert.protocol, packet_count: 0, flow_duration_us: 0, + ae_score: 0.0, + anomaly_score: 0.0, + c2_score: 0.0, }; let _ = detection_tx.try_send(event); @@ -137,6 +136,8 @@ mod tests { attack_type: Some("Reconnaissance".to_string()), confidence: 0.7, ae_score: 0.3, + anomaly_score: 0.0, + c2_score: 0.0, packet_count: 5, flow_duration_us: 100_000, } diff --git a/net-guardia/src/core/detection/beaconing.rs b/net-guardia/src/core/detection/beaconing.rs index 380a9ce..0ea305f 100644 --- a/net-guardia/src/core/detection/beaconing.rs +++ b/net-guardia/src/core/detection/beaconing.rs @@ -2,7 +2,9 @@ use std::time::{Duration, Instant}; use dashmap::DashMap; use macros::log; +use tokio::sync::broadcast::error::RecvError; use tokio::sync::{broadcast, mpsc}; +use tokio::time::interval; use crate::model::detection::ml_detection::AlertMessage; use crate::model::event::{DetectionEvent, DetectionSource}; @@ -61,15 +63,15 @@ impl BeaconingDetector { async fn run(mut self) { log!(DetectionLog::BeaconingDetectorStarted); - let mut analysis_interval = tokio::time::interval(Duration::from_secs(ANALYSIS_INTERVAL_SECS)); + let mut analysis_interval = interval(Duration::from_secs(ANALYSIS_INTERVAL_SECS)); loop { tokio::select! { result = self.alert_rx.recv() => { match result { Ok(alert) => self.record_flow(&alert), - Err(broadcast::error::RecvError::Lagged(_)) => continue, - Err(broadcast::error::RecvError::Closed) => break, + Err(RecvError::Lagged(_)) => continue, + Err(RecvError::Closed) => break, } } _ = analysis_interval.tick() => { @@ -124,13 +126,13 @@ impl BeaconingDetector { // Phase 2: selective write-lock only for entries that need last_alerted update. for (key, cv, count) in alerts { let (src_ip, dst_ip, dst_port) = &key; - log!(DetectionLog::BeaconingDetected { - src_ip: src_ip.clone(), - dst_ip: dst_ip.clone(), - dst_port: *dst_port, + log!(DetectionLog::BeaconingDetected( + src_ip.clone(), + dst_ip.clone(), + *dst_port, cv, count, - }); + )); let event = DetectionEvent { source: DetectionSource::Beaconing, @@ -141,6 +143,9 @@ impl BeaconingDetector { protocol: 6, packet_count: count as u64, flow_duration_us: 0, + ae_score: 0.0, + anomaly_score: 0.0, + c2_score: 0.0, }; let _ = self.detection_tx.try_send(event); diff --git a/net-guardia/src/core/detection/fusion_math.rs b/net-guardia/src/core/detection/fusion_math.rs new file mode 100644 index 0000000..dd93d91 --- /dev/null +++ b/net-guardia/src/core/detection/fusion_math.rs @@ -0,0 +1,135 @@ +//! Fusion policy math primitive — cross-source confidence aggregation. +//! +//! The policy assumes detection sources are conditionally independent given +//! a true attack. In practice ML and CV can share signal on C2 beaconing, +//! so a future calibration pass may introduce per-pair weights; this +//! module stays the canonical home for whichever formula is in force. + +use crate::model::event::DetectionSource; + +/// Compute `1 − ∏(1 − c_i)` over the given per-source confidences. +/// +/// - Empty input → `0.0` (no evidence). +/// - Single input → returns that confidence unchanged. +/// - Values are clamped to `[0.0, 1.0]` to keep the result bounded even if +/// an upstream source ships noisy unnormalized scores. +pub fn fused_confidence(per_source: &[f32]) -> f32 { + if per_source.is_empty() { + return 0.0; + } + let mut inverse: f64 = 1.0; + for &c in per_source { + let clamped = (c as f64).clamp(0.0, 1.0); + inverse *= 1.0 - clamped; + } + (1.0 - inverse).clamp(0.0, 1.0) as f32 +} + +/// Default per-source fusion-window length in seconds. Each value scales +/// the orchestrator's lookahead budget when that source opens a dedup key. +/// Slower sources (Suricata signatures) get longer windows so a follow-up +/// ML hit still lands inside; faster sources (ML ticks) use short windows +/// because they'd otherwise waste latency waiting on downstream signals. +#[derive(Debug, Clone, Copy)] +pub struct FusionWindowLengths { + pub suricata_secs: u64, + pub cv_secs: u64, + pub ml_secs: u64, + pub graph_secs: u64, +} + +/// Valid range, in seconds, for a fusion window. Clamps protect against a +/// misconfigured source opening a wedged (too-long) or useless (zero) key. +pub const FUSION_WINDOW_MIN_SECS: u64 = 1; +pub const FUSION_WINDOW_MAX_SECS: u64 = 30; + +impl Default for FusionWindowLengths { + fn default() -> Self { + Self { + suricata_secs: 10, + cv_secs: 8, + ml_secs: 2, + graph_secs: 5, + } + } +} + +impl FusionWindowLengths { + /// Range-clamped lookup for the window length of the first source to + /// open a fusion key. + pub fn for_source(&self, source: DetectionSource) -> u64 { + let raw = match source { + DetectionSource::Suricata => self.suricata_secs, + DetectionSource::Beaconing => self.cv_secs, + DetectionSource::ML => self.ml_secs, + DetectionSource::Correlation => self.graph_secs, + }; + raw.clamp(FUSION_WINDOW_MIN_SECS, FUSION_WINDOW_MAX_SECS) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_fusion_returns_zero() { + assert_eq!(fused_confidence(&[]), 0.0); + } + + #[test] + fn single_source_passes_through() { + assert!((fused_confidence(&[0.8]) - 0.8).abs() < 1e-5); + assert_eq!(fused_confidence(&[0.0]), 0.0); + assert_eq!(fused_confidence(&[1.0]), 1.0); + } + + #[test] + fn two_sources_boost() { + // 1 − (1 − 0.7)(1 − 0.9) = 1 − 0.03 = 0.97 + let got = fused_confidence(&[0.7, 0.9]); + assert!((got - 0.97).abs() < 1e-5); + } + + #[test] + fn three_sources_monotone_in_count() { + let two = fused_confidence(&[0.6, 0.7]); + let three = fused_confidence(&[0.6, 0.7, 0.5]); + assert!(three >= two); + } + + #[test] + fn four_sources_bounded() { + let four = fused_confidence(&[0.9, 0.8, 0.7, 0.6]); + assert!(four < 1.0); + assert!(four > 0.99); + } + + #[test] + fn out_of_range_confidences_are_clamped() { + // Negative / above-1 inputs don't break the math. + assert_eq!(fused_confidence(&[-0.5, -0.1]), 0.0); + assert_eq!(fused_confidence(&[2.0, 3.0]), 1.0); + } + + #[test] + fn window_lengths_defaults() { + let w = FusionWindowLengths::default(); + assert_eq!(w.for_source(DetectionSource::Suricata), 10); + assert_eq!(w.for_source(DetectionSource::Beaconing), 8); + assert_eq!(w.for_source(DetectionSource::ML), 2); + assert_eq!(w.for_source(DetectionSource::Correlation), 5); + } + + #[test] + fn window_lengths_clamped_to_range() { + let w = FusionWindowLengths { + suricata_secs: 999, + cv_secs: 0, + ml_secs: 2, + graph_secs: 5, + }; + assert_eq!(w.for_source(DetectionSource::Suricata), 30); + assert_eq!(w.for_source(DetectionSource::Beaconing), 1); + } +} diff --git a/net-guardia/src/core/detection/metrics.rs b/net-guardia/src/core/detection/metrics.rs new file mode 100644 index 0000000..59fdd2e --- /dev/null +++ b/net-guardia/src/core/detection/metrics.rs @@ -0,0 +1,223 @@ +//! Lock-free fusion observability counters. The orchestrator bumps these +//! on ingress / emit / eviction; HTTP handlers (and eventually the admin +//! dashboard) read atomic snapshots without touching orchestrator state. + +use std::sync::atomic::{AtomicU64, Ordering}; + +use serde::Serialize; + +use crate::model::event::DetectionSource; + +/// Relaxed ordering is enough for counters: readers tolerate arbitrary +/// interleaving, and no counter's value gates access to another memory +/// location. Anything stronger would just waste fence instructions on the +/// hot packet path without adding any real invariant. +const ORDER: Ordering = Ordering::Relaxed; + +/// Atomic counters maintained by the detection orchestrator. Shared via +/// `Arc` with the HTTP metrics handler so dashboards can read without +/// blocking the orchestrator task. +#[derive(Debug, Default)] +pub struct FusionMetrics { + /// Total fused emits (single-source + multi-source combined). + total_emits: AtomicU64, + /// Fused emits whose final `active_source_count` was ≥ 2 (i.e. fusion + /// actually fired across multiple sources rather than single-source solo). + multi_source_emits: AtomicU64, + /// Fusion windows evicted under LRU pressure before they could emit. + /// Kept separate from shutdown-drain drops — those are legitimate. + windows_evicted: AtomicU64, + /// Per-source event count. Increments once per ingress detection + /// regardless of whether the event fires a fused emit downstream. + ml_fires: AtomicU64, + suricata_fires: AtomicU64, + beaconing_fires: AtomicU64, + correlation_fires: AtomicU64, +} + +impl FusionMetrics { + pub fn new() -> Self { + Self::default() + } + + /// Record an ingress detection from the given source. Called before + /// fusion-window bookkeeping so per-source counters reflect raw + /// volume, not what survives dedup. + pub fn record_fire(&self, source: DetectionSource) { + let counter = match source { + DetectionSource::ML => &self.ml_fires, + DetectionSource::Suricata => &self.suricata_fires, + DetectionSource::Beaconing => &self.beaconing_fires, + DetectionSource::Correlation => &self.correlation_fires, + }; + counter.fetch_add(1, ORDER); + } + + /// Record a fused emit. `source_count` is the number of distinct + /// sources that contributed to this emit — 1 for single-source, + /// 2..=4 when fusion actually agreed. + pub fn record_emit(&self, source_count: usize) { + self.total_emits.fetch_add(1, ORDER); + if source_count >= 2 { + self.multi_source_emits.fetch_add(1, ORDER); + } + } + + /// Record a fusion-window eviction that happened before the window + /// could emit. Shutdown-drain drops are not counted here. + pub fn record_eviction(&self) { + self.windows_evicted.fetch_add(1, ORDER); + } + + /// Take an atomic snapshot of every counter and derive the three + /// rate figures the dashboard surfaces. + pub fn snapshot(&self) -> FusionMetricsSnapshot { + let total_emits = self.total_emits.load(ORDER); + let multi_source_emits = self.multi_source_emits.load(ORDER); + let windows_evicted = self.windows_evicted.load(ORDER); + + let ml = self.ml_fires.load(ORDER); + let suricata = self.suricata_fires.load(ORDER); + let beaconing = self.beaconing_fires.load(ORDER); + let correlation = self.correlation_fires.load(ORDER); + + let agreed_rate = ratio(multi_source_emits, total_emits); + let drop_denominator = total_emits + windows_evicted; + let window_drop_rate = ratio(windows_evicted, drop_denominator); + + let total_fires = ml + suricata + beaconing + correlation; + let per_source_fire_rate = PerSourceRate { + ml: ratio(ml, total_fires), + suricata: ratio(suricata, total_fires), + beaconing: ratio(beaconing, total_fires), + correlation: ratio(correlation, total_fires), + }; + + FusionMetricsSnapshot { + total_emits, + multi_source_emits, + agreed_rate, + windows_evicted, + window_drop_rate, + per_source_fires: PerSourceCount { + ml, + suricata, + beaconing, + correlation, + }, + per_source_fire_rate, + } + } +} + +/// Return `numerator / denominator` as `f64`, or `0.0` when the +/// denominator is zero. Saves every rate caller from an `if denom == 0` +/// rewrite of the same guard. +fn ratio(numerator: u64, denominator: u64) -> f64 { + if denominator == 0 { + 0.0 + } else { + numerator as f64 / denominator as f64 + } +} + +/// Wire-format snapshot consumed by `GET /api/fusion/metrics`. Derived +/// fields (`agreed_rate`, `window_drop_rate`, `per_source_fire_rate`) +/// are precomputed server-side so the UI doesn't have to re-implement +/// the formulas and drift. +#[derive(Debug, Clone, Serialize)] +pub struct FusionMetricsSnapshot { + pub total_emits: u64, + pub multi_source_emits: u64, + /// `multi_source_emits / total_emits`. + pub agreed_rate: f64, + pub windows_evicted: u64, + /// `windows_evicted / (windows_evicted + total_emits)`. + pub window_drop_rate: f64, + pub per_source_fires: PerSourceCount, + pub per_source_fire_rate: PerSourceRate, +} + +#[derive(Debug, Clone, Serialize)] +pub struct PerSourceCount { + pub ml: u64, + pub suricata: u64, + pub beaconing: u64, + pub correlation: u64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct PerSourceRate { + pub ml: f64, + pub suricata: f64, + pub beaconing: f64, + pub correlation: f64, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn zero_traffic_snapshot_reports_zero_rates() { + let m = FusionMetrics::new(); + let s = m.snapshot(); + assert_eq!(s.total_emits, 0); + assert_eq!(s.agreed_rate, 0.0); + assert_eq!(s.window_drop_rate, 0.0); + assert_eq!(s.per_source_fire_rate.ml, 0.0); + } + + #[test] + fn agreed_rate_reflects_multi_source_ratio() { + let m = FusionMetrics::new(); + m.record_emit(1); // single-source + m.record_emit(2); // multi + m.record_emit(3); // multi + m.record_emit(1); // single + let s = m.snapshot(); + assert_eq!(s.total_emits, 4); + assert_eq!(s.multi_source_emits, 2); + assert!((s.agreed_rate - 0.5).abs() < 1e-9); + } + + #[test] + fn window_drop_rate_isolates_evictions_from_emits() { + let m = FusionMetrics::new(); + for _ in 0..9 { + m.record_emit(1); + } + m.record_eviction(); // 1 evicted / 10 total tracked + let s = m.snapshot(); + assert_eq!(s.windows_evicted, 1); + assert!((s.window_drop_rate - 0.1).abs() < 1e-9); + } + + #[test] + fn per_source_fire_rate_sums_to_one_when_nonzero() { + let m = FusionMetrics::new(); + m.record_fire(DetectionSource::ML); + m.record_fire(DetectionSource::ML); + m.record_fire(DetectionSource::Suricata); + m.record_fire(DetectionSource::Beaconing); + let s = m.snapshot(); + let sum = s.per_source_fire_rate.ml + + s.per_source_fire_rate.suricata + + s.per_source_fire_rate.beaconing + + s.per_source_fire_rate.correlation; + assert!((sum - 1.0).abs() < 1e-9, "per-source rates must sum to 1, got {sum}"); + assert!((s.per_source_fire_rate.ml - 0.5).abs() < 1e-9); + } + + #[test] + fn record_fire_routes_to_correct_source_bucket() { + let m = FusionMetrics::new(); + m.record_fire(DetectionSource::Suricata); + m.record_fire(DetectionSource::Correlation); + let s = m.snapshot(); + assert_eq!(s.per_source_fires.suricata, 1); + assert_eq!(s.per_source_fires.correlation, 1); + assert_eq!(s.per_source_fires.ml, 0); + assert_eq!(s.per_source_fires.beaconing, 0); + } +} diff --git a/net-guardia/src/core/detection/mod.rs b/net-guardia/src/core/detection/mod.rs index 0faa443..fedc72c 100644 --- a/net-guardia/src/core/detection/mod.rs +++ b/net-guardia/src/core/detection/mod.rs @@ -1,2 +1,4 @@ pub mod beaconing; +pub mod fusion_math; +pub mod metrics; pub mod orchestrator; diff --git a/net-guardia/src/core/detection/orchestrator.rs b/net-guardia/src/core/detection/orchestrator.rs index 5f95bec..9467aa6 100644 --- a/net-guardia/src/core/detection/orchestrator.rs +++ b/net-guardia/src/core/detection/orchestrator.rs @@ -2,46 +2,86 @@ use std::num::NonZero; use std::sync::Arc; use std::time::{Duration, Instant}; +use lru::LruCache; use macros::log; use tokio::sync::mpsc; +use tokio::time::interval; +use super::fusion_math::{FusionWindowLengths, fused_confidence}; +use super::metrics::FusionMetrics; use crate::infrastructure::communication_manager::CommunicationManager; use crate::infrastructure::geoip::GeoIpService; +use crate::model::detection::attack_type::translate; use crate::model::error::system::SystemError; -use crate::model::event::{DetectionEvent, DetectionSource, ThreatDetectedEvent}; +use crate::model::event::{AuditEvent, DetectionEvent, DetectionSource, ThreatDetectedEvent}; use crate::model::log::detection::DetectionLog; -/// Dedup window: detections for the same (source_ip, attack_type) within this window -/// are suppressed after the first emission. +/// Dedup window: detections for the same `(source_ip, canonical_attack_type)` +/// within this window are suppressed after initial fusion-window expiry. const DEDUP_WINDOW_SECS: u64 = 30; /// How often to sweep expired dedup entries. const CLEANUP_INTERVAL_SECS: u64 = 60; /// Repeat offender detection: same IP within this duration counts as repeat. -const REPEAT_OFFENDER_WINDOW_SECS: u64 = 2 * 60 * 60; // 2 hours +const REPEAT_OFFENDER_WINDOW_SECS: u64 = 2 * 60 * 60; /// Maximum dedup entries to prevent unbounded memory growth under sustained attack. -const MAX_DEDUP_ENTRIES: usize = 50_000; +/// Declared as `NonZero` at compile time so `LruCache::new` never needs a +/// runtime unwrap — if this ever goes to zero, the const expression fails +/// to compile, not the running server. +// SAFETY: NonZero::new on a non-zero literal is infallible; const-evaluated. +const MAX_DEDUP_ENTRIES: NonZero = NonZero::new(50_000).unwrap(); -struct DedupEntry { - sources: Vec, - emitted_at: Instant, +/// Actor recorded on every fusion-chain WORM entry. Stable across releases — +/// downstream audit tooling filters on this string. +const FUSION_AUDIT_ACTOR: &str = "FusionEngine"; +/// Action recorded on every fusion-chain WORM entry. Stable across releases. +const FUSION_AUDIT_ACTION: &str = "fused_threat_emitted"; + +/// Per-source record within an in-flight dedup entry. Keeps the strongest +/// confidence per source so multi-hit from one source doesn't inflate the +/// fused policy. `local_attack_type` is the raw source-specific label seen +/// before canonicalization — preserved for WORM audit evidence so the +/// explain-this-block UI can show Suricata's classtype next to ML's class +/// name that both folded into the same canonical dedup key. +#[derive(Debug, Clone)] +struct SourceSample { + source: DetectionSource, + confidence: f32, + local_attack_type: String, } -/// Coordinates detections from multiple sources (ML, future: rules, correlation, threat feeds). -/// Deduplicates, enriches with GeoIP/hit count/repeat offender, and emits ThreatDetectedEvent. +struct DedupEntry { + sources: Vec, + /// When the orchestrator first emitted for this key. + first_emitted_at: Instant, + /// Most recent emit (reset on each fused re-emit within the fusion window). + emitted_at: Instant, + /// Fusion window length for this key, fixed by the first source to arrive. + /// Later arrivals don't reset it so the lookahead budget stays predictable. + fusion_window: Duration, +} + +/// Coordinates detections from ML / Suricata / Beaconing / Correlation. +/// Incoming events are canonicalized into a shared attack-type dictionary so +/// dedup keys collide across sources; events sharing a key inside the fusion +/// window accumulate, and additional sources arriving mid-window trigger a +/// re-emit with the combined confidence `1 − ∏(1 − c_i)`. pub struct DetectionOrchestrator { rx: mpsc::Receiver, comm: Arc, geoip: Option>, + metrics: Arc, // Enrichment state - // SAFETY: NonZero::new on a literal is infallible. src_ip_counts: lru::LruCache, repeat_tracker: lru::LruCache, - // Dedup state — LRU-bounded to prevent unbounded growth under sustained attack + // Dedup state — LRU-bounded to prevent unbounded growth under sustained attack. dedup: lru::LruCache<(String, String), DedupEntry>, dedup_window: Duration, + /// Per-source fusion window lengths. Future versions may read overrides + /// from DB; the defaults live in `FusionWindowLengths::default`. + fusion_windows: FusionWindowLengths, } impl DetectionOrchestrator { @@ -49,20 +89,23 @@ impl DetectionOrchestrator { rx: mpsc::Receiver, comm: Arc, geoip: Option>, + metrics: Arc, ) -> Self { Self { rx, comm, geoip, - // SAFETY: NonZero::new on a non-zero literal is infallible. - src_ip_counts: lru::LruCache::new(NonZero::new(10_000).unwrap()), - repeat_tracker: lru::LruCache::new(NonZero::new(5_000).unwrap()), - dedup: lru::LruCache::new(NonZero::new(MAX_DEDUP_ENTRIES).unwrap()), + metrics, + // SAFETY: NonZero::new on non-zero literals; MAX_DEDUP_ENTRIES is + // already a NonZero const so no unwrap needed for that one. + src_ip_counts: LruCache::new(NonZero::new(10_000).unwrap()), + repeat_tracker: LruCache::new(NonZero::new(5_000).unwrap()), + dedup: LruCache::new(MAX_DEDUP_ENTRIES), dedup_window: Duration::from_secs(DEDUP_WINDOW_SECS), + fusion_windows: FusionWindowLengths::default(), } } - /// Spawn the orchestrator as a background task. pub fn start(self) { tokio::spawn(async move { self.run().await }); } @@ -70,14 +113,14 @@ impl DetectionOrchestrator { async fn run(mut self) { log!(DetectionLog::OrchestratorStarted); - let mut cleanup_interval = tokio::time::interval(Duration::from_secs(CLEANUP_INTERVAL_SECS)); + let mut cleanup_interval = interval(Duration::from_secs(CLEANUP_INTERVAL_SECS)); loop { tokio::select! { event = self.rx.recv() => { match event { Some(detection) => self.handle_detection(detection).await, - None => break, // All senders dropped + None => break, } } _ = cleanup_interval.tick() => { @@ -87,64 +130,170 @@ impl DetectionOrchestrator { } } - async fn handle_detection(&mut self, event: DetectionEvent) { + async fn handle_detection(&mut self, mut event: DetectionEvent) { + // Count every ingress event per-source before dedup — this is the + // raw firing rate, independent of whether the event survives to emit. + self.metrics.record_fire(event.source); + + // Canonicalize the raw attack_type so Suricata's "brute-force" + // classtype and ML's "Brute Force" class name land on the same dedup + // key — the precondition for cross-source fusion. Keep the original + // label for audit evidence. + let raw_label = event.attack_type.clone(); + let canonical = translate(event.source, &event.attack_type); + event.attack_type = canonical.as_str().to_string(); + let key = (event.source_ip.clone(), event.attack_type.clone()); let now = Instant::now(); - // Dedup check - if let Some(entry) = self.dedup.get(&key) - && now.checked_duration_since(entry.emitted_at).unwrap_or(Duration::ZERO) < self.dedup_window - { - // Within window: add source attribution but don't re-emit - if !entry.sources.contains(&event.source) { - // Re-get as mutable to update sources - if let Some(entry) = self.dedup.get_mut(&key) { - entry.sources.push(event.source.clone()); + // Path A: existing dedup entry. Decide re-emit (fusion window still + // open) vs silence (window closed but dedup still active). + if let Some(entry) = self.dedup.get_mut(&key) { + let since_first = now.saturating_duration_since(entry.first_emitted_at); + + // Post-dedup-window — treat as a brand-new event (fall through). + if since_first >= self.dedup_window { + // Expired dedup; fall through to Path B by dropping the entry. + self.dedup.pop(&key); + } else if since_first < entry.fusion_window { + // Still inside the fusion window — accumulate. + let is_new_source = !entry.sources.iter().any(|s| s.source == event.source); + if is_new_source { + entry.sources.push(SourceSample { + source: event.source, + confidence: event.confidence, + local_attack_type: raw_label.clone(), + }); + entry.emitted_at = now; + } else { + // Same source firing again inside the window — keep the + // strongest confidence (and its raw label) for fusion math. + if let Some(existing) = entry.sources.iter_mut().find(|s| s.source == event.source) + && existing.confidence < event.confidence + { + existing.confidence = event.confidence; + existing.local_attack_type = raw_label.clone(); + } } + + // Only RE-EMIT when a new source joined — same-source + // refires are silenced to avoid SOAR cooldown churn. + if is_new_source { + self.emit_fused(&event, &key).await; + } else { + log!(DetectionLog::DetectionDeduplicated( + event.source_ip.clone(), + event.attack_type.clone(), + )); + } + return; + } else { + // Past fusion window, still inside dedup silence → drop. + log!(DetectionLog::DetectionDeduplicated( + event.source_ip.clone(), + event.attack_type.clone(), + )); + return; } - log!(DetectionLog::DetectionDeduplicated { - source_ip: event.source_ip, - attack_type: event.attack_type, - }); - return; } - // Enrich and emit - let threat_event = self.enrich(&event).await; - let sources = vec![event.source.clone()]; + // Path B: brand-new key (or expired dedup). Emit single-source, + // open a fusion window sized by this source. + let fusion_window = Duration::from_secs(self.fusion_windows.for_source(event.source)); - log!(DetectionLog::DetectionEmitted { - source_ip: event.source_ip.clone(), - attack_type: event.attack_type.clone(), - confidence: event.confidence, - sources_count: sources.len(), - }); + // Detect LRU-pressure eviction: if the dedup map is already at capacity + // and this key wasn't present, inserting will evict the least-recently- + // used entry silently. That's a real lost-signal event; count it and + // warn so the operator sees sustained-attack saturation. + let cap = self.dedup.cap().get(); + let was_full = self.dedup.len() >= cap; + let key_was_absent = self.dedup.peek(&key).is_none(); + if was_full && key_was_absent { + self.metrics.record_eviction(); + log!(DetectionLog::FusionWindowEvicted(key.0.clone(), key.1.clone())); + } - // Record dedup entry (LRU-bounded) self.dedup.put( - key, + key.clone(), DedupEntry { - sources, + sources: vec![SourceSample { + source: event.source, + confidence: event.confidence, + local_attack_type: raw_label, + }], + first_emitted_at: now, emitted_at: now, + fusion_window, }, ); + self.emit_fused(&event, &key).await; + } + + /// Build the fused ThreatDetectedEvent from the current dedup entry's + /// per-source samples, apply enrichment (hit count / repeat / geoip), + /// and publish. Called both on first emit (single source) and on + /// within-window re-emit (2..=4 sources). Also emits a WORM AuditEvent + /// carrying the full per-source evidence chain. + async fn emit_fused(&mut self, trigger_event: &DetectionEvent, key: &(String, String)) { + let per_source_samples: Vec = match self.dedup.get(key) { + Some(entry) => entry.sources.clone(), + None => return, + }; + let confs: Vec = per_source_samples.iter().map(|s| s.confidence).collect(); + let sources_vec: Vec = per_source_samples.iter().map(|s| s.source).collect(); + let fused = fused_confidence(&confs); + + let mut threat_event = self.enrich(trigger_event).await; + threat_event.sources = sources_vec; + threat_event.active_source_count = threat_event.sources.len(); + threat_event.fused_confidence = fused; + threat_event.confidence = fused; + + log!(DetectionLog::DetectionEmitted( + trigger_event.source_ip.clone(), + trigger_event.attack_type.clone(), + threat_event.confidence, + trigger_event.ae_score, + trigger_event.anomaly_score, + trigger_event.c2_score, + threat_event.active_source_count, + )); + + self.metrics.record_emit(threat_event.active_source_count); + + self.publish_fusion_audit(trigger_event, fused, &per_source_samples) + .await; if let Err(e) = self.comm.publish_event(threat_event).await { log!(SystemError::MlSoarBridgeFailed(e)); } } + /// Emit a WORM AuditEvent so the eventual "why was this IP blocked?" + /// explain view can reconstruct the fusion evidence chain — which + /// sources fired, at what confidence, and what raw label each used + /// before the canonical dictionary folded them onto a shared key. + async fn publish_fusion_audit(&self, trigger_event: &DetectionEvent, fused: f32, per_source: &[SourceSample]) { + let audit = AuditEvent { + actor: FUSION_AUDIT_ACTOR.to_string(), + action: FUSION_AUDIT_ACTION.to_string(), + detail: build_fusion_audit_detail(&trigger_event.source_ip, &trigger_event.attack_type, fused, per_source), + }; + + if let Err(e) = self.comm.publish_event(audit).await { + log!(DetectionLog::FusionAuditPublishFailed(e.to_string())); + } + } + async fn enrich(&mut self, event: &DetectionEvent) -> ThreatDetectedEvent { let src_ip = &event.source_ip; - // Compute packet rate let packet_rate = if event.flow_duration_us > 0 { event.packet_count as f64 / (event.flow_duration_us as f64 / 1_000_000.0) } else { 0.0 }; - // Update hit count (LRU bounded) let hit_count = match self.src_ip_counts.get_mut(src_ip) { Some(c) => { *c = c.saturating_add(1); @@ -156,7 +305,6 @@ impl DetectionOrchestrator { } }; - // Check repeat offender (same IP within window) let repeat_window = Duration::from_secs(REPEAT_OFFENDER_WINDOW_SECS); let now = Instant::now(); let is_repeat = self @@ -165,7 +313,6 @@ impl DetectionOrchestrator { .is_some_and(|last| now.checked_duration_since(*last).unwrap_or(Duration::ZERO) < repeat_window); self.repeat_tracker.put(src_ip.clone(), now); - // GeoIP lookup let geoip_country = if let Some(ref svc) = self.geoip { if let Ok(ip) = src_ip.parse() { svc.lookup(ip).await.ok().flatten().and_then(|loc| loc.country_code) @@ -186,20 +333,130 @@ impl DetectionOrchestrator { protocol: event.protocol, geoip_country, is_repeat_offender: is_repeat, - sources: vec![event.source.clone()], + // These three get overwritten in `emit_fused` with the + // accumulated values; initialize to the single-source defaults + // so a direct caller also gets a consistent shape. + sources: vec![event.source], + active_source_count: 1, + fused_confidence: event.confidence, + ae_score: event.ae_score, + anomaly_score: event.anomaly_score, + c2_score: event.c2_score, } } fn cleanup_expired(&mut self) { + // Full scan: LRU order reflects access time, not insertion time, so + // peek_lru + break-on-first-unexpired would skip older idle entries + // sitting in the middle of the map. Dedup touches an entry's LRU + // position via `get_mut` on every re-emit, which can leave an entry + // with an older `first_emitted_at` deeper in the cache than a newly + // inserted neighbour. A full retain is O(N) but cleanup runs every + // 60s and `MAX_DEDUP_ENTRIES` caps N at 50_000 — one walk is cheap. let now = Instant::now(); let window = self.dedup_window; - // Pop expired entries from the LRU (oldest entries are least recently used) - while let Some((_, entry)) = self.dedup.peek_lru() { - if now.checked_duration_since(entry.emitted_at).unwrap_or(Duration::ZERO) >= window { - self.dedup.pop_lru(); - } else { - break; + let mut expired: Vec<(String, String)> = Vec::new(); + for (key, entry) in self.dedup.iter() { + if now + .checked_duration_since(entry.first_emitted_at) + .unwrap_or(Duration::ZERO) + >= window + { + expired.push(key.clone()); } } + for key in expired { + self.dedup.pop(&key); + } + } +} + +/// Serialize the WORM audit evidence payload for a fused threat emission. +/// Extracted as a free function so tests can cover schema shape without a +/// live CommunicationManager harness. +fn build_fusion_audit_detail(src_ip: &str, attack_type: &str, fused: f32, per_source: &[SourceSample]) -> String { + let per_source_json: Vec = per_source + .iter() + .map(|s| { + serde_json::json!({ + "source": s.source.to_string(), + "confidence": s.confidence, + "local_attack_type": s.local_attack_type, + }) + }) + .collect(); + serde_json::json!({ + "src_ip": src_ip, + "attack_type": attack_type, + "fused_confidence": fused, + "per_source": per_source_json, + }) + .to_string() +} + +#[cfg(test)] +mod tests { + //! Orchestrator integration tests require an in-memory CommunicationManager + //! harness. Until then, the fusion math lives in `fusion_math::tests`, + //! canonical translation in `model::detection::attack_type::tests`, and + //! the audit evidence schema is covered below. + + use super::*; + + fn sample(source: DetectionSource, confidence: f32, local: &str) -> SourceSample { + SourceSample { + source, + confidence, + local_attack_type: local.to_string(), + } + } + + #[test] + fn audit_detail_is_valid_json_with_required_top_level_keys() { + let detail = build_fusion_audit_detail( + "1.2.3.4", + "brute_force", + 0.97, + &[sample(DetectionSource::Suricata, 0.8, "brute-force")], + ); + let v: serde_json::Value = serde_json::from_str(&detail).expect("audit detail must be valid JSON"); + assert_eq!(v["src_ip"], "1.2.3.4"); + assert_eq!(v["attack_type"], "brute_force"); + assert!((v["fused_confidence"].as_f64().unwrap() - 0.97).abs() < 1e-5); + assert!(v["per_source"].is_array()); + } + + #[test] + fn audit_detail_empty_per_source_array_is_well_formed() { + // Defensive: should never happen in production (emit_fused requires a + // dedup entry), but the helper must not panic on an empty slice. + let detail = build_fusion_audit_detail("10.0.0.1", "unknown", 0.0, &[]); + let v: serde_json::Value = serde_json::from_str(&detail).unwrap(); + assert_eq!(v["per_source"].as_array().unwrap().len(), 0); + } + + #[test] + fn audit_detail_preserves_per_source_evidence_fields() { + let per_source = [ + sample(DetectionSource::Suricata, 0.8, "brute-force"), + sample(DetectionSource::ML, 0.85, "Brute Force"), + ]; + let detail = build_fusion_audit_detail("1.2.3.4", "brute_force", 0.97, &per_source); + let v: serde_json::Value = serde_json::from_str(&detail).unwrap(); + let arr = v["per_source"].as_array().unwrap(); + assert_eq!(arr.len(), 2); + assert_eq!(arr[0]["source"], "Suricata"); + assert_eq!(arr[0]["local_attack_type"], "brute-force"); + assert!((arr[0]["confidence"].as_f64().unwrap() - 0.8).abs() < 1e-5); + assert_eq!(arr[1]["source"], "ML"); + assert_eq!(arr[1]["local_attack_type"], "Brute Force"); + } + + #[test] + fn audit_constants_are_stable_wire_strings() { + // Downstream audit tooling filters on these exact strings — renaming + // is a breaking change to the WORM chain. + assert_eq!(FUSION_AUDIT_ACTOR, "FusionEngine"); + assert_eq!(FUSION_AUDIT_ACTION, "fused_threat_emitted"); } } diff --git a/net-guardia/src/core/dns_filter_service.rs b/net-guardia/src/core/dns_filter_service.rs index 0d24a20..8d5ce50 100644 --- a/net-guardia/src/core/dns_filter_service.rs +++ b/net-guardia/src/core/dns_filter_service.rs @@ -1,19 +1,19 @@ use std::sync::Arc; -use crate::core::ebpf::dns_filter::DnsFilter; -use crate::interface::port::repository::RepositoryPort; +use crate::interface::port::app_repo::AppRepo; +use crate::interface::port::dns_filter_api::DnsFilterPort; use crate::model::error::Error; use crate::model::error::misc::MiscError; /// Domain service that coordinates DNS filter changes between DB and in-memory service. /// Write order: eBPF/in-memory first, then DB — if eBPF fails, DB remains clean. pub struct DnsFilterService { - db: Arc, - dns_filter: Arc, + db: Arc, + dns_filter: Arc, } impl DnsFilterService { - pub fn new(db: Arc, dns_filter: Arc) -> Self { + pub fn new(db: Arc, dns_filter: Arc) -> Self { Self { db, dns_filter } } @@ -30,10 +30,10 @@ impl DnsFilterService { .and_then(|v| v.parse().ok()) .unwrap_or(1000); if domains.len() > max_domains { - return Err(MiscError::ValidationError { - message: format!("too many domains (max {})", max_domains), - } - .into()); + Err(MiscError::ValidationError(format!( + "too many domains (max {})", + max_domains + )))?; } // eBPF first for domain in domains { diff --git a/net-guardia/src/core/ebpf/protocol_filter.rs b/net-guardia/src/core/ebpf/protocol_filter.rs deleted file mode 100644 index 565cd7d..0000000 --- a/net-guardia/src/core/ebpf/protocol_filter.rs +++ /dev/null @@ -1,290 +0,0 @@ -use std::collections::HashMap; -use std::net::{Ipv4Addr, Ipv6Addr, SocketAddrV4, SocketAddrV6}; - -use aya::maps::{Array as AyaArray, HashMap as AyaHashMap, MapData}; -use aya::{Ebpf, Pod}; -use common::model::http_method::{HttpMethod, HttpMethodBitmap}; -use common::model::ip_address::{AddrPortV4, AddrPortV6, IPv4, IPv6}; -use common::model::placeholder::PlaceHolder; -use tokio::sync::RwLock; - -use crate::model::error::Error; -use crate::model::error::ebpf::EbpfError; -use crate::model::ip_address::NativeConvert; - -pub struct ProtocolFilter { - ipv4_http_service: RwLock>, - ipv6_http_service: RwLock>, - ssh_white_list_enable: RwLock, - ipv4_ssh_service: RwLock>, - ipv6_ssh_service: RwLock>, - ipv4_ssh_white_list: RwLock>, - ipv6_ssh_white_list: RwLock>, - ipv4_ssh_black_list: RwLock>, - ipv6_ssh_black_list: RwLock>, -} - -impl ProtocolFilter { - pub fn new(ebpf: &mut Ebpf) -> Result { - let service = Self { - ipv4_http_service: RwLock::new(HttpServiceWrapper::new(ebpf, "IPV4_HTTP_SERVICE")?), - ipv6_http_service: RwLock::new(HttpServiceWrapper::new(ebpf, "IPV6_HTTP_SERVICE")?), - ssh_white_list_enable: RwLock::new(WhiteListControl::new(ebpf, "SSH_WHITE_LIST_ENABLE")?), - ipv4_ssh_service: RwLock::new(EntryMap::new(ebpf, "IPV4_SSH_SERVICE")?), - ipv6_ssh_service: RwLock::new(EntryMap::new(ebpf, "IPV6_SSH_SERVICE")?), - ipv4_ssh_white_list: RwLock::new(EntryMap::new(ebpf, "IPV4_SSH_WHITE_LIST")?), - ipv6_ssh_white_list: RwLock::new(EntryMap::new(ebpf, "IPV6_SSH_WHITE_LIST")?), - ipv4_ssh_black_list: RwLock::new(EntryMap::new(ebpf, "IPV4_SSH_BLACK_LIST")?), - ipv6_ssh_black_list: RwLock::new(EntryMap::new(ebpf, "IPV6_SSH_BLACK_LIST")?), - }; - Ok(service) - } - - pub async fn get_ipv4_http_service(&self) -> HashMap> { - self.ipv4_http_service.read().await.get_http_method() - } - - pub async fn get_ipv6_http_service(&self) -> HashMap> { - self.ipv6_http_service.read().await.get_http_method() - } - - pub async fn add_ipv4_http_service( - &self, - address: SocketAddrV4, - http_method: Vec, - ) -> Result<(), Error> { - self.ipv4_http_service - .write() - .await - .add_http_service(address, http_method) - } - - pub async fn add_ipv6_http_service( - &self, - address: SocketAddrV6, - http_method: Vec, - ) -> Result<(), Error> { - self.ipv6_http_service - .write() - .await - .add_http_service(address, http_method) - } - - pub async fn remove_ipv4_http_service( - &self, - address: SocketAddrV4, - removed_http_method: Vec, - ) -> Result<(), Error> { - self.ipv4_http_service - .write() - .await - .remove_http_service(address, removed_http_method) - } - - pub async fn remove_ipv6_http_service( - &self, - address: SocketAddrV6, - removed_http_method: Vec, - ) -> Result<(), Error> { - self.ipv6_http_service - .write() - .await - .remove_http_service(address, removed_http_method) - } - - pub async fn is_ssh_white_list_enable(&self) -> bool { - self.ssh_white_list_enable.read().await.is_white_list_enable() - } - - pub async fn enable_ssh_white_list(&self) -> Result<(), Error> { - self.ssh_white_list_enable.write().await.enable_white_list() - } - - pub async fn disable_ssh_white_list(&self) -> Result<(), Error> { - self.ssh_white_list_enable.write().await.disable_white_list() - } - - pub async fn get_ipv4_ssh_service(&self) -> Vec { - self.ipv4_ssh_service.read().await.get_all() - } - - pub async fn get_ipv6_ssh_service(&self) -> Vec { - self.ipv6_ssh_service.read().await.get_all() - } - - pub async fn add_ipv4_ssh_service(&self, address: SocketAddrV4) -> Result<(), Error> { - self.ipv4_ssh_service.write().await.add(address) - } - - pub async fn add_ipv6_ssh_service(&self, address: SocketAddrV6) -> Result<(), Error> { - self.ipv6_ssh_service.write().await.add(address) - } - - pub async fn remove_ipv4_ssh_service(&self, address: SocketAddrV4) -> Result<(), Error> { - self.ipv4_ssh_service.write().await.remove(address) - } - - pub async fn remove_ipv6_ssh_service(&self, address: SocketAddrV6) -> Result<(), Error> { - self.ipv6_ssh_service.write().await.remove(address) - } - - pub async fn get_ipv4_ssh_white_list(&self) -> Vec { - self.ipv4_ssh_white_list.read().await.get_all() - } - - pub async fn get_ipv6_ssh_white_list(&self) -> Vec { - self.ipv6_ssh_white_list.read().await.get_all() - } - - pub async fn add_ipv4_ssh_white_list(&self, ip: Ipv4Addr) -> Result<(), Error> { - self.ipv4_ssh_white_list.write().await.add(ip) - } - - pub async fn add_ipv6_ssh_white_list(&self, ip: Ipv6Addr) -> Result<(), Error> { - self.ipv6_ssh_white_list.write().await.add(ip) - } - - pub async fn remove_ipv4_ssh_white_list(&self, ip: Ipv4Addr) -> Result<(), Error> { - self.ipv4_ssh_white_list.write().await.remove(ip) - } - - pub async fn remove_ipv6_ssh_white_list(&self, ip: Ipv6Addr) -> Result<(), Error> { - self.ipv6_ssh_white_list.write().await.remove(ip) - } - - pub async fn get_ipv4_ssh_black_list(&self) -> Vec { - self.ipv4_ssh_black_list.read().await.get_all() - } - - pub async fn get_ipv6_ssh_black_list(&self) -> Vec { - self.ipv6_ssh_black_list.read().await.get_all() - } - - pub async fn add_ipv4_ssh_black_list(&self, ip: Ipv4Addr) -> Result<(), Error> { - self.ipv4_ssh_black_list.write().await.add(ip) - } - - pub async fn add_ipv6_ssh_black_list(&self, ip: Ipv6Addr) -> Result<(), Error> { - self.ipv6_ssh_black_list.write().await.add(ip) - } - - pub async fn remove_ipv4_ssh_black_list(&self, ip: Ipv4Addr) -> Result<(), Error> { - self.ipv4_ssh_black_list.write().await.remove(ip) - } - - pub async fn remove_ipv6_ssh_black_list(&self, ip: Ipv6Addr) -> Result<(), Error> { - self.ipv6_ssh_black_list.write().await.remove(ip) - } -} - -struct WhiteListControl { - map: AyaArray, -} - -impl WhiteListControl { - fn new(ebpf: &mut Ebpf, map_name: &str) -> Result { - let map = ebpf.take_map(map_name).ok_or(EbpfError::MapNotFound)?; - let map = AyaArray::try_from(map).map_err(EbpfError::MapOperationError)?; - Ok(Self { map }) - } - - fn is_white_list_enable(&self) -> bool { - match self.map.get(&0, 0) { - Ok(status) => status != 0, - Err(_) => false, - } - } - - fn enable_white_list(&mut self) -> Result<(), Error> { - self.map.set(0, 1_u8, 0).map_err(EbpfError::MapOperationError)?; - Ok(()) - } - - fn disable_white_list(&mut self) -> Result<(), Error> { - self.map.set(0, 0_u8, 0).map_err(EbpfError::MapOperationError)?; - Ok(()) - } -} - -struct HttpServiceWrapper { - map: AyaHashMap, -} - -impl HttpServiceWrapper { - fn new(ebpf: &mut Ebpf, map_name: &str) -> Result { - let map = ebpf.take_map(map_name).ok_or(EbpfError::MapNotFound)?; - let map = AyaHashMap::try_from(map).map_err(EbpfError::MapOperationError)?; - Ok(Self { map }) - } - - fn get_http_method(&self) -> HashMap> { - self.map - .iter() - .filter_map(Result::ok) - .map(|(key, value)| { - let address = key.into_native(); - (address, HttpMethod::convert_from_bitmap(value)) - }) - .collect() - } - - fn add_http_service(&mut self, address: T::Native, http_method: Vec) -> Result<(), Error> { - let address = T::from_native(address); - let ebpf_method = HttpMethod::convert_to_bitmap(http_method); - self.map - .insert(address, ebpf_method, 0) - .map_err(EbpfError::MapOperationError)?; - Ok(()) - } - - fn remove_http_service(&mut self, address: T::Native, removed_http_method: Vec) -> Result<(), Error> { - let address = T::from_native(address); - if let Ok(current_http_method) = self.map.get(&address, 0) { - let mut http_method = HttpMethod::convert_from_bitmap(current_http_method); - http_method.retain(|method| !removed_http_method.contains(method)); - if http_method.is_empty() { - self.map.remove(&address).map_err(EbpfError::MapOperationError)?; - } else { - let new_http_method = HttpMethod::convert_to_bitmap(http_method); - self.map - .insert(address, new_http_method, 0) - .map_err(EbpfError::MapOperationError)?; - } - Ok(()) - } else { - Err(EbpfError::IpDoesNotExist)? - } - } -} - -struct EntryMap { - map: AyaHashMap, -} - -impl EntryMap { - fn new(ebpf: &mut Ebpf, map_name: &str) -> Result { - let map = ebpf.take_map(map_name).ok_or(EbpfError::MapNotFound)?; - let map = AyaHashMap::try_from(map).map_err(EbpfError::MapOperationError)?; - Ok(Self { map }) - } - - fn get_all(&self) -> Vec { - self.map - .keys() - .filter_map(Result::ok) - .map(|key| key.into_native()) - .collect() - } - - fn add(&mut self, key: T::Native) -> Result<(), Error> { - let key = T::from_native(key); - self.map.insert(key, 0_u8, 0).map_err(EbpfError::MapOperationError)?; - Ok(()) - } - - fn remove(&mut self, key: T::Native) -> Result<(), Error> { - let key = T::from_native(key); - self.map.remove(&key).map_err(EbpfError::MapOperationError)?; - Ok(()) - } -} diff --git a/net-guardia/src/core/ebpf/rate_limit.rs b/net-guardia/src/core/ebpf/rate_limit.rs deleted file mode 100644 index fb50528..0000000 --- a/net-guardia/src/core/ebpf/rate_limit.rs +++ /dev/null @@ -1,95 +0,0 @@ -use aya::Ebpf; -use aya::maps::{Array, MapData}; -use parking_lot::Mutex; - -use crate::model::error::Error; -use crate::model::error::ebpf::EbpfError; - -pub struct RateLimitConfig { - config_map: Mutex>, -} - -impl RateLimitConfig { - pub fn new(ebpf: &mut Ebpf) -> Result { - let map = ebpf.take_map("RATE_LIMIT_CONFIG").ok_or(EbpfError::MapNotFound)?; - let config_map = Array::try_from(map).map_err(EbpfError::MapOperationError)?; - Ok(Self { - config_map: Mutex::new(config_map), - }) - } - - pub fn set_packet_rate(&self, rate: u64) -> Result<(), Error> { - self.config_map - .lock() - .set(0, rate, 0) - .map_err(EbpfError::MapOperationError)?; - Ok(()) - } - - pub fn set_syn_rate(&self, rate: u64) -> Result<(), Error> { - self.config_map - .lock() - .set(1, rate, 0) - .map_err(EbpfError::MapOperationError)?; - Ok(()) - } - - pub fn set_udp_rate(&self, rate: u64) -> Result<(), Error> { - self.config_map - .lock() - .set(2, rate, 0) - .map_err(EbpfError::MapOperationError)?; - Ok(()) - } - - pub fn set_dns_rate(&self, rate: u64) -> Result<(), Error> { - self.config_map - .lock() - .set(3, rate, 0) - .map_err(EbpfError::MapOperationError)?; - Ok(()) - } - - pub fn set_window_ns(&self, ns: u64) -> Result<(), Error> { - self.config_map - .lock() - .set(4, ns, 0) - .map_err(EbpfError::MapOperationError)?; - Ok(()) - } - - pub fn get_packet_rate(&self) -> Result { - self.config_map - .lock() - .get(&0, 0) - .map_err(|e| EbpfError::MapOperationError(e).into()) - } - - pub fn get_syn_rate(&self) -> Result { - self.config_map - .lock() - .get(&1, 0) - .map_err(|e| EbpfError::MapOperationError(e).into()) - } - - pub fn get_udp_rate(&self) -> Result { - self.config_map - .lock() - .get(&2, 0) - .map_err(|e| EbpfError::MapOperationError(e).into()) - } - - pub fn get_dns_rate(&self) -> Result { - self.config_map - .lock() - .get(&3, 0) - .map_err(|e| EbpfError::MapOperationError(e).into()) - } - - pub fn get_window_ns(&self) -> Result { - self.config_map - .lock() - .get(&4, 0) - .map_err(|e| EbpfError::MapOperationError(e).into()) - } -} diff --git a/net-guardia/src/core/email/report.rs b/net-guardia/src/core/email/report.rs index 8f2e1b1..4a932c9 100644 --- a/net-guardia/src/core/email/report.rs +++ b/net-guardia/src/core/email/report.rs @@ -1,4 +1,6 @@ -use crate::interface::port::repository::RepositoryPort; +use chrono::Local; + +use crate::interface::port::setting::SettingRepo; use crate::model::error::Error; /// Generate an HTML weekly report email body. @@ -12,7 +14,7 @@ use crate::model::error::Error; /// - `weekly_system_health` (JSON object with cpu, memory, disk fields) /// /// If a key is missing the report uses empty/zero defaults. -pub fn generate_weekly_report(db: &dyn RepositoryPort) -> Result { +pub fn generate_weekly_report(db: &dyn SettingRepo) -> Result { let threats_count = db .get_setting("weekly_threats_count")? .unwrap_or_else(|| "0".to_string()); @@ -77,7 +79,7 @@ pub fn generate_weekly_report(db: &dyn RepositoryPort) -> Result let mem = health["memory_percent"].as_f64().unwrap_or(0.0); let disk = health["disk_percent"].as_f64().unwrap_or(0.0); - let now = chrono::Local::now().format("%Y-%m-%d %H:%M"); + let now = Local::now().format("%Y-%m-%d %H:%M"); let html = format!( r#" diff --git a/net-guardia/src/core/email/scheduler.rs b/net-guardia/src/core/email/scheduler.rs index 9754d0a..10bff78 100644 --- a/net-guardia/src/core/email/scheduler.rs +++ b/net-guardia/src/core/email/scheduler.rs @@ -1,13 +1,19 @@ -use crate::interface::port::repository::RepositoryPort; -use crate::interface::port::secret_store::SecretStorePort; -use crate::model::error::Error; -use crate::model::error::notification::NotificationError; +use std::sync::Arc; + +use chrono::{Local, Weekday}; use lettre::message::header::ContentType; use lettre::transport::smtp::authentication::Credentials; use lettre::{Message, SmtpTransport, Transport}; -use std::sync::Arc; +use macros::log; +use tokio::task::{JoinHandle, spawn_blocking}; use tokio::time::{self, Duration}; -use tracing::{error, info, warn}; + +use super::report; +use crate::interface::port::secret_store::SecretStorePort; +use crate::interface::port::setting::SettingRepo; +use crate::model::error::Error; +use crate::model::error::notification::NotificationError; +use crate::model::log::system::SystemLog; /// SMTP client wrapper that builds a `lettre::SmtpTransport` from Database /// settings and sends an email. @@ -25,12 +31,8 @@ impl SmtpClient { /// /// Returns `None` if any required setting (`smtp_host`, `smtp_port`, /// `smtp_username`, `smtp_password`) is missing. - /// If a `SecretStorePort` is provided, reads the password from the secret store - /// (falling back to the settings table for backward compat before migration). - pub fn from_database( - db: &dyn RepositoryPort, - secrets: Option<&dyn SecretStorePort>, - ) -> Result, Error> { + /// If a `SecretStorePort` is provided, reads the password from the secret store. + pub fn from_database(db: &dyn SettingRepo, secrets: Option<&dyn SecretStorePort>) -> Result, Error> { let host = match db.get_setting("smtp_host")? { Some(v) if !v.is_empty() => v, _ => return Ok(None), @@ -44,8 +46,7 @@ impl SmtpClient { _ => return Ok(None), }; - // Try secret store first, fall back to settings - let password = Self::resolve_smtp_password(db, secrets)?; + let password = Self::resolve_smtp_password(secrets)?; let password = match password { Some(v) if !v.is_empty() => v, _ => return Ok(None), @@ -74,12 +75,9 @@ impl SmtpClient { })) } - /// Try to construct an `SmtpClient` from a SOAR port (which also provides `get_setting`). - /// Same logic as `from_database`, but accepts `&dyn SoarPort` instead of `&dyn RepositoryPort`. - pub fn from_soar_port( - db: &dyn crate::interface::port::soar::SoarPort, - secrets: Option<&dyn SecretStorePort>, - ) -> Result, Error> { + /// Try to construct an `SmtpClient` from any SettingRepo implementation. + /// Kept as a separate method name for call-site clarity (SOAR actions). + pub fn from_soar_port(db: &dyn SettingRepo, secrets: Option<&dyn SecretStorePort>) -> Result, Error> { let host = match db.get_setting("smtp_host")? { Some(v) if !v.is_empty() => v, _ => return Ok(None), @@ -93,13 +91,9 @@ impl SmtpClient { _ => return Ok(None), }; - // Try secret store first, fall back to settings via SoarPort let password = match secrets.and_then(|ss| ss.get_secret("smtp_password").ok().flatten()) { Some(pw) if !pw.is_empty() => pw, - _ => match db.get_setting("smtp_password")? { - Some(v) if !v.is_empty() && v != "__encrypted__" => v, - _ => return Ok(None), - }, + _ => return Ok(None), }; let port: u16 = port_str.parse().unwrap_or(587); @@ -123,32 +117,20 @@ impl SmtpClient { } /// Resolve SMTP password: try secret store first, fall back to settings. - fn resolve_smtp_password( - db: &dyn RepositoryPort, - secrets: Option<&dyn SecretStorePort>, - ) -> Result, Error> { - if let Some(ss) = secrets - && let Some(pw) = ss.get_secret("smtp_password")? - && !pw.is_empty() - { - return Ok(Some(pw)); - } - // Fallback: read from settings (pre-migration or no secret store) - let val = db.get_setting("smtp_password")?; - match val { - Some(ref v) if v == "__encrypted__" => Ok(None), - other => Ok(other), + fn resolve_smtp_password(secrets: Option<&dyn SecretStorePort>) -> Result, Error> { + match secrets { + Some(ss) => Ok(ss.get_secret("smtp_password")?.filter(|pw| !pw.is_empty())), + None => Ok(None), } } /// Send an HTML email using the configured SMTP transport. pub fn send(&self, to: &str, subject: &str, html_body: &str) -> Result<(), Error> { - let from_addr = self.sender.parse().map_err(|e| NotificationError::InvalidAddress { - reason: format!("invalid from address: {e}"), - })?; - let to_addr = to.parse().map_err(|e| NotificationError::InvalidAddress { - reason: format!("invalid to address: {e}"), - })?; + let from_addr = self + .sender + .parse() + .map_err(|e| NotificationError::InvalidAddress("from", e))?; + let to_addr = to.parse().map_err(|e| NotificationError::InvalidAddress("to", e))?; let email = Message::builder() .from(from_addr) @@ -156,7 +138,7 @@ impl SmtpClient { .subject(subject) .header(ContentType::TEXT_HTML) .body(html_body.to_string()) - .map_err(|e| NotificationError::MessageBuildFailed { reason: e.to_string() })?; + .map_err(NotificationError::MessageBuildFailed)?; let creds = Credentials::new(self.username.clone(), self.password.clone()); @@ -164,7 +146,7 @@ impl SmtpClient { 465 => { // Implicit TLS (SMTPS) SmtpTransport::relay(&self.host) - .map_err(|e| NotificationError::SmtpConnectionFailed { reason: e.to_string() })? + .map_err(NotificationError::SmtpConnectionFailed)? .port(self.port) .credentials(creds) .build() @@ -172,7 +154,7 @@ impl SmtpClient { 25 | 587 => { // STARTTLS (standard submission ports) SmtpTransport::starttls_relay(&self.host) - .map_err(|e| NotificationError::SmtpConnectionFailed { reason: e.to_string() })? + .map_err(NotificationError::SmtpConnectionFailed)? .port(self.port) .credentials(creds) .build() @@ -186,9 +168,7 @@ impl SmtpClient { } }; - mailer - .send(&email) - .map_err(|e| NotificationError::SmtpSendFailed { reason: e.to_string() })?; + mailer.send(&email).map_err(NotificationError::SmtpSendFailed)?; Ok(()) } @@ -197,21 +177,21 @@ impl SmtpClient { /// Scheduler that checks once per hour whether it is time to send the weekly /// report (Monday 08:00 local time) and dispatches it via SMTP. pub struct ReportScheduler { - db: Arc, + db: Arc, secrets: Option>, } impl ReportScheduler { - pub fn new(db: Arc, secrets: Option>) -> Self { + pub fn new(db: Arc, secrets: Option>) -> Self { Self { db, secrets } } /// Spawn a background tokio task that runs the weekly check loop. - pub fn run(&self) -> tokio::task::JoinHandle<()> { + pub fn run(&self) -> JoinHandle<()> { let db = Arc::clone(&self.db); let secrets = self.secrets.clone(); tokio::spawn(async move { - info!("Weekly report scheduler started"); + log!(SystemLog::WeeklyReportSchedulerStarted); let mut interval = time::interval(Duration::from_secs(3600)); loop { interval.tick().await; @@ -220,19 +200,16 @@ impl ReportScheduler { continue; } - info!("Weekly report window reached — preparing report"); + log!(SystemLog::WeeklyReportWindowReached); let smtp = match SmtpClient::from_database(&*db, secrets.as_deref()) { Ok(Some(client)) => client, Ok(None) => { - warn!( - "SMTP is not configured (missing smtp_host/port/username/password). \ - Skipping weekly report." - ); + log!(SystemLog::SmtpNotConfigured); continue; } Err(e) => { - error!("Failed to read SMTP settings: {e}"); + log!(SystemLog::SmtpSettingsReadFailed(e.to_string())); continue; } }; @@ -240,26 +217,26 @@ impl ReportScheduler { let recipient = match db.get_setting("smtp_recipient") { Ok(Some(r)) if !r.is_empty() => r, _ => { - warn!("No smtp_recipient configured. Skipping weekly report."); + log!(SystemLog::SmtpRecipientMissing); continue; } }; - let html = match super::report::generate_weekly_report(&*db) { + let html = match report::generate_weekly_report(&*db) { Ok(h) => h, Err(e) => { - error!("Failed to generate weekly report: {e}"); + log!(SystemLog::WeeklyReportGenerationFailed(e.to_string())); continue; } }; - let subject = format!("NetGuardia Weekly Report — {}", chrono::Local::now().format("%Y-%m-%d")); - let send_result = tokio::task::spawn_blocking(move || smtp.send(&recipient, &subject, &html)).await; + let subject = format!("NetGuardia Weekly Report — {}", Local::now().format("%Y-%m-%d")); + let send_result = spawn_blocking(move || smtp.send(&recipient, &subject, &html)).await; match send_result { - Ok(Ok(())) => info!("Weekly report sent successfully"), - Ok(Err(e)) => error!("Failed to send weekly report: {e}"), - Err(e) => error!("Send task panicked: {e}"), + Ok(Ok(())) => log!(SystemLog::WeeklyReportSent), + Ok(Err(e)) => log!(SystemLog::WeeklyReportSendFailed(e.to_string())), + Err(e) => log!(SystemLog::WeeklyReportSendPanicked(e.to_string())), } } }) @@ -270,6 +247,6 @@ impl ReportScheduler { /// hour (i.e. Monday, hour == 8). fn is_send_window() -> bool { use chrono::{Datelike, Timelike}; - let now = chrono::Local::now(); - now.weekday() == chrono::Weekday::Mon && now.hour() == 8 + let now = Local::now(); + now.weekday() == Weekday::Mon && now.hour() == 8 } diff --git a/net-guardia/src/core/ml/adapter.rs b/net-guardia/src/core/ml/adapter.rs new file mode 100644 index 0000000..cebeb99 --- /dev/null +++ b/net-guardia/src/core/ml/adapter.rs @@ -0,0 +1,235 @@ +//! `MLModelAdapter` — the three ONNX adapter shapes the inference pipeline +//! understands, plus the wrapping `ModelSourceState` machine used inside +//! `Inference::models: ArcSwap`. +//! +//! Day 1 is `Dormant` (no model loaded); the happy path is `Active { adapter, +//! info }`; failed loads park in `Error { msg, since, last_attempted_path }`. + +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::SystemTime; + +use super::manifest::LabelSpec; +use crate::model::detection::ml_detection::RunnableModel; +use crate::model::detection::model_source::{ModelInfo, ModelSourceStatus}; + +/// Compile-time sanity: `RunnableModel` must be `Send + Sync` because we +/// stuff it inside an `ArcSwap`. If a future `tract-onnx` upgrade silently +/// drops the bounds, this line stops compiling and we catch it before it +/// becomes a production data race. +const _: fn() = || { + fn assert_send_sync() {} + assert_send_sync::(); +}; + +/// One of three adapter shapes. Each variant carries the RunnableModel(s) +/// it needs plus the metadata required to dispatch inference: +/// batch size, feature counts, label map (classifier / multi-task only). +#[derive(Clone)] +pub enum MLModelAdapter { + /// Pure anomaly-detection autoencoder. Output is a single MSE-style + /// reconstruction error per flow; classifier-less. v1 product main path. + AutoencoderOnly { + model: Arc, + batch_size: usize, + n_features: usize, + }, + /// Single classifier, no AE. Output is a per-class softmax tensor. + /// Labels come from the manifest's `labels` map; `normal_idx` is + /// pre-resolved so `infer_batch` doesn't re-scan on every tick. + ClassifierOnly { + model: Arc, + batch_size: usize, + n_features: usize, + labels: BTreeMap, + normal_idx: Option, + }, + /// AE + classifier in one adapter. Classifier consumes (ae_features, ae_score). + /// Three output tensors: anomaly_score / class_probs / c2_score. + MultiTask { + ae: Arc, + classifier: Arc, + batch_size: usize, + n_ae: usize, + n_cls: usize, + labels: BTreeMap, + normal_idx: Option, + c2_idx: Option, + }, +} + +impl MLModelAdapter { + /// Per-attack-type confirmations lookup: resolve the manifest's + /// `labels[*].confirmations` value whose label name matches + /// `attack_type_name` (case-insensitive). Callers fall back to their own + /// default when this returns `None` — `AutoencoderOnly` carries no labels, + /// and classifier labels may legitimately omit the override. + pub fn confirmations_for(&self, attack_type_name: &str) -> Option { + match self { + Self::MultiTask { labels, .. } | Self::ClassifierOnly { labels, .. } => { + confirmations_from_labels(labels, attack_type_name) + } + Self::AutoencoderOnly { .. } => None, + } + } +} + +/// Pure-data lookup extracted so unit tests can cover the matching rules +/// without constructing a full `MLModelAdapter` (which requires a real +/// `RunnableModel` — expensive and fragile to mock). +fn confirmations_from_labels(labels: &BTreeMap, attack_type_name: &str) -> Option { + labels + .values() + .find(|spec| spec.name.eq_ignore_ascii_case(attack_type_name)) + .and_then(|spec| spec.confirmations) +} + +/// Internal state machine. Held inside `ArcSwap` so the +/// inference pipeline can check state once per tick without locks and +/// atomically swap on upload / deletion / reload failure. +/// +/// Variants are deliberately **not** `Serialize` — the `Active` variant +/// holds an `Arc` which isn't serde-friendly. Callers that +/// need a wire representation call `to_status()` to produce the lightweight +/// `ModelSourceStatus` consumed by WebSocket / HTTP. +pub enum ModelSourceState { + /// Day 1 default. `models/` empty, or admin deleted the current model. + /// Drift detector becomes a no-op; fusion math still runs with the + /// remaining 3 sources. + Dormant, + /// Model loaded, inference ticks consume flows. + Active { adapter: MLModelAdapter, info: ModelInfo }, + /// Last load attempt failed — schema mismatch, timeout, corrupted + /// ONNX. Inference skips; UI renders the reason. Swap to Dormant / + /// Active via normal reload path. + Error { + msg: String, + since: SystemTime, + last_attempted_path: Option, + }, +} + +impl ModelSourceState { + pub fn is_active(&self) -> bool { + matches!(self, Self::Active { .. }) + } + + /// Wire-format snapshot for UI / HTTP. Never borrows the adapter — the + /// returned value is safe to send across WebSocket boundaries. + pub fn to_status(&self) -> ModelSourceStatus { + match self { + Self::Dormant => ModelSourceStatus::Dormant, + Self::Active { info, .. } => ModelSourceStatus::Active { info: info.clone() }, + Self::Error { + msg, + since, + last_attempted_path, + } => ModelSourceStatus::Error { + msg: msg.clone(), + since_secs: since + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0), + last_attempted_path: last_attempted_path.as_ref().map(|p| p.display().to_string()), + }, + } + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + + #[test] + fn dormant_state_serializes_to_dormant_status() { + let state = ModelSourceState::Dormant; + let status = state.to_status(); + assert!(status.is_dormant()); + } + + fn sample_labels() -> BTreeMap { + let mut m = BTreeMap::new(); + m.insert( + "0".into(), + LabelSpec { + name: "Bot".into(), + confirmations: Some(1), + playbook: None, + }, + ); + m.insert( + "4".into(), + LabelSpec { + name: "DoS/DDoS".into(), + confirmations: Some(2), + playbook: None, + }, + ); + m.insert( + "7".into(), + LabelSpec { + name: "Normal".into(), + confirmations: None, + playbook: None, + }, + ); + m + } + + #[test] + fn confirmations_lookup_hits_exact_name() { + let labels = sample_labels(); + assert_eq!(confirmations_from_labels(&labels, "Bot"), Some(1)); + assert_eq!(confirmations_from_labels(&labels, "DoS/DDoS"), Some(2)); + } + + #[test] + fn confirmations_lookup_is_case_insensitive() { + let labels = sample_labels(); + assert_eq!(confirmations_from_labels(&labels, "bot"), Some(1)); + assert_eq!(confirmations_from_labels(&labels, "dos/ddos"), Some(2)); + } + + #[test] + fn confirmations_lookup_label_without_override_returns_none() { + let labels = sample_labels(); + assert_eq!(confirmations_from_labels(&labels, "Normal"), None); + } + + #[test] + fn confirmations_lookup_unknown_name_returns_none() { + let labels = sample_labels(); + assert_eq!(confirmations_from_labels(&labels, "Phantom"), None); + } + + #[test] + fn confirmations_lookup_empty_labels_returns_none() { + let labels = BTreeMap::new(); + assert_eq!(confirmations_from_labels(&labels, "Bot"), None); + } + + #[test] + fn error_state_preserves_details() { + let state = ModelSourceState::Error { + msg: "shape mismatch".to_string(), + since: SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000), + last_attempted_path: Some(PathBuf::from("models/bad.onnx")), + }; + let status = state.to_status(); + match status { + ModelSourceStatus::Error { + msg, + since_secs, + last_attempted_path, + } => { + assert_eq!(msg, "shape mismatch"); + assert_eq!(since_secs, 1_700_000_000); + assert_eq!(last_attempted_path.as_deref(), Some("models/bad.onnx")); + } + _ => panic!("expected Error"), + } + } +} diff --git a/net-guardia/src/core/ml/aggregator.rs b/net-guardia/src/core/ml/aggregator.rs index d4527a9..f00ddb7 100644 --- a/net-guardia/src/core/ml/aggregator.rs +++ b/net-guardia/src/core/ml/aggregator.rs @@ -1,51 +1,58 @@ -use std::collections::HashMap; +//! Per-flow detection aggregator. +//! +//! Records per-key hits within a rolling time window; callers decide when to +//! fire based on how many hits a given attack class needs (manifest-driven) +//! and how far the rolling-average score beats the confidence threshold. + use std::time::{Duration, Instant}; -use crate::model::ml_detection::FlowKey; +use dashmap::DashMap; + +use crate::model::detection::ml_detection::FlowKey; pub struct AttackAggregator { - detections: HashMap>, + detections: DashMap>, window_duration: Duration, - min_detections: usize, - alert_threshold_multiplier: f32, } impl AttackAggregator { - pub fn new(window_secs: u64, min_detections: usize) -> Self { + pub fn new(window_secs: u64) -> Self { Self { - detections: HashMap::new(), + detections: DashMap::new(), window_duration: Duration::from_secs(window_secs), - min_detections, - alert_threshold_multiplier: 1.2, } } - pub fn should_alert(&mut self, flow_key: &FlowKey, score: f32, threshold: f32, attack_type: Option<&str>) -> bool { + /// Record a detection and decide whether the flow should fire an alert. + /// + /// - `required_confirmations`: in-window hit count the flow must reach. + /// Resolved by the caller from the active manifest's per-label value; + /// validated at manifest load to be ≥ 1, so no runtime floor is needed. + /// - `alert_multiplier`: scales `threshold` before the average-score + /// comparison, driven by the manifest's `thresholds.alert_multiplier`. + pub fn should_alert( + &self, + flow_key: &FlowKey, + score: f32, + threshold: f32, + required_confirmations: usize, + alert_multiplier: f32, + ) -> bool { let now = Instant::now(); - let detections = self.detections.entry(flow_key.clone()).or_default(); + let mut detections = self.detections.entry(flow_key.clone()).or_default(); detections.retain(|(time, _)| now.duration_since(*time) < self.window_duration); detections.push((now, score)); - // Per-attack-type adaptive min_detections: - // DDoS/DoS: high frequency, need more confirmations to avoid alert storms - // C2/Cryptomining: low frequency, alert on first detection - let effective_min = match attack_type { - Some("DDoS") | Some("DoS") => self.min_detections.saturating_mul(2).max(1), - Some("C2 Communication") | Some("Cryptomining") => 1, - _ => self.min_detections, - }; - - if detections.len() >= effective_min { + if detections.len() >= required_confirmations { let avg_score: f32 = detections.iter().map(|(_, s)| s).sum::() / detections.len() as f32; - - return avg_score > threshold * self.alert_threshold_multiplier; + return avg_score > threshold * alert_multiplier; } false } - pub fn cleanup(&mut self) { + pub fn cleanup(&self) { let now = Instant::now(); self.detections.retain(|_, detections| { detections.retain(|(time, _)| now.duration_since(*time) < self.window_duration); @@ -58,6 +65,8 @@ impl AttackAggregator { mod tests { use super::*; + const TEST_MULTIPLIER: f32 = 1.2; + fn test_key() -> FlowKey { FlowKey { src_ip: [192, 168, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], @@ -70,47 +79,66 @@ mod tests { } #[test] - fn default_attack_type_uses_base_min_detections() { - let mut agg = AttackAggregator::new(60, 3); + fn required_three_fires_on_third_hit() { + let agg = AttackAggregator::new(60); let key = test_key(); - // Need 3 detections for default type - assert!(!agg.should_alert(&key, 5.0, 1.0, Some("Brute Force"))); - assert!(!agg.should_alert(&key, 5.0, 1.0, Some("Brute Force"))); - assert!(agg.should_alert(&key, 5.0, 1.0, Some("Brute Force"))); + assert!(!agg.should_alert(&key, 5.0, 1.0, 3, TEST_MULTIPLIER)); + assert!(!agg.should_alert(&key, 5.0, 1.0, 3, TEST_MULTIPLIER)); + assert!(agg.should_alert(&key, 5.0, 1.0, 3, TEST_MULTIPLIER)); } #[test] - fn ddos_requires_double_min_detections() { - let mut agg = AttackAggregator::new(60, 3); + fn required_one_fires_immediately() { + let agg = AttackAggregator::new(60); + let key = test_key(); + assert!(agg.should_alert(&key, 5.0, 1.0, 1, TEST_MULTIPLIER)); + } + + #[test] + fn required_six_needs_six_hits() { + let agg = AttackAggregator::new(60); let key = test_key(); - // DDoS needs 6 detections (3 * 2) for _ in 0..5 { - assert!(!agg.should_alert(&key, 5.0, 1.0, Some("DDoS"))); + assert!(!agg.should_alert(&key, 5.0, 1.0, 6, TEST_MULTIPLIER)); } - assert!(agg.should_alert(&key, 5.0, 1.0, Some("DDoS"))); + assert!(agg.should_alert(&key, 5.0, 1.0, 6, TEST_MULTIPLIER)); } #[test] - fn c2_alerts_on_first_detection() { - let mut agg = AttackAggregator::new(60, 3); + fn average_score_at_or_below_scaled_threshold_does_not_fire() { + let agg = AttackAggregator::new(60); let key = test_key(); - // C2 Communication alerts immediately (min=1) - assert!(agg.should_alert(&key, 5.0, 1.0, Some("C2 Communication"))); + // score 1.0, threshold 1.0, multiplier 1.2 → gate is 1.2; 1.0 misses. + assert!(!agg.should_alert(&key, 1.0, 1.0, 1, TEST_MULTIPLIER)); } #[test] - fn cryptomining_alerts_on_first_detection() { - let mut agg = AttackAggregator::new(60, 3); + fn larger_multiplier_raises_the_bar() { + let agg = AttackAggregator::new(60); let key = test_key(); - assert!(agg.should_alert(&key, 5.0, 1.0, Some("Cryptomining"))); + // multiplier 2.5, threshold 1.0 → gate is 2.5; score 2.0 misses. + assert!(!agg.should_alert(&key, 2.0, 1.0, 1, 2.5)); } #[test] - fn none_attack_type_uses_default() { - let mut agg = AttackAggregator::new(60, 3); + fn cleanup_preserves_fresh_entries() { + let agg = AttackAggregator::new(60); let key = test_key(); - assert!(!agg.should_alert(&key, 5.0, 1.0, None)); - assert!(!agg.should_alert(&key, 5.0, 1.0, None)); - assert!(agg.should_alert(&key, 5.0, 1.0, None)); + agg.should_alert(&key, 5.0, 1.0, 10, TEST_MULTIPLIER); + assert!(agg.detections.contains_key(&key)); + agg.cleanup(); + assert!(agg.detections.contains_key(&key)); + } + + #[test] + fn independent_flows_track_separately() { + let agg = AttackAggregator::new(60); + let key_a = test_key(); + let mut key_b = test_key(); + key_b.dst_port = 81; + assert!(!agg.should_alert(&key_a, 5.0, 1.0, 2, TEST_MULTIPLIER)); + assert!(!agg.should_alert(&key_b, 5.0, 1.0, 2, TEST_MULTIPLIER)); + assert!(agg.should_alert(&key_a, 5.0, 1.0, 2, TEST_MULTIPLIER)); + assert!(agg.should_alert(&key_b, 5.0, 1.0, 2, TEST_MULTIPLIER)); } } diff --git a/net-guardia/src/core/ml/alert.rs b/net-guardia/src/core/ml/alert.rs index 3e025c4..e2e0aeb 100644 --- a/net-guardia/src/core/ml/alert.rs +++ b/net-guardia/src/core/ml/alert.rs @@ -2,8 +2,8 @@ use macros::log; use tokio::sync::broadcast; use crate::model::config::constants::ML_ALERT_CHANNEL_CAPACITY; +use crate::model::detection::ml_detection::{AlertMessage, DetectionResult}; use crate::model::log::ml::MLLog; -use crate::model::ml_detection::{AlertMessage, DetectionResult}; pub struct MLAlert { broadcast_tx: broadcast::Sender, diff --git a/net-guardia/src/core/ml/config_loader.rs b/net-guardia/src/core/ml/config_loader.rs index 040ca3a..05d64ef 100644 --- a/net-guardia/src/core/ml/config_loader.rs +++ b/net-guardia/src/core/ml/config_loader.rs @@ -1,28 +1,222 @@ +use std::collections::{BTreeMap, HashMap}; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; +use super::manifest::{AdapterKind, LabelSpec, ModelManifest}; use crate::model::error::ml::MLError; - -use crate::model::config::MLInferenceConfig; - -/// Backward-compatible alias so existing `use config_loader::InferenceConfig` paths still compile. -pub type InferenceConfig = MLInferenceConfig; +use crate::model::system::config::MLInferenceConfig; impl MLInferenceConfig { pub fn load_file(file: &str) -> Result { let path = PathBuf::from("models").join(file); - let content = fs::read_to_string(&path).map_err(|_| MLError::ConfigLoadFailed(path.to_path_buf()))?; + Self::load_file_at(&path) + } + + fn load_file_at(path: &Path) -> Result { + let content = fs::read_to_string(path) + .map_err(|e| MLError::ConfigLoadFailed(path.to_path_buf(), format!("read failed: {e}")))?; let config: MLInferenceConfig = serde_json::from_str(&content).map_err(|e| MLError::ConfigParseFailed(e.to_string()))?; - if config.ae_feature_names.is_empty() { - return Err(MLError::ConfigParseFailed("ae_feature_names is empty")); - } - if config.ae_scaler_mean.len() != config.ae_feature_names.len() { - return Err(MLError::ConfigParseFailed("scaler mean length mismatch")); - } - if config.ae_scaler_std.len() != config.ae_feature_names.len() { - return Err(MLError::ConfigParseFailed("scaler std length mismatch")); - } + validate(&config)?; Ok(config) } + + /// Load a `MLInferenceConfig` by combining a manifest with its scaler sidecar. + /// The manifest supplies the authoritative feature list and per-label metadata; + /// the sidecar JSON supplies the numeric preprocessing arrays (scaler / clip / weights). + /// + /// Consistency is enforced: manifest `features` must match the sidecar's + /// `ae_feature_names` exactly (order-sensitive). Any drift between the two is a + /// deployment bug, not a silent override. + pub fn from_manifest_with_sidecar(manifest_path: &Path) -> Result<(Self, ModelManifest), MLError> { + let manifest = ModelManifest::load(manifest_path)?; + let sidecar_rel = manifest + .preprocessing + .as_ref() + .map(|p| p.scaler_sidecar.as_str()) + .ok_or_else(|| { + MLError::ManifestInvalid( + manifest_path.to_path_buf(), + "preprocessing.scaler_sidecar is required for v1 (scaler arrays live there)".to_string(), + ) + })?; + let sidecar_path = ModelManifest::resolve_relative(manifest_path, sidecar_rel); + let mut config = Self::load_file_at(&sidecar_path)?; + + reconcile_features(&manifest, &config, manifest_path)?; + apply_manifest_overrides(&manifest, &mut config); + validate_for_adapter(&config, manifest.adapter)?; + + Ok((config, manifest)) + } +} + +/// Shape checks that apply to every sidecar regardless of adapter kind: +/// feature list non-empty, scaler arrays match feature count. Per-adapter +/// output-head counts live in `validate_for_adapter` and only run via the +/// manifest path where the adapter kind is known. +fn validate(config: &MLInferenceConfig) -> Result<(), MLError> { + if config.ae_feature_names.is_empty() { + return Err(MLError::ConfigParseFailed("ae_feature_names is empty")); + } + if config.ae_scaler_mean.len() != config.ae_feature_names.len() { + return Err(MLError::ConfigParseFailed("scaler mean length mismatch")); + } + if config.ae_scaler_std.len() != config.ae_feature_names.len() { + return Err(MLError::ConfigParseFailed("scaler std length mismatch")); + } + Ok(()) +} + +/// Adapter-specific output-head count check. MultiTask ships three heads +/// (anomaly / class_probs / c2_score); single-head adapters (Classifier, +/// Autoencoder) ship one. Mismatch here means the sidecar belongs to a +/// different adapter kind than the manifest declares — a deployment bug +/// rather than a runtime fault. +fn validate_for_adapter(config: &MLInferenceConfig, adapter: AdapterKind) -> Result<(), MLError> { + let expected = match adapter { + AdapterKind::MultiTask => 3, + AdapterKind::ClassifierOnly | AdapterKind::AutoencoderOnly => 1, + }; + if config.output_names.len() != expected { + return Err(MLError::ConfigParseFailed(match adapter { + AdapterKind::MultiTask => "MultiTaskModel requires exactly 3 output_names (anomaly, class_probs, c2_score)", + AdapterKind::ClassifierOnly => "ClassifierOnly adapter requires exactly 1 output_name (class_probs)", + AdapterKind::AutoencoderOnly => "AutoencoderOnly adapter requires exactly 1 output_name (reconstruction)", + })); + } + Ok(()) +} + +fn reconcile_features( + manifest: &ModelManifest, + config: &MLInferenceConfig, + manifest_path: &Path, +) -> Result<(), MLError> { + if manifest.features.len() != config.ae_feature_names.len() { + return Err(MLError::ManifestInvalid( + manifest_path.to_path_buf(), + format!( + "feature count mismatch with sidecar: manifest declares {}, sidecar lists {}", + manifest.features.len(), + config.ae_feature_names.len() + ), + )); + } + for (i, (mf, sf)) in manifest.features.iter().zip(config.ae_feature_names.iter()).enumerate() { + if mf != sf { + return Err(MLError::ManifestInvalid( + manifest_path.to_path_buf(), + format!("feature[{i}] mismatch: manifest='{mf}' vs sidecar='{sf}'"), + )); + } + } + Ok(()) +} + +fn apply_manifest_overrides(manifest: &ModelManifest, config: &mut MLInferenceConfig) { + if !manifest.labels.is_empty() { + config.attack_labels = manifest_labels_to_map(&manifest.labels); + } + if let Some(v) = manifest.thresholds.anomaly { + config.anomaly_threshold = v; + } + if let Some(v) = manifest.thresholds.c2 { + config.c2_threshold = v; + } + if let Some(v) = manifest.thresholds.class_min_confidence { + config.class_min_confidence = v; + } + if let Some(v) = manifest.thresholds.ae { + config.ae_threshold = v; + } + if let Some(v) = manifest.thresholds.alert_multiplier { + config.alert_threshold_multiplier = v; + } +} + +fn manifest_labels_to_map(labels: &BTreeMap) -> HashMap { + labels.iter().map(|(k, v)| (k.clone(), v.name.clone())).collect() +} + +#[cfg(test)] +mod tests { + use std::env; + use std::io::Write; + + use super::*; + use crate::model::detection::ml_detection::ClipParams; + + /// Integration test: the shipped `models/manifest.yaml` must successfully pair + /// with its scaler sidecar to yield a valid `MLInferenceConfig`. Skipped silently + /// when run outside the repo root (no `models/` directory). + #[test] + fn v10_manifest_and_sidecar_load_successfully() { + let manifest_path = Path::new("models/manifest.yaml"); + if !manifest_path.exists() { + eprintln!("skipping: models/manifest.yaml absent (not in repo root?)"); + return; + } + let (cfg, manifest) = MLInferenceConfig::from_manifest_with_sidecar(manifest_path) + .expect("v10 manifest + sidecar should load cleanly"); + assert_eq!(manifest.name, "netguardia-v10"); + assert_eq!(cfg.ae_feature_names.len(), 31); + assert_eq!(cfg.classifier_feature_names.len(), 32); + // Label map came from manifest, not sidecar. + assert_eq!(cfg.attack_labels.get("0").map(String::as_str), Some("Bot")); + assert_eq!(cfg.attack_labels.get("7").map(String::as_str), Some("Normal")); + } + + #[test] + fn feature_mismatch_between_manifest_and_sidecar_is_rejected() { + // Build a minimal sidecar JSON with 2 features. + let sidecar = MLInferenceConfig { + ae_feature_names: vec!["flow_duration".into(), "fwd_packets".into()], + ae_clip_params: HashMap::from([ + ("flow_duration".into(), ClipParams { lower: 0.0, upper: 1.0 }), + ("fwd_packets".into(), ClipParams { lower: 0.0, upper: 1.0 }), + ]), + ae_scaler_mean: vec![0.0, 0.0], + ae_scaler_std: vec![1.0, 1.0], + ae_post_clip_min: -5.0, + ae_post_clip_max: 5.0, + ae_threshold: 0.5, + classifier_feature_names: vec!["flow_duration".into(), "fwd_packets".into(), "ae_anomaly_score".into()], + attack_labels: HashMap::new(), + anomaly_threshold: 0.5, + c2_threshold: 0.5, + class_min_confidence: 0.4, + alert_threshold_multiplier: 1.2, + model_type: "MultiTaskModel".into(), + output_names: vec!["anomaly".into(), "class_probs".into(), "c2_score".into()], + ae_feature_weights: HashMap::new(), + }; + + let tmp = env::temp_dir().join("netguardia-m1-mismatch-test"); + fs::create_dir_all(&tmp).unwrap(); + let sidecar_path = tmp.join("sidecar.json"); + let manifest_path = tmp.join("manifest.yaml"); + let mut f = fs::File::create(&sidecar_path).unwrap(); + f.write_all(serde_json::to_string(&sidecar).unwrap().as_bytes()) + .unwrap(); + + // Manifest lists 3 features, sidecar has 2 — must fail. + let manifest_yaml = r#" +name: test +adapter: multi_task +models: + autoencoder: ae.onnx + classifier: c.onnx +features: + - flow_duration + - fwd_packets + - dst_port +preprocessing: + scaler_sidecar: sidecar.json +"#; + fs::write(&manifest_path, manifest_yaml).unwrap(); + let err = + MLInferenceConfig::from_manifest_with_sidecar(&manifest_path).expect_err("should reject count mismatch"); + assert!(matches!(err, MLError::ManifestInvalid { .. }), "got {err:?}"); + } } diff --git a/net-guardia/src/core/ml/drift_detector.rs b/net-guardia/src/core/ml/drift_detector.rs index 99438e5..1a4d6ac 100644 --- a/net-guardia/src/core/ml/drift_detector.rs +++ b/net-guardia/src/core/ml/drift_detector.rs @@ -1,11 +1,18 @@ use std::collections::VecDeque; use std::time::{Duration, Instant}; +use tokio::sync::{mpsc, oneshot}; + use crate::model::detection::drift::{DriftReport, FeatureBaselines}; /// Maximum number of snapshots to retain, preventing unbounded memory growth. const MAX_SNAPSHOTS: usize = 10_000; +/// Channel depth for the owner-task command queue. With a typical inference +/// batch of 100 flows per second, 1024 gives ~10s of cushion before the +/// hot path begins shedding samples. +const DRIFT_CMD_CHANNEL_CAPACITY: usize = 1024; + /// Tracks rolling mean/stddev of normalized input features over a configurable window. /// Compares against training-time baselines to detect data drift. pub struct DriftDetector { @@ -106,6 +113,64 @@ impl DriftDetector { } } +/// Command queue between drift-detector callers and the owner task. +enum DriftCmd { + Update(Vec), + CheckDrift { + reply: oneshot::Sender>, + }, +} + +/// Lock-free handle to a `DriftDetector` running on its own tokio task. +/// +/// The hot path is `update`, called from the ML engine's inference tick on +/// the spawn-blocking pool — it must not await, so we use `try_send` and +/// silently drop the sample when the channel is full. Drift is a statistical +/// signal computed over thousands of snapshots in a window; losing a few +/// samples under back-pressure does not change the verdict. +/// +/// `check_drift` is called from the periodic drift monitor (tokio task), so +/// it can `await` the round-trip naturally. +#[derive(Clone)] +pub struct DriftDetectorHandle { + tx: mpsc::Sender, +} + +impl DriftDetectorHandle { + /// Spawn the owner task on the current tokio runtime and return a handle. + pub fn spawn(baselines: Option, drift_window: Duration) -> Self { + let (tx, mut rx) = mpsc::channel::(DRIFT_CMD_CHANNEL_CAPACITY); + tokio::spawn(async move { + let mut detector = DriftDetector::new(baselines, drift_window); + while let Some(cmd) = rx.recv().await { + match cmd { + DriftCmd::Update(features) => detector.update(&features), + DriftCmd::CheckDrift { reply } => { + let _ = reply.send(detector.check_drift()); + } + } + } + }); + Self { tx } + } + + /// Fire-and-forget update. Drops the sample silently when the channel is + /// full or the owner task has shut down (statistical tolerance — see the + /// type-level doc). + pub fn update(&self, features: Vec) { + let _ = self.tx.try_send(DriftCmd::Update(features)); + } + + /// Round-trip drift query. Returns `None` if the channel is closed. + pub async fn check_drift(&self) -> Option { + let (reply_tx, reply_rx) = oneshot::channel(); + if self.tx.send(DriftCmd::CheckDrift { reply: reply_tx }).await.is_err() { + return None; + } + reply_rx.await.unwrap_or(None) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/net-guardia/src/core/ml/engine.rs b/net-guardia/src/core/ml/engine.rs index 6c971b8..315d154 100644 --- a/net-guardia/src/core/ml/engine.rs +++ b/net-guardia/src/core/ml/engine.rs @@ -1,62 +1,83 @@ +//! ML engine — orchestrates flow tracking, feature extraction, adapter- +//! dispatched inference, and alert broadcast. Each inference tick cleans up +//! stale flows, gathers the latest batch, optionally logs a Flow Trace row, +//! and — when the inference source is Active — updates drift and runs +//! adapter-dispatched inference. + use std::sync::Arc; -use std::time::{Duration, Instant}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use macros::log; -use parking_lot::Mutex; use tokio::sync::oneshot; +use tokio::task::spawn_blocking; use tokio::time::interval; use super::aggregator::AttackAggregator; -use super::config_loader::InferenceConfig; -use super::drift_detector::DriftDetector; +use super::alert::MLAlert; +use super::drift_detector::DriftDetectorHandle; use super::flow_tracker::{FlowData, FlowTracker}; use super::inference::Inference; -use super::model_loader::MLModels; use super::traffic_logger::TrafficLogger; +use crate::interface::port::packet_sink::{PacketSink, PacketSinkFactory}; use crate::model::detection::flow_features::FlowFeatures; - -use super::alert::MLAlert; +use crate::model::detection::ml_detection::{EngineConfig, FlowKey, InferenceStats}; use crate::model::log::ml::MLLog; -use crate::model::ml_detection::{EngineConfig, InferenceStats}; +use crate::model::monitoring::user_packet::UserPacket; + +/// Divisor applied to "ticks per aggregator window" to derive the fallback +/// confirmations count: 2 means a default-behaviour detection must fire +/// across at least half the window's ticks before alerting. +const DEFAULT_CONFIRMATION_WINDOW_FRACTION: u64 = 2; + +/// Hard floor on the per-flow packet count that gates ML inference for any +/// protocol / port combination that lacks an explicit low-packet override. +/// Prevents a misconfigured `min_packets` (0..=4) from feeding two-packet +/// flows into the model where the features carry almost no signal and the +/// false-alarm rate dominates. Protocols and ports that are meaningful at +/// very low packet counts (ICMP scans, DNS tunneling, C2 beacons) bypass +/// this floor through explicit overrides in `effective_min_packets`. +const ML_MIN_PACKETS_FLOOR: usize = 5; /// Per-queue tracker. With symmetric hash in eBPF, both directions of a flow /// land on the same queue, so per-queue trackers correctly see bidirectional flows. -pub type ThreadTracker = Arc>; +/// `FlowTracker` itself is internally synchronized (DashMap), so the +/// per-queue handle is a plain `Arc`. +pub type ThreadTracker = Arc; pub struct Engine { trackers: Vec, inference_pipeline: Arc, - aggregator: Mutex, - drift_detector: Arc>, + aggregator: AttackAggregator, + drift_detector: DriftDetectorHandle, ml_alert: Arc, min_packets: usize, + /// Confirmations count used when the active manifest's label has no + /// explicit `confirmations` override. + default_confirmations: usize, batch_size: usize, inference_interval_secs: u64, traffic_logger: Option>, } impl Engine { + /// Build an Engine around an already-constructed Inference pipeline. + /// The Inference's state (Dormant / Active / Error) is consulted per tick. pub fn new( - models: Arc, - config: Arc, + inference_pipeline: Arc, ml_alert: Arc, - drift_detector: Arc>, + drift_detector: DriftDetectorHandle, engine_config: EngineConfig, traffic_logger: Option>, num_threads: u32, ) -> Self { - let inference_pipeline = Arc::new(Inference::new(models, config)); - - let min_detections = - ((engine_config.aggregator_window_secs / engine_config.inference_interval_secs) / 2).max(1) as usize; - let aggregator = Mutex::new(AttackAggregator::new( - engine_config.aggregator_window_secs, - min_detections, - )); + let interval_secs = engine_config.inference_interval_secs.max(1); + let ticks_per_window = engine_config.aggregator_window_secs / interval_secs; + let default_confirmations = (ticks_per_window / DEFAULT_CONFIRMATION_WINDOW_FRACTION).max(1) as usize; + let aggregator = AttackAggregator::new(engine_config.aggregator_window_secs); let max_flows_per_thread = engine_config.max_flows / (num_threads as usize).max(1); let trackers: Vec = (0..num_threads) - .map(|_| Arc::new(Mutex::new(FlowTracker::new(max_flows_per_thread)))) + .map(|_| Arc::new(FlowTracker::new(max_flows_per_thread))) .collect(); Self { @@ -66,6 +87,7 @@ impl Engine { drift_detector, ml_alert, min_packets: engine_config.min_packets, + default_confirmations, batch_size: engine_config.batch_size, inference_interval_secs: engine_config.inference_interval_secs, traffic_logger, @@ -89,6 +111,74 @@ impl Engine { self.traffic_logger.is_some() } + /// Directory the Flow Trace writer is rotating CSV files into. + /// `None` when Flow Trace recording is disabled — the HTTP file-list + /// and download handlers return a dormant response in that case. + pub fn traffic_logger_directory(&self) -> Option<&std::path::Path> { + self.traffic_logger.as_ref().map(|l| l.directory()) + } + + /// Protocol / port combinations whose traffic is overwhelmingly benign + /// under tight structural constraints. Flows that match bypass ML + /// inference entirely — they account for 40–60% of live traffic on a + /// typical enterprise link and their feature vectors look almost + /// identical, so running the model on them is pure cost. + /// + /// Structural constraints matter: "UDP/53 at any packet count" would + /// miss DNS-tunneling attacks that pump hundreds of packets through + /// the same 5-tuple. The rules here each pair a well-known benign + /// protocol with the boundary beyond which the rule should no longer + /// apply. + fn is_strong_benign(flow_key: &FlowKey, fwd_count: usize, bwd_count: usize, total_bytes: u64) -> bool { + match flow_key.protocol { + // TCP/443 bidirectional — a TLS handshake has completed in both + // directions, so this is almost always encrypted browsing rather + // than a C2 / exfil beacon. + 6 if flow_key.dst_port == 443 => fwd_count > 0 && bwd_count > 0, + // UDP/53 small DNS — a standard lookup fits in ≤4 packets (one + // query, up to three response frames). Larger bursts get ML + // scrutiny in case of DNS tunneling. + 17 if flow_key.dst_port == 53 => fwd_count + bwd_count <= 4, + // UDP/123 NTP — a well-formed time sync is 48 bytes of payload + // plus ≈ 28 bytes of IP/UDP headers (~76 B on the wire). Allow + // up to 90 B average as a buffer; amplification attacks spike + // the average size well past that boundary. + 17 if flow_key.dst_port == 123 => { + let total_pkts = (fwd_count + bwd_count) as u64; + total_pkts > 0 && total_bytes / total_pkts <= 90 + } + _ => false, + } + } + + /// Protocol/port-aware min_packets: some traffic patterns are meaningful + /// at very low packet counts and would be invisible to ML at the global + /// threshold. Paths that fall through to `global` are additionally + /// floored at `ML_MIN_PACKETS_FLOOR` so a misconfigured global setting + /// can't feed near-empty flows into inference. + fn effective_min_packets(flow_key: &FlowKey, global: usize) -> usize { + let floored = global.max(ML_MIN_PACKETS_FLOOR); + match flow_key.protocol { + // ICMP: single-packet SYN scans, ping sweeps. + 1 => 1, + // UDP + 17 => match flow_key.dst_port { + 53 => 1, + 123 => 2, + 3333 | 45700 => 2, + _ => floored, + }, + // TCP + 6 => match flow_key.dst_port { + 53 => 2, + 4444 | 8443 | 8080 | 1337 | 31337 => 2, + 3333 | 45700 => 2, + _ => floored, + }, + _ => floored, + } + } + pub async fn run(self: Arc) -> oneshot::Sender<()> { let (shutdown_tx, shutdown_rx) = oneshot::channel(); tokio::spawn(async move { @@ -106,39 +196,42 @@ impl Engine { _ = ticker.tick() => {} } - // Move CPU-bound ML inference off the tokio executor let engine = Arc::clone(&self); - let _ = tokio::task::spawn_blocking(move || { + let _ = spawn_blocking(move || { engine.run_inference_tick(); }) .await; } } + /// Phased tick: clean up stale flows, gather the uninferred batch + /// (min-packet filtered), optionally write a Flow Trace row, and when + /// the inference source is Active update drift then run inference. + /// Dormant / Error states short-circuit before drift + inference. fn run_inference_tick(&self) { let mut all_flows = Vec::new(); let mut total_count = 0; - let now_us = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) + let now_us = SystemTime::now() + .duration_since(UNIX_EPOCH) .map(|d| d.as_micros() as u64) .unwrap_or(0); - // Phase 0: clean up stale / terminated flows for tracker in &self.trackers { - let mut t = tracker.lock(); - t.cleanup_stale_flows(now_us); + tracker.cleanup_stale_flows(now_us); } - // Phase 1: short lock per tracker — clone uninferred flows, mark as inferred for tracker in &self.trackers { - let mut t = tracker.lock(); - total_count += t.flow_count(); - all_flows.extend( - t.get_uninferred_flows() - .into_iter() - .filter(|flow| flow.packet_count() >= self.min_packets), - ); - // lock released here + total_count += tracker.flow_count(); + all_flows.extend(tracker.get_uninferred_flows().into_iter().filter(|flow| { + let total_packets = flow.packet_count(); + total_packets >= Self::effective_min_packets(&flow.flow_key, self.min_packets) + && !Self::is_strong_benign( + &flow.flow_key, + flow.fwd_packets.len(), + flow.bwd_packets.len(), + flow.fwd_total_bytes + flow.bwd_total_bytes, + ) + })); } log!(MLLog::FlowStats( @@ -154,9 +247,14 @@ impl Engine { if let Some(ref logger) = self.traffic_logger { self.log_traffic(&all_flows, logger); - } else { - self.run_inference(&all_flows); } + + if !self.inference_pipeline.is_active() { + return; + } + + self.update_drift(&all_flows); + self.run_inference(&all_flows); } fn log_traffic(&self, flows: &[FlowData], logger: &TrafficLogger) { @@ -167,32 +265,35 @@ impl Engine { } } + /// Update drift baseline — only called when inference state is Active, + /// guaranteeing the ArcSwap snapshot the next `infer_batch` sees matches + /// the features we just normalized against. + fn update_drift(&self, batch: &[FlowData]) { + let batch = &batch[..batch.len().min(self.batch_size)]; + let config = &self.inference_pipeline.config; + for flow in batch { + let features = FlowFeatures::extract(flow, &config.ae_feature_names); + let normalized: Vec = features + .features + .iter() + .zip(config.ae_scaler_mean.iter().zip(config.ae_scaler_std.iter())) + .map(|(&val, (&mean, &std))| if std.abs() > 1e-12 { (val - mean) / std } else { 0.0 }) + .collect(); + self.drift_detector.update(normalized); + } + } + fn run_inference(&self, flows: &[FlowData]) { let batch = &flows[..flows.len().min(self.batch_size)]; log!(MLLog::RunningInference(batch.len())); - // Feed normalized features into drift detector for each flow in the batch - { - let config = &self.inference_pipeline.config; - let mut dd = self.drift_detector.lock(); - for flow in batch.iter() { - let features = FlowFeatures::extract(flow, &config.ae_feature_names); - let normalized: Vec = features - .features - .iter() - .zip(config.ae_scaler_mean.iter().zip(config.ae_scaler_std.iter())) - .map(|(&val, (&mean, &std))| if std.abs() > 1e-12 { (val - mean) / std } else { 0.0 }) - .collect(); - dd.update(&normalized); - } - } - let start = Instant::now(); let results = self.inference_pipeline.infer_batch(batch); let elapsed_us = start.elapsed().as_micros() as u64; let stats = InferenceStats::from_results(&results, elapsed_us); + self.inference_pipeline.record_tick_qps(stats.flows_per_second); if results.len() != batch.len() { log!(MLLog::InferenceResults(batch.len(), results.len())); @@ -206,32 +307,190 @@ impl Engine { stats.flows_per_second )); - { - let mut aggregator = self.aggregator.lock(); - for result in &results { - if result.is_attack { - let should_alert = aggregator.should_alert( - &result.flow_key_raw, + let config = &self.inference_pipeline.config; + for result in &results { + if result.is_attack { + let required_confirmations = result + .attack_type + .as_deref() + .and_then(|at| self.inference_pipeline.confirmations_for_attack_type(at)) + .unwrap_or(self.default_confirmations); + let should_alert = self.aggregator.should_alert( + &result.flow_key_raw, + result.confidence, + config.class_min_confidence, + required_confirmations, + config.alert_threshold_multiplier, + ); + + if should_alert { + log!(MLLog::ThreatDetected( + format!("{:?}", result.direction), + result.flow_key.clone(), + result.attack_type.clone().unwrap_or_else(|| "UNKNOWN".to_string()), + result.confidence, result.ae_score, - result.threshold, - result.attack_type.as_deref(), - ); + )); - if should_alert { - log!(MLLog::ThreatDetected( - format!("{:?}", result.direction), - result.flow_key.clone(), - result.attack_type.clone().unwrap_or_else(|| "UNKNOWN".to_string()), - result.confidence, - result.ae_score, - )); - - self.ml_alert.broadcast_alert(result); - } + self.ml_alert.broadcast_alert(result); } } - - aggregator.cleanup(); } + + self.aggregator.cleanup(); + } +} + +/// Adapter that exposes one `ThreadTracker` (per AF_XDP queue) as a `PacketSink`. +struct QueueTrackerSink { + tracker: ThreadTracker, +} + +impl PacketSink for QueueTrackerSink { + fn process_packet(&self, packet: UserPacket, is_ingress: bool) { + self.tracker.process_packet(packet, is_ingress); + } +} + +impl PacketSinkFactory for Engine { + fn sink_for_queue(&self, queue_id: u32) -> Option> { + let tracker = self.tracker(queue_id).clone(); + Some(Arc::new(QueueTrackerSink { tracker })) + } +} + +#[cfg(test)] +mod tests { + //! `effective_min_packets` coverage. Broader Engine behavior needs a + //! flow-tracker harness and lives in integration-style tests elsewhere. + + use super::*; + + fn flow_key(protocol: u8, dst_port: u16) -> FlowKey { + FlowKey { + src_ip: [0; 16], + dst_ip: [0; 16], + src_port: 12345, + dst_port, + protocol, + ip_version: 4, + } + } + + #[test] + fn floor_applies_when_global_below_five() { + // Bulk TCP / UDP with no low-packet override must not drop below 5 + // even if the operator sets a permissive global. + assert_eq!( + Engine::effective_min_packets(&flow_key(6, 443), 2), + ML_MIN_PACKETS_FLOOR + ); + assert_eq!( + Engine::effective_min_packets(&flow_key(17, 500), 0), + ML_MIN_PACKETS_FLOOR + ); + // Uncommon protocol (SCTP) also honors the floor. + assert_eq!( + Engine::effective_min_packets(&flow_key(132, 9), 1), + ML_MIN_PACKETS_FLOOR + ); + } + + #[test] + fn floor_respects_higher_global() { + // A stricter global wins — the floor is a lower bound, not a clamp. + assert_eq!(Engine::effective_min_packets(&flow_key(6, 443), 12), 12); + assert_eq!(Engine::effective_min_packets(&flow_key(17, 500), 8), 8); + } + + #[test] + fn icmp_override_bypasses_floor() { + // Single-packet ICMP scans must remain visible regardless of the floor. + assert_eq!(Engine::effective_min_packets(&flow_key(1, 0), 100), 1); + } + + #[test] + fn low_packet_overrides_preserved() { + // Every explicit low-packet override keeps its tuned value. + assert_eq!(Engine::effective_min_packets(&flow_key(17, 53), 100), 1); // UDP DNS + assert_eq!(Engine::effective_min_packets(&flow_key(17, 123), 100), 2); // UDP NTP + assert_eq!(Engine::effective_min_packets(&flow_key(17, 3333), 100), 2); // UDP C2 + assert_eq!(Engine::effective_min_packets(&flow_key(17, 45700), 100), 2); // UDP C2 + assert_eq!(Engine::effective_min_packets(&flow_key(6, 53), 100), 2); // TCP DNS + for port in [4444u16, 8443, 8080, 1337, 31337] { + assert_eq!(Engine::effective_min_packets(&flow_key(6, port), 100), 2); + } + assert_eq!(Engine::effective_min_packets(&flow_key(6, 3333), 100), 2); // TCP C2 + } + + #[test] + fn exact_floor_value_passes_through() { + // At the floor boundary, no bump applied. + assert_eq!(Engine::effective_min_packets(&flow_key(6, 443), 5), 5); + } + + #[test] + fn tls_bidirectional_flow_is_strong_benign() { + // TCP/443 with traffic in both directions = completed TLS handshake. + assert!(Engine::is_strong_benign(&flow_key(6, 443), 3, 2, 4096)); + } + + #[test] + fn tls_unidirectional_flow_is_not_benign() { + // Only outbound packets seen — handshake not completed. Could be + // a SYN scan; keep it in the inference path. + assert!(!Engine::is_strong_benign(&flow_key(6, 443), 5, 0, 200)); + assert!(!Engine::is_strong_benign(&flow_key(6, 443), 0, 5, 200)); + } + + #[test] + fn tls_on_non_443_port_is_not_benign() { + // TCP to 8443 is common for stealth C2 / alternate HTTPS; don't + // whitelist without a port match. + assert!(!Engine::is_strong_benign(&flow_key(6, 8443), 3, 2, 4096)); + } + + #[test] + fn dns_small_query_is_strong_benign() { + // Standard DNS: 1 query + up to 3 response packets. + assert!(Engine::is_strong_benign(&flow_key(17, 53), 1, 1, 160)); + assert!(Engine::is_strong_benign(&flow_key(17, 53), 2, 2, 320)); + } + + #[test] + fn dns_large_burst_is_not_benign() { + // 5 packets and above — possible DNS tunneling. + assert!(!Engine::is_strong_benign(&flow_key(17, 53), 3, 2, 400)); + assert!(!Engine::is_strong_benign(&flow_key(17, 53), 50, 50, 10_000)); + } + + #[test] + fn ntp_standard_average_is_strong_benign() { + // Well-formed NTP request + response, each ~76 B on wire. + // 2 packets × ~80 B = 160 B total, avg 80 B. + assert!(Engine::is_strong_benign(&flow_key(17, 123), 1, 1, 160)); + } + + #[test] + fn ntp_amplification_is_not_benign() { + // NTP monlist amplification: 1 query packet + many large responses. + // 1 + 100 packets, 50 000 bytes → avg ~495 B, well above 90 B floor. + assert!(!Engine::is_strong_benign(&flow_key(17, 123), 1, 100, 50_000)); + } + + #[test] + fn empty_flow_does_not_divide_by_zero() { + // Defensive: a zero-packet NTP flow should simply not match the + // benign rule rather than panic. + assert!(!Engine::is_strong_benign(&flow_key(17, 123), 0, 0, 0)); + } + + #[test] + fn other_protocols_are_not_strong_benign() { + // ICMP, SCTP, and unlisted UDP / TCP ports all fall through to ML. + assert!(!Engine::is_strong_benign(&flow_key(1, 0), 10, 10, 1024)); + assert!(!Engine::is_strong_benign(&flow_key(132, 9), 10, 10, 1024)); + assert!(!Engine::is_strong_benign(&flow_key(17, 500), 10, 10, 1024)); + assert!(!Engine::is_strong_benign(&flow_key(6, 22), 10, 10, 1024)); } } diff --git a/net-guardia/src/core/ml/feature_extractor.rs b/net-guardia/src/core/ml/feature_extractor.rs index 5063a4d..c334ffe 100644 --- a/net-guardia/src/core/ml/feature_extractor.rs +++ b/net-guardia/src/core/ml/feature_extractor.rs @@ -1,9 +1,34 @@ +use std::collections::HashMap; +use std::sync::LazyLock; + use common::define::tcp_flags::*; use super::flow_tracker::FlowData; -use crate::model::ml_detection::PacketData; - use crate::model::detection::flow_features::FlowFeatures; +use crate::model::detection::ml_detection::PacketData; + +/// Signature of a feature getter — takes precomputed flow statistics and returns +/// a single f64 feature value. Must be pure (no I/O, no allocation). +/// Kept module-private because `PrecomputedStats` is an implementation detail. +type FeatureGetter = fn(&PrecomputedStats) -> f64; + +/// Returns true if `name` (canonical or alias) is present in FEATURE_REGISTRY. +/// Used by ModelManifest validation at load time. +pub fn feature_is_known(name: &str) -> bool { + FEATURE_REGISTRY.contains_key(name) +} + +/// Every name (canonical or alias) the system accepts inside a +/// `manifest.features` list, sorted alphabetically so the BYO +/// Quickstart endpoint returns a deterministic ordering. Callers +/// treat this as an opaque string list; aliases for the same +/// underlying feature appear next to each other after sort only by +/// coincidence, not as a structural guarantee. +pub fn feature_registry_names() -> Vec<&'static str> { + let mut names: Vec<&'static str> = FEATURE_REGISTRY.keys().copied().collect(); + names.sort_unstable(); + names +} impl FlowFeatures { pub fn extract(flow: &FlowData, feature_names: &[String]) -> Self { @@ -120,7 +145,7 @@ struct PrecomputedStats { idle_mean: f64, idle_std: f64, - // Phase 2: new features for C2/Cryptomining detection + // Phase 2: new features for C2/Bot detection fwd_bwd_bytes_ratio: f64, fwd_iat_skewness: f64, } @@ -207,7 +232,7 @@ impl PrecomputedStats { let (idle_max, idle_min, idle_mean, idle_std) = compute_stats(&flow.idle_periods.iter().map(|&x| x as f64).collect::>()); - // Phase 2: new features for C2/Cryptomining detection + // Phase 2: new features for C2/Bot detection let fwd_bwd_bytes_ratio = safe_div(fwd_total_bytes, fwd_total_bytes + bwd_total_bytes); let fwd_iat_skewness = compute_bowley_skewness(&fwd_iats); @@ -286,104 +311,206 @@ impl PrecomputedStats { } fn get(&self, feature_name: &str) -> f64 { - let safe_div = |a: f64, b: f64| if b > 0.0 { a / b } else { 0.0 }; - - match feature_name { - "Destination Port" | "Dst Port" | "dst_port" => self.dst_port, - "Protocol" | "protocol" => self.protocol, - "Flow Duration" | "flow_duration" => self.duration_us, - "Total Fwd Packets" | "Tot Fwd Pkts" | "fwd_packets" => self.fwd_count, - "Total Backward Packets" | "Tot Bwd Pkts" | "bwd_packets" => self.bwd_count, - "Total Length of Fwd Packets" | "TotLen Fwd Pkts" | "fwd_bytes" => self.fwd_total_bytes, - "Total Length of Bwd Packets" | "TotLen Bwd Pkts" | "bwd_bytes" => self.bwd_total_bytes, - "Fwd Packet Length Max" => self.fwd_len_max, - "Fwd Packet Length Min" => self.fwd_len_min, - "Fwd Packet Length Mean" | "Fwd Pkt Len Mean" | "fwd_pkt_len_mean" => self.fwd_len_mean, - "Fwd Packet Length Std" | "Fwd Pkt Len Std" | "fwd_pkt_len_std" => self.fwd_len_std, - "Bwd Packet Length Max" => self.bwd_len_max, - "Bwd Packet Length Min" => self.bwd_len_min, - "Bwd Packet Length Mean" | "Bwd Pkt Len Mean" | "bwd_pkt_len_mean" => self.bwd_len_mean, - "Bwd Packet Length Std" | "Bwd Pkt Len Std" | "bwd_pkt_len_std" => self.bwd_len_std, - "Flow Bytes/s" | "Flow Byts/s" | "flow_bytes_per_sec" => safe_div(self.total_bytes, self.duration_s), - "Flow Packets/s" | "Flow Pkts/s" | "flow_pkts_per_sec" => safe_div(self.total_count, self.duration_s), - "Flow IAT Mean" | "flow_iat_mean" => self.flow_iat_mean, - "Flow IAT Std" => self.flow_iat_std, - "Flow IAT Max" => self.flow_iat_max, - "Flow IAT Min" => self.flow_iat_min, - "Fwd IAT Total" => self.fwd_iat_total, - "Fwd IAT Mean" | "fwd_iat_mean" => self.fwd_iat_mean, - "Fwd IAT Std" => self.fwd_iat_std, - "Fwd IAT Max" => self.fwd_iat_max, - "Fwd IAT Min" => self.fwd_iat_min, - "Bwd IAT Total" => self.bwd_iat_total, - "Bwd IAT Mean" | "bwd_iat_mean" => self.bwd_iat_mean, - "Bwd IAT Std" => self.bwd_iat_std, - "Bwd IAT Max" => self.bwd_iat_max, - "Bwd IAT Min" => self.bwd_iat_min, - "Fwd PSH Flags" => self.fwd_psh, - "Bwd PSH Flags" => self.bwd_psh, - "Fwd URG Flags" => self.fwd_urg, - "Bwd URG Flags" => self.bwd_urg, - "Fwd Header Length" => self.fwd_header_bytes, - "Bwd Header Length" => self.bwd_header_bytes, - "Fwd Packets/s" => safe_div(self.fwd_count, self.duration_s), - "Bwd Packets/s" => safe_div(self.bwd_count, self.duration_s), - "Min Packet Length" => self.all_len_min, - "Max Packet Length" => self.all_len_max, - "Packet Length Mean" | "Pkt Len Mean" | "pkt_len_mean" => self.all_len_mean, - "Packet Length Std" | "Pkt Len Std" | "pkt_len_std" => self.all_len_std, - "Packet Length Variance" => self.all_len_std * self.all_len_std, - "FIN Flag Count" | "FIN Flag Cnt" | "fin_flag_cnt" => self.fin_count, - "SYN Flag Count" | "SYN Flag Cnt" | "syn_flag_cnt" => self.syn_count, - "RST Flag Count" | "RST Flag Cnt" | "rst_flag_cnt" => self.rst_count, - "PSH Flag Count" | "PSH Flag Cnt" | "psh_flag_cnt" => self.psh_count, - "ACK Flag Count" | "ACK Flag Cnt" | "ack_flag_cnt" => self.ack_count, - "URG Flag Count" => self.urg_count, - "CWE Flag Count" => self.cwe_count, - "ECE Flag Count" => self.ece_count, - "Down/Up Ratio" => safe_div(self.bwd_count, self.fwd_count), - "Average Packet Size" => safe_div(self.total_bytes, self.total_count), - "Avg Fwd Segment Size" => safe_div(self.fwd_total_bytes, self.fwd_count), - "Avg Bwd Segment Size" => safe_div(self.bwd_total_bytes, self.bwd_count), - "Fwd Header Length.1" => self.fwd_header_bytes, - "Fwd Avg Bytes/Bulk" => self.fwd_avg_bytes_bulk, - "Fwd Avg Packets/Bulk" => self.fwd_avg_packets_bulk, - "Fwd Avg Bulk Rate" => self.fwd_avg_bulk_rate, - "Bwd Avg Bytes/Bulk" => self.bwd_avg_bytes_bulk, - "Bwd Avg Packets/Bulk" => self.bwd_avg_packets_bulk, - "Bwd Avg Bulk Rate" => self.bwd_avg_bulk_rate, - "Subflow Fwd Packets" => self.fwd_count, - "Subflow Fwd Bytes" => self.fwd_total_bytes, - "Subflow Bwd Packets" => self.bwd_count, - "Subflow Bwd Bytes" => self.bwd_total_bytes, - "Init_Win_bytes_forward" | "Init Fwd Win Byts" | "fwd_win_bytes" => self.init_win_bytes_fwd, - "Init_Win_bytes_backward" | "Init Bwd Win Byts" | "bwd_win_bytes" => self.init_win_bytes_bwd, - "act_data_pkt_fwd" | "Fwd Act Data Pkts" | "fwd_act_data_pkts" => self.act_data_pkt_fwd, - "min_seg_size_forward" | "Fwd Seg Size Min" | "fwd_seg_size_min" => self.min_seg_size_forward, - "Active Mean" => self.active_mean, - "Active Std" => self.active_std, - "Active Max" => self.active_max, - "Active Min" => self.active_min, - "Idle Mean" => self.idle_mean, - "Idle Std" => self.idle_std, - "Idle Max" => self.idle_max, - "Idle Min" => self.idle_min, - - // Phase 2: unified names for IAT std (already computed, add aliases) - "fwd_iat_std" => self.fwd_iat_std, - "bwd_iat_std" => self.bwd_iat_std, - "flow_iat_std" => self.flow_iat_std, - - // Phase 2: new features for C2/Cryptomining detection - "fwd_bwd_bytes_ratio" => self.fwd_bwd_bytes_ratio, - "pkt_len_variance" => self.all_len_std * self.all_len_std, - "fwd_iat_skewness" => self.fwd_iat_skewness, - - _ => 0.0, - } + FEATURE_REGISTRY.get(feature_name).map(|g| g(self)).unwrap_or(0.0) } } +fn reg_safe_div(a: f64, b: f64) -> f64 { + if b > 0.0 { a / b } else { 0.0 } +} + +fn reg_insert(m: &mut HashMap<&'static str, FeatureGetter>, names: &[&'static str], g: FeatureGetter) { + for n in names { + m.insert(*n, g); + } +} + +/// Central name → getter table. Every name the system recognizes for a feature +/// lives here. Manifest validation refuses any name not present in this map. +/// Aliases (long-form CICFlowMeter names, short snake_case) map to the same getter. +static FEATURE_REGISTRY: LazyLock> = LazyLock::new(|| { + let mut m: HashMap<&'static str, FeatureGetter> = HashMap::new(); + + reg_insert(&mut m, &["Destination Port", "Dst Port", "dst_port"], |s| s.dst_port); + reg_insert(&mut m, &["Protocol", "protocol"], |s| s.protocol); + reg_insert(&mut m, &["Flow Duration", "flow_duration"], |s| s.duration_us); + + reg_insert( + &mut m, + &[ + "Total Fwd Packets", + "Tot Fwd Pkts", + "fwd_packets", + "Subflow Fwd Packets", + ], + |s| s.fwd_count, + ); + reg_insert( + &mut m, + &[ + "Total Backward Packets", + "Tot Bwd Pkts", + "bwd_packets", + "Subflow Bwd Packets", + ], + |s| s.bwd_count, + ); + reg_insert( + &mut m, + &[ + "Total Length of Fwd Packets", + "TotLen Fwd Pkts", + "fwd_bytes", + "Subflow Fwd Bytes", + ], + |s| s.fwd_total_bytes, + ); + reg_insert( + &mut m, + &[ + "Total Length of Bwd Packets", + "TotLen Bwd Pkts", + "bwd_bytes", + "Subflow Bwd Bytes", + ], + |s| s.bwd_total_bytes, + ); + + reg_insert(&mut m, &["Fwd Packet Length Max"], |s| s.fwd_len_max); + reg_insert(&mut m, &["Fwd Packet Length Min"], |s| s.fwd_len_min); + reg_insert( + &mut m, + &["Fwd Packet Length Mean", "Fwd Pkt Len Mean", "fwd_pkt_len_mean"], + |s| s.fwd_len_mean, + ); + reg_insert( + &mut m, + &["Fwd Packet Length Std", "Fwd Pkt Len Std", "fwd_pkt_len_std"], + |s| s.fwd_len_std, + ); + + reg_insert(&mut m, &["Bwd Packet Length Max"], |s| s.bwd_len_max); + reg_insert(&mut m, &["Bwd Packet Length Min"], |s| s.bwd_len_min); + reg_insert( + &mut m, + &["Bwd Packet Length Mean", "Bwd Pkt Len Mean", "bwd_pkt_len_mean"], + |s| s.bwd_len_mean, + ); + reg_insert( + &mut m, + &["Bwd Packet Length Std", "Bwd Pkt Len Std", "bwd_pkt_len_std"], + |s| s.bwd_len_std, + ); + + reg_insert(&mut m, &["Flow Bytes/s", "Flow Byts/s", "flow_bytes_per_sec"], |s| { + reg_safe_div(s.total_bytes, s.duration_s) + }); + reg_insert(&mut m, &["Flow Packets/s", "Flow Pkts/s", "flow_pkts_per_sec"], |s| { + reg_safe_div(s.total_count, s.duration_s) + }); + + reg_insert(&mut m, &["Flow IAT Mean", "flow_iat_mean"], |s| s.flow_iat_mean); + reg_insert(&mut m, &["Flow IAT Std", "flow_iat_std"], |s| s.flow_iat_std); + reg_insert(&mut m, &["Flow IAT Max"], |s| s.flow_iat_max); + reg_insert(&mut m, &["Flow IAT Min"], |s| s.flow_iat_min); + + reg_insert(&mut m, &["Fwd IAT Total"], |s| s.fwd_iat_total); + reg_insert(&mut m, &["Fwd IAT Mean", "fwd_iat_mean"], |s| s.fwd_iat_mean); + reg_insert(&mut m, &["Fwd IAT Std", "fwd_iat_std"], |s| s.fwd_iat_std); + reg_insert(&mut m, &["Fwd IAT Max"], |s| s.fwd_iat_max); + reg_insert(&mut m, &["Fwd IAT Min"], |s| s.fwd_iat_min); + + reg_insert(&mut m, &["Bwd IAT Total"], |s| s.bwd_iat_total); + reg_insert(&mut m, &["Bwd IAT Mean", "bwd_iat_mean"], |s| s.bwd_iat_mean); + reg_insert(&mut m, &["Bwd IAT Std", "bwd_iat_std"], |s| s.bwd_iat_std); + reg_insert(&mut m, &["Bwd IAT Max"], |s| s.bwd_iat_max); + reg_insert(&mut m, &["Bwd IAT Min"], |s| s.bwd_iat_min); + + reg_insert(&mut m, &["Fwd PSH Flags"], |s| s.fwd_psh); + reg_insert(&mut m, &["Bwd PSH Flags"], |s| s.bwd_psh); + reg_insert(&mut m, &["Fwd URG Flags"], |s| s.fwd_urg); + reg_insert(&mut m, &["Bwd URG Flags"], |s| s.bwd_urg); + + // "Fwd Header Length" and "Fwd Header Length.1" are legacy CICFlowMeter aliases. + reg_insert(&mut m, &["Fwd Header Length", "Fwd Header Length.1"], |s| { + s.fwd_header_bytes + }); + reg_insert(&mut m, &["Bwd Header Length"], |s| s.bwd_header_bytes); + + reg_insert(&mut m, &["Fwd Packets/s"], |s| reg_safe_div(s.fwd_count, s.duration_s)); + reg_insert(&mut m, &["Bwd Packets/s"], |s| reg_safe_div(s.bwd_count, s.duration_s)); + + reg_insert(&mut m, &["Min Packet Length"], |s| s.all_len_min); + reg_insert(&mut m, &["Max Packet Length"], |s| s.all_len_max); + reg_insert(&mut m, &["Packet Length Mean", "Pkt Len Mean", "pkt_len_mean"], |s| { + s.all_len_mean + }); + reg_insert(&mut m, &["Packet Length Std", "Pkt Len Std", "pkt_len_std"], |s| { + s.all_len_std + }); + reg_insert(&mut m, &["Packet Length Variance", "pkt_len_variance"], |s| { + s.all_len_std * s.all_len_std + }); + + reg_insert(&mut m, &["FIN Flag Count", "FIN Flag Cnt", "fin_flag_cnt"], |s| { + s.fin_count + }); + reg_insert(&mut m, &["SYN Flag Count", "SYN Flag Cnt", "syn_flag_cnt"], |s| { + s.syn_count + }); + reg_insert(&mut m, &["RST Flag Count", "RST Flag Cnt", "rst_flag_cnt"], |s| { + s.rst_count + }); + reg_insert(&mut m, &["PSH Flag Count", "PSH Flag Cnt", "psh_flag_cnt"], |s| { + s.psh_count + }); + reg_insert(&mut m, &["ACK Flag Count", "ACK Flag Cnt", "ack_flag_cnt"], |s| { + s.ack_count + }); + reg_insert(&mut m, &["URG Flag Count"], |s| s.urg_count); + reg_insert(&mut m, &["CWE Flag Count"], |s| s.cwe_count); + reg_insert(&mut m, &["ECE Flag Count"], |s| s.ece_count); + + reg_insert(&mut m, &["Down/Up Ratio"], |s| reg_safe_div(s.bwd_count, s.fwd_count)); + reg_insert(&mut m, &["Average Packet Size"], |s| { + reg_safe_div(s.total_bytes, s.total_count) + }); + reg_insert(&mut m, &["Avg Fwd Segment Size"], |s| { + reg_safe_div(s.fwd_total_bytes, s.fwd_count) + }); + reg_insert(&mut m, &["Avg Bwd Segment Size"], |s| { + reg_safe_div(s.bwd_total_bytes, s.bwd_count) + }); + + reg_insert(&mut m, &["Fwd Avg Bytes/Bulk"], |s| s.fwd_avg_bytes_bulk); + reg_insert(&mut m, &["Fwd Avg Packets/Bulk"], |s| s.fwd_avg_packets_bulk); + reg_insert(&mut m, &["Fwd Avg Bulk Rate"], |s| s.fwd_avg_bulk_rate); + reg_insert(&mut m, &["Bwd Avg Bytes/Bulk"], |s| s.bwd_avg_bytes_bulk); + reg_insert(&mut m, &["Bwd Avg Packets/Bulk"], |s| s.bwd_avg_packets_bulk); + reg_insert(&mut m, &["Bwd Avg Bulk Rate"], |s| s.bwd_avg_bulk_rate); + + reg_insert(&mut m, &["fwd_win_bytes"], |s| s.init_win_bytes_fwd); + reg_insert(&mut m, &["bwd_win_bytes"], |s| s.init_win_bytes_bwd); + reg_insert(&mut m, &["fwd_act_data_pkts"], |s| s.act_data_pkt_fwd); + reg_insert(&mut m, &["fwd_seg_size_min"], |s| s.min_seg_size_forward); + + reg_insert(&mut m, &["Active Mean"], |s| s.active_mean); + reg_insert(&mut m, &["Active Std"], |s| s.active_std); + reg_insert(&mut m, &["Active Max"], |s| s.active_max); + reg_insert(&mut m, &["Active Min"], |s| s.active_min); + reg_insert(&mut m, &["Idle Mean"], |s| s.idle_mean); + reg_insert(&mut m, &["Idle Std"], |s| s.idle_std); + reg_insert(&mut m, &["Idle Max"], |s| s.idle_max); + reg_insert(&mut m, &["Idle Min"], |s| s.idle_min); + + // Phase 2: C2/Bot-oriented features + reg_insert(&mut m, &["fwd_bwd_bytes_ratio"], |s| s.fwd_bwd_bytes_ratio); + reg_insert(&mut m, &["fwd_iat_skewness"], |s| s.fwd_iat_skewness); + reg_insert(&mut m, &["iat_cv"], |s| reg_safe_div(s.flow_iat_std, s.flow_iat_mean)); + + m +}); + fn compute_stats(values: &[f64]) -> (f64, f64, f64, f64) { if values.is_empty() { return (0.0, 0.0, 0.0, 0.0); @@ -466,6 +593,210 @@ mod tests { assert_eq!(compute_bowley_skewness(&[1.0, 1.0, 1.0, 1.0, 1.0, 1.0]), 0.0); } + /// Hand-crafted PrecomputedStats with distinctive sentinel values per field. + /// Lets us verify registry getter dispatch without constructing a real FlowData. + fn sample_stats() -> PrecomputedStats { + PrecomputedStats { + dst_port: 443.0, + protocol: 6.0, + duration_us: 1_000_000.0, + fwd_count: 10.0, + bwd_count: 4.0, + total_count: 14.0, + fwd_total_bytes: 2000.0, + bwd_total_bytes: 800.0, + total_bytes: 2800.0, + duration_s: 1.0, + fwd_len_max: 1500.0, + fwd_len_min: 40.0, + fwd_len_mean: 200.0, + fwd_len_std: 300.0, + bwd_len_max: 1200.0, + bwd_len_min: 60.0, + bwd_len_mean: 200.0, + bwd_len_std: 250.0, + all_len_max: 1500.0, + all_len_min: 40.0, + all_len_mean: 200.0, + all_len_std: 280.0, + flow_iat_max: 50_000.0, + flow_iat_min: 100.0, + flow_iat_mean: 10_000.0, + flow_iat_std: 5_000.0, + fwd_iat_total: 90_000.0, + fwd_iat_max: 40_000.0, + fwd_iat_min: 200.0, + fwd_iat_mean: 10_000.0, + fwd_iat_std: 6_000.0, + bwd_iat_total: 30_000.0, + bwd_iat_max: 15_000.0, + bwd_iat_min: 300.0, + bwd_iat_mean: 7_500.0, + bwd_iat_std: 4_000.0, + fwd_psh: 2.0, + bwd_psh: 1.0, + fwd_urg: 0.0, + bwd_urg: 0.0, + fwd_header_bytes: 200.0, + bwd_header_bytes: 80.0, + fin_count: 1.0, + syn_count: 1.0, + rst_count: 0.0, + psh_count: 3.0, + ack_count: 10.0, + urg_count: 0.0, + cwe_count: 0.0, + ece_count: 0.0, + fwd_avg_bytes_bulk: 500.0, + fwd_avg_packets_bulk: 5.0, + fwd_avg_bulk_rate: 5000.0, + bwd_avg_bytes_bulk: 400.0, + bwd_avg_packets_bulk: 4.0, + bwd_avg_bulk_rate: 4000.0, + init_win_bytes_fwd: 65535.0, + init_win_bytes_bwd: 65000.0, + act_data_pkt_fwd: 8.0, + min_seg_size_forward: 40.0, + active_max: 1000.0, + active_min: 50.0, + active_mean: 300.0, + active_std: 200.0, + idle_max: 500.0, + idle_min: 10.0, + idle_mean: 100.0, + idle_std: 80.0, + fwd_bwd_bytes_ratio: 0.71, + fwd_iat_skewness: 0.15, + } + } + + #[test] + fn registry_unknown_name_returns_zero() { + let s = sample_stats(); + assert_eq!(s.get("not_a_feature"), 0.0); + } + + #[test] + fn registry_aliases_resolve_identically() { + // Long-form, short-form, and "Subflow" aliases must all map to the same getter. + let s = sample_stats(); + for group in [ + [ + "Total Fwd Packets", + "Tot Fwd Pkts", + "fwd_packets", + "Subflow Fwd Packets", + ], + [ + "Total Length of Fwd Packets", + "TotLen Fwd Pkts", + "fwd_bytes", + "Subflow Fwd Bytes", + ], + ["Flow IAT Std", "flow_iat_std", "Flow IAT Std", "Flow IAT Std"], // pad to 4 + ["Fwd IAT Std", "fwd_iat_std", "Fwd IAT Std", "Fwd IAT Std"], + ["Bwd IAT Std", "bwd_iat_std", "Bwd IAT Std", "Bwd IAT Std"], + [ + "Packet Length Variance", + "pkt_len_variance", + "Packet Length Variance", + "Packet Length Variance", + ], + [ + "Fwd Header Length", + "Fwd Header Length.1", + "Fwd Header Length", + "Fwd Header Length", + ], + ] { + let expected = s.get(group[0]); + for name in &group[1..] { + assert_eq!( + s.get(name), + expected, + "alias '{name}' should resolve to same value as '{}'", + group[0] + ); + } + } + } + + #[test] + fn registry_safe_div_returns_zero_on_zero_denominator() { + let mut s = sample_stats(); + s.flow_iat_mean = 0.0; + s.flow_iat_std = 500.0; + // iat_cv = std / mean, but mean=0 → safe_div → 0.0 + assert_eq!(s.get("iat_cv"), 0.0); + s.duration_s = 0.0; + assert_eq!(s.get("flow_bytes_per_sec"), 0.0); + assert_eq!(s.get("flow_pkts_per_sec"), 0.0); + } + + #[test] + fn registry_covers_v10_manifest_features() { + // Every feature the shipped v10 manifest references must be registered. + // A missing name here means the match → registry refactor dropped a binding. + const V10_FEATURES: &[&str] = &[ + "flow_duration", + "fwd_packets", + "bwd_packets", + "fwd_bytes", + "bwd_bytes", + "flow_bytes_per_sec", + "flow_pkts_per_sec", + "fwd_win_bytes", + "bwd_win_bytes", + "fwd_pkt_len_mean", + "bwd_pkt_len_mean", + "fwd_iat_mean", + "bwd_iat_mean", + "flow_iat_mean", + "pkt_len_mean", + "dst_port", + "protocol", + "psh_flag_cnt", + "ack_flag_cnt", + "syn_flag_cnt", + "fin_flag_cnt", + "rst_flag_cnt", + "pkt_len_std", + "fwd_pkt_len_std", + "bwd_pkt_len_std", + "fwd_seg_size_min", + "fwd_act_data_pkts", + "fwd_iat_std", + "bwd_iat_std", + "fwd_bwd_bytes_ratio", + "iat_cv", + ]; + for f in V10_FEATURES { + assert!(feature_is_known(f), "v10 feature '{f}' missing from FEATURE_REGISTRY"); + } + } + + #[test] + fn feature_registry_names_returns_sorted_unique_list() { + let names = feature_registry_names(); + assert!( + names.len() > 30, + "FEATURE_REGISTRY should carry at least the v10 feature set plus aliases" + ); + let mut sorted = names.clone(); + sorted.sort_unstable(); + assert_eq!(names, sorted, "feature_registry_names must be sorted"); + let mut dedup = names.clone(); + dedup.dedup(); + assert_eq!( + names.len(), + dedup.len(), + "feature_registry_names must have no duplicates" + ); + // Spot-check a canonical + alias pair both surface. + assert!(names.contains(&"Flow Duration")); + assert!(names.contains(&"flow_duration")); + } + #[test] fn bowley_skewness_known_output() { // Symmetric distribution: [1, 2, 3, 4, 5, 6, 7, 8] (n=8) diff --git a/net-guardia/src/core/ml/flow_tracker.rs b/net-guardia/src/core/ml/flow_tracker.rs index df3b009..1aa2aa8 100644 --- a/net-guardia/src/core/ml/flow_tracker.rs +++ b/net-guardia/src/core/ml/flow_tracker.rs @@ -1,15 +1,16 @@ -use std::num::NonZero; +use std::sync::Arc; use common::define::tcp_flags::*; -use lru::LruCache; +use moka::sync::Cache; +use parking_lot::Mutex; use crate::model::config::constants::{ FLOW_BULK_MIN_BYTES, FLOW_BULK_MIN_PACKETS, FLOW_IDLE_THRESHOLD_US, FLOW_IDLE_TIMEOUT_US, FLOW_MAX_PACKETS_PER_DIRECTION, FLOW_MAX_PERIODS, FLOW_TERMINATED_TIMEOUT_US, }; -use crate::model::direction::Direction; -use crate::model::ml_detection::{BulkState, FlowKey, PacketData}; -use crate::model::user_packet::UserPacket; +use crate::model::detection::ml_detection::{BulkState, FlowKey, PacketData}; +use crate::model::monitoring::direction::Direction; +use crate::model::monitoring::user_packet::UserPacket; #[derive(Debug, Clone)] pub struct FlowData { @@ -144,7 +145,7 @@ impl FlowData { if self.fwd_packets.len() < FLOW_MAX_PACKETS_PER_DIRECTION { self.fwd_packets.push(packet_data.clone()); } - self.fwd_total_bytes += packet.payload_length as u64; + self.fwd_total_bytes += packet.packet_length as u64; self.fwd_header_bytes += packet.header_length as u64; if self.init_win_bytes_fwd == 0 { self.init_win_bytes_fwd = packet.tcp_window_size; @@ -154,7 +155,7 @@ impl FlowData { if self.bwd_packets.len() < FLOW_MAX_PACKETS_PER_DIRECTION { self.bwd_packets.push(packet_data.clone()); } - self.bwd_total_bytes += packet.payload_length as u64; + self.bwd_total_bytes += packet.packet_length as u64; self.bwd_header_bytes += packet.header_length as u64; if self.init_win_bytes_bwd == 0 { self.init_win_bytes_bwd = packet.tcp_window_size; @@ -205,31 +206,41 @@ impl FlowData { } } -/// Per-thread flow tracker. No locks — each XSK thread owns one. -/// RSS guarantees the same flow always goes to the same thread. -/// Uses LruCache for O(1) eviction instead of O(n) min_by_key scan. +/// Per-flow handle: an `Arc` so map operations stay copy-cheap, with an inner +/// `Mutex` because `add_packet` is a read-modify-write that needs exclusive +/// access. Same-flow packets land on the same XSK queue (symmetric eBPF +/// hash), so this mutex is effectively single-writer; the inference tick +/// briefly contends only when it clones the entry for a snapshot. +type FlowEntry = Arc>; + +/// Per-queue flow tracker backed by a sharded W-TinyLFU cache (`moka`). +/// +/// The hot path (`process_packet`) acquires only the per-shard moka lock +/// and the per-flow entry mutex — never a global tracker lock — so the +/// inference loop's snapshot pass (`get_uninferred_flows`, +/// `cleanup_stale_flows`) can run in parallel without stalling AF_XDP rx. +/// W-TinyLFU's frequency sketch keeps high-rate attack flows resident +/// even when burst noise floods the cache, which a strict-LRU eviction +/// policy would mishandle. pub struct FlowTracker { - active: LruCache, + active: Cache, } impl FlowTracker { pub fn new(max_flows: usize) -> Self { - // SAFETY: max(1, max_flows) ensures NonZero is never zero. - let cap = NonZero::new(max_flows.max(1)).unwrap_or_else(|| unreachable!()); + let cap = max_flows.max(1) as u64; Self { - active: LruCache::new(cap), + active: Cache::builder().max_capacity(cap).build(), } } - pub fn process_packet(&mut self, mut packet: UserPacket, is_ingress: bool) { + pub fn process_packet(&self, mut packet: UserPacket, is_ingress: bool) { let packet_key = FlowKey::from_packet(&packet); let reversed_key = packet_key.reverse(); - // Try to match an existing flow first (canonical key already established). - // Use peek() to avoid promoting — we'll promote via get_mut() below. - let (actual_key, is_forward) = if self.active.peek(&packet_key).is_some() { + let (actual_key, is_forward) = if self.active.contains_key(&packet_key) { (packet_key, true) - } else if self.active.peek(&reversed_key).is_some() { + } else if self.active.contains_key(&reversed_key) { (reversed_key, false) } else { // New flow: determine initiator using TCP flags, fall back to is_ingress. @@ -261,7 +272,6 @@ impl FlowTracker { packet.is_forward = is_forward; - // Record which interface the initiator is on for this flow. let initiator_direction = if is_forward { if is_ingress { Direction::Ingress @@ -274,62 +284,59 @@ impl FlowTracker { Direction::Ingress }; - // LruCache::push handles eviction automatically when capacity is exceeded (O(1)). - // If the flow already exists, get_mut promotes it to MRU; otherwise push creates it. - if let Some(flow) = self.active.get_mut(&actual_key) { - flow.add_packet(&packet); - } else { - let mut flow = FlowData::new(actual_key.clone(), &packet, initiator_direction); - flow.add_packet(&packet); - self.active.push(actual_key, flow); - } + let key_for_init = actual_key.clone(); + let entry = self.active.get_with(actual_key, || { + Arc::new(Mutex::new(FlowData::new(key_for_init, &packet, initiator_direction))) + }); + entry.lock().add_packet(&packet); } /// Get all active flows (clone, no drain). Used by WebSocket. pub fn get_flows(&self) -> Vec { - self.active.iter().map(|(_, flow)| flow.clone()).collect() + self.active.iter().map(|(_, entry)| entry.lock().clone()).collect() } - /// Get flows that received new packets since their last inference, - /// and mark them as inferred. Used by ML engine. - pub fn get_uninferred_flows(&mut self) -> Vec { + /// Get flows that received new packets since their last inference, and + /// mark them as inferred. Used by ML engine. + pub fn get_uninferred_flows(&self) -> Vec { let mut result = Vec::new(); - // iter_mut does NOT promote entries (preserves LRU order) - for (_, flow) in self.active.iter_mut() { + for (_, entry) in self.active.iter() { + let mut flow = entry.lock(); if flow.last_time_us > flow.last_inferred_us { - result.push(flow.clone()); + let snapshot = flow.clone(); flow.last_inferred_us = flow.last_time_us; + result.push(snapshot); } } result } pub fn flow_count(&self) -> usize { - self.active.len() + self.active.entry_count() as usize } /// Remove flows that have been idle too long or are terminated (FIN/RST seen). /// `now_us`: current timestamp in microseconds (same scale as packet timestamps). /// Returns the number of flows removed. - pub fn cleanup_stale_flows(&mut self, now_us: u64) -> usize { - // LruCache doesn't have retain(), so collect keys to remove then pop them. - let keys_to_remove: Vec = self - .active - .iter() - .filter(|(_, flow)| { - let idle = now_us.saturating_sub(flow.last_time_us); - let is_terminated = flow.fin_count > 0 || flow.rst_count > 0; - if is_terminated { - idle >= FLOW_TERMINATED_TIMEOUT_US - } else { - idle >= FLOW_IDLE_TIMEOUT_US - } - }) - .map(|(k, _)| k.clone()) - .collect(); - let removed = keys_to_remove.len(); + pub fn cleanup_stale_flows(&self, now_us: u64) -> usize { + let mut keys_to_remove = Vec::new(); + for (key, entry) in self.active.iter() { + let flow = entry.lock(); + let idle = now_us.saturating_sub(flow.last_time_us); + let is_terminated = flow.fin_count > 0 || flow.rst_count > 0; + let stale = if is_terminated { + idle >= FLOW_TERMINATED_TIMEOUT_US + } else { + idle >= FLOW_IDLE_TIMEOUT_US + }; + if stale { + keys_to_remove.push((*key).clone()); + } + } + let mut removed = 0; for key in keys_to_remove { - self.active.pop(&key); + self.active.invalidate(&key); + removed += 1; } removed } @@ -357,74 +364,71 @@ mod tests { } } + fn sync_count(tracker: &FlowTracker) -> usize { + tracker.active.run_pending_tasks(); + tracker.flow_count() + } + #[test] fn cleanup_removes_idle_flows() { - let mut tracker = FlowTracker::new(10000); + let tracker = FlowTracker::new(10000); let base_ts = 1_000_000_000u64; // 1000 seconds - // Insert a flow with old timestamp let pkt = make_packet(base_ts, 0x02); // SYN tracker.process_packet(pkt, false); - assert_eq!(tracker.flow_count(), 1); + assert_eq!(sync_count(&tracker), 1); - // 130 seconds later — should be cleaned up (idle > 120s) let now = base_ts + 130_000_000; let removed = tracker.cleanup_stale_flows(now); assert_eq!(removed, 1); - assert_eq!(tracker.flow_count(), 0); + assert_eq!(sync_count(&tracker), 0); } #[test] fn cleanup_keeps_active_flows() { - let mut tracker = FlowTracker::new(10000); + let tracker = FlowTracker::new(10000); let base_ts = 1_000_000_000u64; let pkt = make_packet(base_ts, 0x02); tracker.process_packet(pkt, false); - // Only 10 seconds later — should NOT be cleaned up let now = base_ts + 10_000_000; let removed = tracker.cleanup_stale_flows(now); assert_eq!(removed, 0); - assert_eq!(tracker.flow_count(), 1); + assert_eq!(sync_count(&tracker), 1); } #[test] fn cleanup_removes_terminated_flows_after_short_idle() { - let mut tracker = FlowTracker::new(10000); + let tracker = FlowTracker::new(10000); let base_ts = 1_000_000_000u64; - // SYN packet let pkt1 = make_packet(base_ts, 0x02); tracker.process_packet(pkt1, false); - // FIN packet 1 second later let pkt2 = make_packet(base_ts + 1_000_000, 0x01); // FIN tracker.process_packet(pkt2, false); - // 6 seconds after FIN — terminated flow should be removed (idle > 5s) let now = base_ts + 7_000_000; let removed = tracker.cleanup_stale_flows(now); assert_eq!(removed, 1); - assert_eq!(tracker.flow_count(), 0); + assert_eq!(sync_count(&tracker), 0); } #[test] fn cleanup_keeps_recently_terminated_flows() { - let mut tracker = FlowTracker::new(10000); + let tracker = FlowTracker::new(10000); let base_ts = 1_000_000_000u64; let pkt1 = make_packet(base_ts, 0x02); tracker.process_packet(pkt1, false); - // FIN packet let pkt2 = make_packet(base_ts + 1_000_000, 0x01); tracker.process_packet(pkt2, false); - // Only 2 seconds after FIN — should still be around let now = base_ts + 3_000_000; let removed = tracker.cleanup_stale_flows(now); assert_eq!(removed, 0); - assert_eq!(tracker.flow_count(), 1); + assert_eq!(sync_count(&tracker), 1); } } diff --git a/net-guardia/src/core/ml/inference.rs b/net-guardia/src/core/ml/inference.rs index 3c90702..6d3617e 100644 --- a/net-guardia/src/core/ml/inference.rs +++ b/net-guardia/src/core/ml/inference.rs @@ -1,90 +1,441 @@ -use std::sync::Arc; +//! Inference pipeline — state-aware, adapter-dispatched. +//! +//! The pipeline holds `ArcSwap` so the engine can take a +//! lock-free snapshot per tick and `infer_batch` dispatches on whichever +//! `MLModelAdapter` variant the active state carries. +//! +//! The MultiTask arm fires on any of: anomaly head above threshold, +//! classifier picking a non-Normal class at ≥ `class_min_confidence`, or the +//! C2 head elevated above its own threshold — so AE reconstruction error +//! can't mask a stealthy attack the classifier does recognize. +use std::cmp::Ordering as CmpOrdering; +use std::collections::BTreeMap; +use std::panic::{self, AssertUnwindSafe}; +use std::sync::Arc; +use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use arc_swap::ArcSwap; use macros::log; use tract_onnx::prelude::*; -use super::config_loader::InferenceConfig; +use super::adapter::{MLModelAdapter, ModelSourceState}; use super::flow_tracker::FlowData; -use super::model_loader::MLModels; +use super::manifest::LabelSpec; use crate::model::detection::flow_features::FlowFeatures; +use crate::model::detection::ml_detection::{DetectionResult, RunnableModel}; +use crate::model::detection::model_source::ModelSourceStatus; use crate::model::log::ml::MLLog; -use crate::model::ml_detection::DetectionResult; +use crate::model::system::config::MLInferenceConfig; + +/// (anomaly_scores, per_class_probs, c2_scores) — MultiTask batch output. +type ClassifierBatchOutput = (Vec, Vec>, Vec); + +/// Consecutive failures to trip the circuit breaker. +const CIRCUIT_BREAKER_THRESHOLD: u32 = 5; +/// Window in seconds: failures older than this are forgotten. +const CIRCUIT_BREAKER_WINDOW_SECS: u64 = 60; +/// Cooldown in seconds before re-enabling inference after circuit break. +const CIRCUIT_BREAKER_COOLDOWN_SECS: u64 = 120; pub struct Inference { - pub models: Arc, - pub config: Arc, + state: ArcSwap, + pub config: Arc, + /// Rolling-window QPS estimate, published in `ModelInfo.qps_recent`. + /// Stored as u32 (integer QPS) for lock-free update; fractional QPS + /// information is not useful at the UI grain we're publishing. + qps_recent: AtomicU32, + failure_count: AtomicU32, + failure_window_start: AtomicU64, + circuit_open_since: AtomicU64, } impl Inference { - pub fn new(models: Arc, config: Arc) -> Self { - Self { models, config } + /// Build a new Inference with the given initial state. Use + /// `ModelSourceState::Dormant` when no model is loaded (Day 1 default). + pub fn new(initial_state: ModelSourceState, config: Arc) -> Self { + Self { + state: ArcSwap::from_pointee(initial_state), + config, + qps_recent: AtomicU32::new(0), + failure_count: AtomicU32::new(0), + failure_window_start: AtomicU64::new(0), + circuit_open_since: AtomicU64::new(0), + } } + /// Atomically swap in a new state. Any transition INTO `Active` resets + /// the circuit breaker so a freshly loaded model starts with a clean + /// failure record; `Error ↔ Dormant` transitions leave the CB alone. + pub fn swap_state(&self, new_state: ModelSourceState) { + let new_is_active = new_state.is_active(); + self.state.store(Arc::new(new_state)); + if new_is_active { + self.failure_count.store(0, Ordering::Relaxed); + self.failure_window_start.store(0, Ordering::Relaxed); + self.circuit_open_since.store(0, Ordering::Relaxed); + } + } + + /// Current state snapshot for wire broadcast. Merges the in-memory + /// `qps_recent` atomic into the Active info so the UI sees live QPS. + pub fn current_status(&self) -> ModelSourceStatus { + let guard = self.state.load(); + let mut status = guard.to_status(); + if let ModelSourceStatus::Active { ref mut info } = status { + info.qps_recent = self.qps_recent.load(Ordering::Relaxed) as f32; + } + status + } + + pub fn is_active(&self) -> bool { + self.state.load().is_active() + } + + /// Per-attack-type confirmations from the active manifest's labels. + /// Returns `None` when the source is Dormant/Error or when the label has + /// no `confirmations` override; callers apply their own fallback. + pub fn confirmations_for_attack_type(&self, attack_type_name: &str) -> Option { + match self.state.load().as_ref() { + ModelSourceState::Active { adapter, .. } => adapter.confirmations_for(attack_type_name), + ModelSourceState::Dormant | ModelSourceState::Error { .. } => None, + } + } + + /// Batched inference with circuit breaker protection + state dispatch. + /// Returns an empty Vec when Dormant / Error / circuit-open. pub fn infer_batch(&self, flows: &[FlowData]) -> Vec { - flows.iter().filter_map(|flow| self.infer_single(flow)).collect() - } + if self.is_circuit_open() { + return Vec::new(); + } - pub fn infer_single(&self, flow: &FlowData) -> Option { - // catch_unwind protects against tract-onnx internal panics on edge-case inputs. - // Without this, panic=abort config would kill the entire process. - match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.infer_single_inner(flow))) { - Ok(result) => result, + // Snapshot the state once so the whole batch sees a consistent adapter. + let guard = self.state.load(); + let adapter = match guard.as_ref() { + ModelSourceState::Active { adapter, .. } => adapter, + ModelSourceState::Dormant | ModelSourceState::Error { .. } => { + return Vec::new(); + } + }; + + match panic::catch_unwind(AssertUnwindSafe(|| self.infer_batch_inner(adapter, flows))) { + Ok(results) => { + self.failure_count.store(0, Ordering::Relaxed); + self.failure_window_start.store(0, Ordering::Relaxed); + results + } Err(_) => { log!(MLLog::InferenceFailed( "ONNX".to_string(), - "inference panicked (caught)".to_string(), + "batch inference panicked (caught)".to_string(), )); - None + self.record_failure(); + Vec::new() } } } - fn infer_single_inner(&self, flow: &FlowData) -> Option { - let ae_features = self.preprocess_ae_features(flow); + /// Dispatch on adapter variant. + fn infer_batch_inner(&self, adapter: &MLModelAdapter, flows: &[FlowData]) -> Vec { + match adapter { + MLModelAdapter::MultiTask { + ae, + classifier, + batch_size, + n_ae, + n_cls, + labels, + normal_idx, + c2_idx, + } => self.infer_multitask( + ae, + classifier, + *batch_size, + *n_ae, + *n_cls, + labels, + *normal_idx, + *c2_idx, + flows, + ), + MLModelAdapter::AutoencoderOnly { + model, + batch_size, + n_features, + } => self.infer_autoencoder_only(model, *batch_size, *n_features, flows), + MLModelAdapter::ClassifierOnly { + model, + batch_size, + n_features, + labels, + normal_idx, + } => self.infer_classifier_only(model, *batch_size, *n_features, labels, *normal_idx, flows), + } + } - let ae_input = Self::vec_to_array2(&ae_features); - let ae_score = match self.run_autoencoder(&ae_input) { - Ok(score) => score, - Err(e) => { - log!(MLLog::InferenceFailed("DeepAutoEncoder".to_string(), e.to_string())); - return None; + /// MultiTask path. Runs the AE batch → computes per-flow MSE → feeds the + /// classifier over (ae_features ++ ae_score) → fires on anomaly OR + /// non-Normal classifier agreement OR elevated C2 head. + #[allow(clippy::too_many_arguments)] + fn infer_multitask( + &self, + ae: &RunnableModel, + classifier: &RunnableModel, + batch_size: usize, + n_ae: usize, + n_cls: usize, + labels: &BTreeMap, + normal_idx: Option, + c2_idx: Option, + flows: &[FlowData], + ) -> Vec { + let n = flows.len(); + + let all_ae_features: Vec> = flows.iter().map(|f| self.preprocess_ae_features(f)).collect(); + + let mut ae_scores = Vec::with_capacity(n); + for chunk_start in (0..n).step_by(batch_size) { + let chunk_end = (chunk_start + batch_size).min(n); + let actual = chunk_end - chunk_start; + let ae_input = tract_ndarray::Array2::::from_shape_fn((batch_size, n_ae), |(i, j)| { + if i < actual { + all_ae_features[chunk_start + i][j] + } else { + 0.0 + } + }); + + match run_ae_batch(ae, &ae_input, actual, n_ae) { + Ok(scores) => ae_scores.extend_from_slice(&scores), + Err(e) => { + log!(MLLog::InferenceFailed("DeepAutoEncoder".to_string(), e.to_string())); + self.record_failure(); + return Vec::new(); + } } - }; + } - let cls_input = self.build_classifier_input(&ae_features, ae_score); + let mut all_anomaly = Vec::with_capacity(n); + let mut all_class_probs = Vec::with_capacity(n); + let mut all_c2_scores = Vec::with_capacity(n); - let (attack_type, confidence) = match self.run_classifier(cls_input) { - Ok(result) => result, - Err(e) => { - log!(MLLog::InferenceFailed("LightGBM".to_string(), e.to_string())); - return None; + for chunk_start in (0..n).step_by(batch_size) { + let chunk_end = (chunk_start + batch_size).min(n); + let actual = chunk_end - chunk_start; + + let cls_input = tract_ndarray::Array2::::from_shape_fn((batch_size, n_cls), |(i, j)| { + if i < actual { + if j < n_ae { + all_ae_features[chunk_start + i][j] + } else { + ae_scores[chunk_start + i] + } + } else { + 0.0 + } + }); + + match run_classifier_batch(classifier, &cls_input, actual) { + Ok((anomaly, class_probs, c2)) => { + all_anomaly.extend_from_slice(&anomaly); + all_class_probs.extend(class_probs); + all_c2_scores.extend_from_slice(&c2); + } + Err(e) => { + log!(MLLog::InferenceFailed("MultiTaskModel".to_string(), e.to_string())); + self.record_failure(); + return Vec::new(); + } } - }; + } - let is_attack = ae_score >= self.config.ae_threshold; + let mut results = Vec::with_capacity(n); + let class_min_conf = self.config.class_min_confidence; + let anomaly_thr = self.config.anomaly_threshold; + let c2_thr = self.config.c2_threshold; - let flow_key = format!( - "{}:{} -> {}:{} (proto {}) [{}]", - flow.flow_key.src_ip_string(), - flow.flow_key.src_port, - flow.flow_key.dst_ip_string(), - flow.flow_key.dst_port, - flow.flow_key.protocol, - flow.direction - ); + for i in 0..n { + let flow = &flows[i]; + let class_probs = &all_class_probs[i]; + let anomaly = all_anomaly[i]; + let c2 = all_c2_scores[i]; - Some(DetectionResult { - flow_key, - flow_key_raw: flow.flow_key.clone(), - direction: flow.direction, - is_attack, - attack_type: if is_attack { Some(attack_type) } else { None }, - confidence, - ae_score, - threshold: self.config.ae_threshold, - packet_count: flow.packet_count() as u64, - flow_duration_us: flow.duration_us(), - }) + let (predicted_class, class_confidence) = argmax(class_probs); + let attack_type_name = labels + .get(&predicted_class.to_string()) + .map(|l| l.name.clone()) + .unwrap_or_else(|| "UNKNOWN".to_string()); + + let classifier_fires = + normal_idx.is_none_or(|ni| predicted_class != ni) && class_confidence >= class_min_conf; + let c2_fires = c2 > c2_thr; + let mut is_attack = anomaly > anomaly_thr || classifier_fires || c2_fires; + + // "Normal" with no C2 elevation stays benign regardless of AE noise. + if normal_idx == Some(predicted_class) && !c2_fires { + is_attack = false; + } + + // When the manifest declares a C2 class and its head score beats + // the classifier's probability for that same class, relabel the + // event with the manifest's C2 label and use the head score as + // the outgoing confidence. Manifests without a C2 class keep the + // argmax label and class-confidence untouched. + let mut attack_type = attack_type_name; + let mut confidence = class_confidence; + if c2_fires + && let Some(idx) = c2_idx + && let Some(c2_class_prob) = class_probs.get(idx).copied() + && c2 > c2_class_prob + { + if let Some(spec) = labels.get(&idx.to_string()) { + attack_type = spec.name.clone(); + } + confidence = c2; + } + + results.push(DetectionResult { + flow_key: build_flow_key_label(flow), + flow_key_raw: flow.flow_key.clone(), + direction: flow.direction, + is_attack, + attack_type: if is_attack { Some(attack_type) } else { None }, + confidence, + ae_score: ae_scores[i], + anomaly_score: anomaly, + c2_score: c2, + packet_count: flow.packet_count() as u64, + flow_duration_us: flow.duration_us(), + }); + } + + results + } + + /// AutoencoderOnly path. Output is reconstruction MSE; when it exceeds + /// `anomaly_threshold` the flow is marked as a generic `anomaly`. There + /// are no classifier outputs, so no per-class gating happens here. + fn infer_autoencoder_only( + &self, + model: &RunnableModel, + batch_size: usize, + n_features: usize, + flows: &[FlowData], + ) -> Vec { + let n = flows.len(); + let all_features: Vec> = flows.iter().map(|f| self.preprocess_ae_features(f)).collect(); + let mut scores = Vec::with_capacity(n); + + for chunk_start in (0..n).step_by(batch_size) { + let chunk_end = (chunk_start + batch_size).min(n); + let actual = chunk_end - chunk_start; + let input = tract_ndarray::Array2::::from_shape_fn((batch_size, n_features), |(i, j)| { + if i < actual { + all_features[chunk_start + i][j] + } else { + 0.0 + } + }); + match run_ae_batch(model, &input, actual, n_features) { + Ok(s) => scores.extend_from_slice(&s), + Err(e) => { + log!(MLLog::InferenceFailed("AutoencoderOnly".to_string(), e.to_string())); + self.record_failure(); + return Vec::new(); + } + } + } + + let thr = self.config.anomaly_threshold; + flows + .iter() + .zip(scores.iter()) + .map(|(flow, &score)| { + let is_attack = score > thr; + DetectionResult { + flow_key: build_flow_key_label(flow), + flow_key_raw: flow.flow_key.clone(), + direction: flow.direction, + is_attack, + attack_type: if is_attack { Some("anomaly".to_string()) } else { None }, + // AE-only has no separate classifier confidence; reuse the score. + confidence: score, + ae_score: score, + anomaly_score: score, + c2_score: 0.0, + packet_count: flow.packet_count() as u64, + flow_duration_us: flow.duration_us(), + } + }) + .collect() + } + + /// ClassifierOnly path. Output is per-class softmax; the manifest's + /// labels drive attack_type and the flow fires when the argmax class + /// isn't Normal and confidence ≥ `class_min_confidence`. + fn infer_classifier_only( + &self, + model: &RunnableModel, + batch_size: usize, + n_features: usize, + labels: &BTreeMap, + normal_idx: Option, + flows: &[FlowData], + ) -> Vec { + let n = flows.len(); + let all_features: Vec> = flows.iter().map(|f| self.preprocess_ae_features(f)).collect(); + let class_min_conf = self.config.class_min_confidence; + let mut results = Vec::with_capacity(n); + + for chunk_start in (0..n).step_by(batch_size) { + let chunk_end = (chunk_start + batch_size).min(n); + let actual = chunk_end - chunk_start; + let input = tract_ndarray::Array2::::from_shape_fn((batch_size, n_features), |(i, j)| { + if i < actual { + all_features[chunk_start + i][j] + } else { + 0.0 + } + }); + let class_probs = match run_classifier_only_batch(model, &input, actual) { + Ok(cp) => cp, + Err(e) => { + log!(MLLog::InferenceFailed("ClassifierOnly".to_string(), e.to_string())); + self.record_failure(); + return Vec::new(); + } + }; + + for (i, probs) in class_probs.into_iter().enumerate() { + let flow = &flows[chunk_start + i]; + let (predicted_class, confidence) = argmax(&probs); + let is_attack = normal_idx.is_none_or(|ni| predicted_class != ni) && confidence >= class_min_conf; + let attack_type = if is_attack { + labels + .get(&predicted_class.to_string()) + .map(|l| l.name.clone()) + .unwrap_or_else(|| "UNKNOWN".to_string()) + } else { + "Normal".to_string() + }; + results.push(DetectionResult { + flow_key: build_flow_key_label(flow), + flow_key_raw: flow.flow_key.clone(), + direction: flow.direction, + is_attack, + attack_type: if is_attack { Some(attack_type) } else { None }, + confidence, + ae_score: 0.0, + anomaly_score: 0.0, + c2_score: 0.0, + packet_count: flow.packet_count() as u64, + flow_duration_us: flow.duration_us(), + }); + } + } + + results } fn preprocess_ae_features(&self, flow: &FlowData) -> Vec { @@ -95,58 +446,166 @@ impl Inference { features.features.iter().map(|&x| x as f32).collect() } - fn vec_to_array2(v: &[f32]) -> tract_ndarray::Array2 { - tract_ndarray::Array2::from_shape_fn((1, v.len()), |(_, j)| v[j]) + /// Publish a rolling QPS estimate visible in `current_status().info.qps_recent`. + /// Called by the engine after each inference tick completes. + pub fn record_tick_qps(&self, flows_per_second: f32) { + self.qps_recent + .store(flows_per_second.max(0.0) as u32, Ordering::Relaxed); } - /// Classifier 輸入 = 已預處理的 ae_features ++ [ae_anomaly_score] - fn build_classifier_input(&self, ae_features: &[f32], ae_score: f32) -> tract_ndarray::Array2 { - let n = ae_features.len() + 1; - tract_ndarray::Array2::from_shape_fn((1, n), |(_, j)| { - if j < ae_features.len() { - ae_features[j] - } else { - ae_score - } - }) + // -- Circuit breaker --------------------------------------------------------- + + fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) } - fn run_autoencoder(&self, input: &tract_ndarray::Array2) -> TractResult { - let input_tensor = input.clone().into_tensor(); - let result = self.models.deep_autoencoder.run(tvec![input_tensor.into()])?; - - let output = result[0] - .to_array_view::()? - .into_dimensionality::()?; - - let diff = input - &output; - let mse = (&diff * &diff).sum() / self.config.ae_feature_names.len() as f32; - - Ok(mse) + fn is_circuit_open(&self) -> bool { + let open_since = self.circuit_open_since.load(Ordering::Relaxed); + if open_since == 0 { + return false; + } + let elapsed = Self::now_secs().saturating_sub(open_since); + if elapsed >= CIRCUIT_BREAKER_COOLDOWN_SECS { + self.circuit_open_since.store(0, Ordering::Relaxed); + self.failure_count.store(0, Ordering::Relaxed); + self.failure_window_start.store(0, Ordering::Relaxed); + log!(MLLog::CircuitBreakerReset(CIRCUIT_BREAKER_COOLDOWN_SECS)); + return false; + } + true } - fn run_classifier(&self, input: tract_ndarray::Array2) -> TractResult<(String, f32)> { - let result = self.models.classifier.run(tvec![input.into_tensor().into()])?; + fn record_failure(&self) { + let now = Self::now_secs(); + let window_start = self.failure_window_start.load(Ordering::Relaxed); - let output = result[0].to_array_view::()?; - - let mut max_prob: f32 = 0.0; - let mut predicted_class: usize = 0; - - for (i, &prob) in output.iter().enumerate() { - if prob > max_prob { - max_prob = prob; - predicted_class = i; - } + if window_start == 0 || now.saturating_sub(window_start) > CIRCUIT_BREAKER_WINDOW_SECS { + self.failure_window_start.store(now, Ordering::Relaxed); + self.failure_count.store(1, Ordering::Relaxed); + return; } - let attack_type = self - .config - .attack_labels - .get(&predicted_class.to_string()) - .cloned() - .unwrap_or_else(|| "UNKNOWN".to_string()); - - Ok((attack_type, max_prob)) + let count = self.failure_count.fetch_add(1, Ordering::Relaxed) + 1; + if count >= CIRCUIT_BREAKER_THRESHOLD { + self.circuit_open_since.store(now, Ordering::Relaxed); + log!(MLLog::CircuitBreakerOpen(count, CIRCUIT_BREAKER_WINDOW_SECS)); + } + } +} + +// -- Free helpers (can be unit-tested without an Inference) ------------------- + +fn argmax(probs: &[f32]) -> (usize, f32) { + probs + .iter() + .enumerate() + .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(CmpOrdering::Equal)) + .map(|(i, &p)| (i, p)) + .unwrap_or((0, 0.0)) +} + +fn build_flow_key_label(flow: &FlowData) -> String { + format!( + "{}:{} -> {}:{} (proto {}) [{}]", + flow.flow_key.src_ip_string(), + flow.flow_key.src_port, + flow.flow_key.dst_ip_string(), + flow.flow_key.dst_port, + flow.flow_key.protocol, + flow.direction + ) +} + +/// Run an autoencoder-style model: output shape == input shape, score is +/// per-row mean squared error between input and reconstruction. +fn run_ae_batch( + model: &RunnableModel, + input: &tract_ndarray::Array2, + actual: usize, + n_features: usize, +) -> TractResult> { + let result = model.run(tvec![input.clone().into_tensor().into()])?; + let output = result[0] + .to_array_view::()? + .into_dimensionality::()?; + let diff = input - &output; + let sq = &diff * &diff; + + let mut scores = Vec::with_capacity(actual); + let n_f = n_features as f32; + for i in 0..actual { + scores.push(sq.row(i).sum() / n_f); + } + Ok(scores) +} + +/// Run a 3-output multi-task classifier: (anomaly, class_probs, c2_score). +fn run_classifier_batch( + model: &RunnableModel, + input: &tract_ndarray::Array2, + actual: usize, +) -> TractResult { + let result = model.run(tvec![input.clone().into_tensor().into()])?; + + let anomaly_view = result[0].to_array_view::()?; + let anomaly: Vec = (0..actual) + .map(|i| anomaly_view.as_slice().map(|s| s[i]).unwrap_or(0.0)) + .collect(); + + let class_view = result[1] + .to_array_view::()? + .into_dimensionality::()?; + let class_probs: Vec> = (0..actual) + .map(|i| class_view.row(i).iter().copied().collect()) + .collect(); + + let c2_view = result[2].to_array_view::()?; + let c2: Vec = (0..actual) + .map(|i| c2_view.as_slice().map(|s| s[i]).unwrap_or(0.0)) + .collect(); + + Ok((anomaly, class_probs, c2)) +} + +/// Run a single-output classifier (ClassifierOnly adapter). +fn run_classifier_only_batch( + model: &RunnableModel, + input: &tract_ndarray::Array2, + actual: usize, +) -> TractResult>> { + let result = model.run(tvec![input.clone().into_tensor().into()])?; + let view = result[0] + .to_array_view::()? + .into_dimensionality::()?; + Ok((0..actual).map(|i| view.row(i).iter().copied().collect()).collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn argmax_picks_highest() { + let (idx, val) = argmax(&[0.1, 0.5, 0.3, 0.4]); + assert_eq!(idx, 1); + assert!((val - 0.5).abs() < 1e-6); + } + + #[test] + fn argmax_empty_returns_zero() { + assert_eq!(argmax(&[]), (0, 0.0)); + } + + #[test] + fn argmax_equal_picks_last() { + // Iterator::max_by returns the LAST element when comparisons are + // equal (in contrast to min_by). Ties in softmax probabilities are + // rare in practice, and "last wins" is a consistent contract across + // this codebase. + let (idx, _) = argmax(&[0.25, 0.25, 0.25, 0.25]); + assert_eq!(idx, 3); } } diff --git a/net-guardia/src/core/ml/manifest.rs b/net-guardia/src/core/ml/manifest.rs new file mode 100644 index 0000000..2799160 --- /dev/null +++ b/net-guardia/src/core/ml/manifest.rs @@ -0,0 +1,321 @@ +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use super::feature_extractor::feature_is_known; +use crate::model::error::ml::MLError; + +/// The three fixed adapter shapes a v1 manifest may declare. +/// The user picks one string; never sees the internal `ModelAdapter` trait. +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AdapterKind { + ClassifierOnly, + AutoencoderOnly, + MultiTask, +} + +impl AdapterKind { + pub fn as_str(self) -> &'static str { + match self { + AdapterKind::ClassifierOnly => "classifier_only", + AdapterKind::AutoencoderOnly => "autoencoder_only", + AdapterKind::MultiTask => "multi_task", + } + } +} + +/// ONNX file references within a manifest. `classifier_only` / `autoencoder_only` +/// adapters use `model`; `multi_task` uses `autoencoder` + `classifier`. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct ModelPaths { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub autoencoder: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub classifier: Option, +} + +/// Per-label metadata driving classifier output decoding and alert gating. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct LabelSpec { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub confirmations: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub playbook: Option, +} + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct Thresholds { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub anomaly: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub c2: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub class_min_confidence: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ae: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub alert_multiplier: Option, +} + +/// Pointer to the JSON sidecar carrying scaler / clip / weight arrays. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Preprocessing { + pub scaler_sidecar: String, +} + +/// The user-authored YAML description of a model. Validated at load — unknown +/// feature names are rejected against `FEATURE_REGISTRY`. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ModelManifest { + pub name: String, + pub adapter: AdapterKind, + #[serde(default)] + pub models: ModelPaths, + pub features: Vec, + #[serde(default)] + pub labels: BTreeMap, + #[serde(default)] + pub thresholds: Thresholds, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preprocessing: Option, +} + +impl ModelManifest { + /// Parse and validate a manifest YAML file from disk. + pub fn load(path: impl AsRef) -> Result { + let path = path.as_ref(); + let content = fs::read_to_string(path) + .map_err(|e| MLError::ManifestInvalid(path.to_path_buf(), format!("read failed: {e}")))?; + let manifest: ModelManifest = serde_yaml_ng::from_str(&content) + .map_err(|e| MLError::ManifestInvalid(path.to_path_buf(), format!("YAML parse: {e}")))?; + manifest.validate(path)?; + Ok(manifest) + } + + fn validate(&self, path: &Path) -> Result<(), MLError> { + if self.name.trim().is_empty() { + return Err(MLError::ManifestInvalid( + path.to_path_buf(), + "name is empty".to_string(), + )); + } + if self.features.is_empty() { + return Err(MLError::ManifestInvalid( + path.to_path_buf(), + "features is empty".to_string(), + )); + } + for f in &self.features { + if !feature_is_known(f) { + return Err(MLError::UnknownFeature(f.clone())); + } + } + match self.adapter { + AdapterKind::MultiTask => { + if self.models.autoencoder.is_none() || self.models.classifier.is_none() { + return Err(MLError::ManifestInvalid( + path.to_path_buf(), + "multi_task adapter requires both models.autoencoder and models.classifier".to_string(), + )); + } + } + AdapterKind::ClassifierOnly | AdapterKind::AutoencoderOnly => { + if self.models.model.is_none() { + return Err(MLError::ManifestInvalid( + path.to_path_buf(), + format!("{} adapter requires models.model", self.adapter.as_str()), + )); + } + } + } + self.validate_labels(path)?; + self.validate_thresholds(path)?; + Ok(()) + } + + fn validate_labels(&self, path: &Path) -> Result<(), MLError> { + let mut seen: Vec = Vec::with_capacity(self.labels.len()); + for spec in self.labels.values() { + if let Some(n) = spec.confirmations + && n == 0 + { + return Err(MLError::ManifestInvalid( + path.to_path_buf(), + format!("label '{}' has confirmations: 0 (must be ≥ 1)", spec.name), + )); + } + let lower = spec.name.to_ascii_lowercase(); + if seen.iter().any(|s| s == &lower) { + return Err(MLError::ManifestInvalid( + path.to_path_buf(), + format!("duplicate label name '{}' (case-insensitive)", spec.name), + )); + } + seen.push(lower); + } + Ok(()) + } + + fn validate_thresholds(&self, path: &Path) -> Result<(), MLError> { + if let Some(m) = self.thresholds.alert_multiplier + && !(m.is_finite() && m > 0.0) + { + return Err(MLError::ManifestInvalid( + path.to_path_buf(), + format!("thresholds.alert_multiplier must be finite and > 0 (got {m})"), + )); + } + Ok(()) + } + + /// Resolve a manifest-relative filename (e.g. "deep_autoencoder.onnx") to an + /// absolute-ish path rooted at the manifest's parent directory. + pub fn resolve_relative(manifest_path: &Path, relative: &str) -> PathBuf { + manifest_path.parent().unwrap_or_else(|| Path::new(".")).join(relative) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const V10_MANIFEST: &str = r#" +name: netguardia-v10 +adapter: multi_task +models: + autoencoder: deep_autoencoder.onnx + classifier: classifier.onnx +features: + - flow_duration + - fwd_packets + - bwd_packets +labels: + "0": { name: Bot } + "7": { name: Normal } +thresholds: + anomaly: 0.9 + c2: 0.85 +preprocessing: + scaler_sidecar: inference_config.json +"#; + + #[test] + fn parses_minimal_multitask() { + let m: ModelManifest = serde_yaml_ng::from_str(V10_MANIFEST).expect("parse"); + assert_eq!(m.name, "netguardia-v10"); + assert_eq!(m.adapter, AdapterKind::MultiTask); + assert_eq!(m.features.len(), 3); + assert_eq!(m.models.autoencoder.as_deref(), Some("deep_autoencoder.onnx")); + assert_eq!(m.models.classifier.as_deref(), Some("classifier.onnx")); + assert_eq!(m.labels.len(), 2); + assert_eq!(m.labels.get("0").map(|l| l.name.as_str()), Some("Bot")); + } + + #[test] + fn rejects_unknown_feature() { + let yaml = r#" +name: bad +adapter: classifier_only +models: + model: m.onnx +features: + - this_feature_does_not_exist +"#; + let path = Path::new("/tmp/test-manifest.yaml"); + let parsed: ModelManifest = serde_yaml_ng::from_str(yaml).unwrap(); + let err = parsed.validate(path).expect_err("should reject unknown feature"); + assert!(matches!(err, MLError::UnknownFeature { .. }), "got {err:?}"); + } + + #[test] + fn rejects_multitask_missing_ae() { + let yaml = r#" +name: bad +adapter: multi_task +models: + classifier: c.onnx +features: + - flow_duration +"#; + let path = Path::new("/tmp/test-manifest.yaml"); + let parsed: ModelManifest = serde_yaml_ng::from_str(yaml).unwrap(); + let err = parsed.validate(path).expect_err("should require autoencoder"); + assert!(matches!(err, MLError::ManifestInvalid { .. }), "got {err:?}"); + } + + #[test] + fn rejects_empty_features() { + let yaml = r#" +name: bad +adapter: classifier_only +models: + model: m.onnx +features: [] +"#; + let path = Path::new("/tmp/test-manifest.yaml"); + let parsed: ModelManifest = serde_yaml_ng::from_str(yaml).unwrap(); + let err = parsed.validate(path).expect_err("should reject empty features"); + assert!(matches!(err, MLError::ManifestInvalid { .. }), "got {err:?}"); + } + + #[test] + fn rejects_zero_confirmations() { + let yaml = r#" +name: bad +adapter: classifier_only +models: + model: m.onnx +features: + - flow_duration +labels: + "0": { name: Bot, confirmations: 0 } +"#; + let path = Path::new("/tmp/test-manifest.yaml"); + let parsed: ModelManifest = serde_yaml_ng::from_str(yaml).unwrap(); + let err = parsed.validate(path).expect_err("should reject confirmations: 0"); + assert!(matches!(err, MLError::ManifestInvalid { .. }), "got {err:?}"); + } + + #[test] + fn rejects_duplicate_label_name_ignoring_case() { + let yaml = r#" +name: bad +adapter: classifier_only +models: + model: m.onnx +features: + - flow_duration +labels: + "0": { name: Bot } + "1": { name: BOT } +"#; + let path = Path::new("/tmp/test-manifest.yaml"); + let parsed: ModelManifest = serde_yaml_ng::from_str(yaml).unwrap(); + let err = parsed.validate(path).expect_err("should reject duplicate label names"); + assert!(matches!(err, MLError::ManifestInvalid { .. }), "got {err:?}"); + } + + #[test] + fn rejects_non_positive_alert_multiplier() { + let yaml = r#" +name: bad +adapter: classifier_only +models: + model: m.onnx +features: + - flow_duration +thresholds: + alert_multiplier: 0 +"#; + let path = Path::new("/tmp/test-manifest.yaml"); + let parsed: ModelManifest = serde_yaml_ng::from_str(yaml).unwrap(); + let err = parsed.validate(path).expect_err("should reject alert_multiplier <= 0"); + assert!(matches!(err, MLError::ManifestInvalid { .. }), "got {err:?}"); + } +} diff --git a/net-guardia/src/core/ml/mod.rs b/net-guardia/src/core/ml/mod.rs index d71c694..7c96788 100644 --- a/net-guardia/src/core/ml/mod.rs +++ b/net-guardia/src/core/ml/mod.rs @@ -1,3 +1,4 @@ +pub mod adapter; pub mod aggregator; pub mod alert; pub mod config_loader; @@ -6,5 +7,7 @@ pub mod engine; pub mod feature_extractor; pub mod flow_tracker; pub mod inference; +pub mod manifest; pub mod model_loader; +pub mod model_watcher; pub mod traffic_logger; diff --git a/net-guardia/src/core/ml/model_loader.rs b/net-guardia/src/core/ml/model_loader.rs index 9bc84d1..2ca6dfa 100644 --- a/net-guardia/src/core/ml/model_loader.rs +++ b/net-guardia/src/core/ml/model_loader.rs @@ -1,51 +1,308 @@ -use std::path::PathBuf; +//! Factory that builds an `MLModelAdapter` from a `ModelManifest`: inspects +//! the manifest's `adapter` field, loads the named ONNX file(s) with shape +//! validation against the inference config's feature counts, and wraps the +//! `RunnableModel`s in `Arc` so hot-reload can swap without per-tick clones. +//! A shape mismatch yields `MLError::FeatureMismatch` carrying both +//! expected and observed dims for the upload UI to render. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, mpsc}; +use std::thread; +use std::time::{Duration, Instant}; + +use macros::log; use tract_onnx::prelude::*; +use tract_onnx::tract_hir::infer::Factoid; +use tract_onnx::tract_hir::internal::DimLike; -use crate::infrastructure::app_config::AppConfig; +use super::adapter::MLModelAdapter; +use super::manifest::{AdapterKind, LabelSpec, ModelManifest}; +use crate::model::config::constants::MODELS_DIR; +use crate::model::detection::ml_detection::RunnableModel; use crate::model::error::ml::MLError; -use crate::model::ml_detection::RunnableModel; +use crate::model::log::ml::MLLog; +use crate::model::system::config::MLInferenceConfig; -use super::config_loader::InferenceConfig; +/// Wall-clock budget for a single ONNX parse + optimize + runnable chain. +/// A malformed or maliciously-crafted model can wedge tract's graph solver; +/// the timeout keeps an admin-triggered upload from blocking the watcher +/// indefinitely. Five seconds is generous for the models that currently +/// ship (<10MB) while still bounding pathological inputs. +const ONNX_LOAD_TIMEOUT: Duration = Duration::from_secs(5); -pub struct MLModels { - pub deep_autoencoder: RunnableModel, - pub classifier: RunnableModel, -} -impl MLModels { - pub fn load_models(app_config: &Arc, inference_config: &Arc) -> Result { - Ok(Self { - deep_autoencoder: Self::loader( - &app_config.inference.deep_autoencoder_name, - inference_config.num_ae_features(), - )?, - classifier: Self::loader( - &app_config.inference.classifier_name, - inference_config.num_classifier_features(), - )?, - }) - } +/// Build an `MLModelAdapter` by loading the ONNX file(s) the manifest names, +/// validating shape against the inference config's feature counts, and +/// wrapping the underlying `RunnableModel`s in `Arc` for zero-copy swap. +pub fn build_adapter( + manifest: &ModelManifest, + manifest_path: Option<&Path>, + inference_config: &MLInferenceConfig, + batch_size: usize, +) -> Result { + let resolve = |rel: &str| -> PathBuf { + match manifest_path { + Some(mp) => ModelManifest::resolve_relative(mp, rel), + None => PathBuf::from(MODELS_DIR).join(rel), + } + }; - pub fn loader(model: &str, features: usize) -> Result { - let model_path = PathBuf::from("models").join(model); - - let load = || -> Result> { - let mut model = onnx().model_for_path(&model_path)?; - model.set_input_fact(0, f32::fact([1, features]).into())?; - Ok(model.into_optimized()?.into_runnable()?) - }; - - load().map_err(|_| MLError::ModelLoadFailed(model_path)) - } - - pub fn get_model_info(&self, name: &str) -> String { - let model = match name { - "deep_autoencoder" => &self.deep_autoencoder, - "classifier" => &self.classifier, - _ => return "unknown model".to_string(), - }; - - let inputs = model.model().inputs.len(); - let outputs = model.model().outputs.len(); - format!("{}: inputs: {}, outputs: {}", name, inputs, outputs) + match manifest.adapter { + AdapterKind::AutoencoderOnly => { + let Some(model_name) = manifest.models.model.as_deref() else { + return Err(MLError::ManifestInvalid( + manifest_path.unwrap_or_else(|| Path::new("")).to_path_buf(), + "autoencoder_only adapter requires models.model".to_string(), + )); + }; + let path = resolve(model_name); + let n_features = inference_config.num_ae_features(); + let model = Arc::new(loader(&path, model_name, n_features, batch_size)?); + Ok(MLModelAdapter::AutoencoderOnly { + model, + batch_size, + n_features, + }) + } + AdapterKind::ClassifierOnly => { + let Some(model_name) = manifest.models.model.as_deref() else { + return Err(MLError::ManifestInvalid( + manifest_path.unwrap_or_else(|| Path::new("")).to_path_buf(), + "classifier_only adapter requires models.model".to_string(), + )); + }; + let path = resolve(model_name); + let n_features = inference_config.num_classifier_features(); + let model = Arc::new(loader(&path, model_name, n_features, batch_size)?); + let labels = manifest.labels.clone(); + let normal_idx = find_label_index(&labels, "Normal"); + Ok(MLModelAdapter::ClassifierOnly { + model, + batch_size, + n_features, + labels, + normal_idx, + }) + } + AdapterKind::MultiTask => { + let Some(ae_name) = manifest.models.autoencoder.as_deref() else { + return Err(MLError::ManifestInvalid( + manifest_path.unwrap_or_else(|| Path::new("")).to_path_buf(), + "multi_task adapter requires models.autoencoder".to_string(), + )); + }; + let Some(cls_name) = manifest.models.classifier.as_deref() else { + return Err(MLError::ManifestInvalid( + manifest_path.unwrap_or_else(|| Path::new("")).to_path_buf(), + "multi_task adapter requires models.classifier".to_string(), + )); + }; + let n_ae = inference_config.num_ae_features(); + let n_cls = inference_config.num_classifier_features(); + let ae = Arc::new(loader(&resolve(ae_name), ae_name, n_ae, batch_size)?); + let classifier = Arc::new(loader(&resolve(cls_name), cls_name, n_cls, batch_size)?); + let labels = manifest.labels.clone(); + let normal_idx = find_label_index(&labels, "Normal"); + let c2_idx = find_label_index(&labels, "C2 Communication"); + Ok(MLModelAdapter::MultiTask { + ae, + classifier, + batch_size, + n_ae, + n_cls, + labels, + normal_idx, + c2_idx, + }) + } + } +} + +/// Locate a label index by its `name` field. Used to cache hot-path indices +/// (Normal, C2 Communication) at adapter build time rather than re-scanning +/// the label map on every inference batch. +fn find_label_index(labels: &BTreeMap, target: &str) -> Option { + labels + .iter() + .find(|(_, v)| v.name.eq_ignore_ascii_case(target)) + .and_then(|(k, _)| k.parse::().ok()) +} + +fn loader(model_path: &Path, model_name: &str, features: usize, batch_size: usize) -> Result { + log!(MLLog::ModelLoading(model_name.to_string(), features, batch_size)); + + let start = Instant::now(); + let path_for_thread = model_path.to_path_buf(); + let name_for_thread = model_name.to_string(); + let result = load_with_timeout(model_path.to_path_buf(), ONNX_LOAD_TIMEOUT, move || { + loader_inner(&path_for_thread, &name_for_thread, features, batch_size) + }); + let elapsed_ms = start.elapsed().as_millis() as u64; + log!(MLLog::ModelLoadComplete(model_name.to_string(), elapsed_ms)); + + result +} + +/// Run `f` on a dedicated OS thread with a wall-clock cap. Prevents a +/// pathological ONNX from wedging `tract`'s graph solver and holding up +/// the watcher / bootstrap indefinitely. On timeout the worker thread +/// is detached — it will finish on its own and drop its state; the cost +/// of one leaked thread is acceptable for a low-frequency operation +/// gated behind admin upload + manifest validation. +fn load_with_timeout(path: PathBuf, budget: Duration, f: F) -> Result +where + F: FnOnce() -> Result + Send + 'static, + R: Send + 'static, +{ + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + let _ = tx.send(f()); + }); + match rx.recv_timeout(budget) { + Ok(result) => result, + Err(mpsc::RecvTimeoutError::Timeout) => Err(MLError::ModelLoadTimeout(path, budget.as_secs())), + Err(mpsc::RecvTimeoutError::Disconnected) => Err(MLError::ModelLoadFailed( + path, + "loader thread disconnected before completing".to_string(), + )), + } +} + +fn loader_inner( + model_path: &Path, + model_name: &str, + features: usize, + batch_size: usize, +) -> Result { + let mut onnx_model = onnx() + .model_for_path(model_path) + .map_err(|e| MLError::ModelLoadFailed(model_path.to_path_buf(), format!("parse ONNX: {e}")))?; + + if let Some(onnx_dim) = introspect_input_features(&onnx_model) { + let matched = onnx_dim == features; + log!(MLLog::OnnxShapeChecked( + model_name.to_string(), + features, + onnx_dim, + matched, + )); + if !matched { + return Err(MLError::FeatureMismatch(model_path.to_path_buf(), features, onnx_dim)); + } + } + + onnx_model + .set_input_fact(0, f32::fact([batch_size, features]).into()) + .map_err(|e| MLError::ModelLoadFailed(model_path.to_path_buf(), format!("set_input_fact: {e}")))?; + + onnx_model + .into_optimized() + .and_then(|m| m.into_runnable()) + .map_err(|e| MLError::ModelLoadFailed(model_path.to_path_buf(), format!("optimize/runnable: {e}"))) +} + +/// Read the concrete last-dim (feature count) from an ONNX model's declared input fact. +/// Returns None when the dim is dynamic/symbolic or when the model has no input 0. +fn introspect_input_features(model: &InferenceModel) -> Option { + let fact = model.input_fact(0).ok()?; + let rank = fact.shape.rank().concretize()? as usize; + if rank == 0 { + return None; + } + let last = fact.shape.dim(rank - 1)?; + last.concretize()?.to_usize().ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Repo-root integration: the shipped v10 AE ONNX's input last-dim must + /// still be 31. A change here means the shipped manifest + sidecar drifted. + #[test] + fn introspect_v10_autoencoder_input_is_31() { + let ae_path = PathBuf::from("models/deep_autoencoder.onnx"); + if !ae_path.exists() { + eprintln!("skipping: models/deep_autoencoder.onnx absent"); + return; + } + let model = onnx().model_for_path(&ae_path).expect("load AE onnx"); + let dim = introspect_input_features(&model).expect("v10 AE should expose a concrete final-dim"); + assert_eq!(dim, 31, "v10 AE ONNX input dim changed unexpectedly"); + } + + #[test] + fn introspect_v10_classifier_input_is_32() { + let cls_path = PathBuf::from("models/classifier.onnx"); + if !cls_path.exists() { + eprintln!("skipping: models/classifier.onnx absent"); + return; + } + let model = onnx().model_for_path(&cls_path).expect("load classifier onnx"); + let dim = introspect_input_features(&model).expect("v10 classifier should expose a concrete final-dim"); + assert_eq!(dim, 32, "v10 classifier ONNX input dim changed unexpectedly"); + } + + #[test] + fn load_with_timeout_passes_fast_loader() { + let result: Result = + load_with_timeout(PathBuf::from("/tmp/fast.onnx"), Duration::from_millis(500), || Ok(42)); + assert_eq!(result.ok(), Some(42)); + } + + #[test] + fn load_with_timeout_returns_timeout_error_when_budget_exceeded() { + let path = PathBuf::from("/tmp/slow.onnx"); + let result: Result = load_with_timeout(path.clone(), Duration::from_millis(50), || { + thread::sleep(Duration::from_millis(500)); + Ok(42) + }); + match result { + Err(MLError::ModelLoadTimeout { + path: got_path, + seconds, + }) => { + assert_eq!(got_path, path); + assert_eq!(seconds, 0, "budget < 1s rounds to 0 on `as_secs`"); + } + other => panic!("expected ModelLoadTimeout, got {other:?}"), + } + } + + #[test] + fn load_with_timeout_propagates_loader_error() { + // Failures surface unchanged; timeout wrapping must not swallow them. + let path = PathBuf::from("/tmp/bad.onnx"); + let err_path = path.clone(); + let result: Result = load_with_timeout(path, Duration::from_millis(500), move || { + Err(MLError::ModelLoadFailed( + err_path, + "synthetic parse failure".to_string(), + )) + }); + match result { + Err(MLError::ModelLoadFailed { err, .. }) => assert!(err.contains("synthetic")), + other => panic!("expected ModelLoadFailed, got {other:?}"), + } + } + + /// Smoke: the shipped manifest + sidecar must load and produce a + /// `MultiTask` adapter with both underlying models. + #[test] + fn v10_build_adapter_multitask() { + let manifest_path = Path::new("models/manifest.yaml"); + if !manifest_path.exists() { + eprintln!("skipping: models/manifest.yaml absent"); + return; + } + let (cfg, manifest) = MLInferenceConfig::from_manifest_with_sidecar(manifest_path).expect("config load"); + let adapter = build_adapter(&manifest, Some(manifest_path), &cfg, 8).expect("build adapter"); + match adapter { + MLModelAdapter::MultiTask { n_ae, n_cls, .. } => { + assert_eq!(n_ae, 31); + assert_eq!(n_cls, 32); + } + _ => panic!("v10 manifest should build MultiTask adapter"), + } } } diff --git a/net-guardia/src/core/ml/model_watcher.rs b/net-guardia/src/core/ml/model_watcher.rs new file mode 100644 index 0000000..ceb1f99 --- /dev/null +++ b/net-guardia/src/core/ml/model_watcher.rs @@ -0,0 +1,221 @@ +//! Filesystem watcher over `models/`. Reloads the ML source whenever the +//! manifest or ONNX files change, re-reading both the manifest and the +//! scaler sidecar so feature-changing uploads land without a restart. +//! Events inside the staging subdirectory are filtered so partial uploads +//! can't flicker the UI through transient `Error` states. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, SystemTime}; + +use macros::log; +use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; +use tokio::sync::mpsc; +use tokio::time::sleep; + +use super::adapter::ModelSourceState; +use super::inference::Inference; +use super::model_loader::build_adapter; +use crate::infrastructure::app_config::AppConfig; +use crate::model::config::constants::{MANIFEST_FILENAME, MODELS_DIR, STAGING_SUBDIR}; +use crate::model::detection::model_source::ModelInfo; +use crate::model::error::ml::MLError; +use crate::model::log::ml::MLLog; +use crate::model::system::config::MLInferenceConfig; + +/// Debounce window: wait for both manifest and ONNX to land before reloading. +const DEBOUNCE_SECS: u64 = 5; + +pub struct ModelWatcher { + inference: Arc, + app_config: Arc, +} + +impl ModelWatcher { + pub fn new(inference: Arc, app_config: Arc) -> Self { + Self { inference, app_config } + } + + pub fn start(self) { + tokio::spawn(async move { + if let Err(e) = self.run().await { + log!(MLLog::InferenceFailed( + "ModelWatcher".to_string(), + format!("watcher failed to start: {e}"), + )); + } + }); + } + + async fn run(self) -> Result<(), MLError> { + let models_dir = PathBuf::from(MODELS_DIR); + if !models_dir.exists() { + log!(MLLog::InferenceFailed( + "ModelWatcher".to_string(), + "models/ directory does not exist".to_string(), + )); + return Ok(()); + } + + let (tx, mut rx) = mpsc::channel::<()>(16); + let _watcher = Self::spawn_watcher(models_dir, tx)?; + + log!(MLLog::ModelWatcherStarted); + + loop { + if rx.recv().await.is_none() { + break; + } + sleep(Duration::from_secs(DEBOUNCE_SECS)).await; + while rx.try_recv().is_ok() {} + // `try_reload` loads the manifest + sidecar + ONNX off disk, any + // of which can block for >10ms on a cold cache — move it off the + // tokio worker so the rest of the async runtime keeps turning. + let inference = Arc::clone(&self.inference); + let app_config = Arc::clone(&self.app_config); + let _ = tokio::task::spawn_blocking(move || try_reload(&inference, &app_config)).await; + } + + Ok(()) + } + + fn spawn_watcher(models_dir: PathBuf, tx: mpsc::Sender<()>) -> Result { + let mut watcher = notify::recommended_watcher(move |res: Result| { + if let Ok(event) = res { + if !is_relevant_event(&event) { + return; + } + // The callback runs on notify's OS dispatch thread, which must + // not block on a full channel — the debounce loop coalesces + // duplicates anyway, so dropping when full is safe. + let _ = tx.try_send(()); + } + }) + .map_err(MLError::ModelWatcherFailed)?; + + // Recursive watch so the staging filter gets exercised — otherwise a + // drop-in to `.staging/` wouldn't trigger notify at all on some FSes. + watcher + .watch(&models_dir, RecursiveMode::Recursive) + .map_err(MLError::ModelWatcherFailed)?; + Ok(watcher) + } +} + +/// Full reload: manifest presence check → config re-parse → adapter build → +/// atomic state swap. Any failure lands the pipeline in `Error` rather +/// than crashing. Runs under `spawn_blocking` because manifest + sidecar + +/// ONNX loads are synchronous disk I/O plus a `tract` graph solve that +/// routinely takes >10 ms. +fn try_reload(inference: &Inference, app_config: &AppConfig) { + let manifest_path = PathBuf::from(MODELS_DIR).join(MANIFEST_FILENAME); + + // Path 1 — manifest disappeared: transition to Dormant. + if !manifest_path.exists() { + log!(MLLog::ModelReloadStarting); + inference.swap_state(ModelSourceState::Dormant); + log!(MLLog::ModelReloadSuccess); + return; + } + + // Path 2 — manifest present: re-read + rebuild adapter. + log!(MLLog::ModelReloadStarting); + let batch_size = app_config.inference.inference_batch_size; + match MLInferenceConfig::from_manifest_with_sidecar(&manifest_path) { + Ok((config, manifest)) => match build_adapter(&manifest, Some(&manifest_path), &config, batch_size) { + Ok(adapter) => { + let info = ModelInfo::new( + manifest.name.clone(), + manifest.adapter.as_str().to_string(), + manifest.features.len(), + ); + inference.swap_state(ModelSourceState::Active { adapter, info }); + log!(MLLog::ModelReloadSuccess); + } + Err(e) => { + record_error(inference, e.to_string(), Some(manifest_path.clone())); + } + }, + Err(e) => { + record_error(inference, e.to_string(), Some(manifest_path.clone())); + } + } +} + +fn record_error(inference: &Inference, msg: String, last_attempted_path: Option) { + log!(MLLog::ModelReloadFailed(msg.clone())); + inference.swap_state(ModelSourceState::Error { + msg, + since: SystemTime::now(), + last_attempted_path, + }); +} + +/// Inbound event filter. Ignore `.staging/` paths entirely; pass through +/// `.onnx` / `.yaml` / `.yml` / `.json` changes in `models/`. +fn is_relevant_event(event: &Event) -> bool { + // Only Create / Modify events trigger a reload; renames / removes would + // also surface but debouncing handles both equally well. + if !matches!(event.kind, EventKind::Create(_) | EventKind::Modify(_)) { + return false; + } + event.paths.iter().any(|p| { + if path_is_inside_staging(p) { + return false; + } + matches!( + p.extension().and_then(|e| e.to_str()), + Some("onnx") | Some("yaml") | Some("yml") | Some("json") + ) + }) +} + +fn path_is_inside_staging(path: &Path) -> bool { + path.components().any(|c| c.as_os_str() == STAGING_SUBDIR) +} + +#[cfg(test)] +mod tests { + use super::*; + + use notify::event::{CreateKind, ModifyKind}; + + #[test] + fn staging_paths_are_filtered() { + assert!(path_is_inside_staging(&PathBuf::from("models/.staging/bad.onnx"))); + assert!(path_is_inside_staging(&PathBuf::from( + "/tmp/models/.staging/sub/x.yaml" + ))); + assert!(!path_is_inside_staging(&PathBuf::from("models/good.onnx"))); + } + + #[test] + fn non_relevant_extensions_rejected() { + let event = Event { + kind: EventKind::Create(CreateKind::Any), + paths: vec![PathBuf::from("models/readme.md")], + attrs: Default::default(), + }; + assert!(!is_relevant_event(&event)); + } + + #[test] + fn onnx_outside_staging_accepted() { + let event = Event { + kind: EventKind::Create(CreateKind::Any), + paths: vec![PathBuf::from("models/foo.onnx")], + attrs: Default::default(), + }; + assert!(is_relevant_event(&event)); + } + + #[test] + fn staging_paths_always_rejected() { + let event = Event { + kind: EventKind::Modify(ModifyKind::Any), + paths: vec![PathBuf::from("models/.staging/partial.onnx")], + attrs: Default::default(), + }; + assert!(!is_relevant_event(&event)); + } +} diff --git a/net-guardia/src/core/ml/traffic_logger.rs b/net-guardia/src/core/ml/traffic_logger.rs index d509737..7f31747 100644 --- a/net-guardia/src/core/ml/traffic_logger.rs +++ b/net-guardia/src/core/ml/traffic_logger.rs @@ -1,50 +1,444 @@ -use std::fs::OpenOptions; -use std::io::{BufWriter, Write}; -use std::thread; +//! Flow Trace recording — rotating CSV writer for per-flow feature +//! vectors. Consumers drop rows through a bounded channel; a dedicated +//! writer thread manages the currently-open file, rotates on size or +//! age, and enforces a FIFO total-bytes budget so a long-running +//! recording session can't eat the disk. +//! +//! On FIFO failure (permissions, I/O error) the writer shuts down +//! cleanly, leaves inference untouched, logs through `MLLog`, and +//! (when a `CommunicationManager` is wired in) also publishes a WORM +//! `AuditEvent` so the chain records an auditor-visible reason the +//! recording stopped, not just a tracing line that may be lost. +//! Callers see the channel disconnect and stop sending rows. -use crossbeam::channel::{Sender, TrySendError, bounded}; +use std::fs as std_fs; +use std::fs::{File, OpenOptions}; +use std::io::{self, BufWriter, Write}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use crossbeam::channel::{Receiver, Sender, TrySendError, bounded}; use macros::log; +use crate::infrastructure::communication_manager::CommunicationManager; use crate::model::error::ml::MLError; +use crate::model::event::AuditEvent; use crate::model::log::ml::MLLog; -pub struct TrafficLogger { - sender: Sender>, +/// Default per-file size cap. A single CSV file won't grow past this +/// before the writer rolls to a fresh one. +pub const DEFAULT_MAX_FILE_BYTES: u64 = 500 * 1024 * 1024; + +/// Default per-file age cap. Forces a roll even if the size cap +/// hasn't been hit so analysts have bounded-age shards to download. +pub const DEFAULT_MAX_FILE_AGE: Duration = Duration::from_secs(3600); + +/// Default retained-bytes budget across every `flow-trace-*.csv` in +/// the directory. When the total exceeds this, the writer FIFO-deletes +/// the oldest files to bring the sum back under the cap. +pub const DEFAULT_TOTAL_BUDGET_BYTES: u64 = 10 * 1024 * 1024 * 1024; + +/// Lossy-drop channel capacity. Inference throughput is spiky; if the +/// writer falls behind, callers get a `TrySendError::Full` back rather +/// than blocking the hot path. The lost rows are observable in logs. +const CHANNEL_CAPACITY: usize = 65_536; + +/// Prefix literal baked into every rotated file's name so the HTTP +/// file-list handler can recognize ours and skip unrelated files. +pub const FLOW_TRACE_FILE_MARKER: &str = "flow-trace-"; +/// Suffix literal appended to every rotated file. +pub const FLOW_TRACE_FILE_EXT: &str = ".csv"; + +/// Actor recorded on the WORM `flow_trace_stopped` audit entry. Stable +/// wire string — auditors filter on it to separate system-internal +/// recording stoppages from administrator-initiated actions. Matches +/// the "system" value the drift-detector audit path already uses. +const AUDIT_ACTOR_SYSTEM: &str = "system"; +/// Action string on the WORM audit entry emitted when Flow Trace +/// recording goes dormant for any of the three writer-thread stop +/// reasons. Stable across releases. +const AUDIT_ACTION_FLOW_TRACE_STOPPED: &str = "flow_trace_stopped"; + +/// Rotation thresholds. Immutable after logger construction — change +/// requires a full logger restart through `AppServices`. +#[derive(Debug, Clone)] +pub struct RotationPolicy { + pub max_file_bytes: u64, + pub max_file_age: Duration, + pub total_budget_bytes: u64, } -impl TrafficLogger { - pub fn new(csv_path: &str, header: Vec) -> Result { - let file = OpenOptions::new() - .create(true) - .write(true) - .truncate(true) - .open(csv_path)?; - - let mut writer = BufWriter::new(file); - writeln!(writer, "{}", header.join(","))?; - writer.flush()?; - - let (sender, receiver) = bounded::>(65536); - - thread::Builder::new() - .name("traffic-logger".to_string()) - .spawn(move || { - for record in receiver { - if let Err(e) = writeln!(writer, "{}", record.join(",")) { - log!(MLLog::TrafficLogWriteError(e.to_string())); - } - } - if let Err(e) = writer.flush() { - log!(MLError::TrafficLogFlushFailed(e)); - } - })?; - - Ok(Self { sender }) - } - - pub fn log_row(&self, record: Vec) { - if let Err(TrySendError::Disconnected(_)) = self.sender.try_send(record) { - log!(MLLog::TrafficLogChannelDisconnected); +impl Default for RotationPolicy { + fn default() -> Self { + Self { + max_file_bytes: DEFAULT_MAX_FILE_BYTES, + max_file_age: DEFAULT_MAX_FILE_AGE, + total_budget_bytes: DEFAULT_TOTAL_BUDGET_BYTES, } } } + +pub struct TrafficLogger { + sender: Sender>, + directory: Arc, +} + +impl TrafficLogger { + /// Build a rotating writer rooted at `base_path`'s parent. Any + /// existing `flow-trace-*.csv` in that directory participates in + /// the FIFO budget. `comm` is optional so tests (and paths where + /// the bus isn't wired yet) can exercise the rotation logic without + /// the event-bus dependency; production code always passes `Some`. + pub fn new( + base_path: &Path, + header: Vec, + policy: RotationPolicy, + comm: Option>, + ) -> Result { + let directory = base_path + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")); + std_fs::create_dir_all(&directory)?; + + let (sender, receiver) = bounded::>(CHANNEL_CAPACITY); + + let writer_dir = directory.clone(); + let writer_header = header; + let writer_policy = policy; + thread::Builder::new() + .name("traffic-logger".to_string()) + .spawn(move || { + writer_loop(receiver, writer_dir, writer_header, writer_policy, comm); + })?; + + Ok(Self { + sender, + directory: Arc::new(directory), + }) + } + + pub fn log_row(&self, record: Vec) { + match self.sender.try_send(record) { + Ok(()) => {} + Err(TrySendError::Full(_)) => { + // Writer thread is behind; dropping is preferable to + // stalling inference. The counter is bumped inside the + // logger so the dashboard can surface slow-disk pressure. + log!(MLLog::TrafficLogChannelBackpressure); + } + Err(TrySendError::Disconnected(_)) => { + log!(MLLog::TrafficLogChannelDisconnected); + } + } + } + + /// Absolute path to the directory holding rotated CSV files. The + /// HTTP file-list / download handlers read this to resolve + /// user-supplied filenames. + pub fn directory(&self) -> &Path { + self.directory.as_ref() + } +} + +/// Lightweight descriptor for a single rotated file on disk. Used by +/// `list_flow_trace_files` and by the FIFO sweep. +#[derive(Debug, Clone)] +pub struct FlowTraceFile { + pub name: String, + pub path: PathBuf, + pub size_bytes: u64, + pub modified_unix_secs: u64, +} + +/// Scan `directory` for `flow-trace-*.csv` entries, sorted oldest-first +/// by numeric suffix (so FIFO deletion and the file-list endpoint both +/// use the same deterministic order). +pub fn list_flow_trace_files(directory: &Path) -> io::Result> { + if !directory.exists() { + return Ok(Vec::new()); + } + let mut entries = Vec::new(); + for dirent in std_fs::read_dir(directory)? { + let dirent = dirent?; + let path = dirent.path(); + if !path.is_file() { + continue; + } + let Some(name) = path.file_name().and_then(|s| s.to_str()) else { + continue; + }; + if !name.starts_with(FLOW_TRACE_FILE_MARKER) || !name.ends_with(FLOW_TRACE_FILE_EXT) { + continue; + } + let metadata = dirent.metadata()?; + let size_bytes = metadata.len(); + let modified_unix_secs = metadata + .modified() + .ok() + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_secs()) + .unwrap_or(0); + entries.push(FlowTraceFile { + name: name.to_string(), + path: path.clone(), + size_bytes, + modified_unix_secs, + }); + } + entries.sort_by_key(|e| parse_timestamp_suffix(&e.name).unwrap_or(u64::MAX)); + Ok(entries) +} + +/// Parse the numeric timestamp from `flow-trace-.csv`. Unknown +/// suffixes return `None` so the caller can skip them from the +/// oldest-first ordering. +fn parse_timestamp_suffix(name: &str) -> Option { + let without_prefix = name.strip_prefix(FLOW_TRACE_FILE_MARKER)?; + let without_ext = without_prefix.strip_suffix(FLOW_TRACE_FILE_EXT)?; + without_ext.parse::().ok() +} + +fn writer_loop( + receiver: Receiver>, + directory: PathBuf, + header: Vec, + policy: RotationPolicy, + comm: Option>, +) { + let mut active = match open_new_file(&directory, &header) { + Ok(a) => a, + Err(e) => { + let reason = e.to_string(); + log!(MLLog::FlowTraceStopped(reason.clone())); + emit_flow_trace_stop_audit(comm.as_ref(), &reason, &directory); + return; + } + }; + + while let Ok(record) = receiver.recv() { + if active.bytes_written >= policy.max_file_bytes || active.opened_at.elapsed() >= policy.max_file_age { + // Close current, enforce budget, open a fresh file. + if let Err(e) = active.writer.flush() { + log!(MLLog::TrafficLogWriteError(e.to_string())); + } + drop(active.writer); + if let Err(e) = enforce_fifo_budget(&directory, policy.total_budget_bytes) { + // FIFO failure is the documented "stop Flow Trace, keep + // inference running" path. Drop the channel so callers + // see the disconnect and stop trying. + let reason = format!("FIFO sweep failed: {e}"); + log!(MLLog::FlowTraceStopped(reason.clone())); + emit_flow_trace_stop_audit(comm.as_ref(), &reason, &directory); + return; + } + active = match open_new_file(&directory, &header) { + Ok(a) => a, + Err(e) => { + let reason = format!("rotate failed: {e}"); + log!(MLLog::FlowTraceStopped(reason.clone())); + emit_flow_trace_stop_audit(comm.as_ref(), &reason, &directory); + return; + } + }; + } + + let line = format!("{}\n", record.join(",")); + if let Err(e) = active.writer.write_all(line.as_bytes()) { + log!(MLLog::TrafficLogWriteError(e.to_string())); + continue; + } + active.bytes_written = active.bytes_written.saturating_add(line.len() as u64); + } + + if let Err(e) = active.writer.flush() { + log!(MLError::TrafficLogFlushFailed(e)); + } +} + +/// Publish a WORM `flow_trace_stopped` audit entry so an auditor can +/// later see why Flow Trace recording went dormant without grepping +/// process logs. A `None` bus (tests, or pre-wiring paths) is a +/// deliberate no-op — the sibling `MLLog::FlowTraceStopped` tracing +/// line still fires in both cases. +/// +/// Extracted as a free function so tests can cover the emit path +/// without driving a full writer-loop + failing-disk fixture. +fn emit_flow_trace_stop_audit(comm: Option<&Arc>, reason: &str, directory: &Path) { + let Some(c) = comm else { + return; + }; + let detail = serde_json::json!({ + "reason": reason, + "directory": directory.display().to_string(), + }) + .to_string(); + let _ = c.publish_event_sync(AuditEvent { + actor: AUDIT_ACTOR_SYSTEM.to_string(), + action: AUDIT_ACTION_FLOW_TRACE_STOPPED.to_string(), + detail, + }); +} + +struct ActiveFile { + writer: BufWriter, + opened_at: Instant, + bytes_written: u64, +} + +fn open_new_file(directory: &Path, header: &[String]) -> io::Result { + let ts_ns = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0); + let filename = format!("{FLOW_TRACE_FILE_MARKER}{ts_ns:020}{FLOW_TRACE_FILE_EXT}"); + let path = directory.join(filename); + let file = OpenOptions::new().create(true).write(true).truncate(true).open(&path)?; + let mut writer = BufWriter::new(file); + let header_line = format!("{}\n", header.join(",")); + writer.write_all(header_line.as_bytes())?; + writer.flush()?; + Ok(ActiveFile { + writer, + opened_at: Instant::now(), + bytes_written: header_line.len() as u64, + }) +} + +/// Bring the sum of all `flow-trace-*.csv` byte counts back under +/// `budget` by deleting oldest-first. Exposed to tests; the writer +/// thread calls this after each rotation. +pub fn enforce_fifo_budget(directory: &Path, budget: u64) -> io::Result<()> { + let files = list_flow_trace_files(directory)?; + let total: u64 = files.iter().map(|f| f.size_bytes).sum(); + if total <= budget { + return Ok(()); + } + let mut remaining = total; + for file in files { + if remaining <= budget { + break; + } + std_fs::remove_file(&file.path)?; + remaining = remaining.saturating_sub(file.size_bytes); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::env; + use std::io::Write as _; + + use uuid::Uuid; + + use super::*; + + fn scratch_dir(tag: &str) -> PathBuf { + let dir = env::temp_dir().join(format!("nguardia-flow-trace-{tag}-{}", Uuid::new_v4())); + std_fs::create_dir_all(&dir).unwrap(); + dir + } + + fn write_fake_trace(dir: &Path, ts_ns: u64, bytes: usize) -> PathBuf { + let path = dir.join(format!("{FLOW_TRACE_FILE_MARKER}{ts_ns:020}{FLOW_TRACE_FILE_EXT}")); + let mut f = File::create(&path).unwrap(); + f.write_all(&vec![b'a'; bytes]).unwrap(); + path + } + + #[test] + fn parse_timestamp_suffix_accepts_padded_ns() { + assert_eq!(parse_timestamp_suffix("flow-trace-00000000000000000042.csv"), Some(42)); + } + + #[test] + fn parse_timestamp_suffix_rejects_unrelated_names() { + assert!(parse_timestamp_suffix("random.csv").is_none()); + assert!(parse_timestamp_suffix("flow-trace-hello.csv").is_none()); + assert!(parse_timestamp_suffix("flow-trace-42.txt").is_none()); + } + + #[test] + fn list_returns_files_sorted_oldest_first() { + let dir = scratch_dir("list-order"); + write_fake_trace(&dir, 200, 10); + write_fake_trace(&dir, 100, 10); + write_fake_trace(&dir, 300, 10); + let files = list_flow_trace_files(&dir).unwrap(); + let suffixes: Vec<_> = files.iter().map(|f| parse_timestamp_suffix(&f.name).unwrap()).collect(); + assert_eq!(suffixes, vec![100, 200, 300]); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn list_skips_non_flow_trace_files() { + let dir = scratch_dir("skip"); + write_fake_trace(&dir, 42, 10); + std::fs::write(dir.join("not-ours.csv"), b"foo").unwrap(); + std::fs::write(dir.join("flow-trace-bad-suffix.txt"), b"foo").unwrap(); + let files = list_flow_trace_files(&dir).unwrap(); + assert_eq!(files.len(), 1); + assert_eq!(parse_timestamp_suffix(&files[0].name), Some(42)); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn enforce_budget_removes_oldest_until_under_cap() { + let dir = scratch_dir("budget"); + write_fake_trace(&dir, 100, 1024); + write_fake_trace(&dir, 200, 1024); + write_fake_trace(&dir, 300, 1024); + // Budget 1500 bytes against 3072 total -> must drop oldest two. + enforce_fifo_budget(&dir, 1500).unwrap(); + let remaining = list_flow_trace_files(&dir).unwrap(); + let suffixes: Vec<_> = remaining + .iter() + .map(|f| parse_timestamp_suffix(&f.name).unwrap()) + .collect(); + assert_eq!(suffixes, vec![300]); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn enforce_budget_is_noop_when_under_cap() { + let dir = scratch_dir("budget-noop"); + write_fake_trace(&dir, 100, 512); + write_fake_trace(&dir, 200, 512); + enforce_fifo_budget(&dir, 8192).unwrap(); + assert_eq!(list_flow_trace_files(&dir).unwrap().len(), 2); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn list_on_missing_dir_returns_empty() { + let missing = PathBuf::from("/nonexistent/flow-trace/dir"); + assert!(list_flow_trace_files(&missing).unwrap().is_empty()); + } + + #[tokio::test] + async fn stop_audit_reaches_subscriber_when_comm_provided() { + let comm = Arc::new(CommunicationManager::new()); + comm.register_event_type::(); + let mut rx = comm.subscribe_event::().unwrap(); + + let dir = scratch_dir("audit-emit"); + emit_flow_trace_stop_audit(Some(&comm), "FIFO sweep failed: perm denied", &dir); + + let event = rx.recv().await.expect("audit event must be delivered"); + assert_eq!(event.actor, "system"); + assert_eq!(event.action, "flow_trace_stopped"); + let parsed: serde_json::Value = serde_json::from_str(&event.detail).unwrap(); + assert_eq!(parsed["reason"], "FIFO sweep failed: perm denied"); + assert_eq!(parsed["directory"], dir.display().to_string()); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn stop_audit_noop_when_comm_absent() { + // Passing None is the explicit test-mode path — must not panic. + emit_flow_trace_stop_audit(None, "any reason", Path::new("/tmp/anywhere")); + } +} diff --git a/net-guardia/src/core/mod.rs b/net-guardia/src/core/mod.rs index 746b348..4ed84f9 100644 --- a/net-guardia/src/core/mod.rs +++ b/net-guardia/src/core/mod.rs @@ -4,13 +4,12 @@ pub mod config_service; pub mod correlation; pub mod detection; pub mod dns_filter_service; -pub mod ebpf; pub mod email; pub mod ml; pub mod notification_service; +pub mod observability; pub mod playbook_service; pub mod rate_limit_service; pub mod report; pub mod soar; pub mod stats_aggregator; -pub mod system; diff --git a/net-guardia/src/core/notification_service.rs b/net-guardia/src/core/notification_service.rs index cf47b44..c868832 100644 --- a/net-guardia/src/core/notification_service.rs +++ b/net-guardia/src/core/notification_service.rs @@ -1,26 +1,37 @@ use std::sync::Arc; -use crate::interface::port::notification::{AlertNotifier, NotificationConfigPort}; -use crate::interface::port::repository::RepositoryPort; +use serde_json::Value; + +use crate::core::email::scheduler::SmtpClient; +use crate::interface::port::app_repo::AppRepo; +use crate::interface::port::notification::AlertNotifierFactory; use crate::interface::port::secret_store::SecretStorePort; +use crate::interface::port::setting::SettingRepo; use crate::model::error::Error; use crate::model::error::misc::MiscError; /// Domain service for notification config (Telegram, SMTP). /// Coordinates DB persistence and external service testing. pub struct NotificationService { - notif: Arc, - repo: Arc, + notif: Arc, + repo: Arc, secrets: Arc, + alert_notifier_factory: Arc, } impl NotificationService { pub fn new( - notif: Arc, - repo: Arc, + notif: Arc, + repo: Arc, secrets: Arc, + alert_notifier_factory: Arc, ) -> Self { - Self { notif, repo, secrets } + Self { + notif, + repo, + secrets, + alert_notifier_factory, + } } /// Get Telegram config with redacted bot_token. @@ -39,10 +50,10 @@ impl NotificationService { && t.len() > 8 { let redacted = format!("{}...{}", &t[..4], &t[t.len() - 4..]); - config["bot_token_redacted"] = serde_json::Value::String(redacted); + config["bot_token_redacted"] = Value::String(redacted); } config.as_object_mut().map(|obj| obj.remove("bot_token")); - config["configured"] = serde_json::Value::Bool(true); + config["configured"] = Value::Bool(true); Ok(config) } Err(_) => Ok(serde_json::json!({"configured": false})), @@ -64,33 +75,30 @@ impl NotificationService { self.notif.set_notification_config("telegram", &config_json) } - /// Send a test Telegram message using current config. + /// Send a test Telegram message using current config. The factory + /// constructs a fresh notifier on every call so the test reflects the + /// most-recently-saved config (the user typically clicks "test" + /// immediately after `set_telegram_config`). pub async fn test_telegram(&self) -> Result<(), Error> { - let adapter = crate::adapter::telegram::TelegramAdapter::new( - self.notif.clone(), - self.repo.clone(), - Some(self.secrets.clone()), - )?; - adapter.send_test_message().await + let notifier = self.alert_notifier_factory.create()?; + notifier.send_test_message().await } /// Send a test email using current SMTP config. pub fn test_smtp(&self) -> Result { - let smtp_client = - crate::core::email::scheduler::SmtpClient::from_database(self.repo.as_ref(), Some(self.secrets.as_ref()))?; - let smtp = smtp_client.ok_or_else(|| MiscError::ValidationError { - message: "SMTP not configured. Set smtp_host, smtp_port, smtp_username, smtp_password first. \ - If smtp_username is not an email address, also set smtp_sender." - .into(), + let smtp_client = SmtpClient::from_database(self.repo.as_ref(), Some(self.secrets.as_ref()))?; + let smtp = smtp_client.ok_or_else(|| { + MiscError::ValidationError( + "SMTP not configured. Set smtp_host, smtp_port, smtp_username, smtp_password first. \ + If smtp_username is not an email address, also set smtp_sender.", + ) })?; let recipient = self .repo .get_setting("smtp_recipient")? .filter(|r| !r.is_empty()) - .ok_or_else(|| MiscError::ValidationError { - message: "No smtp_recipient configured.".into(), - })?; + .ok_or_else(|| MiscError::ValidationError("No smtp_recipient configured."))?; smtp.send( &recipient, diff --git a/net-guardia/src/core/observability/log_buffer.rs b/net-guardia/src/core/observability/log_buffer.rs new file mode 100644 index 0000000..f879b80 --- /dev/null +++ b/net-guardia/src/core/observability/log_buffer.rs @@ -0,0 +1,313 @@ +use std::collections::VecDeque; +use std::fmt::{Arguments, Debug, Write as _}; +use std::sync::OnceLock; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use parking_lot::Mutex; +use serde::Serialize; +use tracing::field::{Field, Visit}; +use tracing::{Event, Level, Subscriber}; +use tracing_subscriber::Layer; +use tracing_subscriber::layer::Context; + +/// Ring-buffer capacity. Tuned for ~30 min of INFO traffic on a small SOC +/// deployment; DEBUG floods will churn faster. +const DEFAULT_CAPACITY: usize = 5_000; + +/// Per-entry payload cap. Guards against pathological debug logs from +/// bursting the buffer. +const MAX_MESSAGE_BYTES: usize = 8_192; + +/// Monotonic id allocator. Clients use `since_id` to resume tailing. +/// u64 never wraps in practice (2^64 events at 1 µs/event ≈ 584 000 years). +static NEXT_ID: AtomicU64 = AtomicU64::new(1); + +static BUFFER: OnceLock = OnceLock::new(); + +#[derive(Clone, Debug, Serialize)] +pub struct LogEntry { + pub id: u64, + pub ts_ms: u64, + pub level: &'static str, + pub target: String, + pub message: String, +} + +/// `Mutex` rather than a lock-free ring because `snapshot()` needs +/// an internally-consistent view: it filters by `since_id` + severity, then +/// clones matching entries. A seqlock / `ArrayQueue`-based design would +/// either require a retry loop that tears across concurrent writes or would +/// lose the snapshot API entirely (`ArrayQueue` only supports push/pop, not +/// iteration). The write path holds the lock for one `pop_front` + +/// `push_back` — micros under load — which the tracing subscriber can +/// comfortably pay on the event-emit hot path. Revisit if log volume grows +/// past ~10k events/sec per writer, not before. +struct LogRingBuffer { + entries: Mutex>, + capacity: usize, +} + +impl LogRingBuffer { + fn new(capacity: usize) -> Self { + Self { + entries: Mutex::new(VecDeque::with_capacity(capacity)), + capacity, + } + } + + fn push(&self, entry: LogEntry) { + let mut guard = self.entries.lock(); + if guard.len() >= self.capacity { + guard.pop_front(); + } + guard.push_back(entry); + } + + fn snapshot(&self, since_id: u64, min_severity: u8, limit: usize) -> Snapshot { + let guard = self.entries.lock(); + let total = guard.len(); + let latest_id = guard.back().map(|e| e.id).unwrap_or(0); + let entries: Vec = guard + .iter() + .filter(|e| e.id > since_id && level_severity(e.level) <= min_severity) + .take(limit) + .cloned() + .collect(); + Snapshot { + entries, + latest_id, + total, + } + } +} + +pub struct Snapshot { + pub entries: Vec, + pub latest_id: u64, + pub total: usize, +} + +/// Get a snapshot for the `/api/logs/live` endpoint. +/// +/// `min_severity` follows tracing level numeric ordering (ERROR=1…TRACE=5); +/// an entry at level L is included when `level_severity(L) <= min_severity`. +/// Returns an empty snapshot when the buffer has not been installed yet +/// (tests, dry-runs). +pub fn snapshot(since_id: u64, min_severity: u8, limit: usize) -> Snapshot { + match BUFFER.get() { + Some(buf) => buf.snapshot(since_id, min_severity, limit), + None => Snapshot { + entries: Vec::new(), + latest_id: 0, + total: 0, + }, + } +} + +/// Map a level string to severity rank. Unknown strings sort as TRACE so +/// they are only visible when the caller asks for everything. +pub fn level_severity(level: &str) -> u8 { + match level { + "ERROR" => 1, + "WARN" => 2, + "INFO" => 3, + "DEBUG" => 4, + _ => 5, + } +} + +fn level_str(level: &Level) -> &'static str { + match *level { + Level::ERROR => "ERROR", + Level::WARN => "WARN", + Level::INFO => "INFO", + Level::DEBUG => "DEBUG", + Level::TRACE => "TRACE", + } +} + +fn now_unix_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +/// [`Layer`] that appends each formatted event into the in-memory ring +/// buffer so the UI can tail logs without round-tripping the filesystem. +pub struct LogBufferLayer; + +impl LogBufferLayer { + pub fn new() -> Self { + let _ = BUFFER.set(LogRingBuffer::new(DEFAULT_CAPACITY)); + Self + } +} + +impl Default for LogBufferLayer { + fn default() -> Self { + Self::new() + } +} + +impl Layer for LogBufferLayer { + fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { + let Some(buf) = BUFFER.get() else { + return; + }; + let metadata = event.metadata(); + let mut visitor = MessageVisitor::default(); + event.record(&mut visitor); + let mut message = visitor.into_message(); + if message.len() > MAX_MESSAGE_BYTES { + message.truncate(MAX_MESSAGE_BYTES); + message.push_str("…[truncated]"); + } + let entry = LogEntry { + id: NEXT_ID.fetch_add(1, Ordering::Relaxed), + ts_ms: now_unix_ms(), + level: level_str(metadata.level()), + target: metadata.target().to_string(), + message, + }; + buf.push(entry); + } +} + +/// Collects `message` plus remaining fields as `key=value` pairs. `tracing` +/// macros emit the format-args body under the `message` field; structured +/// fields come through [`Visit::record_*`] for the respective primitive. +#[derive(Default)] +struct MessageVisitor { + message: String, + extra: String, +} + +impl MessageVisitor { + fn into_message(mut self) -> String { + if self.extra.is_empty() { + self.message + } else if self.message.is_empty() { + self.extra + } else { + self.message.push(' '); + self.message.push_str(&self.extra); + self.message + } + } + + fn push_extra(&mut self, name: &str, value: Arguments<'_>) { + if !self.extra.is_empty() { + self.extra.push(' '); + } + let _ = write!(self.extra, "{}={}", name, value); + } +} + +impl Visit for MessageVisitor { + fn record_debug(&mut self, field: &Field, value: &dyn Debug) { + if field.name() == "message" { + let _ = write!(self.message, "{:?}", value); + } else { + self.push_extra(field.name(), format_args!("{:?}", value)); + } + } + + fn record_str(&mut self, field: &Field, value: &str) { + if field.name() == "message" { + self.message.push_str(value); + } else { + self.push_extra(field.name(), format_args!("{}", value)); + } + } + + fn record_i64(&mut self, field: &Field, value: i64) { + self.push_extra(field.name(), format_args!("{}", value)); + } + + fn record_u64(&mut self, field: &Field, value: u64) { + self.push_extra(field.name(), format_args!("{}", value)); + } + + fn record_f64(&mut self, field: &Field, value: f64) { + self.push_extra(field.name(), format_args!("{}", value)); + } + + fn record_bool(&mut self, field: &Field, value: bool) { + self.push_extra(field.name(), format_args!("{}", value)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_entry(id: u64, level: &'static str, message: &str) -> LogEntry { + LogEntry { + id, + ts_ms: 0, + level, + target: "test".into(), + message: message.into(), + } + } + + #[test] + fn severity_ordering() { + assert!(level_severity("ERROR") < level_severity("WARN")); + assert!(level_severity("WARN") < level_severity("INFO")); + assert!(level_severity("INFO") < level_severity("DEBUG")); + assert!(level_severity("DEBUG") < level_severity("TRACE")); + assert_eq!(level_severity("unknown"), 5); + } + + #[test] + fn ring_buffer_drops_oldest_at_capacity() { + let buf = LogRingBuffer::new(3); + for id in 1..=5 { + buf.push(make_entry(id, "INFO", "m")); + } + let snap = buf.snapshot(0, level_severity("TRACE"), 100); + let ids: Vec = snap.entries.iter().map(|e| e.id).collect(); + assert_eq!(ids, vec![3, 4, 5]); + assert_eq!(snap.latest_id, 5); + assert_eq!(snap.total, 3); + } + + #[test] + fn snapshot_filters_since_id_and_severity() { + let buf = LogRingBuffer::new(16); + buf.push(make_entry(1, "INFO", "first")); + buf.push(make_entry(2, "DEBUG", "noisy")); + buf.push(make_entry(3, "ERROR", "boom")); + + let snap = buf.snapshot(1, level_severity("INFO"), 100); + let levels: Vec<&str> = snap.entries.iter().map(|e| e.level).collect(); + assert_eq!(levels, vec!["ERROR"]); + assert_eq!(snap.latest_id, 3); + } + + #[test] + fn snapshot_respects_limit() { + let buf = LogRingBuffer::new(16); + for id in 1..=10 { + buf.push(make_entry(id, "INFO", "m")); + } + let snap = buf.snapshot(0, level_severity("TRACE"), 4); + assert_eq!(snap.entries.len(), 4); + assert_eq!(snap.latest_id, 10); + } + + #[test] + fn visitor_concatenates_message_and_structured_fields() { + let mut v = MessageVisitor::default(); + v.push_extra("count", format_args!("{}", 42u64)); + v.push_extra("ok", format_args!("{}", true)); + v.message.push_str("hello"); + let out = v.into_message(); + assert!(out.contains("hello")); + assert!(out.contains("count=42")); + assert!(out.contains("ok=true")); + } +} diff --git a/net-guardia/src/core/observability/mod.rs b/net-guardia/src/core/observability/mod.rs new file mode 100644 index 0000000..bb1f13a --- /dev/null +++ b/net-guardia/src/core/observability/mod.rs @@ -0,0 +1 @@ +pub mod log_buffer; diff --git a/net-guardia/src/core/playbook_service.rs b/net-guardia/src/core/playbook_service.rs index acfe682..b666c6a 100644 --- a/net-guardia/src/core/playbook_service.rs +++ b/net-guardia/src/core/playbook_service.rs @@ -1,32 +1,28 @@ +use std::collections::HashMap; +use std::net::IpAddr; use std::sync::Arc; +use serde_json::Value; + use crate::core::soar::engine::SoarEngine; use crate::interface::port::access_control::AccessControlPort; -use crate::interface::port::soar::SoarPort; -use macros::log; - +use crate::interface::port::app_repo::AppRepo; use crate::model::error::Error; use crate::model::error::soar::SoarError; use crate::model::soar::playbook_data::{ ActionData, ActiveBlockData, ConditionData, CreatePlaybookInput, ExecutionData, PlaybookData, UpdatePlaybookRow, }; -use std::collections::HashMap; - /// Domain service for SOAR playbook CRUD operations. /// Coordinates DB reads/writes, SOAR engine cache refresh, and eBPF unblock. pub struct PlaybookService { - db: Arc, + db: Arc, soar_engine: Arc, access_control: Arc, } impl PlaybookService { - pub fn new( - db: Arc, - soar_engine: Arc, - access_control: Arc, - ) -> Self { + pub fn new(db: Arc, soar_engine: Arc, access_control: Arc) -> Self { Self { db, soar_engine, @@ -98,7 +94,7 @@ impl PlaybookService { id: aid, action_order: order, action_type: atype, - params: serde_json::from_str(¶ms_str).unwrap_or(serde_json::Value::Null), + params: serde_json::from_str(¶ms_str).unwrap_or(Value::Null), }); } } @@ -125,27 +121,35 @@ impl PlaybookService { } pub fn create_playbook(&self, input: &CreatePlaybookInput) -> Result { - let playbook_id = self.db.insert_playbook( + // Single atomic insert (playbook + actions + conditions). + let actions: Vec<(i64, String, String)> = input + .actions + .iter() + .enumerate() + .map(|(i, (ty, params))| ((i + 1) as i64, ty.clone(), params.clone())) + .collect(); + let conditions: Vec<(String, String, String, Option)> = input + .conditions + .iter() + .map(|c| { + ( + c.condition_type.clone(), + c.operator.clone(), + c.value.clone(), + c.value2.clone(), + ) + }) + .collect(); + let playbook_id = self.db.insert_playbook_atomic( &input.name, &input.trigger_event, input.condition_threshold, input.condition_count, input.condition_window_secs, input.cooldown_secs, + &actions, + &conditions, )?; - for (i, (action_type, params_str)) in input.actions.iter().enumerate() { - self.db - .insert_playbook_action(playbook_id, (i + 1) as i64, action_type, params_str)?; - } - for cond in &input.conditions { - self.db.insert_playbook_condition( - playbook_id, - &cond.condition_type, - &cond.operator, - &cond.value, - cond.value2.as_deref(), - )?; - } self.soar_engine.reload_cache()?; Ok(playbook_id) } @@ -159,28 +163,29 @@ impl PlaybookService { condition_window_secs: input.condition_window_secs, cooldown_secs: input.cooldown_secs, }; - let updated = self.db.update_playbook(id, &row)?; + // Single atomic update (playbook metadata + replace actions/conditions). + let actions: Vec<(i64, String, String)> = input + .actions + .iter() + .enumerate() + .map(|(i, (ty, params))| ((i + 1) as i64, ty.clone(), params.clone())) + .collect(); + let conditions: Vec<(String, String, String, Option)> = input + .conditions + .iter() + .map(|c| { + ( + c.condition_type.clone(), + c.operator.clone(), + c.value.clone(), + c.value2.clone(), + ) + }) + .collect(); + let updated = self.db.update_playbook_atomic(id, &row, &actions, &conditions)?; if !updated { return Ok(false); } - - // Delete old actions and conditions, then re-insert - self.db.delete_playbook_actions(id)?; - self.db.delete_playbook_conditions(id)?; - - for (i, (action_type, params_str)) in input.actions.iter().enumerate() { - self.db - .insert_playbook_action(id, (i + 1) as i64, action_type, params_str)?; - } - for cond in &input.conditions { - self.db.insert_playbook_condition( - id, - &cond.condition_type, - &cond.operator, - &cond.value, - cond.value2.as_deref(), - )?; - } self.soar_engine.reload_cache()?; Ok(true) } @@ -214,29 +219,24 @@ impl PlaybookService { .collect()) } - /// Manually unblock an IP: remove from eBPF, mark DB, decrement counter. + /// Manually unblock an IP: remove from eBPF, atomically clear both DB + /// tables via `DbAdminRepo::commit_soar_unblock_to_db` (tx-3), decrement + /// counter. pub async fn manual_unblock(&self, id: i64) -> Result<(), Error> { // Look up the block to get source_ip let block = self .db .get_soar_block_by_id(id)? - .ok_or_else(|| SoarError::ActionFailed { - action_type: "manual_unblock".to_string(), - reason: format!("Block rule {} not found", id), - })?; + .ok_or_else(|| SoarError::UnblockRuleNotFound(id))?; let source_ip = &block.1; // Remove from eBPF ACL - self.access_control.unblock_ip(source_ip).await?; + self.access_control.unblock_ip(source_ip)?; - // Also remove the auto-added acl_rules entry + // Atomically drop acl_rules entry AND mark soar_block_rules + // unblocked in one transaction. let ip_version = ip_version_from_str(source_ip); - if let Err(e) = self.db.delete_acl_rule(ip_version, "source", "blacklist", source_ip, 0) { - log!(SoarError::AclCleanupFailed(e)); - } - - // Mark as unblocked in DB - self.db.mark_soar_block_unblocked(id)?; + self.db.commit_soar_unblock_to_db(id, ip_version, source_ip)?; // Decrement active block counter self.soar_engine.decrement_block_count(); @@ -254,7 +254,7 @@ impl PlaybookService { playbook_id: pb_id, source_ip, trigger_event, - actions_executed: serde_json::from_str(&actions).unwrap_or(serde_json::Value::Null), + actions_executed: serde_json::from_str(&actions).unwrap_or(Value::Null), created_at, }, ) @@ -280,9 +280,9 @@ impl PlaybookService { /// Determine IP version from a string address using proper parsing. pub fn ip_version_from_str(ip: &str) -> u8 { - match ip.parse::() { - Ok(std::net::IpAddr::V4(_)) => 4, - Ok(std::net::IpAddr::V6(_)) => 6, + match ip.parse::() { + Ok(IpAddr::V4(_)) => 4, + Ok(IpAddr::V6(_)) => 6, Err(_) => { if ip.contains(':') { 6 diff --git a/net-guardia/src/core/rate_limit_service.rs b/net-guardia/src/core/rate_limit_service.rs index 31927b0..7746245 100644 --- a/net-guardia/src/core/rate_limit_service.rs +++ b/net-guardia/src/core/rate_limit_service.rs @@ -1,22 +1,22 @@ use std::sync::Arc; -use crate::core::ebpf::rate_limit::RateLimitConfig; -use crate::interface::port::repository::RepositoryPort; +use crate::interface::port::app_repo::AppRepo; +use crate::interface::port::rate_limit_api::RateLimitPort; use crate::model::error::Error; /// Domain service that coordinates rate limit config updates between DB and eBPF. pub struct RateLimitService { - db: Arc, - config: Arc, + db: Arc, + config: Arc, } impl RateLimitService { - pub fn new(db: Arc, config: Arc) -> Self { + pub fn new(db: Arc, config: Arc) -> Self { Self { db, config } } - pub fn config(&self) -> &RateLimitConfig { - &self.config + pub fn config(&self) -> &dyn RateLimitPort { + self.config.as_ref() } pub fn update(&self, settings: &RateLimitSettings) -> Result<(), Error> { diff --git a/net-guardia/src/core/report/engine.rs b/net-guardia/src/core/report/engine.rs index 7a5d771..19ad6f9 100644 --- a/net-guardia/src/core/report/engine.rs +++ b/net-guardia/src/core/report/engine.rs @@ -1,31 +1,32 @@ +use std::fs; use std::path::PathBuf; -use tracing::info; -use crate::interface::port::repository::RepositoryPort; +use chrono::Local; +use macros::log; + +use crate::interface::port::setting::SettingRepo; use crate::model::error::Error; -use crate::model::error::notification::NotificationError; +use crate::model::error::io::IOError; +use crate::model::error::misc::MiscError; +use crate::model::log::system::SystemLog; use crate::model::report::data::ReportData; /// Generate a self-contained HTML security report and write to disk. /// Returns the path to the generated HTML file. -pub fn generate_html_report(db: &dyn RepositoryPort, output_dir: &str) -> Result { +pub fn generate_html_report(db: &dyn SettingRepo, output_dir: &str) -> Result { let data = ReportData::from_database(db)?; let html = render_html_report(&data); let html_path = PathBuf::from(output_dir).join(format!( "netguardia-report-{}.html", - chrono::Local::now().format("%Y%m%d-%H%M%S") + Local::now().format("%Y%m%d-%H%M%S") )); - std::fs::create_dir_all(output_dir).map_err(|e| NotificationError::TelegramApiError { - reason: format!("Failed to create report directory: {}", e), - })?; + fs::create_dir_all(output_dir).map_err(|e| IOError::CreateDirectoryFailed(PathBuf::from(output_dir), e))?; - std::fs::write(&html_path, &html).map_err(|e| NotificationError::TelegramApiError { - reason: format!("Failed to write HTML report: {}", e), - })?; + fs::write(&html_path, &html).map_err(|e| IOError::WriteFileFailed(html_path.clone(), e))?; - info!("HTML report generated at {:?}", html_path); + log!(SystemLog::HtmlReportGenerated(format!("{html_path:?}"))); Ok(html_path) } @@ -201,12 +202,7 @@ fn html_escape(s: &str) -> String { } /// Generate report data and format as JSON (for API responses). -pub fn generate_report_json(db: &dyn RepositoryPort) -> Result { +pub fn generate_report_json(db: &dyn SettingRepo) -> Result { let data = ReportData::from_database(db)?; - serde_json::to_value(&data).map_err(|e| { - NotificationError::TelegramApiError { - reason: format!("Failed to serialize report: {}", e), - } - .into() - }) + serde_json::to_value(&data).map_err(|e| MiscError::SerializeError(e).into()) } diff --git a/net-guardia/src/core/soar/actions.rs b/net-guardia/src/core/soar/actions.rs new file mode 100644 index 0000000..06217f0 --- /dev/null +++ b/net-guardia/src/core/soar/actions.rs @@ -0,0 +1,564 @@ +//! SOAR application layer: playbook orchestration + action execution. +//! +//! Each action here reaches out to external systems (DB writes, eBPF blocks, +//! Telegram API, SMTP, webhooks). They are all invoked through `execute_action` +//! dispatch, which is itself called from `execute_playbook` after the domain +//! layer (`matcher.rs`) decided the playbook should fire. + +use std::net::{IpAddr, SocketAddr}; +use std::sync::Arc; +use std::sync::atomic::Ordering; +use std::time::Duration; + +use chrono::{Duration as ChronoDuration, Utc}; +use macros::log; +use reqwest::Client; +use tokio::net::lookup_host; +use tokio::task::spawn_blocking; +use url::Url; + +use crate::core::email::scheduler::SmtpClient; +use crate::core::playbook_service::ip_version_from_str; +use crate::core::soar::engine::SoarEngine; +use crate::interface::port::access_control::AccessControlPort; +use crate::interface::port::app_repo::AppRepo; +use crate::interface::port::notification::AlertPayload; +use crate::model::error::Error; +use crate::model::error::soar::SoarError; +use crate::model::event::ThreatDetectedEvent; +use crate::model::log::soar::SoarLog; +use crate::model::soar::playbook::{Playbook, PlaybookAction}; + +/// Default block TTL when a `block_ip` action omits `ttl_secs`. +const DEFAULT_BLOCK_TTL_SECS: u64 = 1800; +/// DB-overridable cap on per-block TTL — see setting `soar_max_ttl_secs`. +const DEFAULT_SOAR_MAX_TTL_SECS: u64 = 86_400; +/// DB-overridable cap on concurrent SOAR-driven blocks — see setting +/// `soar_max_auto_block_cap`. +const DEFAULT_SOAR_MAX_AUTO_BLOCK_CAP: u32 = 100; +/// Default rate-limit reduction factor when an `adjust_rate_limit` action +/// omits `factor`. 0.5 = halve the current rate. +const DEFAULT_RATE_LIMIT_FACTOR: f64 = 0.5; +/// Default rate-limit TTL when an `adjust_rate_limit` action omits `ttl_secs`. +const DEFAULT_RATE_LIMIT_TTL_SECS: u64 = 600; +/// Lower bound on the rate-limit factor — anything below 1% of current +/// would brick traffic flow. +const RATE_LIMIT_FACTOR_MIN: f64 = 0.01; +/// Upper bound on the rate-limit factor — `1.0` is a no-op; values above +/// would *raise* the limit, which isn't a SOAR mitigation. +const RATE_LIMIT_FACTOR_MAX: f64 = 1.0; +/// Default webhook timeout when an action omits `timeout_secs`. +const DEFAULT_WEBHOOK_TIMEOUT_SECS: u64 = 10; +/// Fallback port when the webhook URL has no explicit port and no +/// well-known scheme port. +const DEFAULT_WEBHOOK_HTTPS_PORT: u16 = 443; +/// Sentinel `playbook_id` used by the no-matching-playbook fallback path. +/// `-1` is reserved on the audit / cooldown maps and never assigned to a +/// real DB playbook row. +const FALLBACK_PLAYBOOK_ID: i64 = -1; +/// Cooldown applied to the fallback path so a single noisy IP doesn't +/// spam the WORM audit chain on every fused detection. +const FALLBACK_COOLDOWN_SECS: i64 = 300; + +impl SoarEngine { + /// Check if the system is in enforce mode (as opposed to monitor mode). + /// Reads from the in-memory AtomicU8 cache: Monitor=0, MlOnly=1, Enforce=2. + pub(super) fn is_enforce_mode(&self) -> bool { + self.enforce_level_cache.load(Ordering::Relaxed) == 2 + } + + /// Execute a single playbook against an event. + pub(super) async fn execute_playbook(&self, playbook: &Playbook, event: &ThreatDetectedEvent) -> Result<(), Error> { + // Check cooldown + if self.is_cooldown_active(playbook.id, &event.source_ip, playbook.cooldown_secs) { + log!(SoarLog::CooldownActive(playbook.name.clone(), event.source_ip.clone())); + return Ok(()); + } + + // Check admin whitelist + if self.admin_whitelist.load().contains(&event.source_ip) { + log!(SoarLog::WhitelistSkipped( + event.source_ip.clone(), + playbook.name.clone() + )); + return Ok(()); + } + + // Execute actions in order + let mut action_results = Vec::new(); + for action in &playbook.actions { + let result = self.execute_action(action, event, playbook.id).await; + let result_json = match &result { + Ok(msg) => serde_json::json!({"action": &action.action_type, "status": "ok", "message": msg}), + Err(e) => { + serde_json::json!({"action": &action.action_type, "status": "error", "message": e.to_string()}) + } + }; + action_results.push(result_json); + if let Err(e) = result { + log!(SoarLog::PlaybookError( + playbook.name.clone(), + format!("Action '{}': {}", action.action_type, e) + )); + } + } + + // Record cooldown + self.record_cooldown(playbook.id, &event.source_ip); + + // Write audit trail + let actions_json = serde_json::to_string(&action_results).unwrap_or_default(); + self.db + .insert_soar_execution(playbook.id, Some(&event.source_ip), &event.attack_type, &actions_json)?; + + log!(SoarLog::PlaybookExecuted( + playbook.name.clone(), + event.source_ip.clone(), + event.attack_type.clone() + )); + + Ok(()) + } + + /// Execute a single action. + pub(super) async fn execute_action( + &self, + action: &PlaybookAction, + event: &ThreatDetectedEvent, + playbook_id: i64, + ) -> Result { + match action.action_type.as_str() { + "block_ip" => { + if !self.is_enforce_mode() { + log!(SoarLog::MonitorModeSkipped( + action.action_type.clone(), + event.source_ip.clone() + )); + return Ok(format!("[monitor] Would block IP {} — skipped", event.source_ip)); + } + self.action_block_ip(action, event, playbook_id).await + } + "adjust_rate_limit" => { + if !self.is_enforce_mode() { + log!(SoarLog::MonitorModeSkipped( + action.action_type.clone(), + event.source_ip.clone() + )); + return Ok("[monitor] Would adjust rate limit — skipped".to_string()); + } + self.action_adjust_rate_limit(action, event).await + } + "send_telegram" => self.action_send_telegram(event).await, + "send_email" => self.action_send_email(event).await, + "webhook" => self.action_webhook(action, event).await, + "log" => self.action_log(action, event), + other => Err(SoarError::UnknownActionType(other))?, + } + } + + /// Block an IP via eBPF ACL with TTL. + async fn action_block_ip( + &self, + action: &PlaybookAction, + event: &ThreatDetectedEvent, + playbook_id: i64, + ) -> Result { + let ttl_secs = action + .params + .get("ttl_secs") + .and_then(|v| v.as_u64()) + .unwrap_or(DEFAULT_BLOCK_TTL_SECS); + + // Two settings read together off the tokio worker thread — r2d2's + // pool.get() and rusqlite are blocking, so back-to-back calls inside + // an async fn can stall the executor under burst load. + let (max_ttl, max_cap) = read_block_caps(self.db.clone()).await?; + if ttl_secs > max_ttl { + Err(SoarError::InvalidTtl(ttl_secs, max_ttl))?; + } + + // Atomically check cap and reserve a slot using CAS loop. + loop { + let current_count = self.active_block_count.load(Ordering::SeqCst); + if current_count >= max_cap { + log!(SoarLog::CapReached(current_count, max_cap, event.source_ip.clone())); + Err(SoarError::CapReached(max_cap))?; + } + if self + .active_block_count + .compare_exchange(current_count, current_count + 1, Ordering::SeqCst, Ordering::SeqCst) + .is_ok() + { + break; + } + } + + // Block IP via AccessControlPort (handles IPv4/IPv6 dispatch internally). + // Offloaded to `spawn_blocking` so the tokio worker is not stuck while + // parking_lot::RwLock + aya map syscalls run — under SOAR burst with + // `active_block_count` approaching `max_cap`, this keeps the rest of + // the runtime responsive. + if let Err(e) = block_ip_blocking(Arc::clone(&self.access_control), event.source_ip.clone()).await { + self.decrement_block_count(); + return Err(e); + } + + // Calculate expiry time + let expires_at = Utc::now() + ChronoDuration::seconds(ttl_secs as i64); + let expires_str = expires_at.format("%Y-%m-%d %H:%M:%S").to_string(); + + // Atomically write soar_block_rules + acl_rules. Either both commit + // or both roll back — no half-state possible. Offloaded so the + // SQLite WAL fsync can't block the tokio worker. + let ip_version = ip_version_from_str(&event.source_ip); + let commit_result = commit_block_blocking( + self.db.clone(), + event.source_ip.clone(), + ip_version, + playbook_id, + expires_str.clone(), + ) + .await; + if let Err(e) = commit_result { + // DB tx rolled back both rows; now roll back the eBPF block. + let unblock_outcome = unblock_ip_blocking(Arc::clone(&self.access_control), event.source_ip.clone()).await; + if let Err(unblock_err) = unblock_outcome { + log!(SoarLog::EventHandlingFailed(format!( + "CRITICAL: Failed to unblock IP {} after DB error — queueing for retry: {}", + event.source_ip, unblock_err + ))); + // Write to pending_unblock table so recovery can retry later + if let Err(pend_err) = insert_pending_unblock_blocking(self.db.clone(), event.source_ip.clone()).await { + log!(SoarLog::EventHandlingFailed(format!( + "CRITICAL: Failed to queue pending unblock for IP {}: {}", + event.source_ip, pend_err + ))); + } + } + self.decrement_block_count(); + return Err(e); + } + + Ok(format!("Blocked IP {} for {}s", event.source_ip, ttl_secs)) + } + + /// Temporarily reduce global rate limits by a factor with TTL-based restoration. + /// Params: { "factor": 0.5, "ttl_secs": 600 } + /// factor < 1.0 means stricter (e.g. 0.5 = half the current rate). + async fn action_adjust_rate_limit( + &self, + action: &PlaybookAction, + event: &ThreatDetectedEvent, + ) -> Result { + let owner = self.rate_limit.as_ref().ok_or(SoarError::RateLimitUnavailable)?; + + let factor = action + .params + .get("factor") + .and_then(|v| v.as_f64()) + .unwrap_or(DEFAULT_RATE_LIMIT_FACTOR); + let ttl_secs = action + .params + .get("ttl_secs") + .and_then(|v| v.as_u64()) + .unwrap_or(DEFAULT_RATE_LIMIT_TTL_SECS); + + if !(RATE_LIMIT_FACTOR_MIN..=RATE_LIMIT_FACTOR_MAX).contains(&factor) { + Err(SoarError::InvalidRateLimitFactor(factor))?; + } + + let max_ttl = read_max_ttl(self.db.clone()).await; + if ttl_secs > max_ttl { + Err(SoarError::InvalidTtl(ttl_secs, max_ttl))?; + } + + owner + .adjust(factor, ttl_secs, event.source_ip.clone(), event.attack_type.clone()) + .await + } + + /// Send Telegram notification. + async fn action_send_telegram(&self, event: &ThreatDetectedEvent) -> Result { + if let Some(notifier) = &self.alert_notifier { + let country = if let Some(geoip) = &self.geoip { + if let Ok(ip_addr) = event.source_ip.parse::() { + match geoip.lookup(ip_addr).await { + Ok(Some(loc)) => loc.country, + _ => None, + } + } else { + None + } + } else { + None + }; + let repeat_tag = if event.is_repeat_offender { " [REPEAT]" } else { "" }; + let payload = AlertPayload { + source_ip: event.source_ip.clone(), + dest_ip: event.dest_ip.clone(), + country, + threat_type: event.attack_type.clone(), + confidence: event.confidence, + action_description: format!( + "SOAR auto-response triggered (hits: {}{})", + event.flow_count, repeat_tag, + ), + timestamp: Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(), + }; + notifier.send_alert(&payload).await?; + Ok("Telegram notification sent".to_string()) + } else { + log!(SoarLog::TelegramNotConfigured); + Ok("Telegram not configured, skipped".to_string()) + } + } + + /// Send email alert. + async fn action_send_email(&self, event: &ThreatDetectedEvent) -> Result { + match SmtpClient::from_soar_port(&*self.db, self.secrets.as_deref())? { + Some(smtp) => { + let subject = format!( + "[NetGuardia] Threat Alert: {} from {}", + event.attack_type, event.source_ip + ); + let body = format!( + "

Threat Detected

\ +

Source IP: {}

\ +

Threat Type: {}

\ +

Confidence: {:.1}%

\ +

Time: {}

", + event.source_ip, + event.attack_type, + event.confidence * 100.0, + Utc::now().format("%Y-%m-%d %H:%M:%S UTC"), + ); + if let Some(recipient) = self.db.get_setting("smtp_recipient")? { + spawn_blocking(move || smtp.send(&recipient, &subject, &body)) + .await + .map_err(|e| SoarError::ActionFailed("send_email", e))??; + Ok("Email alert sent".to_string()) + } else { + Ok("No SMTP recipient configured, skipped".to_string()) + } + } + None => Ok("SMTP not configured, skipped".to_string()), + } + } + + /// Send a webhook HTTP POST with SSRF DNS rebinding protection. + /// Params: { "url": "https://example.com/hook", "timeout_secs": 10 } + async fn action_webhook(&self, action: &PlaybookAction, event: &ThreatDetectedEvent) -> Result { + let url_str = action + .params + .get("url") + .and_then(|v| v.as_str()) + .ok_or_else(|| SoarError::WebhookMissingParam("url"))?; + + let timeout_secs = action + .params + .get("timeout_secs") + .and_then(|v| v.as_u64()) + .unwrap_or(DEFAULT_WEBHOOK_TIMEOUT_SECS); + + // Parse URL and extract host + let parsed_url = Url::parse(url_str).map_err(|e| SoarError::ActionFailed("webhook", e))?; + + let host = parsed_url.host_str().ok_or(SoarError::WebhookUrlNoHost)?; + + // DNS resolve all IPs and verify none are private/loopback/link-local + let port = parsed_url.port_or_known_default().unwrap_or(DEFAULT_WEBHOOK_HTTPS_PORT); + let resolve_target = format!("{}:{}", host, port); + let addrs: Vec = lookup_host(&resolve_target) + .await + .map_err(|e| SoarError::ActionFailed(format!("webhook (DNS for {})", host), e))? + .collect(); + + if addrs.is_empty() { + Err(SoarError::WebhookDnsEmpty(host))?; + } + + for addr in &addrs { + if Self::is_private_ip(&addr.ip()) { + log!(SoarLog::EventHandlingFailed(format!( + "SSRF blocked: webhook URL '{}' resolved to private IP {}", + url_str, + addr.ip() + ))); + Err(SoarError::WebhookSsrfBlocked(host, addr.ip().to_string()))?; + } + } + + // Build and send the webhook payload (includes all enriched fields) + let sources_str: Vec = event.sources.iter().map(|s| s.to_string()).collect(); + let payload = serde_json::json!({ + "source_ip": event.source_ip, + "dest_ip": event.dest_ip, + "attack_type": event.attack_type, + "confidence": event.confidence, + "flow_count": event.flow_count, + "packet_rate": event.packet_rate, + "protocol": event.protocol, + "geoip_country": event.geoip_country, + "is_repeat_offender": event.is_repeat_offender, + "detection_sources": sources_str, + "timestamp": Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(), + }); + + // Pin resolved IPs to prevent DNS rebinding: the DNS check above verified + // all resolved addresses are public, so we force reqwest to use those same + // addresses instead of re-resolving (which could return a private IP on TTL expiry). + let mut client_builder = Client::builder().timeout(Duration::from_secs(timeout_secs)); + for addr in &addrs { + client_builder = client_builder.resolve(host, *addr); + } + let client = client_builder + .build() + .map_err(|e| SoarError::ActionFailed("webhook", e))?; + + let resp = client + .post(url_str) + .json(&payload) + .send() + .await + .map_err(|e| SoarError::ActionFailed("webhook", e))?; + + let status = resp.status(); + if status.is_success() { + Ok(format!("Webhook sent to {} (status {})", url_str, status)) + } else { + Err(SoarError::WebhookHttpStatus(status.as_u16()))? + } + } + + /// Log action. + fn action_log(&self, action: &PlaybookAction, event: &ThreatDetectedEvent) -> Result { + let level = action.params.get("level").and_then(|v| v.as_str()).unwrap_or("warn"); + + log!(SoarLog::ActionLog( + level.to_string(), + event.source_ip.clone(), + event.attack_type.clone(), + format!("{:.2}", event.confidence), + format!("{:.3}", event.ae_score), + format!("{:.3}", event.anomaly_score), + format!("{:.3}", event.c2_score), + )); + + Ok(format!("Logged at level '{}'", level)) + } + + /// Fallback execution when no playbook matches. + /// Only fires when source_ip is present. + pub(super) async fn execute_fallback(&self, event: &ThreatDetectedEvent) -> Result<(), Error> { + // Check admin whitelist — never block admin IPs even in fallback + if self.admin_whitelist.load().contains(&event.source_ip) { + log!(SoarLog::WhitelistSkipped( + event.source_ip.clone(), + "fallback".to_string() + )); + return Ok(()); + } + + // Check cooldown — uses FALLBACK_PLAYBOOK_ID as the synthetic key + if self.is_cooldown_active(FALLBACK_PLAYBOOK_ID, &event.source_ip, FALLBACK_COOLDOWN_SECS) { + log!(SoarLog::CooldownActive("fallback".to_string(), event.source_ip.clone())); + return Ok(()); + } + + // Default fallback: block IP for the default block TTL + log + let fake_action = PlaybookAction { + action_order: 1, + action_type: "block_ip".to_string(), + params: serde_json::json!({"ttl_secs": DEFAULT_BLOCK_TTL_SECS}), + }; + + let block_result = self.execute_action(&fake_action, event, FALLBACK_PLAYBOOK_ID).await; + let result_json = match &block_result { + Ok(msg) => serde_json::json!({"action": "block_ip", "status": "ok", "message": msg}), + Err(e) => serde_json::json!({"action": "block_ip", "status": "error", "message": e.to_string()}), + }; + + // Record cooldown for fallback + self.record_cooldown(FALLBACK_PLAYBOOK_ID, &event.source_ip); + + // Audit trail under the fallback synthetic playbook id + self.db.insert_soar_execution( + FALLBACK_PLAYBOOK_ID, + Some(&event.source_ip), + &event.attack_type, + &serde_json::to_string(&[result_json]).unwrap_or_default(), + )?; + + log!(SoarLog::FallbackExecuted(event.source_ip.clone())); + Ok(()) + } +} + +/// Read both block-related caps in a single offloaded blocking call so the +/// async caller pays one spawn_blocking hop instead of two. +async fn read_block_caps(db: Arc) -> Result<(u64, u32), Error> { + spawn_blocking(move || { + let max_ttl: u64 = db + .get_setting("soar_max_ttl_secs") + .ok() + .flatten() + .and_then(|v| v.parse().ok()) + .unwrap_or(DEFAULT_SOAR_MAX_TTL_SECS); + let max_cap: u32 = db + .get_setting("soar_max_auto_block_cap") + .ok() + .flatten() + .and_then(|v| v.parse().ok()) + .unwrap_or(DEFAULT_SOAR_MAX_AUTO_BLOCK_CAP); + Ok::<_, Error>((max_ttl, max_cap)) + }) + .await + .map_err(|e| SoarError::ActionFailed("read_block_caps", e))? +} + +async fn read_max_ttl(db: Arc) -> u64 { + spawn_blocking(move || { + db.get_setting("soar_max_ttl_secs") + .ok() + .flatten() + .and_then(|v| v.parse().ok()) + .unwrap_or(DEFAULT_SOAR_MAX_TTL_SECS) + }) + .await + .unwrap_or(DEFAULT_SOAR_MAX_TTL_SECS) +} + +async fn commit_block_blocking( + db: Arc, + source_ip: String, + ip_version: u8, + playbook_id: i64, + expires_str: String, +) -> Result<(), Error> { + spawn_blocking(move || db.commit_soar_block_to_db(&source_ip, ip_version, playbook_id, &expires_str)) + .await + .map_err(|e| SoarError::ActionFailed("commit_soar_block_to_db", e))??; + Ok(()) +} + +async fn insert_pending_unblock_blocking(db: Arc, source_ip: String) -> Result { + spawn_blocking(move || db.insert_pending_unblock(&source_ip)) + .await + .map_err(|e| SoarError::ActionFailed("insert_pending_unblock", e))? +} + +/// Offload `AccessControlPort::block_ip` onto a blocking thread. The port is +/// synchronous because its eBPF-map critical sections are tiny (microseconds), +/// but under SOAR burst many tokio workers would contend on the same +/// `parking_lot::RwLock` and the aya syscall itself blocks the executor. +async fn block_ip_blocking(access_control: Arc, ip: String) -> Result<(), Error> { + spawn_blocking(move || access_control.block_ip(&ip)) + .await + .map_err(|e| SoarError::ActionFailed("block_ip", e))? +} + +async fn unblock_ip_blocking(access_control: Arc, ip: String) -> Result<(), Error> { + spawn_blocking(move || access_control.unblock_ip(&ip)) + .await + .map_err(|e| SoarError::ActionFailed("unblock_ip", e))? +} diff --git a/net-guardia/src/core/soar/engine.rs b/net-guardia/src/core/soar/engine.rs index e584957..ae31b61 100644 --- a/net-guardia/src/core/soar/engine.rs +++ b/net-guardia/src/core/soar/engine.rs @@ -1,21 +1,27 @@ use std::collections::HashSet; -use std::net::IpAddr; use std::sync::Arc; use std::sync::atomic::{AtomicU8, AtomicU32, Ordering}; +use std::time::Instant; +use arc_swap::ArcSwap; use dashmap::DashMap; use macros::log; +use serde_json::Value; +use tokio::sync::Semaphore; use tokio::sync::broadcast; +use tokio::sync::broadcast::error::RecvError; -use crate::core::ebpf::rate_limit::RateLimitConfig; use crate::core::soar::frequency::FrequencyTracker; +use crate::core::soar::rate_limit_owner::RateLimitOwnerHandle; use crate::infrastructure::communication_manager::CommunicationManager; use crate::infrastructure::geoip::GeoIpService; use crate::interface::port::access_control::AccessControlPort; -use crate::interface::port::notification::{AlertNotifier, AlertPayload}; +use crate::interface::port::app_repo::AppRepo; +use crate::interface::port::notification::AlertNotifier; +use crate::interface::port::rate_limit_api::RateLimitPort; use crate::interface::port::secret_store::SecretStorePort; -use crate::interface::port::soar::SoarPort; use crate::model::config::constants::MAX_PENDING_UNBLOCK_RETRIES; +use crate::model::detection::attack_type::canonical_from_str; use crate::model::error::Error; use crate::model::error::soar::SoarError; use crate::model::event::ThreatDetectedEvent; @@ -26,56 +32,75 @@ use crate::model::soar::playbook::{Playbook, PlaybookAction}; /// Cooldown key: (playbook_id, source_ip) type CooldownKey = (i64, String); +/// Maximum number of `handle_threat_event` futures allowed in flight at the +/// same time. Replaces the previous unbounded `tokio::spawn`-per-event +/// pattern, which under fusion-emit bursts could pile up faster than the +/// executor drains and starve other async work. When all permits are held, +/// the event loop blocks at `Semaphore::acquire_owned` — backpressure then +/// surfaces as broadcast `Lagged` (visible in the receiver-lag metric) +/// rather than as silent task-queue growth. +const SOAR_HANDLE_CONCURRENCY: usize = 16; + /// SOAR Engine — subscribes to ThreatDetectedEvent and executes matching playbooks. +/// +/// The engine is intentionally split across three files within `core::soar`: +/// - `engine.rs` (this file) — struct definition, lifecycle (new/start/event_loop), +/// cache reload, recovery, rate-limit-TTL restoration +/// - `matcher.rs` — domain: playbook matching, condition evaluation, cooldowns +/// - `actions.rs` — application: action dispatch + all action_* implementations +/// +/// Fields are `pub(super)` so the sibling files can read them; external +/// callers still see the struct via its public methods only. pub struct SoarEngine { - db: Arc, - access_control: Arc, + pub(super) db: Arc, + pub(super) access_control: Arc, /// In-memory cache of playbooks (loaded at startup, refreshed on change). - playbooks: parking_lot::RwLock>, + pub(super) playbooks: ArcSwap>>, /// In-memory cache of admin whitelist IPs. - admin_whitelist: parking_lot::RwLock>, + pub(super) admin_whitelist: ArcSwap>, /// Cooldown tracker: maps (playbook_id, source_ip) → last execution time. - cooldowns: DashMap, + pub(super) cooldowns: DashMap, /// Frequency tracker for frequency-based conditions. - frequency_tracker: FrequencyTracker, + pub(super) frequency_tracker: FrequencyTracker, /// AtomicU32 counter for active auto-blocks (avoids DB query per event). - active_block_count: AtomicU32, + pub(super) active_block_count: AtomicU32, /// Optional alert notifier (Telegram, etc.). - alert_notifier: Option>, + pub(super) alert_notifier: Option>, /// Optional GeoIP service for country lookups. - geoip: Option>, - /// Optional rate limit config for adjust_rate_limit action. - rate_limit: Option>, - /// Lock to serialize rate limit read-save-write sequences (Item 6: atomicity). - rate_limit_lock: tokio::sync::Mutex<()>, + pub(super) geoip: Option>, + /// Owner-task handle that serializes the rate-limit DB+eBPF + /// read-modify-write batch. `None` when no `RateLimitPort` was wired + /// up (eBPF unavailable); SOAR actions that need rate-limit then + /// fail with `SoarError::RateLimitUnavailable`. + pub(super) rate_limit: Option, /// Cached enforce level: Monitor=0, MlOnly=1, Enforce=2. - enforce_level_cache: Arc, + pub(super) enforce_level_cache: Arc, /// Secret store for decrypting SMTP passwords etc. - secrets: Option>, + pub(super) secrets: Option>, } impl SoarEngine { pub fn new( - db: Arc, + db: Arc, access_control: Arc, alert_notifier: Option>, geoip: Option>, - rate_limit: Option>, + rate_limit: Option>, enforce_level_cache: Arc, secrets: Option>, ) -> Result { + let rate_limit_owner = rate_limit.map(|rl| RateLimitOwnerHandle::spawn(db.clone(), rl)); let engine = Self { db, access_control, - playbooks: parking_lot::RwLock::new(Vec::new()), - admin_whitelist: parking_lot::RwLock::new(HashSet::new()), + playbooks: ArcSwap::from_pointee(Vec::new()), + admin_whitelist: ArcSwap::from_pointee(HashSet::new()), cooldowns: DashMap::new(), frequency_tracker: FrequencyTracker::new(), active_block_count: AtomicU32::new(0), alert_notifier, geoip, - rate_limit, - rate_limit_lock: tokio::sync::Mutex::new(()), + rate_limit: rate_limit_owner, enforce_level_cache, secrets, }; @@ -107,12 +132,12 @@ impl SoarEngine { // Check if this row belongs to the same playbook as the last one let needs_new = playbooks.last().is_none_or(|last| last.id != pb_id); if needs_new { + let _ = threshold; // persisted for schema stability; runtime gating comes from the Condition rows playbooks.push(Playbook { id: pb_id, name, enabled, trigger_event, - condition_threshold: threshold, cooldown_secs: cooldown, actions: Vec::new(), conditions: Vec::new(), @@ -128,11 +153,11 @@ impl SoarEngine { action_order: order, action_type: atype, params: serde_json::from_str(¶ms_str).unwrap_or_else(|e| { - log!(SoarLog::PlaybookError { - name: pb.name.clone(), - error: format!("Malformed action params JSON: {}", e), - }); - serde_json::Value::Object(Default::default()) + log!(SoarLog::PlaybookError( + pb.name.clone(), + format!("Malformed action params JSON: {e}"), + )); + Value::Object(Default::default()) }), }); } @@ -152,6 +177,9 @@ impl SoarEngine { } ConditionType::RepeatOffender => operator == "==", ConditionType::Frequency => operator == ">=", + ConditionType::MultiSourceMin => operator == ">=", + ConditionType::SingleSourceHigh => operator == ">=", + ConditionType::FusedConfidenceAbove => matches!(operator.as_str(), ">=" | "<="), }; if !valid { log!(SoarLog::InvalidConditionOperator( @@ -170,21 +198,36 @@ impl SoarEngine { } } - *self.playbooks.write() = playbooks; + // Warn on playbooks whose trigger_event isn't in the canonical + // dictionary — those won't ever match a fused event and usually + // signal a typo or stale pre-canonicalization playbook. + for pb in &playbooks { + if canonical_from_str(&pb.trigger_event).is_none() { + log!(SoarLog::NonCanonicalTriggerEvent( + pb.name.clone(), + pb.trigger_event.clone(), + )); + } + } + + let playbook_count = playbooks.len(); + // Wrap each playbook in Arc so the per-event matcher hot path can + // hand out cheap Arc clones instead of cloning the full Playbook + // (with its nested Vec / Vec). + let playbooks: Vec> = playbooks.into_iter().map(Arc::new).collect(); + self.playbooks.store(Arc::new(playbooks)); // Load admin whitelist let whitelist = self.db.load_admin_whitelist()?; - *self.admin_whitelist.write() = whitelist.into_iter().collect(); + let whitelist: HashSet = whitelist.into_iter().collect(); + let whitelist_count = whitelist.len(); + self.admin_whitelist.store(Arc::new(whitelist)); // Initialize block counter from DB let count = self.db.count_active_soar_blocks()?; self.active_block_count.store(count, Ordering::SeqCst); - log!(SoarLog::CacheLoaded( - self.playbooks.read().len(), - self.admin_whitelist.read().len(), - count, - )); + log!(SoarLog::CacheLoaded(playbook_count, whitelist_count, count)); Ok(()) } @@ -197,10 +240,7 @@ impl SoarEngine { "CRITICAL: SOAR engine failed to subscribe — automated threat response is DISABLED: {}", e ))); - SoarError::ActionFailed { - action_type: "subscribe".to_string(), - reason: e.to_string(), - } + SoarError::ActionFailed("subscribe", e) })?; tokio::spawn(async move { Self::event_loop(self, rx).await; @@ -210,20 +250,30 @@ impl SoarEngine { async fn event_loop(self: Arc, mut rx: broadcast::Receiver) { log!(SoarLog::EngineStarted); + let semaphore = Arc::new(Semaphore::new(SOAR_HANDLE_CONCURRENCY)); loop { match rx.recv().await { Ok(event) => { + // Bounded fan-out: hold one permit per in-flight handler. + // When SOAR_HANDLE_CONCURRENCY are already running, this + // await blocks the recv loop, which is the backpressure + // signal — broadcast surfaces it as Lagged on overflow. + let permit = match Arc::clone(&semaphore).acquire_owned().await { + Ok(p) => p, + Err(_) => break, + }; let engine = Arc::clone(&self); tokio::spawn(async move { if let Err(e) = engine.handle_threat_event(&event).await { log!(SoarLog::EventHandlingFailed(e.to_string())); } + drop(permit); }); } - Err(broadcast::error::RecvError::Lagged(n)) => { + Err(RecvError::Lagged(n)) => { log!(SoarLog::ReceiverLagged(n)); } - Err(broadcast::error::RecvError::Closed) => { + Err(RecvError::Closed) => { log!(SoarLog::ChannelClosed); break; } @@ -253,713 +303,6 @@ impl SoarEngine { Ok(()) } - /// Find playbooks matching the event via trigger_event + multi-condition AND logic. - fn find_matching_playbooks(&self, event: &ThreatDetectedEvent) -> Vec { - let playbooks = self.playbooks.read(); - playbooks - .iter() - .filter(|pb| pb.enabled && pb.trigger_event == event.attack_type) - .filter(|pb| self.evaluate_conditions(pb, event)) - .cloned() - .collect() - } - - /// Evaluate all conditions on a playbook (AND logic). - /// If conditions vec is empty, falls back to legacy `condition_threshold` check. - fn evaluate_conditions(&self, pb: &Playbook, event: &ThreatDetectedEvent) -> bool { - if pb.conditions.is_empty() { - // Legacy: use inline threshold if present - if let Some(threshold) = pb.condition_threshold { - return (event.confidence as f64) >= threshold; - } - return true; - } - - // Evaluate non-frequency conditions first (avoid recording non-matching events) - for cond in &pb.conditions { - if cond.condition_type == ConditionType::Frequency { - continue; - } - if !self.evaluate_single_condition(cond, pb, event) { - return false; - } - } - - // Evaluate frequency conditions last - for cond in &pb.conditions { - if cond.condition_type == ConditionType::Frequency && !self.evaluate_single_condition(cond, pb, event) { - return false; - } - } - - true - } - - /// Evaluate a single condition against the event. - /// The `operator` field controls comparison direction: - /// - Threshold: ">=" (default) or "<=" - /// - SourceCountry/IpPattern: "in" (default) or "not_in" - /// - RepeatOffender: "==" only - /// - Frequency: ">=" only - fn evaluate_single_condition(&self, cond: &PlaybookCondition, pb: &Playbook, event: &ThreatDetectedEvent) -> bool { - match cond.condition_type { - ConditionType::Threshold => { - let threshold = match cond.value.parse::() { - Ok(v) => v, - Err(_) => return false, - }; - let confidence = event.confidence as f64; - let met = if cond.operator == "<=" { - confidence <= threshold - } else { - confidence >= threshold - }; - if !met { - log!(SoarLog::ConditionNotMet( - "threshold".to_string(), - pb.name.clone(), - format!("{:.2}", event.confidence), - )); - } - met - } - ConditionType::SourceCountry => { - let countries: Vec<&str> = cond.value.split(',').map(|s| s.trim()).collect(); - let matches = event - .geoip_country - .as_ref() - .is_some_and(|c| countries.iter().any(|&cc| cc.eq_ignore_ascii_case(c))); - let met = if cond.operator == "not_in" { !matches } else { matches }; - if !met { - log!(SoarLog::ConditionNotMet( - "source_country".to_string(), - pb.name.clone(), - event.geoip_country.clone().unwrap_or_else(|| "none".to_string()), - )); - } - met - } - ConditionType::IpPattern => { - let net = match cond.value.parse::() { - Ok(n) => n, - Err(_) => return false, - }; - let ip = match event.source_ip.parse::() { - Ok(a) => a, - Err(_) => return false, - }; - let matches = net.contains(ip); - let met = if cond.operator == "not_in" { !matches } else { matches }; - if !met { - log!(SoarLog::ConditionNotMet( - "ip_pattern".to_string(), - pb.name.clone(), - event.source_ip.clone(), - )); - } - met - } - ConditionType::RepeatOffender => { - let expected = cond.value.eq_ignore_ascii_case("true"); - let met = event.is_repeat_offender == expected; - if !met { - log!(SoarLog::ConditionNotMet( - "repeat_offender".to_string(), - pb.name.clone(), - format!("{}", event.is_repeat_offender), - )); - } - met - } - ConditionType::Frequency => { - let required = match cond.value.parse::() { - Ok(v) => v, - Err(_) => return false, - }; - let window_secs = cond.value2.as_ref().and_then(|s| s.parse::().ok()).unwrap_or(60); - let count = self - .frequency_tracker - .record_and_count(pb.id, &event.source_ip, window_secs); - let met = count >= required; - if !met { - log!(SoarLog::FrequencyNotMet(pb.name.clone(), count, required, window_secs)); - } - met - } - } - } - - /// Check if cooldown is active for this playbook + source IP combination. - fn is_cooldown_active(&self, playbook_id: i64, source_ip: &str, cooldown_secs: i64) -> bool { - let key = (playbook_id, source_ip.to_string()); - if let Some(last_exec) = self.cooldowns.get(&key) { - let elapsed = last_exec.elapsed(); - if elapsed.as_secs() < cooldown_secs as u64 { - return true; - } - } - false - } - - /// Record cooldown for a playbook + source IP combination. - fn record_cooldown(&self, playbook_id: i64, source_ip: &str) { - let key = (playbook_id, source_ip.to_string()); - self.cooldowns.insert(key, std::time::Instant::now()); - } - - /// Execute a single playbook against an event. - async fn execute_playbook(&self, playbook: &Playbook, event: &ThreatDetectedEvent) -> Result<(), Error> { - // Check cooldown - if self.is_cooldown_active(playbook.id, &event.source_ip, playbook.cooldown_secs) { - log!(SoarLog::CooldownActive(playbook.name.clone(), event.source_ip.clone())); - return Ok(()); - } - - // Check admin whitelist - if self.admin_whitelist.read().contains(&event.source_ip) { - log!(SoarLog::WhitelistSkipped( - event.source_ip.clone(), - playbook.name.clone() - )); - return Ok(()); - } - - // Execute actions in order - let mut action_results = Vec::new(); - for action in &playbook.actions { - let result = self.execute_action(action, event, playbook.id).await; - let result_json = match &result { - Ok(msg) => serde_json::json!({"action": &action.action_type, "status": "ok", "message": msg}), - Err(e) => { - serde_json::json!({"action": &action.action_type, "status": "error", "message": e.to_string()}) - } - }; - action_results.push(result_json); - if let Err(e) = result { - log!(SoarLog::PlaybookError( - playbook.name.clone(), - format!("Action '{}': {}", action.action_type, e) - )); - } - } - - // Record cooldown - self.record_cooldown(playbook.id, &event.source_ip); - - // Write audit trail - let actions_json = serde_json::to_string(&action_results).unwrap_or_default(); - self.db - .insert_soar_execution(playbook.id, Some(&event.source_ip), &event.attack_type, &actions_json)?; - - log!(SoarLog::PlaybookExecuted( - playbook.name.clone(), - event.source_ip.clone(), - event.attack_type.clone() - )); - - Ok(()) - } - - /// Check if the system is in enforce mode (as opposed to monitor mode). - /// Reads from the in-memory AtomicU8 cache: Monitor=0, MlOnly=1, Enforce=2. - fn is_enforce_mode(&self) -> bool { - self.enforce_level_cache.load(Ordering::Relaxed) == 2 - } - - /// Execute a single action. - async fn execute_action( - &self, - action: &PlaybookAction, - event: &ThreatDetectedEvent, - playbook_id: i64, - ) -> Result { - match action.action_type.as_str() { - "block_ip" => { - if !self.is_enforce_mode() { - log!(SoarLog::MonitorModeSkipped( - action.action_type.clone(), - event.source_ip.clone() - )); - return Ok(format!("[monitor] Would block IP {} — skipped", event.source_ip)); - } - self.action_block_ip(action, event, playbook_id).await - } - "adjust_rate_limit" => { - if !self.is_enforce_mode() { - log!(SoarLog::MonitorModeSkipped( - action.action_type.clone(), - event.source_ip.clone() - )); - return Ok("[monitor] Would adjust rate limit — skipped".to_string()); - } - self.action_adjust_rate_limit(action, event).await - } - "send_telegram" => self.action_send_telegram(event).await, - "send_email" => self.action_send_email(event).await, - "webhook" => self.action_webhook(action, event).await, - "log" => self.action_log(action, event), - other => Err(SoarError::ActionFailed { - action_type: other.to_string(), - reason: "Unknown action type".to_string(), - } - .into()), - } - } - - /// Block an IP via eBPF ACL with TTL. - async fn action_block_ip( - &self, - action: &PlaybookAction, - event: &ThreatDetectedEvent, - playbook_id: i64, - ) -> Result { - let ttl_secs = action.params.get("ttl_secs").and_then(|v| v.as_u64()).unwrap_or(1800); - - // Validate TTL (runtime-configurable via DB) - let max_ttl: u64 = self - .db - .get_setting("soar_max_ttl_secs") - .ok() - .flatten() - .and_then(|v| v.parse().ok()) - .unwrap_or(86400); - if ttl_secs > max_ttl { - return Err(SoarError::InvalidTtl { - ttl_secs, - max_secs: max_ttl, - } - .into()); - } - - // Atomically check cap and reserve a slot using CAS loop (runtime-configurable via DB) - let max_cap: u32 = self - .db - .get_setting("soar_max_auto_block_cap") - .ok() - .flatten() - .and_then(|v| v.parse().ok()) - .unwrap_or(100); - loop { - let current_count = self.active_block_count.load(Ordering::SeqCst); - if current_count >= max_cap { - log!(SoarLog::CapReached(current_count, max_cap, event.source_ip.clone())); - return Err(SoarError::CapReached { max_cap }.into()); - } - if self - .active_block_count - .compare_exchange(current_count, current_count + 1, Ordering::SeqCst, Ordering::SeqCst) - .is_ok() - { - break; - } - } - - // Block IP via AccessControlPort (handles IPv4/IPv6 dispatch internally) - if let Err(e) = self.access_control.block_ip(&event.source_ip).await { - self.decrement_block_count(); - return Err(e); - } - - // Calculate expiry time - let expires_at = chrono::Utc::now() + chrono::Duration::seconds(ttl_secs as i64); - let expires_str = expires_at.format("%Y-%m-%d %H:%M:%S").to_string(); - - // Record in soar_block_rules - if let Err(e) = self - .db - .insert_soar_block_rule(&event.source_ip, playbook_id, &expires_str) - { - // Attempt to roll back the eBPF block — on failure, queue for retry - if let Err(unblock_err) = self.access_control.unblock_ip(&event.source_ip).await { - log!(SoarLog::EventHandlingFailed(format!( - "CRITICAL: Failed to unblock IP {} after DB error — queueing for retry: {}", - event.source_ip, unblock_err - ))); - // Write to pending_unblock table so recovery can retry later - if let Err(pend_err) = self.db.insert_pending_unblock(&event.source_ip) { - log!(SoarLog::EventHandlingFailed(format!( - "CRITICAL: Failed to queue pending unblock for IP {}: {}", - event.source_ip, pend_err - ))); - } - } - self.decrement_block_count(); - return Err(e); - } - - // Also persist to acl_rules for consistency - let ip_version = crate::core::playbook_service::ip_version_from_str(&event.source_ip); - self.db - .insert_acl_rule(ip_version, "source", "blacklist", &event.source_ip, 0)?; - - Ok(format!("Blocked IP {} for {}s", event.source_ip, ttl_secs)) - } - - /// Temporarily reduce global rate limits by a factor with TTL-based restoration. - /// Params: { "factor": 0.5, "ttl_secs": 600 } - /// factor < 1.0 means stricter (e.g. 0.5 = half the current rate). - async fn action_adjust_rate_limit( - &self, - action: &PlaybookAction, - event: &ThreatDetectedEvent, - ) -> Result { - let rate_limit = self.rate_limit.as_ref().ok_or_else(|| SoarError::ActionFailed { - action_type: "adjust_rate_limit".to_string(), - reason: "Rate limit config not available".to_string(), - })?; - - let factor = action.params.get("factor").and_then(|v| v.as_f64()).unwrap_or(0.5); - let ttl_secs = action.params.get("ttl_secs").and_then(|v| v.as_u64()).unwrap_or(600); - - if !(0.01..=1.0).contains(&factor) { - return Err(SoarError::ActionFailed { - action_type: "adjust_rate_limit".to_string(), - reason: format!("factor must be 0.01..1.0, got {}", factor), - } - .into()); - } - - let max_ttl: u64 = self - .db - .get_setting("soar_max_ttl_secs") - .ok() - .flatten() - .and_then(|v| v.parse().ok()) - .unwrap_or(86400); - if ttl_secs > max_ttl { - return Err(SoarError::InvalidTtl { - ttl_secs, - max_secs: max_ttl, - } - .into()); - } - - // Acquire lock to serialize rate limit read-save-write (Item 6: atomicity) - let _guard = self.rate_limit_lock.lock().await; - - // Read current rates, save originals, apply reduced rates - let current_packet = rate_limit.get_packet_rate().unwrap_or(10000); - let current_syn = rate_limit.get_syn_rate().unwrap_or(1000); - let current_udp = rate_limit.get_udp_rate().unwrap_or(5000); - let current_dns = rate_limit.get_dns_rate().unwrap_or(2000); - - // Store original rates for restoration (only if not already adjusted) - let key = "soar_rate_limit_original"; - if self.db.get_setting(key)?.filter(|s| !s.is_empty()).is_none() { - let original = serde_json::json!({ - "packet_rate": current_packet, - "syn_rate": current_syn, - "udp_rate": current_udp, - "dns_rate": current_dns, - }); - self.db.set_setting(key, &original.to_string())?; - } - - // Store TTL for restoration - let expires_at = chrono::Utc::now() + chrono::Duration::seconds(ttl_secs as i64); - self.db.set_setting( - "soar_rate_limit_expires", - &expires_at.format("%Y-%m-%d %H:%M:%S").to_string(), - )?; - - // Apply reduced rates - let new_packet = (current_packet as f64 * factor) as u64; - let new_syn = (current_syn as f64 * factor) as u64; - let new_udp = (current_udp as f64 * factor) as u64; - let new_dns = (current_dns as f64 * factor) as u64; - - rate_limit.set_packet_rate(new_packet.max(1))?; - rate_limit.set_syn_rate(new_syn.max(1))?; - rate_limit.set_udp_rate(new_udp.max(1))?; - rate_limit.set_dns_rate(new_dns.max(1))?; - - log!(SoarLog::RateLimitAdjusted( - format!("{}", factor), - ttl_secs, - event.source_ip.clone(), - event.attack_type.clone(), - format!( - "packet {}→{}, syn {}→{}, udp {}→{}, dns {}→{}", - current_packet, - new_packet.max(1), - current_syn, - new_syn.max(1), - current_udp, - new_udp.max(1), - current_dns, - new_dns.max(1), - ), - )); - - Ok(format!( - "Rate limits reduced by factor {} for {}s (triggered by {})", - factor, ttl_secs, event.source_ip - )) - } - - /// Send Telegram notification. - async fn action_send_telegram(&self, event: &ThreatDetectedEvent) -> Result { - if let Some(notifier) = &self.alert_notifier { - let country = if let Some(geoip) = &self.geoip { - if let Ok(ip_addr) = event.source_ip.parse::() { - match geoip.lookup(ip_addr).await { - Ok(Some(loc)) => loc.country, - _ => None, - } - } else { - None - } - } else { - None - }; - let repeat_tag = if event.is_repeat_offender { " [REPEAT]" } else { "" }; - let payload = AlertPayload { - source_ip: event.source_ip.clone(), - dest_ip: event.dest_ip.clone(), - country, - threat_type: event.attack_type.clone(), - confidence: event.confidence, - action_description: format!( - "SOAR auto-response triggered (hits: {}{})", - event.flow_count, repeat_tag, - ), - timestamp: chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(), - }; - notifier.send_alert(&payload).await?; - Ok("Telegram notification sent".to_string()) - } else { - log!(SoarLog::TelegramNotConfigured); - Ok("Telegram not configured, skipped".to_string()) - } - } - - /// Send email alert. - async fn action_send_email(&self, event: &ThreatDetectedEvent) -> Result { - // Build SmtpClient from settings stored via SoarPort::get_setting - use crate::core::email::scheduler::SmtpClient; - match SmtpClient::from_soar_port(&*self.db, self.secrets.as_deref())? { - Some(smtp) => { - let subject = format!( - "[NetGuardia] Threat Alert: {} from {}", - event.attack_type, event.source_ip - ); - let body = format!( - "

Threat Detected

\ -

Source IP: {}

\ -

Threat Type: {}

\ -

Confidence: {:.1}%

\ -

Time: {}

", - event.source_ip, - event.attack_type, - event.confidence * 100.0, - chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"), - ); - if let Some(recipient) = self.db.get_setting("smtp_recipient")? { - tokio::task::spawn_blocking(move || smtp.send(&recipient, &subject, &body)) - .await - .map_err(|e| SoarError::ActionFailed { - action_type: "send_email".to_string(), - reason: e.to_string(), - })??; - Ok("Email alert sent".to_string()) - } else { - Ok("No SMTP recipient configured, skipped".to_string()) - } - } - None => Ok("SMTP not configured, skipped".to_string()), - } - } - - /// Send a webhook HTTP POST with SSRF DNS rebinding protection. - /// Params: { "url": "https://example.com/hook", "timeout_secs": 10 } - async fn action_webhook(&self, action: &PlaybookAction, event: &ThreatDetectedEvent) -> Result { - let url_str = action - .params - .get("url") - .and_then(|v| v.as_str()) - .ok_or_else(|| SoarError::ActionFailed { - action_type: "webhook".to_string(), - reason: "Missing 'url' parameter".to_string(), - })?; - - let timeout_secs = action.params.get("timeout_secs").and_then(|v| v.as_u64()).unwrap_or(10); - - // Parse URL and extract host - let parsed_url = url::Url::parse(url_str).map_err(|e| SoarError::ActionFailed { - action_type: "webhook".to_string(), - reason: format!("Invalid URL: {}", e), - })?; - - let host = parsed_url.host_str().ok_or_else(|| SoarError::ActionFailed { - action_type: "webhook".to_string(), - reason: "URL has no host".to_string(), - })?; - - // DNS resolve all IPs and verify none are private/loopback/link-local - let port = parsed_url.port_or_known_default().unwrap_or(443); - let resolve_target = format!("{}:{}", host, port); - let addrs: Vec = tokio::net::lookup_host(&resolve_target) - .await - .map_err(|e| SoarError::ActionFailed { - action_type: "webhook".to_string(), - reason: format!("DNS resolution failed for '{}': {}", host, e), - })? - .collect(); - - if addrs.is_empty() { - return Err(SoarError::ActionFailed { - action_type: "webhook".to_string(), - reason: format!("DNS resolution returned no addresses for '{}'", host), - } - .into()); - } - - for addr in &addrs { - if Self::is_private_ip(&addr.ip()) { - log!(SoarLog::EventHandlingFailed(format!( - "SSRF blocked: webhook URL '{}' resolved to private IP {}", - url_str, - addr.ip() - ))); - return Err(SoarError::ActionFailed { - action_type: "webhook".to_string(), - reason: format!("SSRF blocked: host '{}' resolves to private IP {}", host, addr.ip()), - } - .into()); - } - } - - // Build and send the webhook payload (includes all enriched fields) - let sources_str: Vec = event.sources.iter().map(|s| s.to_string()).collect(); - let payload = serde_json::json!({ - "source_ip": event.source_ip, - "dest_ip": event.dest_ip, - "attack_type": event.attack_type, - "confidence": event.confidence, - "flow_count": event.flow_count, - "packet_rate": event.packet_rate, - "protocol": event.protocol, - "geoip_country": event.geoip_country, - "is_repeat_offender": event.is_repeat_offender, - "detection_sources": sources_str, - "timestamp": chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(), - }); - - // Pin resolved IPs to prevent DNS rebinding: the DNS check above verified - // all resolved addresses are public, so we force reqwest to use those same - // addresses instead of re-resolving (which could return a private IP on TTL expiry). - let mut client_builder = reqwest::Client::builder().timeout(std::time::Duration::from_secs(timeout_secs)); - for addr in &addrs { - client_builder = client_builder.resolve(host, *addr); - } - let client = client_builder.build().map_err(|e| SoarError::ActionFailed { - action_type: "webhook".to_string(), - reason: format!("HTTP client error: {}", e), - })?; - - let resp = client - .post(url_str) - .json(&payload) - .send() - .await - .map_err(|e| SoarError::ActionFailed { - action_type: "webhook".to_string(), - reason: format!("HTTP request failed: {}", e), - })?; - - let status = resp.status(); - if status.is_success() { - Ok(format!("Webhook sent to {} (status {})", url_str, status)) - } else { - Err(SoarError::ActionFailed { - action_type: "webhook".to_string(), - reason: format!("Webhook returned HTTP {}", status), - } - .into()) - } - } - - /// Check if an IP address is private/loopback/link-local (SSRF protection). - fn is_private_ip(ip: &IpAddr) -> bool { - match ip { - IpAddr::V4(v4) => { - v4.is_loopback() // 127.0.0.0/8 - || v4.is_private() // 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 - || v4.is_link_local() // 169.254.0.0/16 - || v4.is_unspecified() // 0.0.0.0 - || v4.is_broadcast() // 255.255.255.255 - } - IpAddr::V6(v6) => { - v6.is_loopback() // ::1 - || v6.is_unspecified() // :: - // fe80::/10 (link-local) - || (v6.segments()[0] & 0xffc0) == 0xfe80 - // fc00::/7 (unique local: fc00::/8 + fd00::/8) - || (v6.segments()[0] & 0xfe00) == 0xfc00 - } - } - } - - /// Log action. - fn action_log(&self, action: &PlaybookAction, event: &ThreatDetectedEvent) -> Result { - let level = action.params.get("level").and_then(|v| v.as_str()).unwrap_or("warn"); - - log!(SoarLog::ActionLog( - level.to_string(), - event.source_ip.clone(), - event.attack_type.clone(), - format!("{:.2}", event.confidence), - )); - - Ok(format!("Logged at level '{}'", level)) - } - - /// Fallback execution when no playbook matches. - /// Only fires when source_ip is present. - async fn execute_fallback(&self, event: &ThreatDetectedEvent) -> Result<(), Error> { - // Check admin whitelist — never block admin IPs even in fallback - if self.admin_whitelist.read().contains(&event.source_ip) { - log!(SoarLog::WhitelistSkipped( - event.source_ip.clone(), - "fallback".to_string() - )); - return Ok(()); - } - - // Check cooldown — use playbook_id=-1 for fallback actions - if self.is_cooldown_active(-1, &event.source_ip, 300) { - log!(SoarLog::CooldownActive("fallback".to_string(), event.source_ip.clone())); - return Ok(()); - } - - // Default fallback: block IP for 30 minutes + log - let fake_action = PlaybookAction { - action_order: 1, - action_type: "block_ip".to_string(), - params: serde_json::json!({"ttl_secs": 1800}), - }; - - let block_result = self.execute_action(&fake_action, event, -1).await; - let result_json = match &block_result { - Ok(msg) => serde_json::json!({"action": "block_ip", "status": "ok", "message": msg}), - Err(e) => serde_json::json!({"action": "block_ip", "status": "error", "message": e.to_string()}), - }; - - // Record cooldown for fallback - self.record_cooldown(-1, &event.source_ip); - - // Audit trail with playbook_id = -1 - self.db.insert_soar_execution( - -1, - Some(&event.source_ip), - &event.attack_type, - &serde_json::to_string(&[result_json]).unwrap_or_default(), - )?; - - log!(SoarLog::FallbackExecuted(event.source_ip.clone())); - Ok(()) - } - /// Recover active block rules on startup by re-applying to eBPF. pub async fn recover_active_blocks(&self) -> Result<(), Error> { // First, retry any pending unblocks from previous orphan failures @@ -970,7 +313,7 @@ impl SoarEngine { for (_id, source_ip, _playbook_id, _expires_at) in &active_blocks { // Preserve original error-swallowing behavior during recovery - if let Err(e) = self.access_control.block_ip(source_ip).await { + if let Err(e) = self.access_control.block_ip(source_ip) { log!(SoarLog::RecoveryFailed(source_ip.clone(), e.to_string())); } } @@ -1006,7 +349,7 @@ impl SoarEngine { continue; } - match self.access_control.unblock_ip(&source_ip).await { + match self.access_control.unblock_ip(&source_ip) { Ok(()) => { let _ = self.db.delete_pending_unblock(id); log!(SoarLog::EventHandlingFailed(format!( @@ -1031,116 +374,9 @@ impl SoarEngine { /// Restore original rate limits if the TTL has expired. /// Called by TTL scheduler on each sweep. pub async fn check_rate_limit_restoration(&self) -> Result<(), Error> { - // Acquire lock to serialize rate limit read-save-write (Item 6: atomicity) - let _guard = self.rate_limit_lock.lock().await; - - let expires_str = match self - .db - .get_setting("soar_rate_limit_expires")? - .filter(|s| !s.is_empty()) - { - Some(s) => s, - None => return Ok(()), // No active adjustment - }; - - let expires = chrono::NaiveDateTime::parse_from_str(&expires_str, "%Y-%m-%d %H:%M:%S") - .map(|dt| dt.and_utc()) - .unwrap_or_else(|_| chrono::Utc::now()); - - if chrono::Utc::now() < expires { - return Ok(()); // Not yet expired - } - - // Restore original rates - let original_str = match self - .db - .get_setting("soar_rate_limit_original")? - .filter(|s| !s.is_empty()) - { - Some(s) => s, - None => { - // No originals saved, just clean up - self.db.set_setting("soar_rate_limit_expires", "")?; - return Ok(()); - } - }; - - if let (Some(rate_limit), Ok(original)) = ( - &self.rate_limit, - serde_json::from_str::(&original_str), - ) { - let mut restore_errors = Vec::new(); - if let Some(v) = original.get("packet_rate").and_then(|v| v.as_u64()) - && let Err(e) = rate_limit.set_packet_rate(v) - { - restore_errors.push(format!("packet_rate: {}", e)); - } - if let Some(v) = original.get("syn_rate").and_then(|v| v.as_u64()) - && let Err(e) = rate_limit.set_syn_rate(v) - { - restore_errors.push(format!("syn_rate: {}", e)); - } - if let Some(v) = original.get("udp_rate").and_then(|v| v.as_u64()) - && let Err(e) = rate_limit.set_udp_rate(v) - { - restore_errors.push(format!("udp_rate: {}", e)); - } - if let Some(v) = original.get("dns_rate").and_then(|v| v.as_u64()) - && let Err(e) = rate_limit.set_dns_rate(v) - { - restore_errors.push(format!("dns_rate: {}", e)); - } - if restore_errors.is_empty() { - log!(SoarLog::RateLimitRestored); - } else { - log!(SoarLog::RateLimitRestoreFailed(restore_errors.join(", "))); - } - } - - // Clean up settings - self.db.set_setting("soar_rate_limit_original", "")?; - self.db.set_setting("soar_rate_limit_expires", "")?; - - Ok(()) - } - - /// Remove expired cooldown entries to prevent unbounded growth. - /// Called by TTL scheduler every 60 seconds. - pub fn cleanup_expired_cooldowns(&self) { - let max_cooldown_secs = { - let playbooks = self.playbooks.read(); - playbooks.iter().map(|p| p.cooldown_secs as u64).max().unwrap_or(3600) - }; - let expiry = std::time::Duration::from_secs(max_cooldown_secs.saturating_mul(2).max(3600)); - let before = self.cooldowns.len(); - self.cooldowns.retain(|_, instant| instant.elapsed() < expiry); - let removed = before.saturating_sub(self.cooldowns.len()); - if removed > 0 { - log!(SoarLog::CooldownCleanup(removed as u32)); - } - - // Also clean up empty frequency tracker entries - let freq_removed = self.frequency_tracker.cleanup(); - if freq_removed > 0 { - log!(SoarLog::FrequencyCleanup(freq_removed)); - } - } - - /// Decrement the active block counter (called by TTL scheduler on unblock). - /// Uses CAS loop to avoid underflow race condition. - pub fn decrement_block_count(&self) { - loop { - let current = self.active_block_count.load(Ordering::SeqCst); - if current == 0 { - return; // Nothing to decrement - } - match self - .active_block_count - .compare_exchange(current, current - 1, Ordering::SeqCst, Ordering::SeqCst) - { - Ok(_) => return, - Err(_) => continue, // Retry on contention - } + match &self.rate_limit { + Some(owner) => owner.restore_if_expired().await, + None => Ok(()), } } } @@ -1149,6 +385,8 @@ impl SoarEngine { mod tests { use super::*; use crate::model::error::ebpf::EbpfError; + use crate::model::event::DetectionSource; + use chrono::{Duration as ChronoDuration, Utc}; use parking_lot::Mutex; use std::sync::atomic::AtomicBool; @@ -1169,38 +407,38 @@ mod tests { } } - #[async_trait::async_trait] - impl crate::interface::port::access_control::AccessControlPort for MockAccessControl { - async fn block_ip(&self, ip: &str) -> Result<(), Error> { + impl AccessControlPort for MockAccessControl { + fn block_ip(&self, ip: &str) -> Result<(), Error> { if self.should_fail.load(Ordering::SeqCst) { - return Err(EbpfError::UnknownError.into()); + Err(EbpfError::UnknownError)?; } self.blocked_ips.lock().push(ip.to_string()); Ok(()) } - async fn unblock_ip(&self, ip: &str) -> Result<(), Error> { + fn unblock_ip(&self, ip: &str) -> Result<(), Error> { if self.should_fail.load(Ordering::SeqCst) { - return Err(EbpfError::UnknownError.into()); + Err(EbpfError::UnknownError)?; } self.unblocked_ips.lock().push(ip.to_string()); Ok(()) } } - fn test_db() -> Arc { + fn test_db() -> Arc { use crate::adapter::persistence::Database; - Arc::new(Database::new(":memory:").expect("Failed to create test database")) as Arc + Arc::new(Database::new(":memory:").expect("Failed to create test database")) } - fn test_engine(ac: Arc) -> SoarEngine { + fn test_engine(ac: Arc) -> SoarEngine { let db = test_db(); db.seed_default_playbooks().ok(); // Tests expect enforce mode to be active so block_ip actions execute db.set_setting("enforce_mode", "enforce").ok(); // enforce=2 let cache = Arc::new(AtomicU8::new(2)); - SoarEngine::new(db, ac, None, None, None, cache, None).expect("Failed to create SOAR engine") + SoarEngine::new(db as Arc, ac, None, None, None, cache, None) + .expect("Failed to create SOAR engine") } #[tokio::test] @@ -1218,7 +456,12 @@ mod tests { protocol: 6, geoip_country: None, is_repeat_offender: false, - sources: vec![crate::model::event::DetectionSource::ML], + sources: vec![DetectionSource::ML], + active_source_count: 1, + fused_confidence: 0.95, + ae_score: 0.0, + anomaly_score: 0.0, + c2_score: 0.0, }; let action = PlaybookAction { @@ -1250,7 +493,12 @@ mod tests { protocol: 6, geoip_country: None, is_repeat_offender: false, - sources: vec![crate::model::event::DetectionSource::ML], + sources: vec![DetectionSource::ML], + active_source_count: 1, + fused_confidence: 0.95, + ae_score: 0.0, + anomaly_score: 0.0, + c2_score: 0.0, }; let action = PlaybookAction { @@ -1274,13 +522,14 @@ mod tests { db.seed_default_playbooks().ok(); // Insert a fake active block - let expires = (chrono::Utc::now() + chrono::Duration::hours(1)) + let expires = (Utc::now() + ChronoDuration::hours(1)) .format("%Y-%m-%d %H:%M:%S") .to_string(); db.insert_soar_block_rule("192.168.1.100", 1, &expires).ok(); let cache = Arc::new(AtomicU8::new(2)); - let engine = SoarEngine::new(db, mock.clone(), None, None, None, cache, None).expect("Failed to create engine"); + let engine = SoarEngine::new(db as Arc, mock.clone(), None, None, None, cache, None) + .expect("Failed to create engine"); engine.recover_active_blocks().await.expect("Recovery should succeed"); let blocked = mock.blocked_ips.lock(); @@ -1295,13 +544,14 @@ mod tests { let db = test_db(); db.seed_default_playbooks().ok(); - let expires = (chrono::Utc::now() + chrono::Duration::hours(1)) + let expires = (Utc::now() + ChronoDuration::hours(1)) .format("%Y-%m-%d %H:%M:%S") .to_string(); db.insert_soar_block_rule("10.0.0.1", 1, &expires).ok(); let cache = Arc::new(AtomicU8::new(2)); - let engine = SoarEngine::new(db, mock.clone(), None, None, None, cache, None).expect("Failed to create engine"); + let engine = SoarEngine::new(db as Arc, mock.clone(), None, None, None, cache, None) + .expect("Failed to create engine"); // Should not panic — errors are logged, not propagated let result = engine.recover_active_blocks().await; @@ -1329,7 +579,12 @@ mod tests { protocol: 6, geoip_country: None, is_repeat_offender: false, - sources: vec![crate::model::event::DetectionSource::ML], + sources: vec![DetectionSource::ML], + active_source_count: 1, + fused_confidence: 0.95, + ae_score: 0.0, + anomaly_score: 0.0, + c2_score: 0.0, }; let action = PlaybookAction { @@ -1354,17 +609,23 @@ mod tests { let event = ThreatDetectedEvent { source_ip: "1.2.3.4".to_string(), dest_ip: "10.0.0.1".to_string(), - attack_type: "threat_detected".to_string(), + attack_type: "c2_beacon".to_string(), confidence: 0.95, flow_count: 1, packet_rate: 0.0, protocol: 6, geoip_country: None, is_repeat_offender: false, - sources: vec![crate::model::event::DetectionSource::ML], + sources: vec![DetectionSource::Suricata, DetectionSource::ML], + active_source_count: 2, + fused_confidence: 0.97, + ae_score: 0.0, + anomaly_score: 0.0, + c2_score: 0.0, }; - // Find a matching playbook — default "threat_detected" playbook should exist + // The multi-source c2_beacon seed playbook matches a 2-source fusion + // event and its first action is block_ip. let playbooks = engine.find_matching_playbooks(&event); assert!(!playbooks.is_empty(), "Should have matching playbooks"); @@ -1391,19 +652,25 @@ mod tests { db.insert_admin_whitelist("1.2.3.4").ok(); let cache = Arc::new(AtomicU8::new(2)); - let engine = SoarEngine::new(db, mock.clone(), None, None, None, cache, None).expect("Failed to create engine"); + let engine = SoarEngine::new(db as Arc, mock.clone(), None, None, None, cache, None) + .expect("Failed to create engine"); let event = ThreatDetectedEvent { source_ip: "1.2.3.4".to_string(), dest_ip: "10.0.0.1".to_string(), - attack_type: "threat_detected".to_string(), + attack_type: "c2_beacon".to_string(), confidence: 0.95, flow_count: 1, packet_rate: 0.0, protocol: 6, geoip_country: None, is_repeat_offender: false, - sources: vec![crate::model::event::DetectionSource::ML], + sources: vec![DetectionSource::Suricata, DetectionSource::ML], + active_source_count: 2, + fused_confidence: 0.97, + ae_score: 0.0, + anomaly_score: 0.0, + c2_score: 0.0, }; let playbooks = engine.find_matching_playbooks(&event); @@ -1448,21 +715,22 @@ mod tests { db.seed_default_playbooks().ok(); let cache = Arc::new(AtomicU8::new(0)); - let engine = SoarEngine::new(db.clone(), mock, None, None, None, cache, None).expect("Failed to create engine"); + let engine = SoarEngine::new(db.clone() as Arc, mock, None, None, None, cache, None) + .expect("Failed to create engine"); // Should have loaded default playbooks - let count = engine.playbooks.read().len(); + let count = engine.playbooks.load().len(); assert!(count > 0, "Should have loaded default playbooks"); // Add a new playbook directly to DB db.insert_playbook("test_pb", "port_scan", None, None, None, 60).ok(); // Cache should not have it yet - assert_eq!(engine.playbooks.read().len(), count); + assert_eq!(engine.playbooks.load().len(), count); // After reload, should have one more engine.reload_cache().expect("reload should succeed"); - assert_eq!(engine.playbooks.read().len(), count + 1); + assert_eq!(engine.playbooks.load().len(), count + 1); } fn test_event(confidence: f32, country: Option<&str>, ip: &str, repeat: bool) -> ThreatDetectedEvent { @@ -1476,7 +744,12 @@ mod tests { protocol: 6, geoip_country: country.map(|s| s.to_string()), is_repeat_offender: repeat, - sources: vec![crate::model::event::DetectionSource::ML], + sources: vec![DetectionSource::ML], + active_source_count: 1, + fused_confidence: 0.95, + ae_score: 0.0, + anomaly_score: 0.0, + c2_score: 0.0, } } @@ -1485,7 +758,6 @@ mod tests { id: 999, name: "test-playbook".to_string(), trigger_event: "threat_detected".to_string(), - condition_threshold: None, cooldown_secs: 60, enabled: true, actions: vec![], diff --git a/net-guardia/src/core/soar/matcher.rs b/net-guardia/src/core/soar/matcher.rs new file mode 100644 index 0000000..2bbfb98 --- /dev/null +++ b/net-guardia/src/core/soar/matcher.rs @@ -0,0 +1,671 @@ +//! SOAR domain: playbook matching, condition evaluation, cooldown tracking. +//! +//! Pure domain logic — no external I/O, no DB writes, no network calls. +//! All methods live in an `impl SoarEngine` block so they can access the +//! engine's in-memory caches (`playbooks`, `cooldowns`, `frequency_tracker`), +//! but none of them touch anything outside those fields. + +use std::net::IpAddr; +use std::str::FromStr; +use std::sync::Arc; +use std::sync::atomic::Ordering; +use std::time::{Duration, Instant}; + +use macros::log; + +use crate::core::soar::engine::SoarEngine; +use crate::model::event::{DetectionSource, ThreatDetectedEvent}; +use crate::model::log::soar::SoarLog; +use crate::model::soar::condition::{ConditionType, PlaybookCondition}; +use crate::model::soar::dry_run::{DryRunAction, DryRunConditionResult, DryRunMatch}; +use crate::model::soar::playbook::Playbook; + +/// Default frequency-condition window when the playbook omits `value2`. +const DEFAULT_FREQUENCY_WINDOW_SECS: u64 = 60; + +/// Default minimum confidence for `SingleSourceHigh` when the playbook +/// omits `value2`. Conservative enough that ad-hoc solo playbooks don't +/// auto-block noisy single-source hits. +const DEFAULT_SINGLE_SOURCE_HIGH_MIN_CONFIDENCE: f32 = 0.95; + +/// Default expiry for cooldown cleanup when no playbook has a cooldown set. +const DEFAULT_COOLDOWN_EXPIRY_SECS: u64 = 3600; + +impl SoarEngine { + /// Find playbooks matching the event via trigger_event + multi-condition AND logic. + /// Returns `Arc` so the per-event hot path bumps a refcount + /// instead of cloning the playbook (with all its nested conditions and + /// actions) on every fired detection. + pub(super) fn find_matching_playbooks(&self, event: &ThreatDetectedEvent) -> Vec> { + self.playbooks + .load() + .iter() + .filter(|pb| pb.enabled && pb.trigger_event == event.attack_type) + .filter(|pb| self.evaluate_conditions(pb, event)) + .cloned() + .collect() + } + + /// Evaluate all conditions on a playbook (AND logic). + /// If no conditions are configured, the playbook matches unconditionally. + pub(super) fn evaluate_conditions(&self, pb: &Playbook, event: &ThreatDetectedEvent) -> bool { + if pb.conditions.is_empty() { + return true; + } + + // Evaluate non-frequency conditions first (avoid recording non-matching events) + for cond in &pb.conditions { + if cond.condition_type == ConditionType::Frequency { + continue; + } + if !self.evaluate_single_condition(cond, pb, event) { + return false; + } + } + + // Evaluate frequency conditions last + for cond in &pb.conditions { + if cond.condition_type == ConditionType::Frequency && !self.evaluate_single_condition(cond, pb, event) { + return false; + } + } + + true + } + + /// Evaluate a single condition against the event. + /// The `operator` field controls comparison direction: + /// - Threshold: ">=" (default) or "<=" + /// - SourceCountry/IpPattern: "in" (default) or "not_in" + /// - RepeatOffender: "==" only + /// - Frequency: ">=" only + pub(super) fn evaluate_single_condition( + &self, + cond: &PlaybookCondition, + pb: &Playbook, + event: &ThreatDetectedEvent, + ) -> bool { + match cond.condition_type { + ConditionType::Threshold => { + let threshold = match cond.value.parse::() { + Ok(v) => v, + Err(_) => return false, + }; + let confidence = event.confidence as f64; + let met = if cond.operator == "<=" { + confidence <= threshold + } else { + confidence >= threshold + }; + if !met { + log!(SoarLog::ConditionNotMet( + "threshold".to_string(), + pb.name.clone(), + format!("{:.2}", event.confidence), + )); + } + met + } + ConditionType::SourceCountry => { + let countries: Vec<&str> = cond.value.split(',').map(|s| s.trim()).collect(); + let matches = event + .geoip_country + .as_ref() + .is_some_and(|c| countries.iter().any(|&cc| cc.eq_ignore_ascii_case(c))); + let met = if cond.operator == "not_in" { !matches } else { matches }; + if !met { + log!(SoarLog::ConditionNotMet( + "source_country".to_string(), + pb.name.clone(), + event.geoip_country.clone().unwrap_or_else(|| "none".to_string()), + )); + } + met + } + ConditionType::IpPattern => { + let net = match cond.value.parse::() { + Ok(n) => n, + Err(_) => return false, + }; + let ip = match event.source_ip.parse::() { + Ok(a) => a, + Err(_) => return false, + }; + let matches = net.contains(ip); + let met = if cond.operator == "not_in" { !matches } else { matches }; + if !met { + log!(SoarLog::ConditionNotMet( + "ip_pattern".to_string(), + pb.name.clone(), + event.source_ip.clone(), + )); + } + met + } + ConditionType::RepeatOffender => { + let expected = cond.value.eq_ignore_ascii_case("true"); + let met = event.is_repeat_offender == expected; + if !met { + log!(SoarLog::ConditionNotMet( + "repeat_offender".to_string(), + pb.name.clone(), + format!("{}", event.is_repeat_offender), + )); + } + met + } + ConditionType::Frequency => { + let required = match cond.value.parse::() { + Ok(v) => v, + Err(_) => return false, + }; + let window_secs = cond + .value2 + .as_ref() + .and_then(|s| s.parse::().ok()) + .unwrap_or(DEFAULT_FREQUENCY_WINDOW_SECS); + let count = self + .frequency_tracker + .record_and_count(pb.id, &event.source_ip, window_secs); + let met = count >= required; + if !met { + log!(SoarLog::FrequencyNotMet(pb.name.clone(), count, required, window_secs)); + } + met + } + ConditionType::MultiSourceMin => { + let required = match cond.value.parse::() { + Ok(v) => v, + Err(_) => return false, + }; + let met = event.active_source_count >= required; + if !met { + log!(SoarLog::ConditionNotMet( + "multi_source_min".to_string(), + pb.name.clone(), + format!("{} / {}", event.active_source_count, required), + )); + } + met + } + ConditionType::SingleSourceHigh => { + // `value` names the target source (canonical Display form + // or a common alias); `value2` is the minimum confidence. + let target_source = match DetectionSource::from_str(&cond.value) { + Ok(s) => s, + Err(_) => return false, + }; + let min_conf = cond + .value2 + .as_ref() + .and_then(|s| s.parse::().ok()) + .unwrap_or(DEFAULT_SINGLE_SOURCE_HIGH_MIN_CONFIDENCE); + // Solo = exactly one contributing source AND it matches the + // target source AND confidence clears the escape-hatch bar. + let solo_match = + event.active_source_count == 1 && event.sources.len() == 1 && event.sources[0] == target_source; + let conf_met = event.confidence >= min_conf; + let met = solo_match && conf_met; + if !met { + log!(SoarLog::ConditionNotMet( + "single_source_high".to_string(), + pb.name.clone(), + format!( + "sources={:?} count={} conf={:.3} target={} need_conf>={:.3}", + event.sources, event.active_source_count, event.confidence, cond.value, min_conf + ), + )); + } + met + } + ConditionType::FusedConfidenceAbove => { + let threshold = match cond.value.parse::() { + Ok(v) => v, + Err(_) => return false, + }; + let met = if cond.operator == "<=" { + event.fused_confidence <= threshold + } else { + event.fused_confidence >= threshold + }; + if !met { + log!(SoarLog::ConditionNotMet( + "fused_confidence_above".to_string(), + pb.name.clone(), + format!("{:.3} vs {:.3}", event.fused_confidence, threshold), + )); + } + met + } + } + } + + /// Check if cooldown is active for this playbook + source IP combination. + pub(super) fn is_cooldown_active(&self, playbook_id: i64, source_ip: &str, cooldown_secs: i64) -> bool { + let key = (playbook_id, source_ip.to_string()); + if let Some(last_exec) = self.cooldowns.get(&key) { + let elapsed = last_exec.elapsed(); + if elapsed.as_secs() < cooldown_secs as u64 { + return true; + } + } + false + } + + /// Record cooldown for a playbook + source IP combination. + pub(super) fn record_cooldown(&self, playbook_id: i64, source_ip: &str) { + let key = (playbook_id, source_ip.to_string()); + self.cooldowns.insert(key, Instant::now()); + } + + /// Remove expired cooldown entries to prevent unbounded growth. + /// Called by TTL scheduler every 60 seconds. + pub fn cleanup_expired_cooldowns(&self) { + let max_cooldown_secs = self + .playbooks + .load() + .iter() + .map(|p| p.cooldown_secs as u64) + .max() + .unwrap_or(DEFAULT_COOLDOWN_EXPIRY_SECS); + let expiry = Duration::from_secs(max_cooldown_secs.saturating_mul(2).max(DEFAULT_COOLDOWN_EXPIRY_SECS)); + let before = self.cooldowns.len(); + self.cooldowns.retain(|_, instant| instant.elapsed() < expiry); + let removed = before.saturating_sub(self.cooldowns.len()); + if removed > 0 { + log!(SoarLog::CooldownCleanup(removed as u32)); + } + + // Also clean up empty frequency tracker entries + let freq_removed = self.frequency_tracker.cleanup(); + if freq_removed > 0 { + log!(SoarLog::FrequencyCleanup(freq_removed)); + } + } + + /// Decrement the active block counter (called by TTL scheduler on unblock). + /// Uses CAS loop to avoid underflow race condition. + pub fn decrement_block_count(&self) { + loop { + let current = self.active_block_count.load(Ordering::SeqCst); + if current == 0 { + return; // Nothing to decrement + } + match self + .active_block_count + .compare_exchange(current, current - 1, Ordering::SeqCst, Ordering::SeqCst) + { + Ok(_) => return, + Err(_) => continue, // Retry on contention + } + } + } + + /// Simulate how each enabled playbook would react to `event` without + /// executing any actions or recording cooldown / frequency state. + /// Used by the dry-run endpoint so an admin can preview a rule + /// change before committing to it. + /// + /// Frequency conditions are reported as met-with-note rather than + /// evaluated, because a real evaluation requires runtime history + /// the synthetic event doesn't carry. The `has_frequency_condition` + /// flag on each `DryRunMatch` lets the UI flag that caveat to the + /// admin so they don't assume a `would_fire=true` playbook will + /// definitely fire on the next matching real event. + pub fn dry_run(&self, event: &ThreatDetectedEvent) -> Vec { + self.playbooks + .load() + .iter() + .map(|pb| simulate_playbook(pb, event)) + .collect() + } + + /// Check if an IP address is private/loopback/link-local (SSRF protection). + pub(super) fn is_private_ip(ip: &IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => { + v4.is_loopback() // 127.0.0.0/8 + || v4.is_private() // 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 + || v4.is_link_local() // 169.254.0.0/16 + || v4.is_unspecified() // 0.0.0.0 + || v4.is_broadcast() // 255.255.255.255 + } + IpAddr::V6(v6) => { + v6.is_loopback() // ::1 + || v6.is_unspecified() // :: + // fe80::/10 (link-local) + || (v6.segments()[0] & 0xffc0) == 0xfe80 + // fc00::/7 (unique local: fc00::/8 + fd00::/8) + || (v6.segments()[0] & 0xfe00) == 0xfc00 + } + } + } +} + +/// Build a `DryRunMatch` for one playbook. Pure — no engine state +/// touched, no logs emitted, no cooldown recording. `would_fire` is +/// true when `trigger_matches` holds and every condition (including +/// the pass-through frequency branch) reports met. Any frequency +/// condition is evaluated as "passes + flagged for admin review" so +/// the preview stays conservative rather than a hard no against a +/// rule that only fails because dry-run has no history to count. +fn simulate_playbook(pb: &Playbook, event: &ThreatDetectedEvent) -> DryRunMatch { + let trigger_matches = pb.trigger_event == event.attack_type; + let mut has_frequency_condition = false; + let conditions: Vec = pb + .conditions + .iter() + .map(|c| { + if c.condition_type == ConditionType::Frequency { + has_frequency_condition = true; + } + simulate_condition(c, event) + }) + .collect(); + let all_conditions_met = conditions.iter().all(|r| r.met); + let would_fire = pb.enabled && trigger_matches && all_conditions_met; + let actions: Vec = pb + .actions + .iter() + .map(|a| DryRunAction { + action_order: a.action_order, + action_type: a.action_type.clone(), + params: a.params.clone(), + }) + .collect(); + + DryRunMatch { + playbook_id: pb.id, + playbook_name: pb.name.clone(), + enabled: pb.enabled, + trigger_event: pb.trigger_event.clone(), + trigger_matches, + has_frequency_condition, + would_fire, + conditions, + actions, + } +} + +/// Evaluate one condition against a synthetic event without touching +/// shared state. Every branch mirrors the live `evaluate_single_condition` +/// logic except `Frequency`, which is reported as "passes-with-note" +/// because a real evaluation would both need historical events and +/// record a new one. +fn simulate_condition(cond: &PlaybookCondition, event: &ThreatDetectedEvent) -> DryRunConditionResult { + let base = |met: bool, note: Option| DryRunConditionResult { + condition_type: cond.condition_type.to_string(), + operator: cond.operator.clone(), + value: cond.value.clone(), + value2: cond.value2.clone(), + met, + note, + }; + + match cond.condition_type { + ConditionType::Threshold => { + let threshold = match cond.value.parse::() { + Ok(v) => v, + Err(_) => return base(false, Some(format!("invalid threshold value: {}", cond.value))), + }; + let confidence = event.confidence as f64; + let met = if cond.operator == "<=" { + confidence <= threshold + } else { + confidence >= threshold + }; + base(met, Some(format!("event.confidence={confidence:.3}"))) + } + ConditionType::SourceCountry => { + let countries: Vec<&str> = cond.value.split(',').map(|s| s.trim()).collect(); + let matches = event + .geoip_country + .as_ref() + .is_some_and(|c| countries.iter().any(|&cc| cc.eq_ignore_ascii_case(c))); + let met = if cond.operator == "not_in" { !matches } else { matches }; + base( + met, + Some(format!( + "event.geoip_country={}", + event.geoip_country.as_deref().unwrap_or("none") + )), + ) + } + ConditionType::IpPattern => { + let net = match cond.value.parse::() { + Ok(n) => n, + Err(_) => return base(false, Some(format!("invalid CIDR: {}", cond.value))), + }; + let ip = match event.source_ip.parse::() { + Ok(a) => a, + Err(_) => return base(false, Some(format!("invalid source_ip: {}", event.source_ip))), + }; + let matches = net.contains(ip); + let met = if cond.operator == "not_in" { !matches } else { matches }; + base(met, Some(format!("event.source_ip={}", event.source_ip))) + } + ConditionType::RepeatOffender => { + let expected = cond.value.eq_ignore_ascii_case("true"); + let met = event.is_repeat_offender == expected; + base( + met, + Some(format!("event.is_repeat_offender={}", event.is_repeat_offender)), + ) + } + ConditionType::Frequency => base( + true, + Some("frequency condition not evaluated in dry-run — requires runtime history".to_string()), + ), + ConditionType::MultiSourceMin => { + let required = match cond.value.parse::() { + Ok(v) => v, + Err(_) => return base(false, Some(format!("invalid required count: {}", cond.value))), + }; + let met = event.active_source_count >= required; + base( + met, + Some(format!( + "event.active_source_count={} required={required}", + event.active_source_count + )), + ) + } + ConditionType::SingleSourceHigh => { + let target_source = match DetectionSource::from_str(&cond.value) { + Ok(s) => s, + Err(_) => return base(false, Some(format!("unknown DetectionSource: {}", cond.value))), + }; + let min_conf = cond + .value2 + .as_ref() + .and_then(|s| s.parse::().ok()) + .unwrap_or(DEFAULT_SINGLE_SOURCE_HIGH_MIN_CONFIDENCE); + let solo_match = + event.active_source_count == 1 && event.sources.len() == 1 && event.sources[0] == target_source; + let conf_met = event.confidence >= min_conf; + let met = solo_match && conf_met; + base( + met, + Some(format!( + "sources={:?} count={} conf={:.3} target={} need_conf>={:.3}", + event.sources, event.active_source_count, event.confidence, cond.value, min_conf + )), + ) + } + ConditionType::FusedConfidenceAbove => { + let threshold = match cond.value.parse::() { + Ok(v) => v, + Err(_) => return base(false, Some(format!("invalid threshold: {}", cond.value))), + }; + let met = if cond.operator == "<=" { + event.fused_confidence <= threshold + } else { + event.fused_confidence >= threshold + }; + base( + met, + Some(format!("event.fused_confidence={:.3}", event.fused_confidence)), + ) + } + } +} + +#[cfg(test)] +mod dry_run_tests { + use super::*; + use crate::model::soar::playbook::PlaybookAction; + + fn event(attack_type: &str, confidence: f32, sources: Vec) -> ThreatDetectedEvent { + let count = sources.len().max(1); + ThreatDetectedEvent { + attack_type: attack_type.to_string(), + confidence, + source_ip: "1.2.3.4".to_string(), + dest_ip: "10.0.0.1".to_string(), + flow_count: 1, + packet_rate: 0.0, + protocol: 6, + geoip_country: None, + is_repeat_offender: false, + sources, + active_source_count: count, + fused_confidence: confidence, + ae_score: 0.0, + anomaly_score: 0.0, + c2_score: 0.0, + } + } + + fn playbook(name: &str, trigger: &str, conditions: Vec) -> Playbook { + Playbook { + id: 1, + name: name.to_string(), + enabled: true, + trigger_event: trigger.to_string(), + cooldown_secs: 60, + actions: vec![PlaybookAction { + action_order: 1, + action_type: "block_ip".to_string(), + params: serde_json::json!({"ttl_secs": 300}), + }], + conditions, + } + } + + #[test] + fn simulate_playbook_fires_when_trigger_matches_and_no_conditions() { + let pb = playbook("trivial", "brute_force", vec![]); + let ev = event("brute_force", 0.9, vec![DetectionSource::ML]); + let result = simulate_playbook(&pb, &ev); + assert!(result.trigger_matches); + assert!(result.would_fire); + assert_eq!(result.actions.len(), 1); + assert_eq!(result.actions[0].action_type, "block_ip"); + } + + #[test] + fn simulate_playbook_does_not_fire_on_mismatched_trigger() { + let pb = playbook("brute-match", "brute_force", vec![]); + let ev = event("c2_beacon", 0.9, vec![DetectionSource::ML]); + let result = simulate_playbook(&pb, &ev); + assert!(!result.trigger_matches); + assert!(!result.would_fire); + } + + #[test] + fn simulate_playbook_does_not_fire_when_disabled() { + let mut pb = playbook("off", "brute_force", vec![]); + pb.enabled = false; + let ev = event("brute_force", 0.9, vec![DetectionSource::ML]); + let result = simulate_playbook(&pb, &ev); + assert!(result.trigger_matches); + assert!(!result.enabled); + assert!(!result.would_fire); + } + + #[test] + fn simulate_condition_frequency_passes_with_note() { + let cond = PlaybookCondition { + condition_type: ConditionType::Frequency, + operator: ">=".to_string(), + value: "5".to_string(), + value2: Some("60".to_string()), + }; + let ev = event("brute_force", 0.8, vec![DetectionSource::ML]); + let result = simulate_condition(&cond, &ev); + assert!(result.met, "frequency must pass in dry-run"); + assert!( + result.note.as_deref().unwrap_or("").contains("frequency"), + "note must explain why frequency wasn't evaluated" + ); + } + + #[test] + fn simulate_condition_threshold_respects_operator() { + let gte = PlaybookCondition { + condition_type: ConditionType::Threshold, + operator: ">=".to_string(), + value: "0.85".to_string(), + value2: None, + }; + let lte = PlaybookCondition { + condition_type: ConditionType::Threshold, + operator: "<=".to_string(), + value: "0.85".to_string(), + value2: None, + }; + let ev = event("brute_force", 0.9, vec![DetectionSource::ML]); + assert!(simulate_condition(>e, &ev).met); + assert!(!simulate_condition(<e, &ev).met); + } + + #[test] + fn simulate_condition_multi_source_min_counts_sources() { + let cond = PlaybookCondition { + condition_type: ConditionType::MultiSourceMin, + operator: ">=".to_string(), + value: "2".to_string(), + value2: None, + }; + let ev_two = event("c2_beacon", 0.9, vec![DetectionSource::Suricata, DetectionSource::ML]); + let ev_one = event("c2_beacon", 0.9, vec![DetectionSource::ML]); + assert!(simulate_condition(&cond, &ev_two).met); + assert!(!simulate_condition(&cond, &ev_one).met); + } + + #[test] + fn simulate_condition_single_source_high_requires_solo_and_confidence() { + let cond = PlaybookCondition { + condition_type: ConditionType::SingleSourceHigh, + operator: ">=".to_string(), + value: "Suricata".to_string(), + value2: Some("0.95".to_string()), + }; + let solo_high = event("c2_beacon", 0.96, vec![DetectionSource::Suricata]); + let solo_low = event("c2_beacon", 0.90, vec![DetectionSource::Suricata]); + let multi = event("c2_beacon", 0.96, vec![DetectionSource::Suricata, DetectionSource::ML]); + let wrong_source = event("c2_beacon", 0.96, vec![DetectionSource::ML]); + assert!(simulate_condition(&cond, &solo_high).met); + assert!(!simulate_condition(&cond, &solo_low).met); + assert!(!simulate_condition(&cond, &multi).met); + assert!(!simulate_condition(&cond, &wrong_source).met); + } + + #[test] + fn simulate_playbook_flags_frequency_condition_presence() { + let cond = PlaybookCondition { + condition_type: ConditionType::Frequency, + operator: ">=".to_string(), + value: "5".to_string(), + value2: Some("60".to_string()), + }; + let pb = playbook("brute_force_block", "brute_force", vec![cond]); + let ev = event("brute_force", 0.9, vec![DetectionSource::ML]); + let result = simulate_playbook(&pb, &ev); + assert!(result.has_frequency_condition); + assert!( + result.would_fire, + "frequency alone must not block would_fire in dry-run" + ); + } +} diff --git a/net-guardia/src/core/soar/mod.rs b/net-guardia/src/core/soar/mod.rs index f010e26..1cfbf5d 100644 --- a/net-guardia/src/core/soar/mod.rs +++ b/net-guardia/src/core/soar/mod.rs @@ -1,3 +1,6 @@ +pub mod actions; pub mod engine; pub mod frequency; +pub mod matcher; +pub mod rate_limit_owner; pub mod scheduler; diff --git a/net-guardia/src/core/soar/rate_limit_owner.rs b/net-guardia/src/core/soar/rate_limit_owner.rs new file mode 100644 index 0000000..c66813c --- /dev/null +++ b/net-guardia/src/core/soar/rate_limit_owner.rs @@ -0,0 +1,246 @@ +//! Owner task that serializes SOAR rate-limit adjustments. +//! +//! The "adjust" and "restore" sequences each touch two systems back-to-back +//! (the `soar_rate_limit_*` settings rows and the eBPF `RATE_LIMIT_CONFIG` +//! map). The atomicity that the original `TokioMutex<()>` was protecting is +//! exactly "no other adjust/restore interleaves between the read and the +//! write" — a SQLite transaction can't cover the eBPF half, so we move the +//! read-modify-write inside a single tokio task that owns both ports. All +//! callers dispatch over an mpsc channel and wait on a one-shot reply. + +use std::sync::Arc; + +use chrono::{Duration as ChronoDuration, NaiveDateTime, Utc}; +use macros::log; +use tokio::sync::{mpsc, oneshot}; + +use crate::interface::port::app_repo::AppRepo; +use crate::interface::port::rate_limit_api::RateLimitPort; +use crate::model::error::Error; +use crate::model::error::soar::SoarError; +use crate::model::log::soar::SoarLog; + +/// Channel depth for the owner-task command queue. SOAR rate-limit operations +/// are bursty but rare (operator action / playbook trigger), so 64 is plenty. +const RATE_LIMIT_CMD_CHANNEL_CAPACITY: usize = 64; + +/// Settings keys persisted across restarts so the next process can resume the +/// same TTL window. Stable wire format with the DB. +const KEY_ORIGINAL: &str = "soar_rate_limit_original"; +const KEY_EXPIRES: &str = "soar_rate_limit_expires"; + +enum RateLimitCmd { + Adjust { + factor: f64, + ttl_secs: u64, + source_ip: String, + attack_type: String, + reply: oneshot::Sender>, + }, + RestoreIfExpired { + reply: oneshot::Sender>, + }, +} + +/// Lock-free handle to the rate-limit owner task. Cloning is cheap (just an +/// `mpsc::Sender`). +#[derive(Clone)] +pub struct RateLimitOwnerHandle { + tx: mpsc::Sender, +} + +impl RateLimitOwnerHandle { + /// Spawn the owner task on the current tokio runtime. The owner holds + /// the only mutating references to the DB rate-limit settings and the + /// eBPF rate-limit map for the duration of an Adjust / Restore batch. + /// + /// Each command body runs inside `spawn_blocking` because both halves + /// (rusqlite via r2d2 and eBPF map writes) are synchronous I/O — running + /// them directly inside the async owner loop would block the tokio + /// worker thread for the entire adjust/restore batch. + pub fn spawn(db: Arc, rate_limit: Arc) -> Self { + let (tx, mut rx) = mpsc::channel::(RATE_LIMIT_CMD_CHANNEL_CAPACITY); + tokio::spawn(async move { + while let Some(cmd) = rx.recv().await { + match cmd { + RateLimitCmd::Adjust { + factor, + ttl_secs, + source_ip, + attack_type, + reply, + } => { + let db_c = db.clone(); + let rl_c = rate_limit.clone(); + let join = tokio::task::spawn_blocking(move || { + adjust(&db_c, rl_c.as_ref(), factor, ttl_secs, &source_ip, &attack_type) + }) + .await; + let result = match join { + Ok(r) => r, + Err(e) => Err(SoarError::RateLimitOwnerJoinFailed(e.to_string()).into()), + }; + let _ = reply.send(result); + } + RateLimitCmd::RestoreIfExpired { reply } => { + let db_c = db.clone(); + let rl_c = rate_limit.clone(); + let join = tokio::task::spawn_blocking(move || restore_if_expired(&db_c, rl_c.as_ref())).await; + let result = match join { + Ok(r) => r, + Err(e) => Err(SoarError::RateLimitOwnerJoinFailed(e.to_string()).into()), + }; + let _ = reply.send(result); + } + } + } + }); + Self { tx } + } + + pub async fn adjust( + &self, + factor: f64, + ttl_secs: u64, + source_ip: String, + attack_type: String, + ) -> Result { + let (reply_tx, reply_rx) = oneshot::channel(); + self.tx + .send(RateLimitCmd::Adjust { + factor, + ttl_secs, + source_ip, + attack_type, + reply: reply_tx, + }) + .await + .map_err(|_| SoarError::RateLimitOwnerUnavailable)?; + reply_rx.await.map_err(|_| SoarError::RateLimitOwnerUnavailable)? + } + + pub async fn restore_if_expired(&self) -> Result<(), Error> { + let (reply_tx, reply_rx) = oneshot::channel(); + self.tx + .send(RateLimitCmd::RestoreIfExpired { reply: reply_tx }) + .await + .map_err(|_| SoarError::RateLimitOwnerUnavailable)?; + reply_rx.await.map_err(|_| SoarError::RateLimitOwnerUnavailable)? + } +} + +fn adjust( + db: &Arc, + rate_limit: &dyn RateLimitPort, + factor: f64, + ttl_secs: u64, + source_ip: &str, + attack_type: &str, +) -> Result { + let current_packet = rate_limit.get_packet_rate().unwrap_or(10000); + let current_syn = rate_limit.get_syn_rate().unwrap_or(1000); + let current_udp = rate_limit.get_udp_rate().unwrap_or(5000); + let current_dns = rate_limit.get_dns_rate().unwrap_or(2000); + + if db.get_setting(KEY_ORIGINAL)?.filter(|s| !s.is_empty()).is_none() { + let original = serde_json::json!({ + "packet_rate": current_packet, + "syn_rate": current_syn, + "udp_rate": current_udp, + "dns_rate": current_dns, + }); + db.set_setting(KEY_ORIGINAL, &original.to_string())?; + } + + let expires_at = Utc::now() + ChronoDuration::seconds(ttl_secs as i64); + db.set_setting(KEY_EXPIRES, &expires_at.format("%Y-%m-%d %H:%M:%S").to_string())?; + + let new_packet = (current_packet as f64 * factor) as u64; + let new_syn = (current_syn as f64 * factor) as u64; + let new_udp = (current_udp as f64 * factor) as u64; + let new_dns = (current_dns as f64 * factor) as u64; + + rate_limit.set_packet_rate(new_packet.max(1))?; + rate_limit.set_syn_rate(new_syn.max(1))?; + rate_limit.set_udp_rate(new_udp.max(1))?; + rate_limit.set_dns_rate(new_dns.max(1))?; + + log!(SoarLog::RateLimitAdjusted( + format!("{}", factor), + ttl_secs, + source_ip.to_string(), + attack_type.to_string(), + format!( + "packet {}→{}, syn {}→{}, udp {}→{}, dns {}→{}", + current_packet, + new_packet.max(1), + current_syn, + new_syn.max(1), + current_udp, + new_udp.max(1), + current_dns, + new_dns.max(1), + ), + )); + + Ok(format!( + "Rate limits reduced by factor {} for {}s (triggered by {})", + factor, ttl_secs, source_ip + )) +} + +fn restore_if_expired(db: &Arc, rate_limit: &dyn RateLimitPort) -> Result<(), Error> { + let expires_str = match db.get_setting(KEY_EXPIRES)?.filter(|s| !s.is_empty()) { + Some(s) => s, + None => return Ok(()), + }; + + let expires = NaiveDateTime::parse_from_str(&expires_str, "%Y-%m-%d %H:%M:%S") + .map(|dt| dt.and_utc()) + .unwrap_or_else(|_| Utc::now()); + + if Utc::now() < expires { + return Ok(()); + } + + let original_str = match db.get_setting(KEY_ORIGINAL)?.filter(|s| !s.is_empty()) { + Some(s) => s, + None => { + db.set_setting(KEY_EXPIRES, "")?; + return Ok(()); + } + }; + + if let Ok(original) = serde_json::from_str::(&original_str) { + let mut restore_errors = Vec::new(); + if let Some(v) = original.get("packet_rate").and_then(|v| v.as_u64()) + && let Err(e) = rate_limit.set_packet_rate(v) + { + restore_errors.push(format!("packet_rate: {}", e)); + } + if let Some(v) = original.get("syn_rate").and_then(|v| v.as_u64()) + && let Err(e) = rate_limit.set_syn_rate(v) + { + restore_errors.push(format!("syn_rate: {}", e)); + } + if let Some(v) = original.get("udp_rate").and_then(|v| v.as_u64()) + && let Err(e) = rate_limit.set_udp_rate(v) + { + restore_errors.push(format!("udp_rate: {}", e)); + } + if let Some(v) = original.get("dns_rate").and_then(|v| v.as_u64()) + && let Err(e) = rate_limit.set_dns_rate(v) + { + restore_errors.push(format!("dns_rate: {}", e)); + } + if restore_errors.is_empty() { + log!(SoarLog::RateLimitRestored); + } else { + log!(SoarLog::RateLimitRestoreFailed(restore_errors.join(", "))); + } + } + + db.set_setting(KEY_ORIGINAL, "")?; + db.set_setting(KEY_EXPIRES, "")?; + Ok(()) +} diff --git a/net-guardia/src/core/soar/scheduler.rs b/net-guardia/src/core/soar/scheduler.rs index 88efd52..cc23db8 100644 --- a/net-guardia/src/core/soar/scheduler.rs +++ b/net-guardia/src/core/soar/scheduler.rs @@ -1,29 +1,26 @@ use std::sync::Arc; use macros::log; +use tokio::task::JoinHandle; use tokio::time::{self, Duration}; +use crate::core::playbook_service::ip_version_from_str; use crate::core::soar::engine::SoarEngine; use crate::interface::port::access_control::AccessControlPort; -use crate::interface::port::soar::SoarPort; +use crate::interface::port::app_repo::AppRepo; use crate::model::error::Error; -use crate::model::error::soar::SoarError; use crate::model::log::soar::SoarLog; /// TTL expiry scheduler: runs every 60 seconds, removes expired auto-block rules. /// Before removing from eBPF, checks if a manual ACL rule exists for the same IP. pub struct TtlScheduler { - db: Arc, + db: Arc, access_control: Arc, soar_engine: Arc, } impl TtlScheduler { - pub fn new( - db: Arc, - access_control: Arc, - soar_engine: Arc, - ) -> Self { + pub fn new(db: Arc, access_control: Arc, soar_engine: Arc) -> Self { Self { db, access_control, @@ -32,7 +29,7 @@ impl TtlScheduler { } /// Spawn a background tokio task that runs the TTL sweep every 60 seconds. - pub fn start(self) -> tokio::task::JoinHandle<()> { + pub fn start(self) -> JoinHandle<()> { tokio::spawn(async move { log!(SoarLog::EngineStarted); // TTL scheduler uses same log channel let mut interval = time::interval(Duration::from_secs(60)); @@ -85,21 +82,17 @@ impl TtlScheduler { } // Remove from eBPF ACL via AccessControlPort - if let Err(e) = self.access_control.unblock_ip(source_ip).await { + if let Err(e) = self.access_control.unblock_ip(source_ip) { log!(SoarLog::RecoveryFailed( source_ip.clone(), format!("unblock failed: {}", e) )); } - // Also remove from acl_rules DB table (the auto-added entry) - let ip_version = crate::core::playbook_service::ip_version_from_str(source_ip); - if let Err(e) = self.db.delete_acl_rule(ip_version, "source", "blacklist", source_ip, 0) { - log!(SoarError::AclCleanupFailed(e)); - } - - // Mark as unblocked - self.db.mark_soar_block_unblocked(*id)?; + // Atomically drop acl_rules entry AND mark soar_block_rules + // unblocked in one transaction. + let ip_version = ip_version_from_str(source_ip); + self.db.commit_soar_unblock_to_db(*id, ip_version, source_ip)?; self.soar_engine.decrement_block_count(); removed += 1; } diff --git a/net-guardia/src/core/stats_aggregator.rs b/net-guardia/src/core/stats_aggregator.rs index e6efc0b..5cd0b05 100644 --- a/net-guardia/src/core/stats_aggregator.rs +++ b/net-guardia/src/core/stats_aggregator.rs @@ -1,37 +1,40 @@ use std::sync::Arc; +use macros::log; +use serde_json::Value; +use tokio::task::JoinHandle; use tokio::time::{self, Duration}; -use tracing::{error, info}; -use crate::interface::port::repository::RepositoryPort; -use crate::interface::port::stats::StatsPort; +use crate::interface::port::setting::SettingRepo; +use crate::interface::port::stats::StatsRepo; use crate::model::error::Error; +use crate::model::log::system::SystemLog; /// Background service that periodically aggregates statistics from SOAR/ML tables /// and writes them to the settings table for the Report engine to consume. pub struct StatsAggregator { - stats: Arc, - repo: Arc, + stats: Arc, + repo: Arc, } impl StatsAggregator { - pub fn new(stats: Arc, repo: Arc) -> Self { + pub fn new(stats: Arc, repo: Arc) -> Self { Self { stats, repo } } /// Spawn a background task that runs aggregation every hour. - pub fn start(self) -> tokio::task::JoinHandle<()> { + pub fn start(self) -> JoinHandle<()> { tokio::spawn(async move { - info!("Stats aggregator started (1h interval)"); + log!(SystemLog::StatsAggregatorStarted); // Run immediately on startup if let Err(e) = self.aggregate() { - error!("Initial stats aggregation failed: {}", e); + log!(SystemLog::InitialStatsAggregationFailed(e.to_string())); } let mut interval = time::interval(Duration::from_secs(3600)); loop { interval.tick().await; if let Err(e) = self.aggregate() { - error!("Stats aggregation failed: {}", e); + log!(SystemLog::StatsAggregationFailed(e.to_string())); } } }) @@ -62,7 +65,7 @@ impl StatsAggregator { let breakdown = self.stats.weekly_threat_breakdown(days)?; let breakdown_json: serde_json::Map = breakdown .into_iter() - .map(|(k, v)| (k, serde_json::Value::Number(v.into()))) + .map(|(k, v)| (k, Value::Number(v.into()))) .collect(); self.repo.set_setting( "weekly_threat_breakdown", @@ -133,10 +136,12 @@ impl StatsAggregator { self.repo.set_setting("weekly_geo_distribution", "[]")?; } - info!( - "Stats aggregated: {} threats, {} blocks, {} unblocks, {} active rules", - threats_count, blocks_count, unblocks_count, active_rules - ); + log!(SystemLog::StatsAggregated( + threats_count, + blocks_count, + unblocks_count, + active_rules, + )); Ok(()) } @@ -158,7 +163,7 @@ mod tests { db.insert_soar_execution(1, Some("5.6.7.8"), "brute_force", "[]").ok(); db.insert_soar_block_rule("1.2.3.4", 1, "2099-01-01 00:00:00").ok(); - let aggregator = StatsAggregator::new(db.clone() as Arc, db.clone() as Arc); + let aggregator = StatsAggregator::new(db.clone() as Arc, db.clone() as Arc); aggregator.aggregate().expect("aggregation should succeed"); // Verify settings were written @@ -187,7 +192,7 @@ mod tests { #[test] fn aggregator_handles_empty_db() { let db = Arc::new(Database::new(":memory:").expect("test db")); - let aggregator = StatsAggregator::new(db.clone() as Arc, db.clone() as Arc); + let aggregator = StatsAggregator::new(db.clone() as Arc, db.clone() as Arc); aggregator .aggregate() .expect("aggregation should succeed with empty data"); diff --git a/net-guardia/src/infrastructure/app_config.rs b/net-guardia/src/infrastructure/app_config.rs index 6a6c5c3..4aa1f64 100644 --- a/net-guardia/src/infrastructure/app_config.rs +++ b/net-guardia/src/infrastructure/app_config.rs @@ -1,14 +1,17 @@ use crate::adapter::persistence::Database; -use crate::model::config::{HttpConfig, InferenceConfig as InfConfig, MiscConfig, NetworkConfig, PipelineConfig}; use crate::model::error::Error; use crate::model::error::system::SystemError; +use crate::model::system::config::{ + HttpConfig, InferenceConfig, MiscConfig, NetworkConfig, PipelineConfig, SuricataConfig, +}; pub struct AppConfig { pub http: HttpConfig, pub network: NetworkConfig, - pub inference: InfConfig, + pub inference: InferenceConfig, pub misc: MiscConfig, pub pipeline: PipelineConfig, + pub suricata: SuricataConfig, } impl AppConfig { @@ -55,6 +58,18 @@ impl AppConfig { ("inference_batch_size", "200".into()), ("traffic_logging_mode", "false".into()), ("traffic_log_csv_path", "traffic_log.csv".into()), + // Flow Trace rotation (defaults match the DEFAULT_* constants + // in traffic_logger.rs; DB overrides let admins tune per env). + ("flow_trace_max_file_bytes", (500 * 1024 * 1024_u64).to_string()), + ("flow_trace_max_file_age_secs", "3600".into()), + ( + "flow_trace_total_budget_bytes", + (10 * 1024 * 1024 * 1024_u64).to_string(), + ), + // Model upload size caps (per-field multipart ceilings). + ("model_upload_max_onnx_bytes", (100 * 1024 * 1024_usize).to_string()), + ("model_upload_max_manifest_bytes", (64 * 1024_usize).to_string()), + ("model_upload_max_scaler_bytes", (64 * 1024_usize).to_string()), // Misc ("geoip_db_name", "net-guardia/static/geo/dbip-city-lite.mmdb".into()), // Pipeline @@ -66,7 +81,8 @@ impl AppConfig { // ML ("ml_drift_window_secs", "3600".into()), // Telegram - ("telegram_max_messages_per_minute", "20".into()), + ("telegram_rate_limit_max_messages", "20".into()), + ("telegram_rate_limit_window_secs", "60".into()), // Directories ("report_dir", "/var/lib/netguardia/reports".into()), ("log_dir", "logs".into()), @@ -74,6 +90,13 @@ impl AppConfig { ("dns_max_domains_per_request", "1000".into()), // HTTPS redirect ("force_https", "false".into()), + // Suricata bridge + ("suricata_enabled", "false".into()), + ("suricata_binary_path", "/usr/bin/suricata".into()), + ("suricata_config_path", "/etc/netguardia/suricata.yaml".into()), + ("suricata_eve_log_path", "/var/log/netguardia/eve.json".into()), + ("suricata_auto_restart_on_crash", "true".into()), + ("suricata_restart_backoff_secs", "10".into()), ]; for (key, value) in defaults { @@ -109,7 +132,7 @@ impl AppConfig { packet_buffer_size: 2048, buffer_pool_capacity: 1024, }, - inference: InfConfig { + inference: InferenceConfig { deep_autoencoder_name: "deep_autoencoder.onnx".into(), classifier_name: "classifier.onnx".into(), models_config_name: "inference_config.json".into(), @@ -120,6 +143,12 @@ impl AppConfig { inference_batch_size: 200, traffic_logging_mode: false, traffic_log_csv_path: "traffic_log.csv".into(), + flow_trace_max_file_bytes: 500 * 1024 * 1024, + flow_trace_max_file_age_secs: 3600, + flow_trace_total_budget_bytes: 10 * 1024 * 1024 * 1024, + model_upload_max_onnx_bytes: 100 * 1024 * 1024, + model_upload_max_manifest_bytes: 64 * 1024, + model_upload_max_scaler_bytes: 64 * 1024, }, misc: MiscConfig { geoip_db_name: "net-guardia/static/geo/dbip-city-lite.mmdb".into(), @@ -129,6 +158,14 @@ impl AppConfig { ingress: vec!["access_control".into(), "rate_limit".into(), "service".into()], egress: vec![], }, + suricata: SuricataConfig { + enabled: false, + binary_path: "/usr/bin/suricata".into(), + config_path: "/etc/netguardia/suricata.yaml".into(), + eve_log_path: "/var/log/netguardia/eve.json".into(), + auto_restart_on_crash: true, + restart_backoff_secs: 10, + }, } } @@ -225,6 +262,36 @@ impl AppConfig { { config.inference.inference_batch_size = n; } + if let Ok(Some(v)) = db.get_setting("flow_trace_max_file_bytes") + && let Ok(n) = v.parse::() + { + config.inference.flow_trace_max_file_bytes = n; + } + if let Ok(Some(v)) = db.get_setting("flow_trace_max_file_age_secs") + && let Ok(n) = v.parse::() + { + config.inference.flow_trace_max_file_age_secs = n; + } + if let Ok(Some(v)) = db.get_setting("flow_trace_total_budget_bytes") + && let Ok(n) = v.parse::() + { + config.inference.flow_trace_total_budget_bytes = n; + } + if let Ok(Some(v)) = db.get_setting("model_upload_max_onnx_bytes") + && let Ok(n) = v.parse::() + { + config.inference.model_upload_max_onnx_bytes = n; + } + if let Ok(Some(v)) = db.get_setting("model_upload_max_manifest_bytes") + && let Ok(n) = v.parse::() + { + config.inference.model_upload_max_manifest_bytes = n; + } + if let Ok(Some(v)) = db.get_setting("model_upload_max_scaler_bytes") + && let Ok(n) = v.parse::() + { + config.inference.model_upload_max_scaler_bytes = n; + } if let Ok(Some(v)) = db.get_setting("refresh_interval") && let Ok(n) = v.parse::() { @@ -278,6 +345,34 @@ impl AppConfig { config.misc.geoip_db_name = v; } + // Suricata bridge + if let Ok(Some(v)) = db.get_setting("suricata_enabled") { + config.suricata.enabled = v == "true" || v == "1"; + } + if let Ok(Some(v)) = db.get_setting("suricata_binary_path") + && !v.is_empty() + { + config.suricata.binary_path = v; + } + if let Ok(Some(v)) = db.get_setting("suricata_config_path") + && !v.is_empty() + { + config.suricata.config_path = v; + } + if let Ok(Some(v)) = db.get_setting("suricata_eve_log_path") + && !v.is_empty() + { + config.suricata.eve_log_path = v; + } + if let Ok(Some(v)) = db.get_setting("suricata_auto_restart_on_crash") { + config.suricata.auto_restart_on_crash = v == "true" || v == "1"; + } + if let Ok(Some(v)) = db.get_setting("suricata_restart_backoff_secs") + && let Ok(n) = v.parse::() + { + config.suricata.restart_backoff_secs = n; + } + // Pipeline (stored as comma-separated) if let Ok(Some(v)) = db.get_setting("pipeline_ingress") { config.pipeline.ingress = if v.is_empty() { diff --git a/net-guardia/src/infrastructure/app_services.rs b/net-guardia/src/infrastructure/app_services.rs index ff989a3..b99ed22 100644 --- a/net-guardia/src/infrastructure/app_services.rs +++ b/net-guardia/src/infrastructure/app_services.rs @@ -1,25 +1,36 @@ +use std::path::PathBuf; use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, SystemTime}; +use arc_swap::ArcSwap; use crossbeam::queue::SegQueue; use macros::log; use tokio::sync::oneshot; +use crate::core::detection::metrics::FusionMetrics; +use crate::core::ml::adapter::ModelSourceState; use crate::core::ml::alert::MLAlert; -use crate::core::ml::config_loader::InferenceConfig; -use crate::core::ml::drift_detector::DriftDetector; +use crate::core::ml::drift_detector::DriftDetectorHandle; use crate::core::ml::engine::Engine; -use crate::core::ml::model_loader::MLModels; -use crate::core::ml::traffic_logger::TrafficLogger; +use crate::core::ml::inference::Inference; +use crate::core::ml::manifest::ModelManifest; +use crate::core::ml::model_loader::build_adapter; +use crate::core::ml::traffic_logger::{RotationPolicy, TrafficLogger}; use crate::infrastructure::app_config::AppConfig; +use crate::infrastructure::communication_manager::CommunicationManager; use crate::infrastructure::health::SystemHealth; use crate::infrastructure::statistics::FlowStatistics; +use crate::model::config::constants::{MANIFEST_FILENAME, MODELS_DIR}; use crate::model::detection::flow_features::FlowFeatures; +use crate::model::detection::ml_detection::EngineConfig; +use crate::model::detection::model_source::ModelInfo; use crate::model::error::Error; use crate::model::error::misc::MiscError; use crate::model::error::system::SystemError; +use crate::model::log::ml::MLLog; use crate::model::log::system::SystemLog; -use crate::model::ml_detection::EngineConfig; +use crate::model::system::config::MLInferenceConfig; +use crate::model::system::health::EbpfHealth; /// Application-level service orchestrator. /// Holds all runtime services (health monitoring, ML inference, flow statistics) @@ -27,28 +38,77 @@ use crate::model::ml_detection::EngineConfig; pub struct AppServices { pub health: Arc, pub ml_alert: Arc, - pub ml_models: Arc, + pub ml_inference: Arc, pub ml_engine: Arc, pub flow_statistics: Arc, + pub fusion_metrics: Arc, shutdowns: SegQueue>, } impl AppServices { pub fn new( app_config: Arc, - inference_config: Arc, - drift_detector: Arc>, + inference_config: Arc, + ml_manifest: Option, + drift_detector: DriftDetectorHandle, + ebpf_health: Arc>, + comm: Arc, ) -> Result { - let health = SystemHealth::new(app_config.clone())?; + let health = SystemHealth::new(app_config.clone(), ebpf_health)?; - let ml_models = Arc::new(MLModels::load_models(&app_config, &inference_config)?); + let batch_size = app_config.inference.inference_batch_size; + + let initial_state = match ml_manifest.as_ref() { + Some(manifest) => { + let manifest_path = PathBuf::from(MODELS_DIR).join(MANIFEST_FILENAME); + match build_adapter(manifest, Some(&manifest_path), &inference_config, batch_size) { + Ok(adapter) => { + let info = ModelInfo::new( + manifest.name.clone(), + manifest.adapter.as_str().to_string(), + manifest.features.len(), + ); + log!(MLLog::ModelsLoaded(format!( + "{} ({}) — {} features, {} labels", + manifest.name, + manifest.adapter.as_str(), + manifest.features.len(), + manifest.labels.len() + ))); + ModelSourceState::Active { adapter, info } + } + Err(e) => { + log!(MLLog::ModelReloadFailed(e.to_string())); + ModelSourceState::Error { + msg: e.to_string(), + since: SystemTime::now(), + last_attempted_path: Some(manifest_path), + } + } + } + } + None => { + log!(MLLog::ModelsLoaded( + "no manifest present — ML source dormant".to_string() + )); + ModelSourceState::Dormant + } + }; + + let ml_inference = Arc::new(Inference::new(initial_state, inference_config.clone())); let ml_alert = Arc::new(MLAlert::new()); let traffic_logger = if app_config.inference.traffic_logging_mode { let csv_path = app_config.inference.traffic_log_csv_path.clone(); let mut header = FlowFeatures::all_feature_names_owned(); header.push("Label".to_string()); - let logger = TrafficLogger::new(&csv_path, header) + let base_path = PathBuf::from(&csv_path); + let policy = RotationPolicy { + max_file_bytes: app_config.inference.flow_trace_max_file_bytes, + max_file_age: Duration::from_secs(app_config.inference.flow_trace_max_file_age_secs), + total_budget_bytes: app_config.inference.flow_trace_total_budget_bytes, + }; + let logger = TrafficLogger::new(&base_path, header, policy, Some(comm.clone())) .map_err(|e| MiscError::TrafficLogCreateError(csv_path.clone(), e.to_string()))?; log!(SystemLog::TrafficLoggingEnabled(csv_path)); Some(Arc::new(logger)) @@ -65,8 +125,7 @@ impl AppServices { }; let ml_engine = Arc::new(Engine::new( - ml_models.clone(), - inference_config.clone(), + ml_inference.clone(), ml_alert.clone(), drift_detector, engine_config, @@ -75,13 +134,15 @@ impl AppServices { )); let flow_statistics = Arc::new(FlowStatistics::new(ml_engine.clone())); + let fusion_metrics = Arc::new(FusionMetrics::new()); Ok(Self { health: Arc::new(health), ml_alert, - ml_models, + ml_inference, ml_engine, flow_statistics, + fusion_metrics, shutdowns: SegQueue::new(), }) } diff --git a/net-guardia/src/infrastructure/audit_logger.rs b/net-guardia/src/infrastructure/audit_logger.rs index 92e37cd..54b0e81 100644 --- a/net-guardia/src/infrastructure/audit_logger.rs +++ b/net-guardia/src/infrastructure/audit_logger.rs @@ -1,20 +1,21 @@ use std::sync::Arc; use macros::log; +use tokio::sync::broadcast::error::RecvError; use crate::infrastructure::communication_manager::CommunicationManager; -use crate::interface::port::audit::AuditPort; +use crate::interface::port::audit::AuditRepo; use crate::model::event::{AuditEvent, DriftDetectedEvent}; use crate::model::log::audit::AuditLog; /// Subscribes to `AuditEvent` and persists each entry to the `audit_log` table. /// Falls back to log-only when DB writes fail (never panics). pub struct AuditLogger { - db: Arc, + db: Arc, } impl AuditLogger { - pub fn new(db: Arc) -> Self { + pub fn new(db: Arc) -> Self { Self { db } } @@ -28,12 +29,12 @@ impl AuditLogger { loop { match rx.recv().await { Ok(event) => { - this.handle_audit_event(&event); + this.handle_audit_event(event).await; } - Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { - log!(AuditLog::AuditLagged { count: n }); + Err(RecvError::Lagged(n)) => { + log!(AuditLog::AuditLagged(n)); } - Err(tokio::sync::broadcast::error::RecvError::Closed) => { + Err(RecvError::Closed) => { log!(AuditLog::AuditChannelClosed); break; } @@ -51,12 +52,12 @@ impl AuditLogger { loop { match rx.recv().await { Ok(event) => { - this.handle_drift_event(&event); + this.handle_drift_event(event).await; } - Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { - log!(AuditLog::AuditLagged { count: n }); + Err(RecvError::Lagged(n)) => { + log!(AuditLog::AuditLagged(n)); } - Err(tokio::sync::broadcast::error::RecvError::Closed) => { + Err(RecvError::Closed) => { log!(AuditLog::AuditChannelClosed); break; } @@ -68,36 +69,60 @@ impl AuditLogger { } } - fn handle_audit_event(&self, event: &AuditEvent) { + async fn handle_audit_event(&self, event: AuditEvent) { // Always emit a structured log line - log!(AuditLog::AuditEvent { - actor: event.actor.clone(), - action: event.action.clone() - }); + log!(AuditLog::AuditEvent(event.actor.clone(), event.action.clone(),)); - // Attempt DB insert; on failure, log a warning but do not panic - if let Err(e) = self.db.insert_audit_log(&event.actor, &event.action, &event.detail) { - log!(AuditLog::AuditDbWriteFailed { - error: e.to_string(), - actor: event.actor.clone(), - action: event.action.clone() - }); + // SQLite insert via r2d2 is blocking; offload so it can't stall the + // tokio worker that drains the broadcast channel. The actor/action + // strings are cloned for the log call above; the event itself moves + // into the blocking task. + let db = self.db.clone(); + let join = tokio::task::spawn_blocking(move || { + db.insert_audit_log(&event.actor, &event.action, &event.detail) + .map_err(|e| (e.to_string(), event.actor, event.action)) + }) + .await; + match join { + Ok(Ok(())) => {} + Ok(Err((err, actor, action))) => { + log!(AuditLog::AuditDbWriteFailed(err, actor, action)); + } + Err(join_err) => { + log!(AuditLog::AuditDbWriteFailed( + format!("blocking task join failed: {join_err}"), + "".to_string(), + "".to_string(), + )); + } } } - fn handle_drift_event(&self, event: &DriftDetectedEvent) { + async fn handle_drift_event(&self, event: DriftDetectedEvent) { let detail = serde_json::json!({ "drifted_features": event.drifted_features, "max_deviation": event.max_deviation, }) .to_string(); - log!(AuditLog::AuditDriftEvent { - count: event.drifted_features.len() - }); + log!(AuditLog::AuditDriftEvent(event.drifted_features.len())); - if let Err(e) = self.db.insert_audit_log("system", "ml_drift_detected", &detail) { - log!(AuditLog::AuditDriftDbWriteFailed { error: e.to_string() }); + let db = self.db.clone(); + let join = tokio::task::spawn_blocking(move || { + db.insert_audit_log("system", "ml_drift_detected", &detail) + .map_err(|e| e.to_string()) + }) + .await; + match join { + Ok(Ok(())) => {} + Ok(Err(err)) => { + log!(AuditLog::AuditDriftDbWriteFailed(err)); + } + Err(join_err) => { + log!(AuditLog::AuditDriftDbWriteFailed(format!( + "blocking task join failed: {join_err}" + ))); + } } } } diff --git a/net-guardia/src/infrastructure/communication_manager.rs b/net-guardia/src/infrastructure/communication_manager.rs index d8ee5d7..526433e 100644 --- a/net-guardia/src/infrastructure/communication_manager.rs +++ b/net-guardia/src/infrastructure/communication_manager.rs @@ -1,3 +1,11 @@ +//! Cross-BC in-process event bus (technical service, not a BC). +//! +//! Per `docs/strategy/DOMAIN_MAP.md` §2, Communication Bus is a Technical +//! Service — it has no ubiquitous language, no domain expert, no aggregate. +//! It stays in `infrastructure/` and never takes a BC folder name. The trait +//! surface (`Event`, `Command`, `Query`, `EventBroadcaster`, `CommandHandler`) +//! lives at `interface/communication/` and remains untouched. + use crate::interface::communication::command::*; use crate::interface::communication::event::Event; use crate::interface::communication::event::EventBroadcaster; @@ -126,6 +134,21 @@ impl CommunicationManager { .ok_or(MiscError::TypeNotRegistered)?; broadcaster.broadcast_event(Box::new(event)) } + + /// Synchronous counterpart for callers that live outside the tokio + /// runtime — in particular, the Flow Trace writer thread, which + /// runs on a dedicated `std::thread` and can't `.await`. The + /// internal broadcast channel is already non-blocking, so the + /// `async fn` sibling never actually yields; this variant exposes + /// the same work without the ceremony. + pub fn publish_event_sync(&self, event: E) -> Result<(), Error> { + let type_id = TypeId::of::(); + let broadcaster = self + .event_broadcasters + .get(&type_id) + .ok_or(MiscError::TypeNotRegistered)?; + broadcaster.broadcast_event(Box::new(event)) + } } /// Fluent builder for registering a service's command/query/event handlers. @@ -164,6 +187,8 @@ impl ServiceRegistrar { #[cfg(test)] mod tests { + use std::sync::Mutex; + use super::*; use crate::interface::communication::command::Command; use crate::interface::communication::event::Event; @@ -183,7 +208,7 @@ mod tests { impl Command for TestCommand {} struct TestCommandHandler { - received: Arc>>, + received: Arc>>, } #[async_trait] @@ -226,7 +251,7 @@ mod tests { #[tokio::test] async fn test_command_dispatch() { - let received = Arc::new(std::sync::Mutex::new(Vec::new())); + let received = Arc::new(Mutex::new(Vec::new())); let handler = Arc::new(TestCommandHandler { received: received.clone(), }); @@ -285,6 +310,27 @@ mod tests { assert!(result.is_err()); } + #[tokio::test] + async fn publish_event_sync_delivers_to_subscriber() { + let comm = CommunicationManager::new(); + comm.register_event_type::(); + let mut rx = comm.subscribe_event::().unwrap(); + + comm.publish_event_sync(TestEvent { message: "sync".into() }).unwrap(); + + let event = rx.recv().await.unwrap(); + assert_eq!(event.message, "sync"); + } + + #[test] + fn publish_event_sync_errors_when_type_unregistered() { + let comm = CommunicationManager::new(); + let result = comm.publish_event_sync(TestEvent { + message: "dropped".into(), + }); + assert!(result.is_err()); + } + #[tokio::test] async fn test_event_multiple_subscribers() { let comm = CommunicationManager::new(); @@ -305,7 +351,7 @@ mod tests { #[tokio::test] async fn test_service_registrar() { - let received = Arc::new(std::sync::Mutex::new(Vec::new())); + let received = Arc::new(Mutex::new(Vec::new())); let handler = Arc::new(TestCommandHandler { received: received.clone(), }); diff --git a/net-guardia/src/infrastructure/ebpf_preflight.rs b/net-guardia/src/infrastructure/ebpf_preflight.rs new file mode 100644 index 0000000..fe8a9a5 --- /dev/null +++ b/net-guardia/src/infrastructure/ebpf_preflight.rs @@ -0,0 +1,179 @@ +//! Classifier for eBPF bring-up failures. +//! +//! Takes a raw error plus interface/stage context and produces an +//! `EbpfHealth::Unavailable { stage, category, reason }` suitable for the +//! frontend status display. +//! +//! Classification is best-effort: we inspect `std::io::ErrorKind` where we +//! have one, then fall back to substring matching on the rendered error +//! string. The produced `reason` always includes the interface name, +//! host kernel release, and the NIC driver where those are obtainable, +//! so the operator can diagnose directly from the UI without shelling in. + +use std::fs; + +use crate::model::error::Error; +use crate::model::system::health::{EbpfFailCategory, EbpfFailStage, EbpfHealth}; + +/// Read the running kernel release from `/proc/sys/kernel/osrelease`. +/// Returns the trimmed value, or `"unknown"` if the file cannot be read. +pub fn kernel_release() -> String { + fs::read_to_string("/proc/sys/kernel/osrelease") + .ok() + .map(|s| s.trim().to_string()) + .unwrap_or_else(|| "unknown".to_string()) +} + +/// Look up the driver name bound to a network interface via +/// `/sys/class/net//device/driver`. Returns the basename of +/// the symlink target, or `"unknown"` if the interface has no driver +/// (e.g., virtual or renamed) or the path is not readable. +pub fn interface_driver(ifname: &str) -> String { + let link = format!("/sys/class/net/{}/device/driver", ifname); + match fs::read_link(&link) { + Ok(target) => target + .file_name() + .and_then(|s| s.to_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| "unknown".to_string()), + Err(_) => "unknown".to_string(), + } +} + +/// Build an `EbpfHealth::Unavailable` from an error and stage context. +/// +/// `ifname` is optional because some stages (Load, LoggerInit, PipelineSetup) +/// fail before any interface is involved. +pub fn classify(stage: EbpfFailStage, err: &Error, ifname: Option<&str>) -> EbpfHealth { + let raw = err.to_string(); + let category = categorize(&raw); + let kernel = kernel_release(); + let driver = ifname.map(interface_driver); + + let mut reason = format!("stage={:?}: {}", stage, raw); + reason.push_str(&format!(" (kernel {}", kernel)); + if let Some(iface) = ifname { + reason.push_str(&format!(", interface {}", iface)); + if let Some(drv) = driver.as_deref() { + reason.push_str(&format!(", driver {}", drv)); + } + } + reason.push(')'); + + // For the igb-before-6.17 case, augment the reason with a targeted hint. + if matches!(category, EbpfFailCategory::AfXdpUnsupported) + && driver.as_deref() == Some("igb") + && !kernel_meets_igb_af_xdp(&kernel) + { + reason.push_str(". The igb driver supports AF_XDP only on kernel 6.17 or newer."); + } + + EbpfHealth::Unavailable { + stage, + category, + reason, + } +} + +/// Heuristic categorization based on the rendered error string. +/// Kept intentionally shallow — aya does not currently expose structured +/// enums for all kernel errno paths, so substring matching is the realistic +/// fallback. +fn categorize(raw: &str) -> EbpfFailCategory { + let lower = raw.to_lowercase(); + + if lower.contains("permission denied") || lower.contains("operation not permitted") || lower.contains("eperm") { + return EbpfFailCategory::Permission; + } + if lower.contains("no such device") + || lower.contains("enodev") + || lower.contains("no such file or directory") + && (lower.contains("/sys/class/net") || lower.contains("interface")) + { + return EbpfFailCategory::InterfaceNotFound; + } + if lower.contains("operation not supported") || lower.contains("eopnotsupp") || lower.contains("enotsup") { + // The same errno covers both "driver does not support XDP" and + // "driver does not support AF_XDP". Disambiguate by XDP vs XSK/AF_XDP + // mention in the message when possible. + if lower.contains("xsk") || lower.contains("af_xdp") || lower.contains("afxdp") || lower.contains("bind") { + return EbpfFailCategory::AfXdpUnsupported; + } + return EbpfFailCategory::XdpUnsupported; + } + if lower.contains("cannot allocate memory") || lower.contains("enomem") || lower.contains("rlimit") { + return EbpfFailCategory::MemlockExhausted; + } + if lower.contains("invalid argument") + && (lower.contains("verifier") || lower.contains("bpf_prog_load") || lower.contains("program load")) + { + return EbpfFailCategory::VerifierRejected; + } + if lower.contains("no such file") + && (lower.contains("net-guardia-ingress") || lower.contains("net-guardia-egress") || lower.contains(".o")) + { + return EbpfFailCategory::ObjectNotFound; + } + + EbpfFailCategory::Unknown +} + +/// Parse a kernel release string like "6.17.4-generic" and return true +/// if it is >= 6.17. We only care about the first two numeric components. +fn kernel_meets_igb_af_xdp(release: &str) -> bool { + // Pull leading "MAJOR.MINOR" out of strings like "6.12.0-124.45.1.el10_1.x86_64". + let mut parts = release.split(|c: char| !c.is_ascii_digit()).filter(|s| !s.is_empty()); + let Some(major_str) = parts.next() else { + return false; + }; + let Some(minor_str) = parts.next() else { + return false; + }; + let Ok(major): Result = major_str.parse() else { + return false; + }; + let Ok(minor): Result = minor_str.parse() else { + return false; + }; + major > 6 || (major == 6 && minor >= 17) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn kernel_version_matrix() { + assert!(kernel_meets_igb_af_xdp("6.17.0-generic")); + assert!(kernel_meets_igb_af_xdp("6.18.1")); + assert!(kernel_meets_igb_af_xdp("7.0.0")); + assert!(!kernel_meets_igb_af_xdp("6.16.9-generic")); + assert!(!kernel_meets_igb_af_xdp("6.12.0-124.45.1.el10_1.x86_64")); + assert!(!kernel_meets_igb_af_xdp("5.15.0")); + assert!(!kernel_meets_igb_af_xdp("nonsense")); + } + + #[test] + fn categorizes_permission_errors() { + assert!(matches!( + categorize("Permission denied (os error 13)"), + EbpfFailCategory::Permission + )); + assert!(matches!( + categorize("Operation not permitted"), + EbpfFailCategory::Permission + )); + } + + #[test] + fn categorizes_af_xdp_vs_xdp() { + assert!(matches!( + categorize("bind failed: Operation not supported (os error 95)"), + EbpfFailCategory::AfXdpUnsupported + )); + assert!(matches!( + categorize("xdp attach: Operation not supported"), + EbpfFailCategory::XdpUnsupported + )); + } +} diff --git a/net-guardia/src/infrastructure/enforce_mode_handler.rs b/net-guardia/src/infrastructure/enforce_mode_handler.rs index d8dd9fa..69f26e0 100644 --- a/net-guardia/src/infrastructure/enforce_mode_handler.rs +++ b/net-guardia/src/infrastructure/enforce_mode_handler.rs @@ -9,7 +9,7 @@ use crate::interface::communication::command::CommandHandler; use crate::interface::communication::command_types::ChangeEnforceModeCommand; use crate::interface::communication::query::QueryHandler; use crate::interface::communication::query_types::GetEnforceModeQuery; -use crate::interface::port::repository::RepositoryPort; +use crate::interface::port::app_repo::AppRepo; use crate::model::error::Error; use crate::model::event::AuditEvent; use crate::model::log::system::SystemLog; @@ -25,14 +25,14 @@ pub fn enforce_mode_to_u8(mode: &str) -> u8 { /// Handles enforce-mode commands and queries by delegating to the repository. pub struct EnforceModeHandler { - db: Arc, + db: Arc, comm: Arc, /// Shared AtomicU8 cache: Monitor=0, MlOnly=1, Enforce=2. enforce_cache: Arc, } impl EnforceModeHandler { - pub fn new(db: Arc, comm: Arc, enforce_cache: Arc) -> Self { + pub fn new(db: Arc, comm: Arc, enforce_cache: Arc) -> Self { Self { db, comm, @@ -82,10 +82,10 @@ mod tests { use crate::interface::communication::query_types::GetEnforceModeQuery; fn test_handler() -> (Arc, Arc) { - let db = Arc::new(Database::new(":memory:").unwrap()) as Arc; + let db = Arc::new(Database::new(":memory:").unwrap()) as Arc; let cache = Arc::new(AtomicU8::new(0)); let comm = Arc::new(CommunicationManager::new()); - comm.register_event_type::(); + comm.register_event_type::(); let handler = Arc::new(EnforceModeHandler::new(db, comm.clone(), cache)); let _ = comm .clone() diff --git a/net-guardia/src/infrastructure/geoip.rs b/net-guardia/src/infrastructure/geoip.rs index 3e5a679..efe3888 100644 --- a/net-guardia/src/infrastructure/geoip.rs +++ b/net-guardia/src/infrastructure/geoip.rs @@ -2,10 +2,8 @@ use std::net::IpAddr; use std::path::{Path, PathBuf}; use std::sync::Arc; -use lru::LruCache; use maxminddb::{MaxMindDbError, Reader, geoip2}; -use std::num::NonZeroUsize; -use tokio::sync::RwLock; +use moka::sync::Cache; use tokio::task; use crate::model::monitoring::geolocation::GeoLocation; @@ -13,7 +11,7 @@ use crate::utils::ip_address; pub struct GeoIpService { reader: Arc>>, - cache: Arc>>>, + cache: Cache>, } impl GeoIpService { @@ -24,11 +22,11 @@ impl GeoIpService { pub fn with_cache_size>(db_path: P, cache_size: usize) -> Result { let reader = Reader::open_readfile(db_path)?; - let cache_capacity = NonZeroUsize::new(cache_size).unwrap_or_else(|| NonZeroUsize::new(10000).unwrap()); + let capacity = if cache_size == 0 { 10_000 } else { cache_size } as u64; Ok(Self { reader: Arc::new(reader), - cache: Arc::new(RwLock::new(LruCache::new(cache_capacity))), + cache: Cache::new(capacity), }) } @@ -44,11 +42,8 @@ impl GeoIpService { })); } - { - let cache = self.cache.read().await; - if let Some(cached) = cache.peek(&ip) { - return Ok(cached.clone()); - } + if let Some(cached) = self.cache.get(&ip) { + return Ok(cached); } let reader = self.reader.clone(); @@ -59,10 +54,7 @@ impl GeoIpService { offset: None, })??; - { - let mut cache = self.cache.write().await; - cache.put(ip, result.clone()); - } + self.cache.insert(ip, result.clone()); Ok(result) } diff --git a/net-guardia/src/infrastructure/health.rs b/net-guardia/src/infrastructure/health.rs index 83b2547..f6c050b 100644 --- a/net-guardia/src/infrastructure/health.rs +++ b/net-guardia/src/infrastructure/health.rs @@ -1,59 +1,108 @@ +use std::env::consts; use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use arc_swap::ArcSwap; use macros::log; use sysinfo::{Components, Networks, System}; -use tokio::sync::{RwLock, broadcast, oneshot}; +use tokio::sync::{broadcast, oneshot}; use tokio::time::interval; use crate::infrastructure::app_config::AppConfig; use crate::model::error::Error; -use crate::model::health::{ - ConfiguredNetworkStats, CpuCoreInfo, CpuDetails, LoadAverage, MemoryUsage, NetworkStats, SystemHealthMetrics, - SystemHealthStatus, SystemInfo, -}; use crate::model::log::health::Health; +use crate::model::system::health::{ + ConfiguredNetworkStats, CpuCoreInfo, CpuDetails, EbpfHealth, LoadAverage, MemoryUsage, NetworkStats, + SystemHealthMetrics, SystemHealthStatus, SystemInfo, +}; +/// Lock-free system health. +/// +/// A single owner task on the tokio runtime owns the sysinfo handles +/// (`System`, `Networks`, `Components`). It refreshes them on a tick, +/// computes a fresh `SystemHealthMetrics`, publishes the snapshot via +/// `ArcSwap`, and broadcasts to streaming subscribers. Readers +/// (`get_current_metrics`, `is_system_healthy`, HTTP handlers) just +/// `.load()` the `ArcSwap` — no locks crossed, no `await` needed. pub struct SystemHealth { - system: RwLock, - networks: RwLock, - components: RwLock, + metrics: Arc>, broadcast_tx: broadcast::Sender, ingress_interface: String, egress_interface: String, + ebpf_health: Arc>, } impl SystemHealth { - pub fn new(config: Arc) -> Result { + pub fn new(config: Arc, ebpf_health: Arc>) -> Result { let (broadcast_tx, _) = broadcast::channel(100); + let ingress_interface = config.network.ingress_ifname.clone(); + let egress_interface = config.network.egress_ifname.clone(); - let health = SystemHealth { - system: RwLock::new(System::new_all()), - networks: RwLock::new(Networks::new_with_refreshed_list()), - components: RwLock::new(Components::new_with_refreshed_list()), + // Bootstrap snapshot so readers don't have to handle a "no metrics yet" + // case before the refresh task fires for the first time. The + // `*_with_refreshed_list` constructors already do an initial refresh. + let system = System::new_all(); + let networks = Networks::new_with_refreshed_list(); + let components = Components::new_with_refreshed_list(); + let initial = Self::collect_metrics( + &system, + &networks, + &components, + &ingress_interface, + &egress_interface, + (**ebpf_health.load()).clone(), + ); + + Ok(SystemHealth { + metrics: Arc::new(ArcSwap::from_pointee(initial)), broadcast_tx, - ingress_interface: config.network.ingress_ifname.clone(), - egress_interface: config.network.egress_ifname.clone(), - }; - - Ok(health) + ingress_interface, + egress_interface, + ebpf_health, + }) } pub async fn run(self: Arc, monitoring_interval: Duration) -> oneshot::Sender<()> { let (sender, mut receiver) = oneshot::channel(); - let health = self.clone(); + let metrics = self.metrics.clone(); + let broadcast_tx = self.broadcast_tx.clone(); + let ingress_interface = self.ingress_interface.clone(); + let egress_interface = self.egress_interface.clone(); + let ebpf_health = self.ebpf_health.clone(); tokio::spawn(async move { + // Owner task exclusively holds these sysinfo handles, so no locks + // are needed on the data plane. + let mut system = System::new_all(); + let mut networks = Networks::new_with_refreshed_list(); + let mut components = Components::new_with_refreshed_list(); let mut interval_timer = interval(monitoring_interval); loop { tokio::select! { biased; - _ = &mut receiver => { - break; - } + _ = &mut receiver => break, _ = interval_timer.tick() => { - health.refresh_and_broadcast().await; + system.refresh_all(); + networks.refresh(true); + components.refresh(true); + + let snapshot = Self::collect_metrics( + &system, + &networks, + &components, + &ingress_interface, + &egress_interface, + (**ebpf_health.load()).clone(), + ); + + metrics.store(Arc::new(snapshot.clone())); + + if broadcast_tx.receiver_count() > 0 + && let Err(e) = broadcast_tx.send(snapshot) + { + log!(Health::BroadcastFailed(e.to_string())); + } } } } @@ -62,43 +111,16 @@ impl SystemHealth { sender } - async fn refresh_and_broadcast(&self) { - self.system.write().await.refresh_all(); - self.networks.write().await.refresh(true); - self.components.write().await.refresh(true); - - let system = self.system.read().await; - let networks = self.networks.read().await; - let components = self.components.read().await; - - let metrics = Self::collect_metrics( - &system, - &networks, - &components, - &self.ingress_interface, - &self.egress_interface, - ); - - drop(system); - drop(networks); - drop(components); - - if self.broadcast_tx.receiver_count() > 0 - && let Err(e) = self.broadcast_tx.send(metrics) - { - log!(Health::BroadcastFailed(e.to_string())); - } - } - fn collect_metrics( system: &System, networks: &Networks, components: &Components, ingress_interface: &str, egress_interface: &str, + ebpf: EbpfHealth, ) -> SystemHealthMetrics { - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); @@ -148,6 +170,7 @@ impl SystemHealth { network_stats, load_average, temperature, + ebpf, } } @@ -156,7 +179,7 @@ impl SystemHealth { kernel_version: System::kernel_version(), os_name: System::name(), os_version: System::os_version(), - architecture: std::env::consts::ARCH.to_string(), + architecture: consts::ARCH.to_string(), total_processes: system.processes().len(), } } @@ -231,26 +254,23 @@ impl SystemHealth { ConfiguredNetworkStats { ingress, egress } } - pub async fn get_current_metrics(&self) -> SystemHealthMetrics { - let system = self.system.read().await; - let networks = self.networks.read().await; - let components = self.components.read().await; + pub fn get_current_metrics(&self) -> SystemHealthMetrics { + (**self.metrics.load()).clone() + } - Self::collect_metrics( - &system, - &networks, - &components, - &self.ingress_interface, - &self.egress_interface, - ) + /// Returns a handle to the shared eBPF health state. Consumers (HTTP + /// handlers, setup wizard, frontend) can read the current eBPF state + /// without going through the full metrics broadcast. + pub fn ebpf_health(&self) -> &Arc> { + &self.ebpf_health } pub fn subscribe_to_metrics(&self) -> broadcast::Receiver { self.broadcast_tx.subscribe() } - pub async fn is_system_healthy(&self) -> SystemHealthStatus { - let metrics = self.get_current_metrics().await; + pub fn is_system_healthy(&self) -> SystemHealthStatus { + let metrics = self.get_current_metrics(); let mut status = SystemHealthStatus { overall_healthy: true, diff --git a/net-guardia/src/infrastructure/http_server.rs b/net-guardia/src/infrastructure/http_server.rs index aa9b90e..ae27f43 100644 --- a/net-guardia/src/infrastructure/http_server.rs +++ b/net-guardia/src/infrastructure/http_server.rs @@ -1,48 +1,57 @@ +use std::net::{Ipv4Addr, Ipv6Addr}; use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use actix_cors::Cors; +use actix_web::dev::ServerHandle; use actix_web::web::route; -use actix_web::{App, HttpServer, web}; +use actix_web::{App, HttpResponse, HttpServer, web}; +use macros::log; +use crate::adapter::ebpf::EbpfServices; +use crate::adapter::http::model_upload::PromoteGate; use crate::adapter::http::{ - acl, api_keys, audit as audit_api, auth, default, filter, health as health_api, logs as logs_api, ml, - notification as notification_api, rate_limit as rate_limit_api, report as report_api, setup as setup_api, soar, - stats, system as system_api, + acl, api_keys, audit as audit_api, auth, byo, default, filter, flow_trace, fusion, health as health_api, + logs as logs_api, ml, model_upload, notification as notification_api, rate_limit as rate_limit_api, + report as report_api, setup as setup_api, soar, stats, system as system_api, }; use crate::adapter::persistence::Database; use crate::adapter::websocket::routes as ws; use crate::core::acl_service::AclService; +use crate::core::auth::csrf::CsrfMiddleware; use crate::core::auth::https_redirect::{ForceHttpsFlag, HttpsRedirect}; use crate::core::auth::jwt::JwtService; +use crate::core::auth::middleware::AuthMiddleware; use crate::core::auth::setup_guard::{SetupCompleteFlag, SetupGuard}; use crate::core::config_service::ConfigService; use crate::core::dns_filter_service::DnsFilterService; -use crate::core::ebpf::EbpfServices; -use crate::core::ml::config_loader::InferenceConfig; use crate::core::notification_service::NotificationService; use crate::core::playbook_service::PlaybookService; use crate::core::rate_limit_service::RateLimitService; -use crate::core::system::ShutdownHandle; +use crate::core::soar::engine::SoarEngine; use crate::infrastructure::app_config::AppConfig; use crate::infrastructure::app_services::AppServices; use crate::infrastructure::communication_manager::CommunicationManager; use crate::infrastructure::secret_store::SecretStore; -use crate::interface::port::api_key::ApiKeyPort; -use crate::interface::port::repository::RepositoryPort; +use crate::infrastructure::suricata_manager::SuricataManager; +use crate::infrastructure::system::ShutdownHandle; +use crate::interface::port::api_key::ApiKeyRepo; +use crate::interface::port::app_repo::AppRepo; +use crate::interface::port::audit::AuditRepo; use crate::model::config::constants::HTTP_FALLBACK_PORT; use crate::model::error::Error; use crate::model::error::http::HttpError; use crate::model::log::http::HttpLog; -use macros::log; +use crate::model::system::config::MLInferenceConfig; +use crate::model::system::readiness::ReadinessState; /// Shared flag: true when all services (eBPF, ML, SOAR) are fully initialized. -pub type ReadyFlag = Arc; - -use crate::model::system::readiness::ReadinessState; +pub type ReadyFlag = Arc; /// Parameters for starting the HTTP server, avoiding `#[cfg]` on function params. pub struct HttpServerParams { pub app_config: Arc, - pub inference_config: Arc, + pub inference_config: Arc, pub ebpf_services: Arc, pub app_services: Arc, pub db: Arc, @@ -60,6 +69,8 @@ pub struct HttpServerParams { pub rate_limit_service: Arc, pub force_https: ForceHttpsFlag, pub shutdown_handle: Arc, + pub suricata_manager: Arc, + pub soar_engine: Arc, } /// CORS configuration shared by both full and setup servers. @@ -71,7 +82,7 @@ pub struct HttpServerParams { /// The host is parsed as an IP address — domain names like "10.malware.net" /// are rejected because they fail IP parsing. fn cors(allowed_origins: Vec) -> actix_cors::Cors { - actix_cors::Cors::default() + Cors::default() .allowed_origin_fn(move |origin, _req_head| { let origin_str = origin.to_str().unwrap_or(""); if !allowed_origins.is_empty() { @@ -116,7 +127,7 @@ fn is_private_origin(origin: &str) -> bool { } // Try parsing as IPv4 - if let Ok(ipv4) = host.parse::() { + if let Ok(ipv4) = host.parse::() { let octets = ipv4.octets(); return octets[0] == 127 // 127.0.0.0/8 || octets[0] == 10 // 10.0.0.0/8 @@ -125,7 +136,7 @@ fn is_private_origin(origin: &str) -> bool { } // Try parsing as IPv6 - if let Ok(ipv6) = host.parse::() { + if let Ok(ipv6) = host.parse::() { return ipv6.is_loopback(); } @@ -142,19 +153,20 @@ pub fn start_setup_server( jwt_service: Arc, setup_complete: SetupCompleteFlag, port: u16, -) -> Result { +) -> Result { let make_app = move || { App::new() .wrap(cors(vec![])) - .app_data(web::Data::from(db.clone() as Arc)) - .app_data(web::Data::from(db.clone() as Arc)) + .app_data(web::Data::from(db.clone() as Arc)) + .app_data(web::Data::from(db.clone() as Arc)) + .app_data(web::Data::from(db.clone() as Arc)) .app_data(web::Data::from(db.clone())) .app_data(web::Data::from(secret_store.clone())) .app_data(web::Data::from(jwt_service.clone())) .app_data(web::Data::new(setup_complete.clone())) .service( web::scope("/api") - .wrap(crate::core::auth::middleware::AuthMiddleware) + .wrap(AuthMiddleware) .service(auth::initialize()) .service(setup_api::initialize()) .service(health_api::initialize()), @@ -176,7 +188,7 @@ pub fn start_setup_server( .bind(format!("0.0.0.0:{}", HTTP_FALLBACK_PORT)) .map_err(HttpError::BindPortError)? } - Err(e) => return Err(HttpError::BindPortError(e).into()), + Err(e) => Err(HttpError::BindPortError(e))?, } .run(); @@ -202,6 +214,8 @@ pub async fn run(params: HttpServerParams) -> Result<(), Error> { let health = params.app_services.health.clone(); let ml_alert = params.app_services.ml_alert.clone(); let ml_engine = params.app_services.ml_engine.clone(); + let ml_inference = params.app_services.ml_inference.clone(); + let fusion_metrics = params.app_services.fusion_metrics.clone(); let flow_statistics = params.app_services.flow_statistics.clone(); let drop_monitor = params.ebpf_services.drop_monitor.clone(); let app_config = params.app_config; @@ -221,8 +235,16 @@ pub async fn run(params: HttpServerParams) -> Result<(), Error> { let rate_limit_service = params.rate_limit_service; let force_https = params.force_https; let shutdown_handle = params.shutdown_handle; + let suricata_manager = params.suricata_manager; + let soar_engine = params.soar_engine; let port = app_config.http.http_server_bind_port; + // Shared across every actix worker so concurrent model uploads + // serialize their rename-into-`models/` critical section. Built + // here rather than threaded through HttpServerParams because + // nothing outside the HTTP boundary needs to observe it. + let promote_lock: Arc = Arc::new(PromoteGate::new()); + HttpServer::new(move || { let app = App::new() .wrap(HttpsRedirect) @@ -239,10 +261,13 @@ pub async fn run(params: HttpServerParams) -> Result<(), Error> { .app_data(web::Data::from(health.clone())) .app_data(web::Data::from(ml_alert.clone())) .app_data(web::Data::from(ml_engine.clone())) + .app_data(web::Data::from(ml_inference.clone())) + .app_data(web::Data::from(fusion_metrics.clone())) .app_data(web::Data::from(flow_statistics.clone())) .app_data(web::Data::from(drop_monitor.clone())) - .app_data(web::Data::from(db.clone() as Arc)) - .app_data(web::Data::from(db.clone() as Arc)) + .app_data(web::Data::from(db.clone() as Arc)) + .app_data(web::Data::from(db.clone() as Arc)) + .app_data(web::Data::from(db.clone() as Arc)) .app_data(web::Data::from(db.clone())) .app_data(web::Data::from(secret_store.clone())) .app_data(web::Data::from(jwt_service.clone())) @@ -255,11 +280,15 @@ pub async fn run(params: HttpServerParams) -> Result<(), Error> { .app_data(web::Data::from(dns_filter_service.clone())) .app_data(web::Data::from(notification_service.clone())) .app_data(web::Data::from(playbook_service.clone())) - .app_data(web::Data::from(rate_limit_service.clone())); + .app_data(web::Data::from(rate_limit_service.clone())) + .app_data(web::Data::from(suricata_manager.clone())) + .app_data(web::Data::from(soar_engine.clone())) + .app_data(web::Data::from(promote_lock.clone())); app.wrap(SetupGuard) .service( web::scope("/api") - .wrap(crate::core::auth::middleware::AuthMiddleware) + .wrap(CsrfMiddleware) + .wrap(AuthMiddleware) .service(auth::initialize()) .service(acl::initialize()) .service(filter::initialize()) @@ -267,6 +296,10 @@ pub async fn run(params: HttpServerParams) -> Result<(), Error> { .service(stats::initialize()) .service(health_api::initialize()) .service(ml::initialize()) + .service(model_upload::initialize()) + .service(byo::initialize()) + .service(fusion::initialize()) + .service(flow_trace::initialize()) .service(system_api::initialize()) .service(soar::initialize()) .service(notification_api::initialize()) @@ -290,13 +323,13 @@ pub async fn run(params: HttpServerParams) -> Result<(), Error> { Ok(()) } -async fn health_ready(ready: web::Data, state: web::Data) -> actix_web::HttpResponse { +async fn health_ready(ready: web::Data, state: web::Data) -> HttpResponse { use std::sync::atomic::Ordering::SeqCst; let is_ready = ready.load(SeqCst); let uptime_secs = state.started_at.elapsed().as_secs(); - actix_web::HttpResponse::Ok().json(serde_json::json!({ + HttpResponse::Ok().json(serde_json::json!({ "ready": is_ready, "subsystems": { "db_connected": state.db_connected.load(SeqCst), diff --git a/net-guardia/src/infrastructure/mod.rs b/net-guardia/src/infrastructure/mod.rs index 5ed0812..99f0033 100644 --- a/net-guardia/src/infrastructure/mod.rs +++ b/net-guardia/src/infrastructure/mod.rs @@ -2,6 +2,7 @@ pub mod app_config; pub mod app_services; pub mod audit_logger; pub mod communication_manager; +pub mod ebpf_preflight; pub mod enforce_mode_handler; pub mod geoip; pub mod health; @@ -9,3 +10,6 @@ pub mod http_server; pub mod secret_store; pub mod service_factory; pub mod statistics; +pub mod suricata_manager; +pub mod suricata_monitor; +pub mod system; diff --git a/net-guardia/src/infrastructure/secret_store.rs b/net-guardia/src/infrastructure/secret_store.rs index c416594..87ec5eb 100644 --- a/net-guardia/src/infrastructure/secret_store.rs +++ b/net-guardia/src/infrastructure/secret_store.rs @@ -1,3 +1,4 @@ +use std::env; use std::sync::Arc; use aes_gcm::aead::{Aead, KeyInit, OsRng}; @@ -24,10 +25,10 @@ pub struct SecretStore { impl SecretStore { pub fn new(db: Arc) -> Self { - let raw_key = std::env::var("NETGUARDIA_SECRETS_KEY") + let raw_key = env::var("NETGUARDIA_SECRETS_KEY") .ok() .filter(|k| !k.is_empty()) - .or_else(|| std::env::var("NETGUARDIA_DB_KEY").ok().filter(|k| !k.is_empty())); + .or_else(|| env::var("NETGUARDIA_DB_KEY").ok().filter(|k| !k.is_empty())); let cipher = raw_key.map(|key| { let hk = Hkdf::::new(Some(b"netguardia-v1-salt"), key.as_bytes()); @@ -53,7 +54,7 @@ impl SecretStore { let nonce = Aes256Gcm::generate_nonce(&mut OsRng); let ciphertext = cipher .encrypt(&nonce, plaintext.as_bytes()) - .map_err(|e| CryptoError::EncryptionFailed { reason: e.to_string() })?; + .map_err(CryptoError::EncryptionFailed)?; let envelope = serde_json::json!({ "v": 1, "alg": "aes-256-gcm", @@ -76,24 +77,18 @@ impl SecretStore { } fn decrypt(&self, envelope_json: &str) -> Result { - let env: serde_json::Value = - serde_json::from_str(envelope_json).map_err(|e| CryptoError::InvalidEnvelope { reason: e.to_string() })?; + let env: serde_json::Value = serde_json::from_str(envelope_json).map_err(CryptoError::EnvelopeParseFailed)?; let version = env.get("v").and_then(|v| v.as_u64()).unwrap_or(0); if version != 1 { - return Err(CryptoError::InvalidEnvelope { - reason: format!("unsupported envelope version: {version}"), - } - .into()); + Err(CryptoError::UnsupportedEnvelopeVersion(version))?; } let alg = env.get("alg").and_then(|v| v.as_str()).unwrap_or(""); let ct_b64 = env .get("ct") .and_then(|v| v.as_str()) - .ok_or_else(|| CryptoError::InvalidEnvelope { - reason: "missing ct field".to_string(), - })?; + .ok_or_else(|| CryptoError::MissingEnvelopeField("ct"))?; match alg { "none" => { @@ -101,105 +96,46 @@ impl SecretStore { // Prevents downgrade attack where attacker replaces encrypted envelope // with alg:none + attacker-controlled plaintext. if self.cipher.is_some() { - return Err(CryptoError::InvalidEnvelope { - reason: "alg:none rejected in production mode (encryption key is set)".to_string(), - } - .into()); + Err(CryptoError::AlgNoneRejected)?; } - let plaintext_bytes = B64 - .decode(ct_b64) - .map_err(|e| CryptoError::DecryptionFailed { reason: e.to_string() })?; - String::from_utf8(plaintext_bytes) - .map_err(|e| CryptoError::DecryptionFailed { reason: e.to_string() }.into()) + let plaintext_bytes = B64.decode(ct_b64).map_err(CryptoError::DecryptionFailed)?; + Ok(String::from_utf8(plaintext_bytes).map_err(CryptoError::DecryptionFailed)?) } "aes-256-gcm" => { let cipher = self.cipher.as_ref().ok_or(CryptoError::MasterKeyUnavailable)?; - let nonce_b64 = - env.get("nonce") - .and_then(|v| v.as_str()) - .ok_or_else(|| CryptoError::InvalidEnvelope { - reason: "missing nonce field".to_string(), - })?; + let nonce_b64 = env + .get("nonce") + .and_then(|v| v.as_str()) + .ok_or_else(|| CryptoError::MissingEnvelopeField("nonce"))?; - let nonce_bytes = B64 - .decode(nonce_b64) - .map_err(|e| CryptoError::DecryptionFailed { reason: e.to_string() })?; - let nonce = - Nonce::from_exact_iter(nonce_bytes.into_iter()).ok_or_else(|| CryptoError::DecryptionFailed { - reason: "invalid nonce length".to_string(), - })?; + let nonce_bytes = B64.decode(nonce_b64).map_err(CryptoError::DecryptionFailed)?; + let nonce = Nonce::from_exact_iter(nonce_bytes).ok_or(CryptoError::InvalidNonceLength)?; - let ciphertext = B64 - .decode(ct_b64) - .map_err(|e| CryptoError::DecryptionFailed { reason: e.to_string() })?; + let ciphertext = B64.decode(ct_b64).map_err(CryptoError::DecryptionFailed)?; let plaintext_bytes = cipher .decrypt(&nonce, ciphertext.as_ref()) - .map_err(|e| CryptoError::DecryptionFailed { reason: e.to_string() })?; + .map_err(CryptoError::DecryptionFailed)?; - String::from_utf8(plaintext_bytes) - .map_err(|e| CryptoError::DecryptionFailed { reason: e.to_string() }.into()) + Ok(String::from_utf8(plaintext_bytes).map_err(CryptoError::DecryptionFailed)?) } - other => Err(CryptoError::InvalidEnvelope { - reason: format!("unsupported algorithm: {other}"), - } - .into()), + other => Err(CryptoError::UnsupportedAlgorithm(other))?, + } + } +} + +impl SecretStorePort for SecretStore { + fn get_secret(&self, key: &str) -> Result, Error> { + match self.db.get_app_secret(key)? { + Some(envelope_json) => Ok(Some(self.decrypt(&envelope_json)?)), + None => Ok(None), } } - /// Idempotent startup migration: moves plaintext secrets from settings/notification_config - /// into the encrypted `app_secrets` table. - pub fn migrate_plaintext_secrets(&self) -> Result<(), Error> { - // Check if migration already done - if let Some(v) = self.db.get_setting("secrets_migrated")? - && v == "true" - { - log!(CryptoLog::MigrationSkipped); - return Ok(()); - } - - let mut count = 0usize; - - // 1. Migrate smtp_password - if let Some(password) = self.db.get_setting("smtp_password")? - && password != "__encrypted__" - && !password.is_empty() - { - self.set_secret("smtp_password", &password)?; - self.db.set_setting("smtp_password", "__encrypted__")?; - log!(CryptoLog::SecretMigrated("smtp_password".to_string())); - count += 1; - } - - // 2. Migrate telegram_bot_token from notification_config JSON - if let Some(json_str) = self.db.get_notification_config("telegram")? - && let Ok(mut config) = serde_json::from_str::(&json_str) - && let Some(token) = config.get("bot_token").and_then(|v| v.as_str()).map(|s| s.to_string()) - && token != "__encrypted__" - && !token.is_empty() - { - self.set_secret("telegram_bot_token", &token)?; - config["bot_token"] = serde_json::Value::String("__encrypted__".to_string()); - self.db.set_notification_config("telegram", &config.to_string())?; - log!(CryptoLog::SecretMigrated("telegram_bot_token".to_string())); - count += 1; - } - - // 3. Migrate jwt_secret - if let Some(secret) = self.db.get_setting("jwt_secret")? - && secret != "__encrypted__" - && !secret.is_empty() - { - self.set_secret("jwt_secret", &secret)?; - self.db.set_setting("jwt_secret", "__encrypted__")?; - log!(CryptoLog::SecretMigrated("jwt_secret".to_string())); - count += 1; - } - - self.db.set_setting("secrets_migrated", "true")?; - log!(CryptoLog::MigrationComplete(count)); - Ok(()) + fn set_secret(&self, key: &str, plaintext: &str) -> Result<(), Error> { + let envelope = self.encrypt(plaintext)?; + self.db.set_app_secret(key, &envelope) } } @@ -308,17 +244,3 @@ mod tests { assert!(store.decrypt(&envelope).is_err()); } } - -impl SecretStorePort for SecretStore { - fn get_secret(&self, key: &str) -> Result, Error> { - match self.db.get_app_secret(key)? { - Some(envelope_json) => Ok(Some(self.decrypt(&envelope_json)?)), - None => Ok(None), - } - } - - fn set_secret(&self, key: &str, plaintext: &str) -> Result<(), Error> { - let envelope = self.encrypt(plaintext)?; - self.db.set_app_secret(key, &envelope) - } -} diff --git a/net-guardia/src/infrastructure/service_factory.rs b/net-guardia/src/infrastructure/service_factory.rs index 9384c94..990a512 100644 --- a/net-guardia/src/infrastructure/service_factory.rs +++ b/net-guardia/src/infrastructure/service_factory.rs @@ -1,7 +1,11 @@ use std::collections::HashMap; use std::net::{Ipv4Addr, Ipv6Addr, SocketAddrV4, SocketAddrV6}; +use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicU8; +use std::time::Duration; + +use arc_swap::ArcSwap; use aya::Ebpf; use aya::maps::{Array, MapData, ProgramArray}; @@ -12,15 +16,15 @@ use common::define::pipeline::*; use crate::core::auth::jwt::JwtService; use crate::adapter::access_control_adapter::EbpfAccessControlAdapter; +use crate::adapter::ebpf::EbpfServices; use crate::adapter::persistence::Database; -use crate::adapter::telegram::TelegramAdapter; +use crate::adapter::telegram::{TelegramAdapter, TelegramAdapterFactory}; use crate::core::acl_service::AclService; use crate::core::config_service::ConfigService; use crate::core::dns_filter_service::DnsFilterService; -use crate::core::ebpf::EbpfServices; use crate::core::email::scheduler::ReportScheduler; -use crate::core::ml::config_loader::InferenceConfig; -use crate::core::ml::drift_detector::DriftDetector; +use crate::core::ml::drift_detector::DriftDetectorHandle; +use crate::core::ml::manifest::ModelManifest; use crate::core::notification_service::NotificationService; use crate::core::playbook_service::PlaybookService; use crate::core::rate_limit_service::RateLimitService; @@ -29,30 +33,42 @@ use crate::core::soar::scheduler::TtlScheduler; use crate::infrastructure::app_config::AppConfig; use crate::infrastructure::app_services::AppServices; use crate::infrastructure::communication_manager::CommunicationManager; +use crate::infrastructure::ebpf_preflight; use crate::infrastructure::enforce_mode_handler::EnforceModeHandler; use crate::infrastructure::geoip::GeoIpService; use crate::infrastructure::secret_store::SecretStore; +use crate::infrastructure::suricata_manager::SuricataManager; use crate::interface::communication::command_types::ChangeEnforceModeCommand; use crate::interface::communication::query_types::GetEnforceModeQuery; use crate::interface::port::access_control::AccessControlPort; -use crate::interface::port::notification::{AlertNotifier, NotificationConfigPort}; -use crate::interface::port::repository::RepositoryPort; +use crate::interface::port::access_control_admin::AccessControlAdminPort; +use crate::interface::port::app_repo::AppRepo; +use crate::interface::port::dns_filter_api::DnsFilterPort; +use crate::interface::port::geo_block_api::GeoBlockPort; +use crate::interface::port::notification::{AlertNotifier, AlertNotifierFactory}; +use crate::interface::port::rate_limit_api::RateLimitPort; use crate::interface::port::secret_store::SecretStorePort; -use crate::interface::port::soar::SoarPort; +use crate::interface::port::setting::SettingRepo; +use crate::interface::port::soar::SoarRepo; +use crate::model::access_control::list_type::ListType; use crate::model::detection::drift::FeatureBaselines; -use crate::model::direction::FlowDirection; use crate::model::error::Error; use crate::model::error::ebpf::EbpfError; use crate::model::error::misc::MiscError; -use crate::model::list_type::ListType; +use crate::model::event::{AuditEvent, DriftDetectedEvent, ThreatDetectedEvent}; use crate::model::log::ebpf::EbpfLog; +use crate::model::log::ml::MLLog; use crate::model::log::system::SystemLog; +use crate::model::monitoring::direction::FlowDirection; +use crate::model::system::config::MLInferenceConfig; +use crate::model::system::health::EbpfFailStage; +use crate::model::system::health::EbpfHealth; use macros::log; /// Holds all Arc-wrapped services that make up the running application. pub struct AppState { pub app_config: Arc, - pub inference_config: Arc, + pub inference_config: Arc, pub ebpf_services: Arc, pub app_services: Arc, pub db: Arc, @@ -69,11 +85,18 @@ pub struct AppState { pub playbook_service: Arc, pub rate_limit_service: Arc, pub geoip: Option>, - pub drift_detector: Arc>, - pub ingress_ebpf: Ebpf, - pub egress_ebpf: Ebpf, - /// Held to keep the eBPF program array map FD alive. - pub _ingress_program_array: ProgramArray, + pub drift_detector: DriftDetectorHandle, + pub ingress_ebpf: Option, + pub egress_ebpf: Option, + /// Held to keep the eBPF program array map FD alive. `None` when eBPF + /// failed to load. + pub _ingress_program_array: Option>, + /// Shared eBPF health state. Populated to `Healthy` on successful bring-up, + /// or to `Unavailable { stage, category, reason }` when any stage fails. + /// Read by `SystemHealth` for the metrics broadcast and by HTTP handlers + /// that render runtime status to the frontend. + pub ebpf_health: Arc>, + pub suricata_manager: Arc, } /// Maps stage name (from config.toml) to (function_name, stage_id). @@ -96,36 +119,63 @@ impl ServiceFactory { AppConfig::seed_defaults(&db)?; let app_config = Arc::new(AppConfig::new(&db)?); - let mut ingress_ebpf = Self::load_ebpf("ingress")?; - let mut egress_ebpf = Self::load_ebpf("egress")?; + // Prefer `models/manifest.yaml` when present (v12 BYO-model path). The manifest + // is the user-authored source of truth for features, labels, thresholds, and + // model filenames; the legacy JSON-only path is the fallback. + let manifest_path = PathBuf::from("models/manifest.yaml"); + let (inference_config, ml_manifest): (Arc, Option) = if manifest_path.exists() + { + let (cfg, manifest) = MLInferenceConfig::from_manifest_with_sidecar(&manifest_path)?; + log!(MLLog::ManifestLoaded( + manifest.name.clone(), + manifest.adapter.as_str().to_string(), + manifest.features.len(), + manifest.labels.len(), + )); + (Arc::new(cfg), Some(manifest)) + } else { + ( + Arc::new(MLInferenceConfig::load_file(&app_config.inference.models_config_name)?), + None, + ) + }; - let ingress_program_array = Self::configure_ingress_pipeline(&mut ingress_ebpf, &app_config.pipeline.ingress)?; + // Shared eBPF health handle. Initialized Healthy; downgraded to + // Unavailable with a classified reason if any stage below fails. + let ebpf_health = Arc::new(ArcSwap::from_pointee(EbpfHealth::Healthy)); - let inference_config = Arc::new(InferenceConfig::load_file(&app_config.inference.models_config_name)?); - - // Write queue count to eBPF maps for symmetric hash redirect - let num_queues = app_config.network.combined_queue_count; - Self::write_num_queues(&mut ingress_ebpf, num_queues)?; - Self::write_num_queues(&mut egress_ebpf, num_queues)?; + // Attempt full eBPF bring-up. On any failure we classify the error, + // write it into `ebpf_health`, and fall back to an `EbpfServices` + // whose eBPF-backed operations return `EbpfError::NotLoaded`. The + // rest of the system (HTTP API, SOAR, ML engine, auth) is built + // regardless so the operator can still reach the frontend and see + // the reason. + let (ingress_ebpf, egress_ebpf, ingress_program_array, ebpf_services) = match Self::try_build_ebpf(&app_config) + { + Ok((ingress, egress, pa, services)) => (Some(ingress), Some(egress), Some(pa), Arc::new(services)), + Err((stage, err)) => { + let health = ebpf_preflight::classify(stage, &err, None); + log!(SystemLog::EbpfBringupFailed(format!("{:?}", health))); + ebpf_health.store(Arc::new(health)); + ( + None, + None, + None, + Arc::new(EbpfServices::unavailable(app_config.clone())), + ) + } + }; // Ensure enforce_mode setting exists (default: monitor) if db.get_setting("enforce_mode")?.is_none() { db.set_setting("enforce_mode", "monitor")?; } - // Create secret store and run plaintext migration before anything reads secrets let secret_store = Arc::new(SecretStore::new(db.clone())); - secret_store.migrate_plaintext_secrets()?; let secret_store_port: Arc = secret_store.clone(); let jwt_service = Arc::new(JwtService::new(&secret_store_port, app_config.http.jwt_expiry_hours)?); - let ebpf_services = Arc::new(EbpfServices::new( - app_config.clone(), - &mut ingress_ebpf, - &mut egress_ebpf, - )?); - // Initialize ML drift detector from inference config baselines let baselines = FeatureBaselines::from_inference_config(&inference_config); let drift_window_secs: u64 = db @@ -134,16 +184,7 @@ impl ServiceFactory { .flatten() .and_then(|v| v.parse().ok()) .unwrap_or(3600); - let drift_detector = Arc::new(parking_lot::Mutex::new(DriftDetector::new( - baselines, - std::time::Duration::from_secs(drift_window_secs), - ))); - - let app_services = Arc::new(AppServices::new( - app_config.clone(), - inference_config.clone(), - drift_detector.clone(), - )?); + let drift_detector = DriftDetectorHandle::spawn(baselines, Duration::from_secs(drift_window_secs)); // Create AtomicU8 enforce-level cache (Monitor=0, MlOnly=1, Enforce=2) let enforce_level_cache = Arc::new(AtomicU8::new({ @@ -152,10 +193,27 @@ impl ServiceFactory { enforce_mode_to_u8(&mode_str) })); - // Create CommunicationManager and register enforce-mode handler + // CommunicationManager and its event channels must exist before + // AppServices spins up the TrafficLogger: the writer thread can + // publish `flow_trace_stopped` audit events the moment it tries + // to open its first rotated file, and an unregistered channel + // would silently drop that evidence. let comm = Arc::new(CommunicationManager::new()); + comm.register_event_type::(); + comm.register_event_type::(); + comm.register_event_type::(); + + let app_services = Arc::new(AppServices::new( + app_config.clone(), + inference_config.clone(), + ml_manifest.clone(), + drift_detector.clone(), + ebpf_health.clone(), + comm.clone(), + )?); + let enforce_handler = Arc::new(EnforceModeHandler::new( - db.clone() as Arc, + db.clone() as Arc, comm.clone(), enforce_level_cache.clone(), )); @@ -166,13 +224,8 @@ impl ServiceFactory { .query::() .build(); - // Register event type channels - comm.register_event_type::(); - comm.register_event_type::(); - comm.register_event_type::(); - // Seed default SOAR playbooks if empty - (db.as_ref() as &dyn SoarPort).seed_default_playbooks()?; + (db.as_ref() as &dyn SoarRepo).seed_default_playbooks()?; // Restore persisted state from database Self::restore_dns_blacklist(&db, &ebpf_services); @@ -182,8 +235,8 @@ impl ServiceFactory { // Create TelegramAdapter as alert notifier (may fail if not configured yet) let alert_notifier: Option> = match TelegramAdapter::new( - db.clone() as Arc, - db.clone() as Arc, + db.clone() as Arc, + db.clone() as Arc, Some(secret_store_port.clone()), ) { Ok(adapter) => Some(Arc::new(adapter)), @@ -210,12 +263,13 @@ impl ServiceFactory { Arc::new(EbpfAccessControlAdapter::new(ebpf_services.access_control.clone())); // Create SOAR engine + let rate_limit_port: Arc = ebpf_services.rate_limit.clone(); let soar_engine = Arc::new(SoarEngine::new( db.clone(), access_control_port.clone(), alert_notifier.clone(), geoip.clone(), - Some(ebpf_services.rate_limit.clone()), + Some(rate_limit_port.clone()), enforce_level_cache, Some(secret_store_port.clone()), )?); @@ -225,35 +279,40 @@ impl ServiceFactory { // Create Report scheduler let report_scheduler = - ReportScheduler::new(db.clone() as Arc, Some(secret_store_port.clone())); + ReportScheduler::new(db.clone() as Arc, Some(secret_store_port.clone())); - // Create domain services (Phase 2B) + // Create domain services (Phase 2B) — upcast concrete eBPF services to + // their port-layer traits so the core services see only abstract ports. + let access_control_admin: Arc = ebpf_services.access_control.clone(); + let geo_block_port: Arc = ebpf_services.geo_block.clone(); + let dns_filter_port: Arc = ebpf_services.dns_filter.clone(); let acl_service = Arc::new(AclService::new( - db.clone() as Arc, - ebpf_services.access_control.clone(), - ebpf_services.geo_block.clone(), - )); - let dns_filter_service = Arc::new(DnsFilterService::new( - db.clone() as Arc, - ebpf_services.dns_filter.clone(), - )); - let rate_limit_service = Arc::new(RateLimitService::new( - db.clone() as Arc, - ebpf_services.rate_limit.clone(), + db.clone() as Arc, + access_control_admin, + geo_block_port, )); + let dns_filter_service = Arc::new(DnsFilterService::new(db.clone() as Arc, dns_filter_port)); + let rate_limit_service = Arc::new(RateLimitService::new(db.clone() as Arc, rate_limit_port)); let playbook_service = Arc::new(PlaybookService::new( db.clone(), soar_engine.clone(), access_control_port, )); - let config_service = Arc::new( - ConfigService::new(db.clone() as Arc).with_secret_store(secret_store_port.clone()), - ); - let notification_service = Arc::new(NotificationService::new( - db.clone() as Arc, - db.clone() as Arc, - secret_store_port, + let config_service = + Arc::new(ConfigService::new(db.clone() as Arc).with_secret_store(secret_store_port.clone())); + let notifier_factory: Arc = Arc::new(TelegramAdapterFactory::new( + db.clone() as Arc, + db.clone() as Arc, + Some(secret_store_port.clone()), )); + let notification_service = Arc::new(NotificationService::new( + db.clone() as Arc, + db.clone() as Arc, + secret_store_port, + notifier_factory, + )); + + let suricata_manager = SuricataManager::new(app_config.clone()); Ok(AppState { app_config, @@ -278,16 +337,44 @@ impl ServiceFactory { ingress_ebpf, egress_ebpf, _ingress_program_array: ingress_program_array, + ebpf_health, + suricata_manager, }) } // --- eBPF loading helpers --- + /// Attempt the full eBPF bring-up chain: load both .o files, configure the + /// ingress pipeline, write queue counts, and hand out map handles to the + /// services. Returns the original stage on the first failure so the + /// classifier can render targeted diagnostics. + #[allow(clippy::type_complexity)] + fn try_build_ebpf( + app_config: &Arc, + ) -> Result<(Ebpf, Ebpf, ProgramArray, EbpfServices), (EbpfFailStage, Error)> { + use crate::model::system::health::EbpfFailStage; + + let mut ingress = Self::load_ebpf("ingress").map_err(|e| (EbpfFailStage::Load, e))?; + let mut egress = Self::load_ebpf("egress").map_err(|e| (EbpfFailStage::Load, e))?; + + let pipeline = Self::configure_ingress_pipeline(&mut ingress, &app_config.pipeline.ingress) + .map_err(|e| (EbpfFailStage::PipelineSetup, e))?; + + let num_queues = app_config.network.combined_queue_count; + Self::write_num_queues(&mut ingress, num_queues).map_err(|e| (EbpfFailStage::PipelineSetup, e))?; + Self::write_num_queues(&mut egress, num_queues).map_err(|e| (EbpfFailStage::PipelineSetup, e))?; + + let services = EbpfServices::new(app_config.clone(), &mut ingress, &mut egress) + .map_err(|e| (EbpfFailStage::MapsBind, e))?; + + Ok((ingress, egress, pipeline, services)) + } + fn load_ebpf(name: &str) -> Result { let bytes = match name { "ingress" => aya::include_bytes_aligned!(concat!(env!("OUT_DIR"), "/net-guardia-ingress")), "egress" => aya::include_bytes_aligned!(concat!(env!("OUT_DIR"), "/net-guardia-egress")), - _ => return Err(EbpfError::ProgramNotFound.into()), + _ => Err(EbpfError::ProgramNotFound)?, }; Ok(Ebpf::load(bytes).map_err(EbpfError::EbpfNotFound)?) } @@ -356,7 +443,7 @@ impl ServiceFactory { .try_into() .map_err(EbpfError::MapOperationError)?; program.load().map_err(EbpfError::AttachProgramFailed)?; - let fd = program.fd().map_err(|_| EbpfError::UnknownError)?; + let fd = program.fd().map_err(EbpfError::ProgramFdFailed)?; program_array.set(slot, fd, 0).map_err(EbpfError::MapOperationError)?; Ok(()) } @@ -409,7 +496,7 @@ impl ServiceFactory { } Err(skb_err) => { log!(EbpfLog::XdpAttachFailed(ifname.to_string(), skb_err.to_string())); - Err(EbpfError::AttachProgramFailed(skb_err).into()) + Err(EbpfError::AttachProgramFailed(skb_err))? } } } @@ -490,12 +577,9 @@ impl ServiceFactory { }; let result = match ip_version { 4 => match ip_address.parse::() { - Ok(addr) => { - ebpf_services - .access_control - .add_ipv4_list(dir, lt, SocketAddrV4::new(addr, *port)) - .await - } + Ok(addr) => ebpf_services + .access_control + .add_ipv4_list(dir, lt, SocketAddrV4::new(addr, *port)), Err(e) => { log!(SystemLog::AclIpv4ParseFailed(ip_address.clone(), e.to_string())); continue; @@ -506,7 +590,6 @@ impl ServiceFactory { ebpf_services .access_control .add_ipv6_list(dir, lt, SocketAddrV6::new(addr, *port, 0, 0)) - .await } Err(e) => { log!(SystemLog::AclIpv6ParseFailed(ip_address.clone(), e.to_string())); diff --git a/net-guardia/src/infrastructure/statistics.rs b/net-guardia/src/infrastructure/statistics.rs index b0b69fe..9ef9156 100644 --- a/net-guardia/src/infrastructure/statistics.rs +++ b/net-guardia/src/infrastructure/statistics.rs @@ -1,10 +1,12 @@ use std::sync::Arc; -use std::time; +use std::time::{SystemTime, UNIX_EPOCH}; use crate::core::ml::engine::Engine; use crate::core::ml::flow_tracker::FlowData; -use crate::model::direction::Direction; -use crate::model::flow_stats::{FlowPushPayload, FlowStatsEntry, FlowSubscription, FlowSummary, StatsSummary}; +use crate::model::monitoring::direction::Direction; +use crate::model::monitoring::flow_stats::{ + FlowPushPayload, FlowStatsEntry, FlowSubscription, FlowSummary, StatsSummary, +}; /// Conversion from core::ml::FlowData to model::FlowStatsEntry. /// Placed here (core layer) to maintain dependency rule: model/ must not import core/. @@ -40,15 +42,14 @@ impl FlowStatistics { pub fn get_all_flows(&self) -> Vec { let mut entries = Vec::new(); for tracker in self.engine.trackers() { - let t = tracker.lock(); - entries.extend(t.get_flows().iter().map(FlowStatsEntry::from)); + entries.extend(tracker.get_flows().iter().map(FlowStatsEntry::from)); } entries } pub fn get_filtered_flows(&self, sub: &FlowSubscription) -> Vec { - let now_us = time::SystemTime::now() - .duration_since(time::UNIX_EPOCH) + let now_us = SystemTime::now() + .duration_since(UNIX_EPOCH) .map(|d| d.as_micros() as u64) .unwrap_or(0); @@ -63,7 +64,7 @@ impl FlowStatistics { flows.retain(|f| f.last_seen_us >= cutoff); } - flows.sort_by(|a, b| (b.fwd_bytes + b.bwd_bytes).cmp(&(a.fwd_bytes + a.bwd_bytes))); + flows.sort_by_key(|f| std::cmp::Reverse(f.fwd_bytes + f.bwd_bytes)); if let Some(n) = sub.top_n { flows.truncate(n.min(10000)); @@ -100,8 +101,8 @@ impl FlowStatistics { } } - let now_ms = time::SystemTime::now() - .duration_since(time::UNIX_EPOCH) + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or(0); diff --git a/net-guardia/src/infrastructure/suricata_manager.rs b/net-guardia/src/infrastructure/suricata_manager.rs new file mode 100644 index 0000000..882b8fa --- /dev/null +++ b/net-guardia/src/infrastructure/suricata_manager.rs @@ -0,0 +1,180 @@ +//! Suricata subprocess manager — M1 scope. +//! +//! Responsibilities: +//! - Spawn Suricata as a child process bound to the ingress interface via AF_PACKET, +//! writing eve.json to the configured log path. +//! - Track liveness; update `SuricataHealth` shared state. +//! - On crash, restart with configured backoff (if enabled in config). +//! - On shutdown signal, send SIGTERM first then wait briefly, then SIGKILL +//! if the child still hasn't exited. +//! +//! M2 will add eve.json tail + parse; M3 will add SOAR translation. This module +//! does not read eve.json itself — downstream consumers tail the log path. + +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; + +use arc_swap::ArcSwap; +use macros::log; +use tokio::process::{Child, Command}; +use tokio::sync::oneshot; +use tokio::time::{sleep, timeout}; + +use crate::infrastructure::app_config::AppConfig; +use crate::model::error::Error; +use crate::model::error::suricata::SuricataError; +use crate::model::log::suricata::SuricataLog; +use crate::model::system::suricata::SuricataHealth; + +pub struct SuricataManager { + config: Arc, + health: Arc>, +} + +impl SuricataManager { + pub fn new(config: Arc) -> Arc { + let initial = if config.suricata.enabled { + SuricataHealth::Stopped { + reason: "not yet started".to_string(), + } + } else { + SuricataHealth::Disabled + }; + Arc::new(Self { + config, + health: Arc::new(ArcSwap::from_pointee(initial)), + }) + } + + /// Shared handle for HTTP handlers and the health broadcast. + pub fn health(&self) -> Arc> { + self.health.clone() + } + + /// Supervisor loop. Returns a `oneshot::Sender` — dropping or sending on it + /// initiates graceful shutdown (SIGTERM → wait → SIGKILL). + pub fn run(self: Arc) -> oneshot::Sender<()> { + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + + if !self.config.suricata.enabled { + log!(SuricataLog::Disabled); + return shutdown_tx; + } + + tokio::spawn(async move { + self.supervisor_loop(shutdown_rx).await; + }); + + shutdown_tx + } + + async fn supervisor_loop(self: Arc, mut shutdown_rx: oneshot::Receiver<()>) { + loop { + // Pre-flight: validate binary + config exist before spawning. + if let Err(e) = Self::preflight(&self.config) { + self.health + .store(Arc::new(SuricataHealth::Stopped { reason: e.to_string() })); + return; + } + + let mut child = match self.spawn_child() { + Ok(c) => c, + Err(e) => { + self.health + .store(Arc::new(SuricataHealth::Stopped { reason: e.to_string() })); + return; + } + }; + + let pid = child.id().unwrap_or(0); + self.health.store(Arc::new(SuricataHealth::Running { pid })); + log!(SuricataLog::Started(pid)); + + tokio::select! { + exit = child.wait() => { + let reason = match exit { + Ok(status) => format!("exited with {status}"), + Err(e) => format!("wait error: {e}"), + }; + if self.config.suricata.auto_restart_on_crash { + let backoff = self.config.suricata.restart_backoff_secs; + log!(SuricataLog::CrashedRestartPending(reason.clone(), backoff)); + self.health.store(Arc::new(SuricataHealth::Stopped { reason })); + sleep(Duration::from_secs(backoff)).await; + continue; + } else { + log!(SuricataLog::Stopped(reason.clone())); + self.health.store(Arc::new(SuricataHealth::Stopped { reason })); + return; + } + } + _ = &mut shutdown_rx => { + log!(SuricataLog::ShutdownRequested); + Self::graceful_stop(&mut child).await; + self.health.store(Arc::new(SuricataHealth::Stopped { + reason: "shutdown".to_string(), + })); + return; + } + } + } + } + + fn preflight(config: &AppConfig) -> Result<(), Error> { + let bin = &config.suricata.binary_path; + if !Path::new(bin).exists() { + Err(SuricataError::BinaryNotFound(bin.clone()))?; + } + let cfg = &config.suricata.config_path; + if !Path::new(cfg).exists() { + Err(SuricataError::ConfigNotFound(cfg.clone()))?; + } + Ok(()) + } + + fn spawn_child(&self) -> Result { + let sc = &self.config.suricata; + let iface = &self.config.network.ingress_ifname; + + log!(SuricataLog::Spawning( + sc.binary_path.clone(), + sc.config_path.clone(), + iface.clone(), + )); + + let mut cmd = Command::new(&sc.binary_path); + cmd.arg("-c") + .arg(&sc.config_path) + .arg("--af-packet") + .arg(iface) + .arg("-l") + // Log dir is the parent of the configured eve.json path. + .arg( + Path::new(&sc.eve_log_path) + .parent() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| ".".to_string()), + ) + .kill_on_drop(true); + + cmd.spawn().map_err(|e| SuricataError::SpawnFailed(e).into()) + } + + /// Send SIGTERM, wait up to 5s, then SIGKILL if still alive. + async fn graceful_stop(child: &mut Child) { + if let Some(pid) = child.id() { + // SAFETY: SIGTERM to a known child pid. pid was obtained from tokio::process::Child + // and is valid as long as we haven't reaped it, which we haven't. + unsafe { + libc::kill(pid as libc::pid_t, libc::SIGTERM); + } + } + match timeout(Duration::from_secs(5), child.wait()).await { + Ok(_) => {} + Err(_) => { + let _ = child.kill().await; + } + } + } +} diff --git a/net-guardia/src/infrastructure/suricata_monitor.rs b/net-guardia/src/infrastructure/suricata_monitor.rs new file mode 100644 index 0000000..45a7e01 --- /dev/null +++ b/net-guardia/src/infrastructure/suricata_monitor.rs @@ -0,0 +1,213 @@ +//! Suricata eve.json tail + parse (M2) + translate to DetectionEvent (M3). +//! +//! Runs as a tokio background task. Waits for the eve.json file to appear +//! (Suricata may take a few seconds after spawn to create it), then tails +//! new lines and parses each as JSON. Only `event_type=alert` lines are +//! forwarded; everything else (flow/stats/fileinfo) is ignored for v0.9. +//! +//! Forwarded events land on the shared `detection_tx` mpsc — the same +//! channel the ML + correlation + beaconing detectors feed. The detection +//! orchestrator handles dedup, GeoIP enrichment, and publishes the final +//! `ThreatDetectedEvent` that SOAR consumes. +//! +//! File rotation is handled by detecting a shrunken file length on next +//! poll — we reopen from offset 0. Suricata itself rotates eve.json on +//! SIGHUP; we don't send SIGHUP in v0.9 so rotation will be rare. + +use std::io::SeekFrom; +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; + +use macros::log; +use tokio::fs::{self, File}; +use tokio::io::{AsyncBufReadExt, AsyncSeekExt, BufReader}; +use tokio::sync::mpsc; +use tokio::time::sleep; + +use crate::infrastructure::app_config::AppConfig; +use crate::model::event::{DetectionEvent, DetectionSource}; +use crate::model::log::suricata::SuricataLog; + +/// Fixed poll interval for new eve.json content. eve.json is line-appended +/// so a short interval yields low latency; 200ms is well under any human +/// reaction time and negligible CPU cost. +const POLL_INTERVAL: Duration = Duration::from_millis(200); + +/// How long to wait between checks while the file does not yet exist. +const FILE_WAIT_INTERVAL: Duration = Duration::from_secs(1); + +/// IANA protocol numbers for Suricata's `proto` strings. +const IANA_PROTO_ICMP: u8 = 1; +const IANA_PROTO_TCP: u8 = 6; +const IANA_PROTO_UDP: u8 = 17; + +/// Suricata severity → SOAR confidence mapping. Higher severity ⇒ higher +/// confidence so SOAR thresholds tend to trip on real alerts. +const SURICATA_CONFIDENCE_HIGH: f32 = 0.95; +const SURICATA_CONFIDENCE_MEDIUM: f32 = 0.80; +const SURICATA_CONFIDENCE_LOW: f32 = 0.65; +const SURICATA_CONFIDENCE_INFO: f32 = 0.50; + +/// Suricata severity numeric encoding (eve.json `alert.severity`). +const SURICATA_SEVERITY_HIGH: u64 = 1; +const SURICATA_SEVERITY_MEDIUM: u64 = 2; +const SURICATA_SEVERITY_LOW: u64 = 3; + +pub struct SuricataMonitor { + config: Arc, + detection_tx: mpsc::Sender, +} + +impl SuricataMonitor { + pub fn new(config: Arc, detection_tx: mpsc::Sender) -> Arc { + Arc::new(Self { config, detection_tx }) + } + + /// Spawn the tail loop. No-op if the bridge is disabled. + pub fn start(self: Arc) { + if !self.config.suricata.enabled { + return; + } + tokio::spawn(async move { + self.tail_loop().await; + }); + } + + async fn tail_loop(self: Arc) { + let path = self.config.suricata.eve_log_path.clone(); + loop { + // Wait until the file exists — Suricata spawns asynchronously and + // may take a few seconds to create eve.json. + if !Path::new(&path).exists() { + log!(SuricataLog::MonitorWaitingForFile(path.clone())); + while !Path::new(&path).exists() { + sleep(FILE_WAIT_INTERVAL).await; + } + } + + let mut file = match File::open(&path).await { + Ok(f) => f, + Err(_) => { + sleep(FILE_WAIT_INTERVAL).await; + continue; + } + }; + // Seek to end so we only see new content from this point. Suricata + // writes a large volume at startup that we don't want to replay. + let mut pos: u64 = file.seek(SeekFrom::End(0)).await.unwrap_or_default(); + log!(SuricataLog::MonitorAttached(path.clone())); + + let mut reader = BufReader::new(file); + let mut line = String::new(); + + loop { + line.clear(); + match reader.read_line(&mut line).await { + Ok(0) => { + // EOF — check for rotation (file truncated or replaced). + if let Ok(meta) = fs::metadata(&path).await + && meta.len() < pos + { + log!(SuricataLog::MonitorFileRotated); + break; // reopen + } + sleep(POLL_INTERVAL).await; + } + Ok(n) => { + pos += n as u64; + self.handle_line(line.trim_end()).await; + } + Err(_) => { + // Read error — treat as rotation and reopen. + sleep(POLL_INTERVAL).await; + break; + } + } + } + } + } + + async fn handle_line(&self, raw: &str) { + if raw.is_empty() { + return; + } + // Cheap pre-filter: a busy Suricata writes flow/stats/dns/http/fileinfo + // lines that vastly outnumber alerts. Parsing every line into + // serde_json::Value just to drop it dominates CPU on this tail. The + // substring match is a sound over-approximation — false positives go + // through the full parse + the strict event_type=="alert" check below. + if !raw.contains("\"event_type\":\"alert\"") { + return; + } + let v: serde_json::Value = match serde_json::from_str(raw) { + Ok(v) => v, + Err(_) => return, + }; + if v.get("event_type").and_then(|x| x.as_str()) != Some("alert") { + return; + } + let Some(event) = Self::translate_alert(&v) else { + return; + }; + // mpsc is bounded; if the orchestrator is backed up, drop rather than + // block the tail (eve.json will fill the disk if we block). + let _ = self.detection_tx.try_send(event); + } + + /// Map a Suricata alert JSON object to a DetectionEvent. Returns None if + /// the event lacks the fields we need. + fn translate_alert(v: &serde_json::Value) -> Option { + let src_ip = v.get("src_ip")?.as_str()?.to_string(); + let dest_ip = v.get("dest_ip")?.as_str()?.to_string(); + let proto_str = v.get("proto").and_then(|x| x.as_str()).unwrap_or(""); + let protocol: u8 = match proto_str { + "TCP" => IANA_PROTO_TCP, + "UDP" => IANA_PROTO_UDP, + "ICMP" => IANA_PROTO_ICMP, + _ => 0, + }; + + let alert = v.get("alert")?; + let sid = alert.get("signature_id").and_then(|x| x.as_u64()).unwrap_or(0) as u32; + let signature = alert + .get("signature") + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_string(); + // Suricata severity: 1=high, 2=medium, 3=low, 4=informational. + // Map to confidence in [0.5, 1.0] so high-severity alerts tend to trip + // SOAR condition thresholds. + let severity = alert + .get("severity") + .and_then(|x| x.as_u64()) + .unwrap_or(SURICATA_SEVERITY_LOW); + let confidence = match severity { + SURICATA_SEVERITY_HIGH => SURICATA_CONFIDENCE_HIGH, + SURICATA_SEVERITY_MEDIUM => SURICATA_CONFIDENCE_MEDIUM, + SURICATA_SEVERITY_LOW => SURICATA_CONFIDENCE_LOW, + _ => SURICATA_CONFIDENCE_INFO, + }; + + log!(SuricataLog::AlertForwarded( + sid, + src_ip.clone(), + dest_ip.clone(), + signature, + )); + + Some(DetectionEvent { + source: DetectionSource::Suricata, + attack_type: "suricata_alert".to_string(), + confidence, + source_ip: src_ip, + dest_ip, + protocol, + packet_count: 0, + flow_duration_us: 0, + ae_score: 0.0, + anomaly_score: 0.0, + c2_score: 0.0, + }) + } +} diff --git a/net-guardia/src/core/system.rs b/net-guardia/src/infrastructure/system.rs similarity index 52% rename from net-guardia/src/core/system.rs rename to net-guardia/src/infrastructure/system.rs index 020c2d3..4958dcf 100644 --- a/net-guardia/src/core/system.rs +++ b/net-guardia/src/infrastructure/system.rs @@ -1,38 +1,62 @@ +use std::path::PathBuf; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; +use arc_swap::ArcSwap; use aya::Ebpf; use aya::maps::{MapData, ProgramArray}; use macros::log; +use sd_notify::NotifyState; +use tokio::signal::ctrl_c; +use tokio::sync::broadcast::{Receiver, error::RecvError}; +use tokio::sync::mpsc::{self, Sender}; +use tokio::sync::oneshot; +use tokio::time::{interval, sleep}; +use crate::adapter::ebpf::EbpfServices; +use crate::adapter::http::model_upload; use crate::adapter::persistence::Database; use crate::core::acl_service::AclService; use crate::core::auth::jwt::JwtService; use crate::core::config_service::ConfigService; +use crate::core::correlation::engine::CorrelationEngine; +use crate::core::detection::beaconing::BeaconingDetector; +use crate::core::detection::orchestrator::DetectionOrchestrator; use crate::core::dns_filter_service::DnsFilterService; -use crate::core::ebpf::EbpfServices; use crate::core::email::scheduler::ReportScheduler; -use crate::core::ml::config_loader::InferenceConfig; -use crate::core::ml::drift_detector::DriftDetector; +use crate::core::ml::drift_detector::DriftDetectorHandle; +use crate::core::ml::model_watcher::ModelWatcher; use crate::core::notification_service::NotificationService; use crate::core::playbook_service::PlaybookService; use crate::core::rate_limit_service::RateLimitService; use crate::core::soar::engine::SoarEngine; use crate::core::soar::scheduler::TtlScheduler; +use crate::core::stats_aggregator::StatsAggregator; use crate::infrastructure::app_config::AppConfig; use crate::infrastructure::app_services::AppServices; use crate::infrastructure::audit_logger::AuditLogger; use crate::infrastructure::communication_manager::CommunicationManager; use crate::infrastructure::geoip::GeoIpService; -use crate::infrastructure::http_server::HttpServerParams; +use crate::infrastructure::http_server::{self, HttpServerParams}; use crate::infrastructure::secret_store::SecretStore; use crate::infrastructure::service_factory::ServiceFactory; +use crate::infrastructure::suricata_manager::SuricataManager; +use crate::infrastructure::suricata_monitor::SuricataMonitor; +use crate::interface::port::audit::AuditRepo; +use crate::interface::port::packet_sink::PacketSinkFactory; +use crate::interface::port::setting::SettingRepo; +use crate::interface::port::stats::StatsRepo; +use crate::model::config::constants::{MODELS_DIR, STAGING_SUBDIR}; +use crate::model::detection::ml_detection::AlertMessage; use crate::model::error::Error; use crate::model::error::system::SystemError; use crate::model::event::{DetectionEvent, DetectionSource, DriftDetectedEvent}; use crate::model::log::detection::DetectionLog; use crate::model::log::ml::MLLog; use crate::model::log::system::SystemLog; -use crate::model::ml_detection::AlertMessage; +use crate::model::system::config::MLInferenceConfig; +use crate::model::system::health::EbpfHealth; use crate::model::system::readiness::ReadinessState; /// API-triggered shutdown mode. @@ -42,26 +66,23 @@ pub enum ShutdownMode { Restart, } -/// Handle for triggering shutdown from HTTP endpoints. -/// Uses a parking_lot::Mutex> so it can be shared as app_data. +/// Handle for triggering shutdown from HTTP endpoints. Backed by a +/// capacity-1 mpsc so the first trigger atomically wins via `try_send`, +/// and subsequent calls receive `TrySendError::Full` — no lock, no +/// `Option::take`, no `Mutex`. pub struct ShutdownHandle { - tx: parking_lot::Mutex>>, + tx: mpsc::Sender, } impl ShutdownHandle { - fn new(tx: tokio::sync::oneshot::Sender) -> Self { - Self { - tx: parking_lot::Mutex::new(Some(tx)), - } + fn new(tx: mpsc::Sender) -> Self { + Self { tx } } - /// Trigger shutdown. Returns false if already triggered. + /// Trigger shutdown. Returns false if already triggered or the + /// receiver has been dropped. pub fn trigger(&self, mode: ShutdownMode) -> bool { - if let Some(tx) = self.tx.lock().take() { - tx.send(mode).is_ok() - } else { - false - } + self.tx.try_send(mode).is_ok() } } @@ -70,7 +91,7 @@ impl ShutdownHandle { /// Setup mode is handled by main.rs — System only runs when setup is complete. pub struct System { pub app_config: Arc, - pub inference_config: Arc, + pub inference_config: Arc, pub ebpf_services: Arc, pub app_services: Arc, pub db: Arc, @@ -80,8 +101,8 @@ pub struct System { pub soar_engine: Arc, pub ttl_scheduler: Option, pub report_scheduler: Option, - pub ingress_ebpf: Ebpf, - pub egress_ebpf: Ebpf, + pub ingress_ebpf: Option, + pub egress_ebpf: Option, pub acl_service: Arc, pub config_service: Arc, pub dns_filter_service: Arc, @@ -89,9 +110,12 @@ pub struct System { pub playbook_service: Arc, pub rate_limit_service: Arc, pub geoip: Option>, - pub drift_detector: Arc>, + pub drift_detector: DriftDetectorHandle, pub shutdown_handle: Option>, - _ingress_program_array: ProgramArray, + _ingress_program_array: Option>, + pub ebpf_health: Arc>, + pub suricata_manager: Arc, + suricata_shutdown: Option>, } impl System { @@ -122,6 +146,9 @@ impl System { drift_detector: state.drift_detector, shutdown_handle: None, _ingress_program_array: state._ingress_program_array, + ebpf_health: state.ebpf_health, + suricata_manager: state.suricata_manager, + suricata_shutdown: None, }) } @@ -130,27 +157,70 @@ impl System { pub async fn run(&mut self) -> Result { log!(SystemLog::Initializing); - log!(MLLog::ModelsLoaded( - self.app_services.ml_models.get_model_info("deep_autoencoder") - )); - log!(MLLog::ModelsLoaded( - self.app_services.ml_models.get_model_info("classifier") - )); - log!(MLLog::ConfigLoaded { - features: self.inference_config.num_ae_features(), - attacks: self.inference_config.num_attack_types() - }); + // Sweep model-upload staging directories left over from failed + // uploads before the model watcher starts listening. A stale + // `.staging//` would otherwise outlive restarts and eat + // disk if the admin repeatedly aborted uploads mid-stream. + { + let staging_root = PathBuf::from(MODELS_DIR).join(STAGING_SUBDIR); + match model_upload::clean_staging_orphans(&staging_root, Duration::from_secs(3600)) { + Ok(0) => {} + Ok(n) => log!(SystemLog::StagingOrphansCleaned(n as u64)), + Err(e) => log!(SystemLog::StagingOrphansSweepFailed(e.to_string())), + } + } - ServiceFactory::aya_log_init(&mut self.ingress_ebpf, &mut self.egress_ebpf)?; - log!(SystemLog::InitializeComplete); - self.attach_ebpf()?; + // ML source state snapshot. Day 1 with no manifest renders as Dormant + // — the rest of the stack still runs (3-source fusion). + { + let status = self.app_services.ml_inference.current_status(); + match serde_json::to_string(&status) { + Ok(s) => log!(MLLog::ModelsLoaded(s)), + Err(e) => log!(MLLog::ModelsLoaded(format!(""))), + } + } + log!(MLLog::ConfigLoaded( + self.inference_config.num_ae_features(), + self.inference_config.num_attack_types(), + )); + + // aya_log_init + attach_xdp only make sense if the eBPF objects + // loaded. When eBPF is unavailable we skip both; the rest of the + // system runs normally and the eBPF health broadcast tells the UI why. + // aya_log_init failure is logged but non-fatal — the kernel programs + // still run, we just lose the in-kernel log channel. + if let (Some(ingress), Some(egress)) = (self.ingress_ebpf.as_mut(), self.egress_ebpf.as_mut()) { + if let Err(e) = ServiceFactory::aya_log_init(ingress, egress) { + use crate::infrastructure::ebpf_preflight; + use crate::model::system::health::EbpfFailStage; + let health = ebpf_preflight::classify(EbpfFailStage::LoggerInit, &e, None); + log!(SystemLog::EbpfBringupFailed(format!("{:?}", health))); + } + log!(SystemLog::InitializeComplete); + self.attach_ebpf()?; + } else { + log!(SystemLog::InitializeComplete); + } // Subscribe to ML alerts BEFORE starting services to avoid race condition let ml_alert_rx = self.app_services.ml_alert.subscribe_to_alerts(); let ebpf_services = self.ebpf_services.clone(); let app_services = self.app_services.clone(); - ebpf_services.run(app_services.ml_engine.clone()).await?; + // AF_XDP socket bind + drop-ring-buf consumer. If eBPF maps are + // unavailable the call already returns Ok(()) without doing anything. + // When maps exist but bind fails (e.g. igb on kernel < 6.17), record + // the classified reason and continue — the ML engine will see no + // packets, same as a network that is simply quiet. + let sink_factory: Arc = app_services.ml_engine.clone(); + if let Err(e) = ebpf_services.run(sink_factory).await { + use crate::infrastructure::ebpf_preflight; + use crate::model::system::health::EbpfFailStage; + let iface = self.app_config.network.ingress_ifname.as_str(); + let health = ebpf_preflight::classify(EbpfFailStage::AfXdpBind, &e, Some(iface)); + log!(SystemLog::EbpfBringupFailed(format!("{:?}", health))); + self.ebpf_health.store(Arc::new(health)); + } app_services.run().await?; // Start SOAR engine @@ -168,15 +238,13 @@ impl System { } // Start audit logger (subscribe to AuditEvent + DriftDetectedEvent, persist to DB) - let audit_logger = Arc::new(AuditLogger::new( - self.db.clone() as Arc - )); + let audit_logger = Arc::new(AuditLogger::new(self.db.clone() as Arc)); audit_logger.start(&self.comm); // Start stats aggregator (writes weekly_* settings for Report engine) - let stats_aggregator = crate::core::stats_aggregator::StatsAggregator::new( - self.db.clone() as Arc, - self.db.clone() as Arc, + let stats_aggregator = StatsAggregator::new( + self.db.clone() as Arc, + self.db.clone() as Arc, ); stats_aggregator.start(); @@ -190,28 +258,28 @@ impl System { } // Start detection orchestrator (dedup + enrichment + source attribution) - let (detection_tx, detection_rx) = tokio::sync::mpsc::channel::(1024); - let orchestrator = crate::core::detection::orchestrator::DetectionOrchestrator::new( + let (detection_tx, detection_rx) = mpsc::channel::(1024); + let orchestrator = DetectionOrchestrator::new( detection_rx, self.comm.clone(), self.geoip.clone(), + self.app_services.fusion_metrics.clone(), ); orchestrator.start(); // Clone detection_tx for correlation engine and beaconing detector let correlation_detection_tx = detection_tx.clone(); let beaconing_detection_tx = detection_tx.clone(); + let suricata_detection_tx = detection_tx.clone(); // Start cross-flow correlation engine (botnet, scan, lateral movement detection) let correlation_alert_rx = self.app_services.ml_alert.subscribe_to_alerts(); - let correlation_engine = - crate::core::correlation::engine::CorrelationEngine::new(correlation_alert_rx, correlation_detection_tx); + let correlation_engine = CorrelationEngine::new(correlation_alert_rx, correlation_detection_tx); correlation_engine.start(); // Start temporal beaconing detector (CV-based C2 periodicity detection) let beaconing_alert_rx = self.app_services.ml_alert.subscribe_to_alerts(); - let beaconing_detector = - crate::core::detection::beaconing::BeaconingDetector::new(beaconing_alert_rx, beaconing_detection_tx); + let beaconing_detector = BeaconingDetector::new(beaconing_alert_rx, beaconing_detection_tx); beaconing_detector.start(); // Bridge ML alerts → DetectionEvent (thin adapter, no enrichment) @@ -219,8 +287,12 @@ impl System { Self::bridge_ml_to_detection(ml_alert_rx, detection_tx).await; }); + // Start model hot-reload watcher (monitors models/ for .onnx changes) + let model_watcher = ModelWatcher::new(self.app_services.ml_inference.clone(), self.app_config.clone()); + model_watcher.start(); + // Initialize force_https flag from DB setting - let force_https = Arc::new(std::sync::atomic::AtomicBool::new( + let force_https = Arc::new(AtomicBool::new( self.db .get_setting("force_https") .ok() @@ -232,29 +304,23 @@ impl System { // Build per-subsystem readiness flags for /health/ready let readiness_state = Arc::new(ReadinessState::new()); // DB is connected (System::new succeeded), ML models loaded (AppServices::new succeeded) - readiness_state - .db_connected - .store(true, std::sync::atomic::Ordering::SeqCst); - readiness_state - .ml_model_loaded - .store(true, std::sync::atomic::Ordering::SeqCst); + readiness_state.db_connected.store(true, Ordering::SeqCst); + readiness_state.ml_model_loaded.store(true, Ordering::SeqCst); // eBPF was attached above (self.attach_ebpf succeeded) - readiness_state - .ebpf_attached - .store(true, std::sync::atomic::Ordering::SeqCst); + readiness_state.ebpf_attached.store(true, Ordering::SeqCst); // SOAR engine started above (self.soar_engine.start succeeded) - readiness_state - .soar_engine_running - .store(true, std::sync::atomic::Ordering::SeqCst); + readiness_state.soar_engine_running.store(true, Ordering::SeqCst); - // Create shutdown channel for API-triggered shutdown/restart - let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::(); + // Create shutdown channel for API-triggered shutdown/restart. + // Capacity 1 means only the first `try_send` lands a value; later + // ones report Full, which `ShutdownHandle::trigger` surfaces as `false`. + let (shutdown_tx, mut shutdown_rx) = mpsc::channel::(1); let shutdown_handle = Arc::new(ShutdownHandle::new(shutdown_tx)); self.shutdown_handle = Some(shutdown_handle.clone()); // Start HTTP server in background (!Send, use actix::spawn) - let setup_flag = Arc::new(std::sync::atomic::AtomicBool::new(true)); - let ready_flag = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let setup_flag = Arc::new(AtomicBool::new(true)); + let ready_flag = Arc::new(AtomicBool::new(false)); let ready_flag_for_set = ready_flag.clone(); let params = HttpServerParams { app_config: self.app_config.clone(), @@ -276,57 +342,70 @@ impl System { rate_limit_service: self.rate_limit_service.clone(), force_https, shutdown_handle: shutdown_handle.clone(), + suricata_manager: self.suricata_manager.clone(), + soar_engine: self.soar_engine.clone(), }; let ready_for_http = ready_flag_for_set.clone(); actix::spawn(async move { - if let Err(e) = crate::infrastructure::http_server::run(params).await { + if let Err(e) = http_server::run(params).await { // HTTP server failed — mark system as NOT ready so health checks fail - ready_for_http.store(false, std::sync::atomic::Ordering::SeqCst); + ready_for_http.store(false, Ordering::SeqCst); log!(SystemError::HttpServerError(e)); } }); // Brief delay to catch immediate bind failures before reporting ready - tokio::time::sleep(std::time::Duration::from_millis(100)).await; + sleep(Duration::from_millis(100)).await; // Mark system as ready — /api/health/ready will now return {"ready": true} - ready_flag_for_set.store(true, std::sync::atomic::Ordering::SeqCst); + ready_flag_for_set.store(true, Ordering::SeqCst); // Notify systemd that we are ready (Type=notify) - let _ = sd_notify::notify(true, &[sd_notify::NotifyState::Ready]); + let _ = sd_notify::notify(true, &[NotifyState::Ready]); log!(SystemLog::FullInitComplete); // Start systemd watchdog keepalive task { let mut usec: u64 = 0; if sd_notify::watchdog_enabled(false, &mut usec) && usec > 0 { - let notify_interval = std::time::Duration::from_micros(usec / 2); + let notify_interval = Duration::from_micros(usec / 2); tokio::spawn(async move { - let mut tick = tokio::time::interval(notify_interval); + let mut tick = interval(notify_interval); loop { tick.tick().await; - let _ = sd_notify::notify(false, &[sd_notify::NotifyState::Watchdog]); + let _ = sd_notify::notify(false, &[NotifyState::Watchdog]); } }); } } + // Start Suricata subprocess supervisor (no-op if disabled in config). + self.suricata_shutdown = Some(self.suricata_manager.clone().run()); + + // Start Suricata eve.json monitor — tails the log file, translates + // alert events into DetectionEvent on the shared mpsc. No-op if the + // bridge is disabled in config. + SuricataMonitor::new(self.app_config.clone(), suricata_detection_tx).start(); + // Wait for shutdown signal (ctrl-c OR API-triggered) tokio::select! { - _ = tokio::signal::ctrl_c() => { + _ = ctrl_c() => { Ok(ShutdownMode::Shutdown) } - mode = shutdown_rx => { + mode = shutdown_rx.recv() => { Ok(mode.unwrap_or(ShutdownMode::Shutdown)) } } } - pub async fn terminate(&self) -> Result<(), Error> { + pub async fn terminate(&mut self) -> Result<(), Error> { let ebpf_services = self.ebpf_services.clone(); let app_services = self.app_services.clone(); log!(SystemLog::Terminating); + if let Some(tx) = self.suricata_shutdown.take() { + let _ = tx.send(()); + } ebpf_services.terminate(); app_services.terminate(); log!(SystemLog::TerminateComplete); @@ -337,9 +416,12 @@ impl System { fn normalize_attack_type(raw: &str) -> String { match raw { "Brute Force" => "brute_force".to_string(), - "DDoS" | "DoS" => "threat_detected".to_string(), - "Exploitation" => "threat_detected".to_string(), + "C2 Communication" => "c2_communication".to_string(), + "DoS/DDoS" => "threat_detected".to_string(), + "Exploitation" | "Malware" | "Web Attack" => "threat_detected".to_string(), + "Bot" | "DNS Tunneling" => "threat_detected".to_string(), "Reconnaissance" => "port_scan".to_string(), + "Normal" => "normal".to_string(), other => { log!(DetectionLog::UnknownMlAttackType(other.to_string())); "threat_detected".to_string() @@ -348,14 +430,11 @@ impl System { } /// Periodically check the drift detector and publish DriftDetectedEvent when drift is found. - async fn run_drift_monitor( - drift_detector: Arc>, - comm: Arc, - ) { - let mut interval = tokio::time::interval(std::time::Duration::from_secs(60)); + async fn run_drift_monitor(drift_detector: DriftDetectorHandle, comm: Arc) { + let mut interval = interval(Duration::from_secs(60)); loop { interval.tick().await; - let report = drift_detector.lock().check_drift(); + let report = drift_detector.check_drift().await; if let Some(report) = report { log!(SystemLog::DriftDetected( report.drifted_features.len(), @@ -374,35 +453,41 @@ impl System { /// Thin ML bridge: converts AlertMessage → DetectionEvent and sends to orchestrator. /// Enrichment (GeoIP, hit count, repeat offender) is handled by the DetectionOrchestrator. - async fn bridge_ml_to_detection( - mut rx: tokio::sync::broadcast::Receiver, - tx: tokio::sync::mpsc::Sender, - ) { + async fn bridge_ml_to_detection(mut rx: Receiver, tx: Sender) { log!(DetectionLog::MlBridgeStarted); loop { match rx.recv().await { Ok(alert) => { + let raw_type = alert.attack_type.as_deref().unwrap_or("unknown"); + let normalized = Self::normalize_attack_type(raw_type); + + // "Normal" class means benign — no SOAR trigger needed + if normalized == "normal" { + continue; + } + let event = DetectionEvent { source: DetectionSource::ML, - attack_type: Self::normalize_attack_type( - &alert.attack_type.unwrap_or_else(|| "unknown".into()), - ), + attack_type: normalized, confidence: alert.confidence, source_ip: alert.src_ip, dest_ip: alert.dst_ip, protocol: alert.protocol, packet_count: alert.packet_count, flow_duration_us: alert.flow_duration_us, + ae_score: alert.ae_score, + anomaly_score: alert.anomaly_score, + c2_score: alert.c2_score, }; if tx.send(event).await.is_err() { break; // Orchestrator dropped } } - Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + Err(RecvError::Lagged(n)) => { log!(DetectionLog::MlBridgeLagged(n)); } - Err(tokio::sync::broadcast::error::RecvError::Closed) => { + Err(RecvError::Closed) => { log!(DetectionLog::MlAlertChannelClosed); break; } @@ -410,19 +495,44 @@ impl System { } } + /// Attempt to attach the XDP programs to the configured interfaces. + /// If either attach fails, record the reason in `ebpf_health` and + /// continue — the rest of the system keeps running. fn attach_ebpf(&mut self) -> Result<(), Error> { let ingress_ifname = self.app_config.network.ingress_ifname.clone(); let egress_ifname = self.app_config.network.egress_ifname.clone(); ServiceFactory::set_memory_limit()?; - let ingress_mode = ServiceFactory::attach_xdp(&mut self.ingress_ebpf, &ingress_ifname, true)?; - let egress_mode = ServiceFactory::attach_xdp(&mut self.egress_ebpf, &egress_ifname, false)?; + let (ingress, egress) = match (self.ingress_ebpf.as_mut(), self.egress_ebpf.as_mut()) { + (Some(i), Some(e)) => (i, e), + _ => return Ok(()), + }; - if let Err(e) = self.db.set_setting("xdp_ingress_mode", &ingress_mode) { - log!(SystemError::XdpModeStoreFailed(e)); - } - if let Err(e) = self.db.set_setting("xdp_egress_mode", &egress_mode) { - log!(SystemError::XdpModeStoreFailed(e)); + let ingress_result = ServiceFactory::attach_xdp(ingress, &ingress_ifname, true); + let egress_result = ServiceFactory::attach_xdp(egress, &egress_ifname, false); + + match (ingress_result, egress_result) { + (Ok(ingress_mode), Ok(egress_mode)) => { + if let Err(e) = self.db.set_setting("xdp_ingress_mode", &ingress_mode) { + log!(SystemError::XdpModeStoreFailed(e)); + } + if let Err(e) = self.db.set_setting("xdp_egress_mode", &egress_mode) { + log!(SystemError::XdpModeStoreFailed(e)); + } + } + (ingress_res, egress_res) => { + use crate::infrastructure::ebpf_preflight; + use crate::model::system::health::EbpfFailStage; + + let (err, iface) = match (&ingress_res, &egress_res) { + (Err(e), _) => (e, ingress_ifname.as_str()), + (_, Err(e)) => (e, egress_ifname.as_str()), + _ => unreachable!("at least one branch is Err here"), + }; + let health = ebpf_preflight::classify(EbpfFailStage::XdpAttach, err, Some(iface)); + log!(SystemLog::EbpfBringupFailed(format!("{:?}", health))); + self.ebpf_health.store(Arc::new(health)); + } } Ok(()) @@ -439,13 +549,28 @@ mod tests { } #[test] - fn normalize_ddos() { - assert_eq!(System::normalize_attack_type("DDoS"), "threat_detected"); + fn normalize_c2_communication() { + assert_eq!(System::normalize_attack_type("C2 Communication"), "c2_communication"); } #[test] - fn normalize_dos() { - assert_eq!(System::normalize_attack_type("DoS"), "threat_detected"); + fn normalize_malware() { + assert_eq!(System::normalize_attack_type("Malware"), "threat_detected"); + } + + #[test] + fn normalize_bot() { + assert_eq!(System::normalize_attack_type("Bot"), "threat_detected"); + } + + #[test] + fn normalize_dns_tunneling() { + assert_eq!(System::normalize_attack_type("DNS Tunneling"), "threat_detected"); + } + + #[test] + fn normalize_dos_ddos() { + assert_eq!(System::normalize_attack_type("DoS/DDoS"), "threat_detected"); } #[test] @@ -458,6 +583,11 @@ mod tests { assert_eq!(System::normalize_attack_type("Reconnaissance"), "port_scan"); } + #[test] + fn normalize_normal() { + assert_eq!(System::normalize_attack_type("Normal"), "normal"); + } + #[test] fn normalize_unknown_falls_back_to_threat_detected() { assert_eq!(System::normalize_attack_type("SomethingNew"), "threat_detected"); diff --git a/net-guardia/src/interface/port/access_control.rs b/net-guardia/src/interface/port/access_control.rs index ba15521..0f400de 100644 --- a/net-guardia/src/interface/port/access_control.rs +++ b/net-guardia/src/interface/port/access_control.rs @@ -1,16 +1,13 @@ -use async_trait::async_trait; - use crate::model::error::Error; /// Port for blocking/unblocking IP addresses in the network data plane. /// Adapters: EbpfAccessControlAdapter (wraps eBPF AccessControl) -#[async_trait] pub trait AccessControlPort: Send + Sync { /// Block an IP address (adds to source blacklist in the data plane). /// Accepts both IPv4 ("1.2.3.4") and IPv6 ("::1") strings. - async fn block_ip(&self, ip: &str) -> Result<(), Error>; + fn block_ip(&self, ip: &str) -> Result<(), Error>; /// Unblock an IP address (removes from source blacklist in the data plane). /// Accepts both IPv4 and IPv6 strings. No-op if IP was not blocked. - async fn unblock_ip(&self, ip: &str) -> Result<(), Error>; + fn unblock_ip(&self, ip: &str) -> Result<(), Error>; } diff --git a/net-guardia/src/interface/port/access_control_admin.rs b/net-guardia/src/interface/port/access_control_admin.rs new file mode 100644 index 0000000..4f4a8ad --- /dev/null +++ b/net-guardia/src/interface/port/access_control_admin.rs @@ -0,0 +1,37 @@ +use std::collections::HashMap; +use std::net::{Ipv4Addr, Ipv6Addr, SocketAddrV4, SocketAddrV6}; + +use common::model::ip_address::Port; + +use crate::model::access_control::list_type::ListType; +use crate::model::error::Error; +use crate::model::monitoring::direction::FlowDirection; + +/// Admin-level ACL port — add/remove individual IPv4/IPv6 ACL list entries. +/// +/// Distinct from `AccessControlPort` (which only exposes `block_ip` / +/// `unblock_ip` for SOAR). `AclService` uses this richer API to serve the +/// `/api/acl` HTTP routes. +pub trait AccessControlAdminPort: Send + Sync { + fn add_ipv4_list(&self, direction: FlowDirection, list_type: ListType, address: SocketAddrV4) -> Result<(), Error>; + + fn add_ipv6_list(&self, direction: FlowDirection, list_type: ListType, address: SocketAddrV6) -> Result<(), Error>; + + fn remove_ipv4_list( + &self, + direction: FlowDirection, + list_type: ListType, + address: SocketAddrV4, + ) -> Result<(), Error>; + + fn remove_ipv6_list( + &self, + direction: FlowDirection, + list_type: ListType, + address: SocketAddrV6, + ) -> Result<(), Error>; + + fn get_ipv4_list(&self, direction: FlowDirection, list_type: ListType) -> HashMap>; + + fn get_ipv6_list(&self, direction: FlowDirection, list_type: ListType) -> HashMap>; +} diff --git a/net-guardia/src/interface/port/acl.rs b/net-guardia/src/interface/port/acl.rs new file mode 100644 index 0000000..0552dbc --- /dev/null +++ b/net-guardia/src/interface/port/acl.rs @@ -0,0 +1,38 @@ +use crate::model::error::Error; + +/// Type alias for ACL rule tuples: (ip_version, direction, list_type, ip_address, port) +pub type AclRuleTuple = (u8, String, String, String, u16); + +/// Data Plane BC — ACL aggregate repository. +/// +/// Owns ACL rules (user-managed block/allow lists) and the admin whitelist that +/// SOAR must not block. Kept disjoint from `EnforcementRepo` (rate-limit / DNS / +/// geo) so policy tables can evolve independently of packet-matching tables. +pub trait AclRepo: Send + Sync { + fn insert_acl_rule( + &self, + ip_version: u8, + direction: &str, + list_type: &str, + ip_address: &str, + port: u16, + ) -> Result<(), Error>; + + fn delete_acl_rule( + &self, + ip_version: u8, + direction: &str, + list_type: &str, + ip_address: &str, + port: u16, + ) -> Result<(), Error>; + + /// Returns true if a manual (non-SOAR) ACL rule exists for this IP. + /// Used by the TTL scheduler to avoid removing an eBPF block that the user + /// explicitly installed. + fn has_manual_acl_rule(&self, ip_address: &str) -> Result; + + fn load_admin_whitelist(&self) -> Result, Error>; + fn insert_admin_whitelist(&self, ip: &str) -> Result<(), Error>; + fn delete_admin_whitelist(&self, ip: &str) -> Result<(), Error>; +} diff --git a/net-guardia/src/interface/port/api_key.rs b/net-guardia/src/interface/port/api_key.rs index 697d4df..0cdb492 100644 --- a/net-guardia/src/interface/port/api_key.rs +++ b/net-guardia/src/interface/port/api_key.rs @@ -1,13 +1,15 @@ -use crate::model::auth::Claims; use crate::model::error::Error; +use crate::model::identity::auth::Claims; /// Type alias for API key list items: (id, name, permission_level, created_at, last_used_at) #[allow(clippy::type_complexity)] pub type ApiKeyListItem = (i64, String, String, String, Option); -/// Port for API key management and validation. -pub trait ApiKeyPort: Send + Sync { +/// Identity BC — API key CRUD + validation (distinct from user login, +/// used by MCP / programmatic clients). +pub trait ApiKeyRepo: Send + Sync { fn validate_api_key(&self, api_key: &str) -> Result, Error>; + fn hmac_api_key(&self, raw_key: &str) -> String; fn insert_api_key(&self, key_hash: &str, name: &str, permission_level: &str) -> Result; fn list_api_keys(&self) -> Result, Error>; fn delete_api_key(&self, id: i64) -> Result; diff --git a/net-guardia/src/interface/port/app_repo.rs b/net-guardia/src/interface/port/app_repo.rs new file mode 100644 index 0000000..4cd1fd7 --- /dev/null +++ b/net-guardia/src/interface/port/app_repo.rs @@ -0,0 +1,49 @@ +use super::acl::AclRepo; +use super::api_key::ApiKeyRepo; +use super::audit::AuditRepo; +use super::db_admin::DbAdminRepo; +use super::enforcement::EnforcementRepo; +use super::identity::IdentityRepo; +use super::setting::SettingRepo; +use super::soar::SoarRepo; +use super::stats::StatsRepo; + +/// Composition-root supertrait bundling every aggregate Repo trait + +/// `DbAdminRepo`. +/// +/// Services that operate on a single aggregate should take the +/// aggregate-specific trait (`Arc`, `Arc`, …) so +/// their dependency surface matches their responsibility. `AppRepo` exists +/// for composition wiring and for legacy call sites that span many +/// aggregates; it is an implementation convenience, not an aggregate +/// definition. +pub trait AppRepo: + AclRepo + + ApiKeyRepo + + AuditRepo + + DbAdminRepo + + EnforcementRepo + + IdentityRepo + + SettingRepo + + SoarRepo + + StatsRepo + + Send + + Sync +{ +} + +impl AppRepo for T where + T: AclRepo + + ApiKeyRepo + + AuditRepo + + DbAdminRepo + + EnforcementRepo + + IdentityRepo + + SettingRepo + + SoarRepo + + StatsRepo + + Send + + Sync + + ?Sized +{ +} diff --git a/net-guardia/src/interface/port/audit.rs b/net-guardia/src/interface/port/audit.rs index 727c3e5..d42740e 100644 --- a/net-guardia/src/interface/port/audit.rs +++ b/net-guardia/src/interface/port/audit.rs @@ -1,6 +1,32 @@ use crate::model::error::Error; -/// Port for audit trail persistence. -pub trait AuditPort: Send + Sync { - fn insert_audit_log(&self, actor: &str, action: &str, detail: &str) -> Result<(), Error>; +/// Audit log entry returned by `list_audit_logs` and +/// `verify_audit_log_chain` APIs. +#[derive(Debug, Clone)] +pub struct AuditLogEntry { + pub id: i64, + pub actor: String, + pub action: String, + pub detail: String, + pub created_at: String, +} + +/// Audit BC (supporting) — append-only WORM hash-chained audit log. +/// +/// The append-only constraint is enforced by SQLite triggers +/// (`audit_log_no_update` / `audit_log_no_delete`), not by this trait. +pub trait AuditRepo: Send + Sync { + /// Append a new audit entry. `detail` is typically a JSON blob. + fn insert_audit_log(&self, actor: &str, action: &str, detail: &str) -> Result<(), Error>; + + /// Read audit entries whose `action` exactly matches, newest first, + /// capped at `limit`. Drives the fusion explain endpoint, which + /// filters on `fused_threat_emitted` rather than walking the full + /// chain for every request. + fn list_audit_logs_by_action(&self, action: &str, limit: i64) -> Result, Error>; + + /// Walk the full chain and verify every `row_hash` matches + /// `H(ts || actor || action || detail || prev_hash)`. Returns the number + /// of entries verified. Errors on the first broken link. + fn verify_audit_log_chain(&self) -> Result; } diff --git a/net-guardia/src/interface/port/db_admin.rs b/net-guardia/src/interface/port/db_admin.rs new file mode 100644 index 0000000..4ecfc2e --- /dev/null +++ b/net-guardia/src/interface/port/db_admin.rs @@ -0,0 +1,29 @@ +use crate::model::error::Error; + +/// Persistence technical service — cross-aggregate atomic business operations +/// and database-wide administration (migration, encryption, backup). +/// +/// The atomic operations exposed here each span multiple aggregates in one +/// SQLite transaction. Rather than expose a generic `with_transaction` +/// primitive (which cannot be used through a trait object because Rust +/// forbids generic methods on dyn traits), each cross-aggregate use case +/// gets a dedicated business method. +pub trait DbAdminRepo: Send + Sync { + /// Atomically record a SOAR-driven IP block to both + /// `soar_block_rules` and `acl_rules`. Returns the new + /// `soar_block_rules.id`. Callers are responsible for eBPF rollback if + /// this fails. + fn commit_soar_block_to_db( + &self, + source_ip: &str, + ip_version: u8, + playbook_id: i64, + expires_at: &str, + ) -> Result; + + /// Atomically clear a SOAR-driven IP block: removes the corresponding + /// `acl_rules` row (if present) and marks the `soar_block_rules` row as + /// unblocked. Callers handle eBPF unblock separately. Used by both the + /// TTL-unblock and manual-unblock paths. + fn commit_soar_unblock_to_db(&self, soar_block_id: i64, ip_version: u8, source_ip: &str) -> Result<(), Error>; +} diff --git a/net-guardia/src/interface/port/dns_filter_api.rs b/net-guardia/src/interface/port/dns_filter_api.rs new file mode 100644 index 0000000..2788e8a --- /dev/null +++ b/net-guardia/src/interface/port/dns_filter_api.rs @@ -0,0 +1,11 @@ +use crate::model::error::Error; + +/// Admin-level DNS filter port — add/remove/list domains on the blacklist. +/// Used by `DnsFilterService` (HTTP-driven CRUD). Kept separate from +/// `DnsQueryFilter` (which is the fast-path check) to reflect their distinct +/// call sites and latency profiles. +pub trait DnsFilterPort: Send + Sync { + fn add_domain(&self, domain: &str) -> Result<(), Error>; + fn remove_domain(&self, domain: &str) -> Result<(), Error>; + fn list_domains(&self) -> Vec; +} diff --git a/net-guardia/src/interface/port/dns_query_filter.rs b/net-guardia/src/interface/port/dns_query_filter.rs new file mode 100644 index 0000000..e18a4e3 --- /dev/null +++ b/net-guardia/src/interface/port/dns_query_filter.rs @@ -0,0 +1,12 @@ +/// Data-plane DNS query filter — checks raw UDP-payload bytes against a +/// blacklist. Used by `XskManager` on the fast path to drop malicious DNS +/// queries before they reach the forwarding stage. +/// +/// Keeping this port byte-oriented (instead of exposing parsed wire names) +/// means the implementation owns the parse + lookup together, which matters +/// for hot-path performance. +pub trait DnsQueryFilter: Send + Sync { + /// Returns `true` when `raw` is a DNS query whose QNAME is on the + /// blacklist. Returns `false` for non-DNS traffic and for clean DNS. + fn is_query_blacklisted(&self, raw: &[u8]) -> bool; +} diff --git a/net-guardia/src/interface/port/enforcement.rs b/net-guardia/src/interface/port/enforcement.rs new file mode 100644 index 0000000..374c029 --- /dev/null +++ b/net-guardia/src/interface/port/enforcement.rs @@ -0,0 +1,20 @@ +use crate::model::error::Error; + +/// Data Plane BC — rate-limit, DNS blacklist, geo-block aggregate repository. +/// +/// These tables back three distinct eBPF map populations but share the +/// lifecycle of "data-plane policy that is not per-IP ACL". Kept disjoint from +/// `AclRepo` so the per-packet matching rules evolve independently from the +/// aggregate policy knobs. +pub trait EnforcementRepo: Send + Sync { + // --- Rate Limit --- + fn set_rate_limit(&self, key: &str, value: u64) -> Result<(), Error>; + + // --- DNS --- + fn insert_dns_domain(&self, domain: &str) -> Result<(), Error>; + fn delete_dns_domain(&self, domain: &str) -> Result<(), Error>; + + // --- Geo --- + fn insert_geo_country(&self, code: &str) -> Result<(), Error>; + fn delete_geo_country(&self, code: &str) -> Result<(), Error>; +} diff --git a/net-guardia/src/interface/port/geo_block_api.rs b/net-guardia/src/interface/port/geo_block_api.rs new file mode 100644 index 0000000..de51c3b --- /dev/null +++ b/net-guardia/src/interface/port/geo_block_api.rs @@ -0,0 +1,16 @@ +use crate::model::error::Error; + +/// Data-plane geo-block admin port — block / unblock / list country codes. +/// Used by `AclService` for the `/api/acl/geo` HTTP routes. +pub trait GeoBlockPort: Send + Sync { + /// Add every ISO-3166-1 alpha-2 code in `codes` to the block set. + /// Returns the number of /24 ranges actually added (existing codes + /// count as zero). + fn block_countries(&self, codes: &[String]) -> Result; + + /// Remove every code in `codes` from the block set. Returns the number + /// of /24 ranges actually removed. + fn unblock_countries(&self, codes: &[String]) -> Result; + + fn list_blocked(&self) -> Vec; +} diff --git a/net-guardia/src/interface/port/repository.rs b/net-guardia/src/interface/port/identity.rs similarity index 54% rename from net-guardia/src/interface/port/repository.rs rename to net-guardia/src/interface/port/identity.rs index 6c5efe2..364f397 100644 --- a/net-guardia/src/interface/port/repository.rs +++ b/net-guardia/src/interface/port/identity.rs @@ -1,65 +1,23 @@ use crate::model::error::Error; -/// Type alias for ACL rule tuples: (ip_version, direction, list_type, ip_address, port) -pub type AclRuleTuple = (u8, String, String, String, u16); - /// Type alias for user record tuples: (id, username, password_hash, role, force_password_change) pub type UserTuple = (i64, String, String, String, bool); -/// Type alias for user list items: (id, username, role, force_password_change, created_at) -pub type UserListItem = (i64, String, String, bool, String); - /// Type alias for user-with-groups: (id, username, role, force_password_change, created_at, groups: Vec<(group_id, group_name)>) pub type UserWithGroups = (i64, String, String, bool, String, Vec<(i64, String)>); /// Type alias for user group tuples: (id, name, description, permissions, created_at) pub type UserGroupTuple = (i64, String, String, String, String); -/// Port for persistent storage operations. -/// Adapters: SQLite (current), could be Postgres, etc. -/// All methods are used via the concrete Database adapter; the trait -/// defines the hexagonal-architecture boundary. -#[allow(dead_code)] -pub trait RepositoryPort: Send + Sync { - // --- ACL --- - fn insert_acl_rule( - &self, - ip_version: u8, - direction: &str, - list_type: &str, - ip_address: &str, - port: u16, - ) -> Result<(), Error>; - fn delete_acl_rule( - &self, - ip_version: u8, - direction: &str, - list_type: &str, - ip_address: &str, - port: u16, - ) -> Result<(), Error>; - fn load_acl_rules(&self) -> Result, Error>; - - // --- Rate Limit --- - fn set_rate_limit(&self, key: &str, value: u64) -> Result<(), Error>; - fn load_rate_limit_config(&self) -> Result, Error>; - - // --- DNS --- - fn insert_dns_domain(&self, domain: &str) -> Result<(), Error>; - fn delete_dns_domain(&self, domain: &str) -> Result<(), Error>; - fn load_dns_domains(&self) -> Result, Error>; - - // --- Geo --- - fn insert_geo_country(&self, code: &str) -> Result<(), Error>; - fn delete_geo_country(&self, code: &str) -> Result<(), Error>; - fn load_geo_countries(&self) -> Result, Error>; - - // --- Settings --- - fn get_setting(&self, key: &str) -> Result, Error>; - fn set_setting(&self, key: &str, value: &str) -> Result<(), Error>; - +/// Identity BC (generic) — users, groups, membership, and login rate-limit counter. +/// +/// Kept as one aggregate because user lifecycle, group membership, permission +/// resolution and login-attempt counters all share the `users` table lifecycle +/// and are enforced together at login time. +pub trait IdentityRepo: Send + Sync { // --- Users --- fn find_user(&self, username: &str) -> Result, Error>; + fn find_user_by_id(&self, user_id: i64) -> Result, Error>; fn insert_user( &self, username: &str, @@ -68,15 +26,10 @@ pub trait RepositoryPort: Send + Sync { force_password_change: bool, ) -> Result; fn update_user_password(&self, user_id: i64, password_hash: &str) -> Result<(), Error>; - fn user_count(&self) -> Result; - - // --- User Management --- - fn list_users(&self) -> Result, Error>; fn list_users_with_groups(&self) -> Result, Error>; fn delete_user(&self, user_id: i64) -> Result; fn update_user_role(&self, user_id: i64, role: &str) -> Result<(), Error>; fn reset_user_password(&self, user_id: i64, password_hash: &str) -> Result<(), Error>; - fn find_user_by_id(&self, user_id: i64) -> Result, Error>; // --- User Groups --- fn list_user_groups(&self) -> Result, Error>; @@ -85,11 +38,10 @@ pub trait RepositoryPort: Send + Sync { fn delete_user_group(&self, id: i64) -> Result; fn get_user_group(&self, id: i64) -> Result, Error>; - // --- User Group Membership --- + // --- Membership --- fn get_user_groups(&self, user_id: i64) -> Result, Error>; fn set_user_groups(&self, user_id: i64, group_ids: &[i64]) -> Result<(), Error>; fn get_user_permissions(&self, user_id: i64) -> Result, Error>; - fn cleanup_user_memberships(&self, user_id: i64) -> Result<(), Error>; fn get_group_member_ids(&self, group_id: i64) -> Result, Error>; fn get_group_members(&self, group_id: i64) -> Result, Error>; diff --git a/net-guardia/src/interface/port/mod.rs b/net-guardia/src/interface/port/mod.rs index e6b104e..9730899 100644 --- a/net-guardia/src/interface/port/mod.rs +++ b/net-guardia/src/interface/port/mod.rs @@ -1,8 +1,19 @@ pub mod access_control; +pub mod access_control_admin; +pub mod acl; pub mod api_key; +pub mod app_repo; pub mod audit; +pub mod db_admin; +pub mod dns_filter_api; +pub mod dns_query_filter; +pub mod enforcement; +pub mod geo_block_api; +pub mod identity; pub mod notification; -pub mod repository; +pub mod packet_sink; +pub mod rate_limit_api; pub mod secret_store; +pub mod setting; pub mod soar; pub mod stats; diff --git a/net-guardia/src/interface/port/notification.rs b/net-guardia/src/interface/port/notification.rs index 3ac2f46..f067f59 100644 --- a/net-guardia/src/interface/port/notification.rs +++ b/net-guardia/src/interface/port/notification.rs @@ -1,6 +1,9 @@ -use crate::model::error::Error; +use std::sync::Arc; + use async_trait::async_trait; +use crate::model::error::Error; + /// Alert notification data sent by SOAR engine. #[derive(Debug, Clone)] pub struct AlertPayload { @@ -21,8 +24,14 @@ pub trait AlertNotifier: Send + Sync { async fn send_test_message(&self) -> Result<(), Error>; } -/// Port for notification channel configuration (Telegram, email, etc.). -pub trait NotificationConfigPort: Send + Sync { - fn get_notification_config(&self, channel: &str) -> Result, Error>; - fn set_notification_config(&self, channel: &str, config_json: &str) -> Result<(), Error>; +/// Port for constructing an `AlertNotifier` on demand. +/// +/// `NotificationService::test_telegram` needs to test the *current* DB config, +/// which can have been set after the service was wired. A pre-constructed +/// notifier wouldn't reflect the new credentials, and `core/` cannot reach +/// into `adapter/telegram` to build one. The factory inverts this: the +/// implementation lives in the adapter layer, the core service depends only +/// on this trait, and each call gets a fresh notifier reading the latest config. +pub trait AlertNotifierFactory: Send + Sync { + fn create(&self) -> Result, Error>; } diff --git a/net-guardia/src/interface/port/packet_sink.rs b/net-guardia/src/interface/port/packet_sink.rs new file mode 100644 index 0000000..d1c34ce --- /dev/null +++ b/net-guardia/src/interface/port/packet_sink.rs @@ -0,0 +1,23 @@ +use std::sync::Arc; + +use crate::model::monitoring::user_packet::UserPacket; + +/// Data-plane packet sink — receives parsed packets from the AF_XDP RX path. +/// +/// Implementations wrap a `FlowTracker` (or other per-queue state) and forward +/// each packet into the ML inference pipeline. `XskManager` sees only this +/// trait, never `core::ml`, so the dependency direction stays +/// `adapter/ebpf → interface/port`. +pub trait PacketSink: Send + Sync { + /// Process one parsed packet. The boolean says whether the packet arrived + /// on the ingress interface (`true`) or the egress interface (`false`). + fn process_packet(&self, packet: UserPacket, is_ingress: bool); +} + +/// Factory that hands out a per-queue `PacketSink` for each AF_XDP queue the +/// manager spins up. `XskManager` calls this once per queue during bring-up. +pub trait PacketSinkFactory: Send + Sync { + /// Return a sink bound to `queue_id`, or `None` to skip per-packet + /// tracking on that queue. + fn sink_for_queue(&self, queue_id: u32) -> Option>; +} diff --git a/net-guardia/src/interface/port/rate_limit_api.rs b/net-guardia/src/interface/port/rate_limit_api.rs new file mode 100644 index 0000000..90ba8a8 --- /dev/null +++ b/net-guardia/src/interface/port/rate_limit_api.rs @@ -0,0 +1,20 @@ +use crate::model::error::Error; + +/// Data-plane rate-limit config port. +/// +/// Split into five per-protocol knobs to match the underlying eBPF per-class +/// counters. `RateLimitService` (HTTP CRUD) and `SoarEngine` (the +/// adjust-rate-limit action) both depend on this port. +pub trait RateLimitPort: Send + Sync { + fn set_packet_rate(&self, rate: u64) -> Result<(), Error>; + fn set_syn_rate(&self, rate: u64) -> Result<(), Error>; + fn set_udp_rate(&self, rate: u64) -> Result<(), Error>; + fn set_dns_rate(&self, rate: u64) -> Result<(), Error>; + fn set_window_ns(&self, ns: u64) -> Result<(), Error>; + + fn get_packet_rate(&self) -> Result; + fn get_syn_rate(&self) -> Result; + fn get_udp_rate(&self) -> Result; + fn get_dns_rate(&self) -> Result; + fn get_window_ns(&self) -> Result; +} diff --git a/net-guardia/src/interface/port/secret_store.rs b/net-guardia/src/interface/port/secret_store.rs index d289050..156b796 100644 --- a/net-guardia/src/interface/port/secret_store.rs +++ b/net-guardia/src/interface/port/secret_store.rs @@ -1,5 +1,9 @@ use crate::model::error::Error; +/// Port for plaintext access to sensitive values (e.g. SMTP password, JWT +/// secret). The adapter (`infrastructure/secret_store.rs`) wraps +/// `SettingRepo::get_app_secret` / `set_app_secret` with AES-256-GCM +/// envelope encryption, so callers of this port never see ciphertext. pub trait SecretStorePort: Send + Sync { fn get_secret(&self, key: &str) -> Result, Error>; fn set_secret(&self, key: &str, plaintext: &str) -> Result<(), Error>; diff --git a/net-guardia/src/interface/port/setting.rs b/net-guardia/src/interface/port/setting.rs new file mode 100644 index 0000000..56e6b0d --- /dev/null +++ b/net-guardia/src/interface/port/setting.rs @@ -0,0 +1,23 @@ +use crate::model::error::Error; + +/// Configuration technical service — key/value settings, encrypted app secrets, +/// and per-channel notification config blobs. +/// +/// Per DOMAIN_MAP §2 this is a Technical Service (no BC), but it has an +/// aggregate-shaped DB footprint (three tables: `settings`, `app_secrets`, +/// `notification_config`) with identical K/V semantics, so it gets a single +/// repo trait rather than three. +#[allow(dead_code)] +pub trait SettingRepo: Send + Sync { + // --- Plain settings (cleartext K/V) --- + fn get_setting(&self, key: &str) -> Result, Error>; + fn set_setting(&self, key: &str, value: &str) -> Result<(), Error>; + + // --- App secrets (encrypted-at-rest in `app_secrets` table) --- + fn get_app_secret(&self, key: &str) -> Result, Error>; + fn set_app_secret(&self, key: &str, plaintext: &str) -> Result<(), Error>; + + // --- Notification channel config blobs (JSON) --- + fn get_notification_config(&self, channel: &str) -> Result, Error>; + fn set_notification_config(&self, channel: &str, config_json: &str) -> Result<(), Error>; +} diff --git a/net-guardia/src/interface/port/soar.rs b/net-guardia/src/interface/port/soar.rs index 0d04dad..0930ca2 100644 --- a/net-guardia/src/interface/port/soar.rs +++ b/net-guardia/src/interface/port/soar.rs @@ -24,77 +24,31 @@ pub type PlaybookRow = ( #[allow(clippy::type_complexity)] pub type SoarExecutionRow = (i64, i64, Option, String, String, String); -/// Port for SOAR-related persistence: playbooks, block rules, execution log, admin whitelist, -/// plus the settings and ACL methods that SOAR actions depend on. -pub trait SoarPort: Send + Sync { - // --- Settings (used by rate-limit adjust/restore and email actions) --- - fn get_setting(&self, key: &str) -> Result, Error>; - fn set_setting(&self, key: &str, value: &str) -> Result<(), Error>; - - // --- ACL Rules (used by block_ip action and TTL scheduler cleanup) --- - fn insert_acl_rule( - &self, - ip_version: u8, - direction: &str, - list_type: &str, - ip_address: &str, - port: u16, - ) -> Result<(), Error>; - fn delete_acl_rule( - &self, - ip_version: u8, - direction: &str, - list_type: &str, - ip_address: &str, - port: u16, - ) -> Result<(), Error>; - +/// Threat Response BC — SOAR aggregate repository. +/// +/// Covers playbook CRUD, condition CRUD, block-rule lifecycle, pending-unblock +/// recovery queue, and the execution log. The settings (`soar_*`) and ACL +/// writes that block actions depend on live in `SettingRepo` and `AclRepo` +/// respectively; cross-aggregate atomicity is handled via +/// `DbAdminRepo::with_transaction` + `TxRepos`. +pub trait SoarRepo: Send + Sync { // --- Playbooks --- - fn insert_playbook( - &self, - name: &str, - trigger_event: &str, - threshold: Option, - count: Option, - window: Option, - cooldown: i64, - ) -> Result; - fn insert_playbook_action( - &self, - playbook_id: i64, - action_order: i64, - action_type: &str, - params_json: &str, - ) -> Result; fn load_playbooks_with_actions(&self) -> Result, Error>; - fn update_playbook(&self, id: i64, row: &UpdatePlaybookRow) -> Result; fn update_playbook_enabled(&self, id: i64, enabled: bool) -> Result; fn delete_playbook(&self, id: i64) -> Result; - fn delete_playbook_actions(&self, playbook_id: i64) -> Result<(), Error>; - fn delete_playbook_conditions(&self, playbook_id: i64) -> Result<(), Error>; fn seed_default_playbooks(&self) -> Result<(), Error>; // --- Playbook Conditions --- - fn insert_playbook_condition( - &self, - playbook_id: i64, - condition_type: &str, - operator: &str, - value: &str, - value2: Option<&str>, - ) -> Result; /// Returns: (condition_id, playbook_id, condition_type, operator, value, value2) #[allow(clippy::type_complexity)] fn load_all_playbook_conditions(&self) -> Result)>, Error>; // --- Block Rules --- - fn insert_soar_block_rule(&self, source_ip: &str, playbook_id: i64, expires_at: &str) -> Result; fn count_active_soar_blocks(&self) -> Result; fn get_active_soar_blocks(&self) -> Result, Error>; fn get_soar_block_by_id(&self, id: i64) -> Result, Error>; fn get_expired_soar_blocks(&self) -> Result, Error>; fn mark_soar_block_unblocked(&self, id: i64) -> Result<(), Error>; - fn has_manual_acl_rule(&self, ip_address: &str) -> Result; // --- Pending Unblock Recovery --- fn insert_pending_unblock(&self, source_ip: &str) -> Result; @@ -112,8 +66,35 @@ pub trait SoarPort: Send + Sync { ) -> Result; fn list_soar_executions(&self, limit: i64) -> Result, Error>; - // --- Admin Whitelist --- - fn load_admin_whitelist(&self) -> Result, Error>; - fn insert_admin_whitelist(&self, ip: &str) -> Result<(), Error>; - fn delete_admin_whitelist(&self, ip: &str) -> Result<(), Error>; + // --- Intra-aggregate atomic operations --- + + /// Atomically create a playbook with its conditions and actions. + /// All rows (playbook + conditions + actions) commit together; any error + /// rolls back the whole insert. Returns the new playbook id. + /// + /// `actions` tuples: `(action_order, action_type, params_json)`. + /// `conditions` tuples: `(condition_type, operator, value, value2)`. + #[allow(clippy::too_many_arguments)] + fn insert_playbook_atomic( + &self, + name: &str, + trigger_event: &str, + threshold: Option, + count: Option, + window: Option, + cooldown: i64, + actions: &[(i64, String, String)], + conditions: &[(String, String, String, Option)], + ) -> Result; + + /// Atomically update a playbook's metadata and replace its conditions + /// and actions. Returns `Ok(false)` if no playbook with that id exists; + /// otherwise `Ok(true)` after the whole update commits. + fn update_playbook_atomic( + &self, + id: i64, + row: &UpdatePlaybookRow, + actions: &[(i64, String, String)], + conditions: &[(String, String, String, Option)], + ) -> Result; } diff --git a/net-guardia/src/interface/port/stats.rs b/net-guardia/src/interface/port/stats.rs index df56c15..d939e24 100644 --- a/net-guardia/src/interface/port/stats.rs +++ b/net-guardia/src/interface/port/stats.rs @@ -1,7 +1,8 @@ use crate::model::error::Error; -/// Port for statistics aggregation queries. -pub trait StatsPort: Send + Sync { +/// Reporting BC (generic) — weekly aggregation queries used by the report +/// scheduler and dashboard APIs. +pub trait StatsRepo: Send + Sync { fn count_weekly_executions(&self, days: i64) -> Result; fn count_weekly_blocks(&self, days: i64) -> Result; fn count_weekly_unblocks(&self, days: i64) -> Result; diff --git a/net-guardia/src/main.rs b/net-guardia/src/main.rs index 5566905..fbdcc97 100644 --- a/net-guardia/src/main.rs +++ b/net-guardia/src/main.rs @@ -5,16 +5,24 @@ mod interface; mod model; mod utils; +use std::env; +use std::path::PathBuf; +use std::process; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; +use std::thread; +use std::time::Duration; use macros::log; +use sd_notify::NotifyState; +use tokio::{signal, time}; use crate::adapter::persistence::Database; use crate::core::auth::jwt::JwtService; use crate::core::auth::password; -use crate::core::system::System; +use crate::infrastructure::http_server; use crate::infrastructure::secret_store::SecretStore; +use crate::infrastructure::system::{ShutdownMode, System}; use crate::interface::port::secret_store::SecretStorePort; use crate::model::error::Error; use crate::model::error::system::SystemError; @@ -44,16 +52,16 @@ async fn main() -> Result<(), Error> { Logging::initialize()?; // Handle DB encrypt/decrypt subcommands before full startup - let args: Vec = std::env::args().collect(); + let args: Vec = env::args().collect(); if args.len() >= 2 { - let db_path = std::env::var("NETGUARDIA_DB_PATH").unwrap_or_else(|_| "net-guardia.db".to_string()); + let db_path = env::var("NETGUARDIA_DB_PATH").unwrap_or_else(|_| "net-guardia.db".to_string()); match args[1].as_str() { "--decrypt-db" => { - let key = match std::env::var("NETGUARDIA_DB_KEY") { + let key = match env::var("NETGUARDIA_DB_KEY") { Ok(k) if !k.is_empty() => k, _ => { eprintln!("Error: NETGUARDIA_DB_KEY must be set for decrypt"); - std::process::exit(1); + process::exit(1); } }; let dest = args.get(2).map(|s| s.as_str()).unwrap_or("net-guardia-decrypted.db"); @@ -63,11 +71,11 @@ async fn main() -> Result<(), Error> { return Ok(()); } "--encrypt-db" => { - let key = match std::env::var("NETGUARDIA_DB_KEY") { + let key = match env::var("NETGUARDIA_DB_KEY") { Ok(k) if !k.is_empty() => k, _ => { eprintln!("Error: NETGUARDIA_DB_KEY must be set for encrypt"); - std::process::exit(1); + process::exit(1); } }; let dest = args.get(2).map(|s| s.as_str()).unwrap_or("net-guardia-encrypted.db"); @@ -76,12 +84,26 @@ async fn main() -> Result<(), Error> { println!("Done. Encrypted database written to {}", dest); return Ok(()); } + "--verify-audit-log" => { + println!("Verifying audit_log hash chain in {}", db_path); + let db = Database::new(&db_path)?; + match db.verify_audit_log_chain() { + Ok(count) => { + println!("OK: {} audit_log rows verified, chain intact.", count); + return Ok(()); + } + Err(e) => { + eprintln!("FAIL: {}", e); + process::exit(2); + } + } + } _ => {} } } // Phase 1: Create DB (fast — needed for setup check and setup server) - let db_path = std::env::var("NETGUARDIA_DB_PATH").unwrap_or_else(|_| "net-guardia.db".to_string()); + let db_path = env::var("NETGUARDIA_DB_PATH").unwrap_or_else(|_| "net-guardia.db".to_string()); let db = Arc::new(Database::new(&db_path)?); // Seed default admin user if no users exist @@ -109,19 +131,13 @@ async fn main() -> Result<(), Error> { let setup_flag = Arc::new(AtomicBool::new(false)); // Start setup server — returns handle for graceful shutdown - let handle = infrastructure::http_server::start_setup_server( - db.clone(), - secret_store, - jwt_service, - setup_flag.clone(), - 8080, - )?; + let handle = http_server::start_setup_server(db.clone(), secret_store, jwt_service, setup_flag.clone(), 8080)?; // Wait for setup completion or shutdown signal let flag = setup_flag.clone(); let setup_done = async move { loop { - tokio::time::sleep(std::time::Duration::from_millis(500)).await; + time::sleep(Duration::from_millis(500)).await; if flag.load(Ordering::SeqCst) { return; } @@ -132,7 +148,7 @@ async fn main() -> Result<(), Error> { _ = setup_done => { log!(SystemLog::SetupCompleted); } - _ = tokio::signal::ctrl_c() => { + _ = signal::ctrl_c() => { log!(SystemLog::ShutdownDuringSetup); handle.stop(true).await; return Ok(()); @@ -150,24 +166,24 @@ async fn main() -> Result<(), Error> { system.terminate().await?; match mode { - crate::core::system::ShutdownMode::Restart => { + ShutdownMode::Restart => { log!(SystemLog::ApiRestart); - let _ = sd_notify::notify(false, &[sd_notify::NotifyState::Reloading]); + let _ = sd_notify::notify(false, &[NotifyState::Reloading]); // Drop System to detach eBPF XDP programs before re-exec drop(system); // Brief delay for kernel to release XDP/AF_XDP resources - std::thread::sleep(std::time::Duration::from_millis(500)); + thread::sleep(Duration::from_millis(500)); // Re-exec self — works with or without systemd use std::os::unix::process::CommandExt; - let exe = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("net-guardia")); - let err = std::process::Command::new(exe).args(std::env::args().skip(1)).exec(); // replaces current process + let exe = env::current_exe().unwrap_or_else(|_| PathBuf::from("net-guardia")); + let err = process::Command::new(exe).args(env::args().skip(1)).exec(); // replaces current process // If exec fails, fall through to exit log!(SystemError::UnexpectedError(err)); - std::process::exit(1); + process::exit(1); } - crate::core::system::ShutdownMode::Shutdown => { + ShutdownMode::Shutdown => { log!(SystemLog::ApiShutdown); - let _ = sd_notify::notify(false, &[sd_notify::NotifyState::Stopping]); + let _ = sd_notify::notify(false, &[NotifyState::Stopping]); } } Ok(()) diff --git a/net-guardia/src/model/config/constants.rs b/net-guardia/src/model/config/constants.rs index 1257401..3b4401a 100644 --- a/net-guardia/src/model/config/constants.rs +++ b/net-guardia/src/model/config/constants.rs @@ -14,6 +14,11 @@ pub const FLOW_BULK_MIN_BYTES: u64 = 1000; pub const FLOW_IDLE_TIMEOUT_US: u64 = 120_000_000; pub const FLOW_TERMINATED_TIMEOUT_US: u64 = 5_000_000; +// ── ML Model Directory ───────────────────────────────────────────── +pub const MODELS_DIR: &str = "models"; +pub const MANIFEST_FILENAME: &str = "manifest.yaml"; +pub const STAGING_SUBDIR: &str = ".staging"; + // ── Notification ─────────────────────────────────────────────────── pub const TELEGRAM_MAX_RETRIES: u32 = 2; diff --git a/net-guardia/src/model/config/mod.rs b/net-guardia/src/model/config/mod.rs index 81c710f..0b6d2c4 100644 --- a/net-guardia/src/model/config/mod.rs +++ b/net-guardia/src/model/config/mod.rs @@ -1,5 +1 @@ pub mod constants; - -// Backward-compatible re-exports: `crate::model::config::*` continues to resolve -// the domain config types that previously lived at `crate::model::system::config::*`. -pub use super::system::config::*; diff --git a/net-guardia/src/model/detection/attack_type.rs b/net-guardia/src/model/detection/attack_type.rs new file mode 100644 index 0000000..af9394d --- /dev/null +++ b/net-guardia/src/model/detection/attack_type.rs @@ -0,0 +1,366 @@ +//! Canonical attack-type dictionary and cross-source translator. +//! +//! Fusion v1 requires that Suricata / ML / CV (Beaconing) / Graph (Correlation) +//! use a shared vocabulary — otherwise the orchestrator's dedup key +//! `(source_ip, attack_type)` never collides across sources, and the +//! cross-source "sources agreed" fusion signal is impossible. +//! +//! The 13 canonical types below are the v1 seed set. Each source ships a +//! translation table from its own raw labels (Suricata classtype, ML class +//! name, Beaconing tag, Correlation sub-type) into the canonical vocabulary. +//! Unknown labels land in `Unknown` — a valid dedup bucket that still +//! participates in fusion. + +use std::fmt; + +use serde::{Deserialize, Serialize}; + +use crate::model::event::DetectionSource; + +/// The v1 seed dictionary — 13 canonical attack types every detection source +/// maps into. New types may be added without breaking change; renaming or +/// removing one IS a breaking change (dedup keys drift). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CanonicalAttackType { + BruteForce, + PortScan, + C2Beacon, + DnsTunnel, + SqlInjection, + Xss, + Exploit, + LateralMovement, + Reconnaissance, + Cryptomining, + DosDdos, + BotActivity, + /// Fallback bucket. Still a valid dedup key — two sources firing + /// `Unknown` on the same src_ip within the fusion window DO fuse. + Unknown, +} + +impl CanonicalAttackType { + /// Stable wire-format string. Must match `DedupKey.attack_type` exactly + /// across releases — renaming breaks dedup on in-flight alerts. + pub const fn as_str(self) -> &'static str { + match self { + Self::BruteForce => "brute_force", + Self::PortScan => "port_scan", + Self::C2Beacon => "c2_beacon", + Self::DnsTunnel => "dns_tunnel", + Self::SqlInjection => "sql_injection", + Self::Xss => "xss", + Self::Exploit => "exploit", + Self::LateralMovement => "lateral_movement", + Self::Reconnaissance => "reconnaissance", + Self::Cryptomining => "cryptomining", + Self::DosDdos => "dos_ddos", + Self::BotActivity => "bot_activity", + Self::Unknown => "unknown", + } + } + + /// All 13 canonical values, in declaration order. Used by BYO-contract + /// docs + CI consistency check. + pub const ALL: &'static [Self] = &[ + Self::BruteForce, + Self::PortScan, + Self::C2Beacon, + Self::DnsTunnel, + Self::SqlInjection, + Self::Xss, + Self::Exploit, + Self::LateralMovement, + Self::Reconnaissance, + Self::Cryptomining, + Self::DosDdos, + Self::BotActivity, + Self::Unknown, + ]; +} + +impl fmt::Display for CanonicalAttackType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Reverse lookup: canonical wire string → enum. Returns `None` when the +/// input isn't one of the 13 seeds (caller typically treats this as an +/// upstream bug, not an `Unknown` bucket). +pub fn canonical_from_str(s: &str) -> Option { + CanonicalAttackType::ALL.iter().copied().find(|c| c.as_str() == s) +} + +/// Translate a source-specific raw label into the canonical dictionary. +/// +/// Never fails — unknown labels map to `CanonicalAttackType::Unknown` so +/// the event still lands in a valid dedup bucket. The `raw_label` +/// comparison is case-insensitive and trims whitespace; callers that pass +/// user-visible labels verbatim don't need to pre-normalize. +pub fn translate(source: DetectionSource, raw_label: &str) -> CanonicalAttackType { + let normalized = raw_label.trim().to_ascii_lowercase(); + match source { + DetectionSource::ML => translate_ml(&normalized), + DetectionSource::Beaconing => translate_beaconing(&normalized), + DetectionSource::Correlation => translate_correlation(&normalized), + DetectionSource::Suricata => translate_suricata(&normalized), + } +} + +/// Map ML classifier class names into the canonical dictionary. Case-insensitive; +/// covers the baseline class vocabulary. +fn translate_ml(label: &str) -> CanonicalAttackType { + match label { + "brute force" | "brute_force" | "bruteforce" => CanonicalAttackType::BruteForce, + // `port_scan` / `portscan` map to the specific `PortScan` bucket so + // ML agreement with Correlation's `scan` / `port_scan` collapses onto + // the same dedup key — the precondition for cross-source fusion. + // Generic recon labels stay on `Reconnaissance`. + "port_scan" | "portscan" => CanonicalAttackType::PortScan, + "reconnaissance" | "recon" => CanonicalAttackType::Reconnaissance, + "c2 communication" | "c2" | "c2_beacon" | "command_and_control" => CanonicalAttackType::C2Beacon, + "dns tunneling" | "dns_tunnel" | "dns tunnel" => CanonicalAttackType::DnsTunnel, + "sql injection" | "sql_injection" | "sqli" => CanonicalAttackType::SqlInjection, + "xss" | "cross_site_scripting" => CanonicalAttackType::Xss, + "web attack" | "web_attack" => CanonicalAttackType::Exploit, + "exploitation" | "exploit" => CanonicalAttackType::Exploit, + "dos/ddos" | "dos_ddos" | "ddos" | "dos" => CanonicalAttackType::DosDdos, + "cryptomining" | "cryptocurrency_mining" | "mining" => CanonicalAttackType::Cryptomining, + "bot" | "bot_activity" | "botnet" | "malware" => CanonicalAttackType::BotActivity, + "lateral movement" | "lateral_movement" => CanonicalAttackType::LateralMovement, + // "Normal" is not a threat — translators should not see it, but if they do, + // fall through to Unknown rather than panicking. + _ => CanonicalAttackType::Unknown, + } +} + +/// Beaconing (Layer 2 CV) only produces C2-style temporal beacons in v1. +/// Sub-tags (e.g. "c2_beacon", "heartbeat") all collapse here. +fn translate_beaconing(_label: &str) -> CanonicalAttackType { + CanonicalAttackType::C2Beacon +} + +/// Correlation (Layer 3 graph) splits across scan / lateral / botnet. +/// +/// Note: Correlation's scan detector looks for fanout on the port dimension, +/// so every scan-flavored sub-tag maps to `PortScan`. ML's own `port_scan` +/// label uses the same bucket so the dedup orchestrator fuses agreement +/// from both sources onto one `(src_ip, port_scan)` key. +fn translate_correlation(label: &str) -> CanonicalAttackType { + match label { + "scan" | "port_scan" | "reconnaissance" => CanonicalAttackType::PortScan, + "lateral" | "lateral_movement" => CanonicalAttackType::LateralMovement, + "botnet" | "bot" | "bot_activity" => CanonicalAttackType::BotActivity, + _ => CanonicalAttackType::Unknown, + } +} + +/// Map Suricata classtypes (`eve.json.alert.category`, not sid) into the +/// canonical dictionary. We match on classtype because sid numbering isn't +/// stable across rule packs; classtype is part of the rule DSL and stable +/// across ET Open / Talos releases. +fn translate_suricata(label: &str) -> CanonicalAttackType { + // classtype strings come lowercase+trimmed from `translate` + match label { + // Port scan specifically — must match ML `port_scan` + Correlation + // `scan` on the same canonical key so cross-source fusion fires. + "network-scan" => CanonicalAttackType::PortScan, + // Broader recon (non-port-scan host discovery, protocol probing) + "attempted-recon" | "misc-activity" => CanonicalAttackType::Reconnaissance, + // Exploits / admin compromise + "attempted-admin" | "successful-admin" | "attempted-user" | "successful-user" | "shellcode-detect" + | "attempted-exploit" => CanonicalAttackType::Exploit, + // Web-application attacks + "web-application-attack" => CanonicalAttackType::Exploit, + "web-application-activity" => CanonicalAttackType::Exploit, + // SQL injection is usually emitted as web-application-attack, but some + // rule packs use "sql-injection" directly. + "sql-injection" => CanonicalAttackType::SqlInjection, + // XSS — same note as SQLi + "xss" | "cross-site-scripting" => CanonicalAttackType::Xss, + // DoS / DDoS + "attempted-dos" | "successful-dos" | "denial-of-service" => CanonicalAttackType::DosDdos, + // Trojan / malware / C2 + "trojan-activity" | "malware-cnc" | "command-and-control" => CanonicalAttackType::C2Beacon, + // Credential attacks + "suspicious-login" | "unsuccessful-user" | "brute-force" => CanonicalAttackType::BruteForce, + // Policy / Crypto miner + "coin-mining" | "policy-violation" => CanonicalAttackType::Cryptomining, + // DNS tunneling detections emitted by some ET Open rules + "dns-tunnel" | "protocol-command-decode" => CanonicalAttackType::DnsTunnel, + _ => CanonicalAttackType::Unknown, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wire_format_roundtrip() { + for canonical in CanonicalAttackType::ALL { + let wire = canonical.as_str(); + let round = canonical_from_str(wire).expect("wire format must round-trip"); + assert_eq!(*canonical, round, "roundtrip mismatch for {wire}"); + } + } + + #[test] + fn unknown_wire_string_returns_none() { + assert!(canonical_from_str("this_attack_does_not_exist").is_none()); + assert!(canonical_from_str("").is_none()); + } + + #[test] + fn ml_v10_class_names_translate() { + // v10 ships 10 classes — every non-"Normal" class must hit a canonical entry. + // "Normal" is a legitimate benign class and IS expected to return Unknown + // because translators should not be called on benign flows in the first place. + let cases = [ + ("Brute Force", CanonicalAttackType::BruteForce), + ("Reconnaissance", CanonicalAttackType::Reconnaissance), + ("C2 Communication", CanonicalAttackType::C2Beacon), + ("DoS/DDoS", CanonicalAttackType::DosDdos), + ("Exploitation", CanonicalAttackType::Exploit), + ("Web Attack", CanonicalAttackType::Exploit), + ("Bot", CanonicalAttackType::BotActivity), + ("Malware", CanonicalAttackType::BotActivity), + ("Cryptomining", CanonicalAttackType::Cryptomining), + ]; + for (raw, expected) in cases { + assert_eq!( + translate(DetectionSource::ML, raw), + expected, + "ML class {raw:?} must translate to {expected:?}", + ); + } + } + + #[test] + fn beaconing_always_c2() { + assert_eq!( + translate(DetectionSource::Beaconing, "anything"), + CanonicalAttackType::C2Beacon + ); + assert_eq!( + translate(DetectionSource::Beaconing, "c2_beacon"), + CanonicalAttackType::C2Beacon + ); + } + + #[test] + fn correlation_subtypes_split() { + assert_eq!( + translate(DetectionSource::Correlation, "scan"), + CanonicalAttackType::PortScan + ); + assert_eq!( + translate(DetectionSource::Correlation, "lateral"), + CanonicalAttackType::LateralMovement + ); + assert_eq!( + translate(DetectionSource::Correlation, "botnet"), + CanonicalAttackType::BotActivity + ); + } + + #[test] + fn suricata_classtype_mapping() { + let cases = [ + ("attempted-admin", CanonicalAttackType::Exploit), + ("web-application-attack", CanonicalAttackType::Exploit), + ("trojan-activity", CanonicalAttackType::C2Beacon), + ("attempted-recon", CanonicalAttackType::Reconnaissance), + ("attempted-dos", CanonicalAttackType::DosDdos), + ("coin-mining", CanonicalAttackType::Cryptomining), + ("brute-force", CanonicalAttackType::BruteForce), + ("sql-injection", CanonicalAttackType::SqlInjection), + ]; + for (raw, expected) in cases { + assert_eq!( + translate(DetectionSource::Suricata, raw), + expected, + "Suricata classtype {raw:?} must translate to {expected:?}", + ); + } + } + + #[test] + fn unknown_label_lands_in_unknown_bucket() { + // Unknown is the fallback — MUST NOT panic, MUST be dedup-safe. + for source in [ + DetectionSource::ML, + DetectionSource::Correlation, + DetectionSource::Suricata, + ] { + assert_eq!( + translate(source, "this_is_not_a_real_label"), + CanonicalAttackType::Unknown, + ); + } + } + + #[test] + fn case_insensitive_and_whitespace_tolerant() { + assert_eq!( + translate(DetectionSource::ML, " BRUTE FORCE "), + CanonicalAttackType::BruteForce, + ); + assert_eq!( + translate(DetectionSource::Suricata, "Attempted-Admin"), + CanonicalAttackType::Exploit, + ); + } + + #[test] + fn dedup_key_non_collision_across_sources() { + // The central invariant: two sources hitting the SAME attack on the + // SAME src_ip produce the SAME canonical wire string, so dedup collides. + let pairs = [ + // ML vs Suricata: brute force + ( + translate(DetectionSource::ML, "Brute Force"), + translate(DetectionSource::Suricata, "brute-force"), + ), + // ML vs Correlation: port scan — historically mapped to two + // different canonicals (Reconnaissance vs PortScan) until + // 2026-04-19. Kept as a regression guard. + ( + translate(DetectionSource::ML, "port_scan"), + translate(DetectionSource::Correlation, "scan"), + ), + // Suricata network-scan vs Correlation scan — both land on + // `PortScan` so a Suricata scan alert fuses with Correlation's + // graph-based scan detection on the same src_ip. + ( + translate(DetectionSource::Suricata, "network-scan"), + translate(DetectionSource::Correlation, "scan"), + ), + // ML vs Suricata: C2 + ( + translate(DetectionSource::ML, "C2 Communication"), + translate(DetectionSource::Suricata, "trojan-activity"), + ), + ]; + for (a, b) in pairs { + assert_eq!( + a.as_str(), + b.as_str(), + "cross-source dedup key MUST match for the same canonical attack ({a:?} vs {b:?})", + ); + } + } + + #[test] + fn all_canonical_have_unique_wire_strings() { + use std::collections::HashSet; + let strings: HashSet<&str> = CanonicalAttackType::ALL.iter().map(|c| c.as_str()).collect(); + assert_eq!( + strings.len(), + CanonicalAttackType::ALL.len(), + "duplicate wire string in CanonicalAttackType::ALL", + ); + } +} diff --git a/net-guardia/src/model/detection/flow_features.rs b/net-guardia/src/model/detection/flow_features.rs index 7b6d39c..4ad67d0 100644 --- a/net-guardia/src/model/detection/flow_features.rs +++ b/net-guardia/src/model/detection/flow_features.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use crate::model::ml_detection::ClipParams; +use crate::model::detection::ml_detection::ClipParams; #[derive(Debug, Clone)] pub struct FlowFeatures { diff --git a/net-guardia/src/model/detection/ml_detection.rs b/net-guardia/src/model/detection/ml_detection.rs index 00c3f50..fbebfae 100644 --- a/net-guardia/src/model/detection/ml_detection.rs +++ b/net-guardia/src/model/detection/ml_detection.rs @@ -1,10 +1,11 @@ use std::net::{Ipv4Addr, Ipv6Addr}; +use std::time::{SystemTime, UNIX_EPOCH}; use serde::{Deserialize, Serialize}; use tract_onnx::prelude::{Graph, SimplePlan, TypedFact, TypedOp}; -use crate::model::direction::Direction; -use crate::model::user_packet::UserPacket; +use crate::model::monitoring::direction::Direction; +use crate::model::monitoring::user_packet::UserPacket; pub type RunnableModel = SimplePlan, Graph>>; @@ -104,7 +105,8 @@ pub struct DetectionResult { pub attack_type: Option, pub confidence: f32, pub ae_score: f32, - pub threshold: f32, + pub anomaly_score: f32, + pub c2_score: f32, pub packet_count: u64, pub flow_duration_us: u64, } @@ -151,14 +153,16 @@ pub struct AlertMessage { pub attack_type: Option, pub confidence: f32, pub ae_score: f32, + pub anomaly_score: f32, + pub c2_score: f32, pub packet_count: u64, pub flow_duration_us: u64, } impl AlertMessage { pub fn from_detection_result(result: &DetectionResult) -> Self { - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); @@ -174,6 +178,8 @@ impl AlertMessage { attack_type: result.attack_type.clone(), confidence: result.confidence, ae_score: result.ae_score, + anomaly_score: result.anomaly_score, + c2_score: result.c2_score, packet_count: result.packet_count, flow_duration_us: result.flow_duration_us, } diff --git a/net-guardia/src/model/detection/mod.rs b/net-guardia/src/model/detection/mod.rs index f960169..f80bef1 100644 --- a/net-guardia/src/model/detection/mod.rs +++ b/net-guardia/src/model/detection/mod.rs @@ -1,3 +1,5 @@ +pub mod attack_type; pub mod drift; pub mod flow_features; pub mod ml_detection; +pub mod model_source; diff --git a/net-guardia/src/model/detection/model_source.rs b/net-guardia/src/model/detection/model_source.rs new file mode 100644 index 0000000..9b43325 --- /dev/null +++ b/net-guardia/src/model/detection/model_source.rs @@ -0,0 +1,113 @@ +//! Wire-level types for broadcasting ML source state to the frontend. +//! +//! Two layers: +//! - `ModelInfo` — stable facts about a loaded model (name, adapter kind, feature count). +//! - `ModelSourceStatus` — current state of the ML source: Dormant / Active / Error. +//! +//! The internal `core::ml::state::ModelSourceState` holds the actual model +//! adapter plus this metadata; it converts into `ModelSourceStatus` for +//! WebSocket / HTTP responses via `From`. + +use std::time::SystemTime; + +use serde::{Deserialize, Serialize}; + +/// Public facts about a currently-loaded model. Drives the UI's ML Status panel. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelInfo { + /// Human-readable manifest name (e.g., "netguardia-v10"). + pub name: String, + /// One of "autoencoder_only" | "classifier_only" | "multi_task". + pub adapter_kind: String, + /// When this model became Active (unix epoch seconds). + pub loaded_at_secs: u64, + /// Number of features the manifest declares. Useful for UI "31 features" display. + pub features_count: usize, + /// Recent inference QPS (rolling window). Zero before first tick. + pub qps_recent: f32, +} + +impl ModelInfo { + pub fn new(name: String, adapter_kind: String, features_count: usize) -> Self { + let loaded_at_secs = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + Self { + name, + adapter_kind, + loaded_at_secs, + features_count, + qps_recent: 0.0, + } + } +} + +/// Wire-format ML source status. Broadcast to frontend; returned by +/// `GET /api/ml/models/current`. Serde-tagged so the frontend can discriminate +/// on the `state` field. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum ModelSourceStatus { + /// No model loaded. Day 1 default; fusion runs with 3 sources. + Dormant, + /// Model loaded and serving inference. `info` populates the UI card. + Active { info: ModelInfo }, + /// Last load attempt failed. UI shows the reason in red. + /// `since_secs` is unix epoch seconds; `last_attempted_path` is the + /// file that failed (if any). + Error { + msg: String, + since_secs: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + last_attempted_path: Option, + }, +} + +impl ModelSourceStatus { + pub fn is_active(&self) -> bool { + matches!(self, Self::Active { .. }) + } + + pub fn is_dormant(&self) -> bool { + matches!(self, Self::Dormant) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serde_roundtrip_dormant() { + let json = serde_json::to_string(&ModelSourceStatus::Dormant).unwrap(); + assert!(json.contains("\"state\":\"dormant\"")); + let back: ModelSourceStatus = serde_json::from_str(&json).unwrap(); + assert!(back.is_dormant()); + } + + #[test] + fn serde_roundtrip_active() { + let status = ModelSourceStatus::Active { + info: ModelInfo::new("netguardia-v10".into(), "multi_task".into(), 31), + }; + let json = serde_json::to_string(&status).unwrap(); + assert!(json.contains("\"state\":\"active\"")); + assert!(json.contains("\"adapter_kind\":\"multi_task\"")); + let back: ModelSourceStatus = serde_json::from_str(&json).unwrap(); + assert!(back.is_active()); + } + + #[test] + fn serde_roundtrip_error() { + let status = ModelSourceStatus::Error { + msg: "feature mismatch".into(), + since_secs: 1_700_000_000, + last_attempted_path: Some("models/.staging/bad.onnx".into()), + }; + let json = serde_json::to_string(&status).unwrap(); + assert!(json.contains("\"state\":\"error\"")); + let back: ModelSourceStatus = serde_json::from_str(&json).unwrap(); + assert!(matches!(back, ModelSourceStatus::Error { .. })); + } +} diff --git a/net-guardia/src/model/error/crypto.rs b/net-guardia/src/model/error/crypto.rs index d6a39a1..f24eed3 100644 --- a/net-guardia/src/model/error/crypto.rs +++ b/net-guardia/src/model/error/crypto.rs @@ -2,17 +2,34 @@ use macros::traceable; traceable! { CryptoError { - #[no_source] - #[error("Encryption failed: {reason}")] - EncryptionFailed { reason: String } => tracing::Level::ERROR, + #[error("Encryption failed: {err}")] + EncryptionFailed => tracing::Level::ERROR, + + #[error("Decryption failed: {err}")] + DecryptionFailed => tracing::Level::ERROR, + + #[error("Failed to parse secret envelope: {err}")] + EnvelopeParseFailed => tracing::Level::ERROR, #[no_source] - #[error("Decryption failed: {reason}")] - DecryptionFailed { reason: String } => tracing::Level::ERROR, + #[error("Unsupported envelope version: {version}")] + UnsupportedEnvelopeVersion { version: u64 } => tracing::Level::ERROR, #[no_source] - #[error("Invalid secret envelope: {reason}")] - InvalidEnvelope { reason: String } => tracing::Level::ERROR, + #[error("Missing envelope field: {field}")] + MissingEnvelopeField { field: String } => tracing::Level::ERROR, + + #[no_source] + #[error("Envelope algorithm 'none' rejected in production mode (encryption key is set)")] + AlgNoneRejected => tracing::Level::ERROR, + + #[no_source] + #[error("Invalid envelope nonce length")] + InvalidNonceLength => tracing::Level::ERROR, + + #[no_source] + #[error("Unsupported envelope algorithm: {alg}")] + UnsupportedAlgorithm { alg: String } => tracing::Level::ERROR, #[no_source] #[error("Master key not available")] diff --git a/net-guardia/src/model/error/database.rs b/net-guardia/src/model/error/database.rs index d3d1c80..060e8d6 100644 --- a/net-guardia/src/model/error/database.rs +++ b/net-guardia/src/model/error/database.rs @@ -2,9 +2,8 @@ use macros::traceable; traceable! { DatabaseError { - #[no_source] - #[error("Database error: {reason}")] - QueryFailed { reason: String } => tracing::Level::ERROR, + #[error("Database error: {err}")] + QueryFailed => tracing::Level::ERROR, #[error("Database connection failed")] ConnectionFailed => tracing::Level::ERROR, @@ -12,12 +11,36 @@ traceable! { #[no_source] #[error("User '{username}' already exists")] UserAlreadyExists { username: String } => tracing::Level::WARN, + + #[no_source] + #[error("User group '{name}' already exists")] + GroupAlreadyExists { name: String } => tracing::Level::WARN, + + #[no_source] + #[error("Database encryption key is incorrect or database is corrupted")] + EncryptionKeyInvalid => tracing::Level::ERROR, + + #[no_source] + #[error("Cannot read database with provided key — wrong key or not encrypted")] + DatabaseNotReadable => tracing::Level::ERROR, + + #[no_source] + #[error("Cannot read source database — may already be encrypted")] + SourceDatabaseNotReadable => tracing::Level::ERROR, + + #[no_source] + #[error("Audit log prev_hash mismatch at id {id}: expected {expected}, found {found}")] + AuditPrevHashMismatch { id: i64, expected: String, found: String } => tracing::Level::ERROR, + + #[no_source] + #[error("Audit log row_hash mismatch at id {id}: computed {computed}, stored {stored}")] + AuditRowHashMismatch { id: i64, computed: String, stored: String } => tracing::Level::ERROR, } } impl From for DatabaseError { fn from(e: rusqlite::Error) -> Self { - DatabaseError::QueryFailed { reason: e.to_string() } + DatabaseError::QueryFailed(e) } } diff --git a/net-guardia/src/model/error/ebpf.rs b/net-guardia/src/model/error/ebpf.rs index d390922..cf2187a 100644 --- a/net-guardia/src/model/error/ebpf.rs +++ b/net-guardia/src/model/error/ebpf.rs @@ -22,6 +22,9 @@ traceable! { #[error("Failed to attach XDP program")] AttachProgramFailed => tracing::Level::ERROR, + #[error("Failed to obtain eBPF program FD")] + ProgramFdFailed => tracing::Level::ERROR, + #[error("Failed to set umem")] UmemSetFailed => tracing::Level::ERROR, @@ -75,5 +78,9 @@ traceable! { #[error("eBPF rollback failed during ACL update: {err}")] RollbackFailed => tracing::Level::ERROR, + + #[no_source] + #[error("eBPF data plane is not loaded on this run — operation unavailable")] + NotLoaded => tracing::Level::WARN, } } diff --git a/net-guardia/src/model/error/io.rs b/net-guardia/src/model/error/io.rs index e46cc3c..150283b 100644 --- a/net-guardia/src/model/error/io.rs +++ b/net-guardia/src/model/error/io.rs @@ -6,5 +6,8 @@ traceable! { IOError { #[error("Failed to create directory: {path}")] CreateDirectoryFailed { path: PathBuf } => tracing::Level::ERROR, + + #[error("Failed to write file: {path}")] + WriteFileFailed { path: PathBuf } => tracing::Level::ERROR, } } diff --git a/net-guardia/src/model/error/misc.rs b/net-guardia/src/model/error/misc.rs index 9d8764e..b007c29 100644 --- a/net-guardia/src/model/error/misc.rs +++ b/net-guardia/src/model/error/misc.rs @@ -19,17 +19,19 @@ traceable! { #[error("Network interface '{interface}' not found")] NetworkInterfaceNotFound { interface: String } => tracing::Level::ERROR, - #[no_source] - #[error("Failed to open GeoIP database '{path}': {reason}")] - GeoIPDatabaseError { path: String, reason: String } => tracing::Level::ERROR, + #[error("Failed to open GeoIP database '{path}': {err}")] + GeoIPDatabaseError { path: String } => tracing::Level::ERROR, + + #[error("Failed to create traffic log file '{path}': {err}")] + TrafficLogCreateError { path: String } => tracing::Level::ERROR, #[no_source] - #[error("Failed to create traffic log file '{path}': {reason}")] - TrafficLogCreateError { path: String, reason: String } => tracing::Level::ERROR, + #[error("DNS label length out of range: {len} (must be 1..64)")] + DnsLabelOutOfRange { len: usize } => tracing::Level::WARN, #[no_source] - #[error("Invalid DNS domain name: {reason}")] - InvalidDnsName { reason: String } => tracing::Level::WARN, + #[error("DNS domain name too long: '{domain}'")] + DnsDomainTooLong { domain: String } => tracing::Level::WARN, #[no_source] #[error("Type mismatch during message dispatch")] diff --git a/net-guardia/src/model/error/ml.rs b/net-guardia/src/model/error/ml.rs index eef9c87..447cda8 100644 --- a/net-guardia/src/model/error/ml.rs +++ b/net-guardia/src/model/error/ml.rs @@ -8,19 +8,34 @@ traceable! { #[error("Initialize Machine Learning detection failed")] InitializeFailed => tracing::Level::ERROR, - #[no_source] - #[error("Failed to load ONNX model from: {path:?}")] + #[error("Failed to load ONNX model from {path:?}: {err}")] ModelLoadFailed { path: PathBuf } => tracing::Level::ERROR, - #[no_source] - #[error("Failed to load inference configuration from: {path:?}")] + #[error("Failed to load inference configuration from {path:?}: {err}")] ConfigLoadFailed { path: PathBuf } => tracing::Level::ERROR, - #[no_source] - #[error("Failed to parse inference configuration: {reason}")] - ConfigParseFailed { reason: String } => tracing::Level::ERROR, + #[error("Failed to parse inference configuration: {err}")] + ConfigParseFailed => tracing::Level::ERROR, #[error("Failed to flush traffic log: {err}")] TrafficLogFlushFailed => tracing::Level::ERROR, + + #[error("Model manifest at {path:?} is invalid: {err}")] + ManifestInvalid { path: PathBuf } => tracing::Level::ERROR, + + #[no_source] + #[error("Feature count mismatch for {model:?}: manifest declares {declared}, ONNX input expects {onnx_dim}")] + FeatureMismatch { model: PathBuf, declared: usize, onnx_dim: usize } => tracing::Level::ERROR, + + #[no_source] + #[error("Model load timed out after {seconds}s: {path:?}")] + ModelLoadTimeout { path: PathBuf, seconds: u64 } => tracing::Level::ERROR, + + #[no_source] + #[error("Unknown feature '{name}' — not registered in FEATURE_REGISTRY")] + UnknownFeature { name: String } => tracing::Level::ERROR, + + #[error("Model watcher failed: {err}")] + ModelWatcherFailed => tracing::Level::ERROR, } } diff --git a/net-guardia/src/model/error/mod.rs b/net-guardia/src/model/error/mod.rs index 0ef7cd1..c6c6246 100644 --- a/net-guardia/src/model/error/mod.rs +++ b/net-guardia/src/model/error/mod.rs @@ -9,6 +9,7 @@ pub mod misc; pub mod ml; pub mod notification; pub mod soar; +pub mod suricata; pub mod system; use serde::{Deserialize, Serialize}; @@ -24,6 +25,7 @@ use crate::model::error::misc::MiscError; use crate::model::error::ml::MLError; use crate::model::error::notification::NotificationError; use crate::model::error::soar::SoarError; +use crate::model::error::suricata::SuricataError; use crate::model::error::system::SystemError; #[derive(Clone, Debug, thiserror::Error, Serialize, Deserialize)] @@ -51,6 +53,8 @@ pub enum Error { #[error("{0}")] Soar(SoarError), #[error("{0}")] + Suricata(SuricataError), + #[error("{0}")] System(SystemError), } @@ -120,6 +124,12 @@ impl From for Error { } } +impl From for Error { + fn from(error: SuricataError) -> Self { + Self::Suricata(error) + } +} + impl From for Error { fn from(error: McpError) -> Self { Self::Mcp(error) diff --git a/net-guardia/src/model/error/notification.rs b/net-guardia/src/model/error/notification.rs index 5d634de..fcb46db 100644 --- a/net-guardia/src/model/error/notification.rs +++ b/net-guardia/src/model/error/notification.rs @@ -2,29 +2,27 @@ use macros::traceable; traceable! { NotificationError { - #[no_source] - #[error("SMTP connection failed: {reason}")] - SmtpConnectionFailed { reason: String } => tracing::Level::ERROR, + #[error("SMTP connection failed: {err}")] + SmtpConnectionFailed => tracing::Level::ERROR, + + #[error("SMTP authentication failed: {err}")] + SmtpAuthFailed => tracing::Level::ERROR, + + #[error("Failed to send email: {err}")] + SmtpSendFailed => tracing::Level::ERROR, + + #[error("Invalid {field} email address: {err}")] + InvalidAddress { field: String } => tracing::Level::WARN, + + #[error("Failed to build email message: {err}")] + MessageBuildFailed => tracing::Level::ERROR, + + #[error("Telegram notification error: {err}")] + TelegramRequestFailed => tracing::Level::ERROR, #[no_source] - #[error("SMTP authentication failed: {reason}")] - SmtpAuthFailed { reason: String } => tracing::Level::ERROR, - - #[no_source] - #[error("Failed to send email: {reason}")] - SmtpSendFailed { reason: String } => tracing::Level::ERROR, - - #[no_source] - #[error("Invalid email address: {reason}")] - InvalidAddress { reason: String } => tracing::Level::WARN, - - #[no_source] - #[error("Failed to build email message: {reason}")] - MessageBuildFailed { reason: String } => tracing::Level::ERROR, - - #[no_source] - #[error("Telegram API error: {reason}")] - TelegramApiError { reason: String } => tracing::Level::ERROR, + #[error("Telegram HTTP {status}: {body}")] + TelegramHttpError { status: u16, body: String } => tracing::Level::ERROR, #[no_source] #[error("Telegram authentication failed (invalid bot token)")] diff --git a/net-guardia/src/model/error/soar.rs b/net-guardia/src/model/error/soar.rs index 0d4e488..2403140 100644 --- a/net-guardia/src/model/error/soar.rs +++ b/net-guardia/src/model/error/soar.rs @@ -10,15 +10,58 @@ traceable! { #[error("Invalid TTL: {ttl_secs}s exceeds maximum of {max_secs}s")] InvalidTtl { ttl_secs: u64, max_secs: u64 } => tracing::Level::WARN, + #[error("SOAR action failed: {action_type} — {err}")] + ActionFailed { action_type: String } => tracing::Level::ERROR, + #[no_source] - #[error("SOAR action failed: {action_type} — {reason}")] - ActionFailed { action_type: String, reason: String } => tracing::Level::ERROR, + #[error("Unknown SOAR action type: {action_type}")] + UnknownActionType { action_type: String } => tracing::Level::WARN, + + #[no_source] + #[error("Rate limit config not available for SOAR action")] + RateLimitUnavailable => tracing::Level::WARN, + + #[no_source] + #[error("Invalid rate limit factor: {factor} (must be 0.01..=1.0)")] + InvalidRateLimitFactor { factor: f64 } => tracing::Level::WARN, + + #[no_source] + #[error("Webhook action missing required parameter: {param}")] + WebhookMissingParam { param: String } => tracing::Level::WARN, + + #[no_source] + #[error("Webhook URL has no host")] + WebhookUrlNoHost => tracing::Level::WARN, + + #[no_source] + #[error("Webhook DNS resolution returned no addresses for '{host}'")] + WebhookDnsEmpty { host: String } => tracing::Level::WARN, + + #[no_source] + #[error("Webhook SSRF blocked: host '{host}' resolves to private IP {ip}")] + WebhookSsrfBlocked { host: String, ip: String } => tracing::Level::WARN, + + #[no_source] + #[error("Webhook returned non-success HTTP status: {status}")] + WebhookHttpStatus { status: u16 } => tracing::Level::WARN, + + #[no_source] + #[error("Manual unblock failed: block rule {id} not found")] + UnblockRuleNotFound { id: i64 } => tracing::Level::WARN, #[error("Failed to clean up ACL rule after unblock: {err}")] AclCleanupFailed => tracing::Level::WARN, #[no_source] - #[error("Invalid playbook condition: {condition_type} — {reason}")] - InvalidCondition { condition_type: String, reason: String } => tracing::Level::WARN, + #[error("Unknown SOAR condition type: {condition_type}")] + UnknownConditionType { condition_type: String } => tracing::Level::WARN, + + #[no_source] + #[error("Rate-limit owner task is unavailable (channel closed)")] + RateLimitOwnerUnavailable => tracing::Level::ERROR, + + #[no_source] + #[error("Rate-limit owner blocking task panicked or was cancelled: {detail}")] + RateLimitOwnerJoinFailed { detail: String } => tracing::Level::ERROR, } } diff --git a/net-guardia/src/model/error/suricata.rs b/net-guardia/src/model/error/suricata.rs new file mode 100644 index 0000000..36b7af2 --- /dev/null +++ b/net-guardia/src/model/error/suricata.rs @@ -0,0 +1,28 @@ +use macros::traceable; +use tracing; + +traceable! { + SuricataError { + #[no_source] + #[error("Suricata binary not found at '{path}'")] + BinaryNotFound { path: String } => tracing::Level::ERROR, + + #[no_source] + #[error("Suricata config not found at '{path}'")] + ConfigNotFound { path: String } => tracing::Level::ERROR, + + #[error("Failed to spawn Suricata subprocess")] + SpawnFailed => tracing::Level::ERROR, + + #[no_source] + #[error("Suricata subprocess exited: {reason}")] + SubprocessExited { reason: String } => tracing::Level::WARN, + + #[error("Failed to open eve.json stream at '{path}'")] + EveOpenFailed { path: String } => tracing::Level::ERROR, + + #[no_source] + #[error("Failed to parse eve.json line: {reason}")] + EveParseFailed { reason: String } => tracing::Level::WARN, + } +} diff --git a/net-guardia/src/model/event.rs b/net-guardia/src/model/event.rs index 2686ba1..7cf48f4 100644 --- a/net-guardia/src/model/event.rs +++ b/net-guardia/src/model/event.rs @@ -1,16 +1,25 @@ use std::fmt; +use std::str::FromStr; + +use serde::Serialize; use crate::interface::communication::event::Event; // -- Detection Source --------------------------------------------------------- /// Identifies which detection subsystem produced a detection. -/// Used for attribution tracking and future cross-source deduplication. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] +/// Used for attribution tracking and cross-source deduplication. +/// +/// Serialized as the canonical `Display` form ("ML", "Suricata", "Beaconing", +/// "Correlation") so the WebSocket wire matches the SOAR `SingleSourceHigh` +/// `value` field — frontend rendering and playbook authoring share one +/// vocabulary. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)] pub enum DetectionSource { ML, Correlation, Beaconing, + Suricata, } impl fmt::Display for DetectionSource { @@ -19,6 +28,23 @@ impl fmt::Display for DetectionSource { DetectionSource::ML => write!(f, "ML"), DetectionSource::Correlation => write!(f, "Correlation"), DetectionSource::Beaconing => write!(f, "Beaconing"), + DetectionSource::Suricata => write!(f, "Suricata"), + } + } +} + +impl FromStr for DetectionSource { + type Err = (); + + /// Accepts the canonical `Display` form plus common aliases so playbook + /// authors can write `"CV"` for Beaconing or `"Graph"` for Correlation. + fn from_str(s: &str) -> Result { + match s { + "ML" | "ml" => Ok(Self::ML), + "Suricata" | "suricata" => Ok(Self::Suricata), + "Beaconing" | "beaconing" | "CV" | "cv" => Ok(Self::Beaconing), + "Correlation" | "correlation" | "Graph" | "graph" => Ok(Self::Correlation), + _ => Err(()), } } } @@ -38,13 +64,20 @@ pub struct DetectionEvent { pub protocol: u8, pub packet_count: u64, pub flow_duration_us: u64, + /// Per-model scores for observability (ML source only) + pub ae_score: f32, + pub anomaly_score: f32, + pub c2_score: f32, } // -- Threat Events ------------------------------------------------------------ /// Fired when the DetectionOrchestrator emits a deduplicated, enriched threat. -/// Consumed by the SOAR engine to trigger automated responses. -#[derive(Debug, Clone)] +/// Consumed by the SOAR engine to trigger automated responses, and broadcast +/// to the dashboard over `/ws/fusion` so the operator's "Recent Threats" +/// stream surfaces post-fusion (multi-source) detections rather than raw +/// per-flow ML alerts. +#[derive(Debug, Clone, Serialize)] pub struct ThreatDetectedEvent { pub attack_type: String, pub confidence: f32, @@ -62,8 +95,22 @@ pub struct ThreatDetectedEvent { pub geoip_country: Option, /// Whether this src_ip had a block action in the past 24h pub is_repeat_offender: bool, - /// Which detection sources contributed to this threat (for attribution) + /// Which detection sources contributed to this threat (for attribution). + /// Single-source events have length 1; fused events have 2..=4. pub sources: Vec, + /// Number of distinct sources that contributed to this event. + /// Used by SOAR `MultiSourceMin` / `SingleSourceHigh` conditions — + /// counts unique sources, not per-source fires within the window. + pub active_source_count: usize, + /// Cross-source fused confidence (1 − ∏(1 − c_i)). Equal to + /// `confidence` after the fusion engine runs; retained as a separate + /// field so SOAR policies can discriminate "single source" from + /// "fused multi-source" numerically identical confidences. + pub fused_confidence: f32, + /// Per-model scores for debugging false positives. + pub ae_score: f32, + pub anomaly_score: f32, + pub c2_score: f32, } impl Event for ThreatDetectedEvent {} diff --git a/net-guardia/src/model/log/crypto.rs b/net-guardia/src/model/log/crypto.rs index e17b630..176d6e1 100644 --- a/net-guardia/src/model/log/crypto.rs +++ b/net-guardia/src/model/log/crypto.rs @@ -7,14 +7,5 @@ loggable! { #[error("Envelope encryption disabled — no master key (dev mode)")] EnvelopeDisabled => tracing::Level::WARN, - - #[error("Migrated secret: {key}")] - SecretMigrated { key: String } => tracing::Level::INFO, - - #[error("Secret migration complete: {count} secrets encrypted")] - MigrationComplete { count: usize } => tracing::Level::INFO, - - #[error("Secret migration skipped — already done")] - MigrationSkipped => tracing::Level::DEBUG, } } diff --git a/net-guardia/src/model/log/detection.rs b/net-guardia/src/model/log/detection.rs index 4a01f00..5f8b87f 100644 --- a/net-guardia/src/model/log/detection.rs +++ b/net-guardia/src/model/log/detection.rs @@ -9,8 +9,8 @@ loggable! { #[error("Detection deduplicated: {source_ip} {attack_type} (within window)")] DetectionDeduplicated { source_ip: String, attack_type: String } => tracing::Level::DEBUG, - #[error("Detection emitted: {source_ip} {attack_type} confidence={confidence:.2} sources={sources_count}")] - DetectionEmitted { source_ip: String, attack_type: String, confidence: f32, sources_count: usize } => tracing::Level::DEBUG, + #[error("Detection emitted: {source_ip} {attack_type} confidence={confidence:.2} ae={ae_score:.3} anomaly={anomaly_score:.3} c2={c2_score:.3} sources={sources_count}")] + DetectionEmitted { source_ip: String, attack_type: String, confidence: f32, ae_score: f32, anomaly_score: f32, c2_score: f32, sources_count: usize } => tracing::Level::DEBUG, #[error("ML detection bridge started")] MlBridgeStarted => tracing::Level::INFO, @@ -44,5 +44,17 @@ loggable! { #[error("Correlation cleanup: removed {removed} expired entries")] CorrelationCleanup { removed: usize } => tracing::Level::DEBUG, + + #[error("Fusion: emitted {source_ip} {attack_type} fused={fused:.3} sources={count}")] + FusionEmitted { source_ip: String, attack_type: String, fused: f32, count: usize } => tracing::Level::DEBUG, + + #[error("Fusion: window evicted under LRU pressure ({key_src} {key_type})")] + FusionWindowEvicted { key_src: String, key_type: String } => tracing::Level::WARN, + + #[error("Fusion: failed to publish ThreatDetectedEvent: {err}")] + FusionPublishFailed { err: String } => tracing::Level::ERROR, + + #[error("Fusion: failed to publish AuditEvent: {err}")] + FusionAuditPublishFailed { err: String } => tracing::Level::ERROR, } } diff --git a/net-guardia/src/model/log/http.rs b/net-guardia/src/model/log/http.rs index 9f27e9f..2809636 100644 --- a/net-guardia/src/model/log/http.rs +++ b/net-guardia/src/model/log/http.rs @@ -11,5 +11,8 @@ loggable! { #[error("Setup HTTP server error: {error}")] SetupServerError { error: String } => tracing::Level::ERROR, + + #[error("Fusion WebSocket failed to subscribe to ThreatDetectedEvent: {err}")] + FusionSubscribeFailed { err: String } => tracing::Level::ERROR, } } diff --git a/net-guardia/src/model/log/misc.rs b/net-guardia/src/model/log/misc.rs index f3e2dd3..fbfc280 100644 --- a/net-guardia/src/model/log/misc.rs +++ b/net-guardia/src/model/log/misc.rs @@ -5,17 +5,5 @@ loggable! { MiscLog { #[error("NETGUARDIA_DB_KEY is not set — database will NOT be encrypted (dev mode)")] DbEncryptionDisabled => tracing::Level::WARN, - - #[error("Database file exists but is neither valid plaintext nor valid encrypted — skipping migration")] - DbMigrationSkipped => tracing::Level::ERROR, - - #[error("Migrating plaintext database to encrypted format")] - DbMigrationStarted => tracing::Level::INFO, - - #[error("Database migration to encrypted format completed successfully")] - DbMigrationCompleted => tracing::Level::INFO, - - #[error("Database encryption migration failed — keeping original plaintext DB: {error}")] - DbMigrationFailed { error: String } => tracing::Level::ERROR, } } diff --git a/net-guardia/src/model/log/ml.rs b/net-guardia/src/model/log/ml.rs index 2557416..d93f869 100644 --- a/net-guardia/src/model/log/ml.rs +++ b/net-guardia/src/model/log/ml.rs @@ -38,5 +38,41 @@ loggable! { #[error("Traffic logger channel disconnected")] TrafficLogChannelDisconnected => tracing::Level::WARN, + + #[error("Traffic logger dropped a row (channel full — writer thread falling behind)")] + TrafficLogChannelBackpressure => tracing::Level::WARN, + + #[error("Flow Trace recording stopped: {reason}")] + FlowTraceStopped { reason: String } => tracing::Level::WARN, + + #[error("ML circuit breaker OPEN: {failures} failures in {window_secs}s, inference disabled until reset")] + CircuitBreakerOpen { failures: u32, window_secs: u64 } => tracing::Level::ERROR, + + #[error("ML circuit breaker RESET: inference re-enabled after {cooldown_secs}s cooldown")] + CircuitBreakerReset { cooldown_secs: u64 } => tracing::Level::WARN, + + #[error("Loading ONNX model '{name}' (features={features}, batch_size={batch_size})...")] + ModelLoading { name: String, features: usize, batch_size: usize } => tracing::Level::INFO, + + #[error("Model '{name}' loaded and optimized in {elapsed_ms}ms")] + ModelLoadComplete { name: String, elapsed_ms: u64 } => tracing::Level::INFO, + + #[error("Model watcher started, monitoring models/ for .onnx changes")] + ModelWatcherStarted => tracing::Level::INFO, + + #[error("Model reload triggered, loading new ONNX models...")] + ModelReloadStarting => tracing::Level::INFO, + + #[error("Model reload successful, inference pipeline updated")] + ModelReloadSuccess => tracing::Level::INFO, + + #[error("Model reload failed, keeping current models: {error}")] + ModelReloadFailed { error: String } => tracing::Level::ERROR, + + #[error("Model manifest loaded: name='{name}', adapter={adapter}, features={features}, labels={labels}")] + ManifestLoaded { name: String, adapter: String, features: usize, labels: usize } => tracing::Level::INFO, + + #[error("ONNX input shape introspected for {model}: declared={declared}, onnx_dim={onnx_dim}, matched={matched}")] + OnnxShapeChecked { model: String, declared: usize, onnx_dim: usize, matched: bool } => tracing::Level::DEBUG, } } diff --git a/net-guardia/src/model/log/mod.rs b/net-guardia/src/model/log/mod.rs index 819d656..77f3423 100644 --- a/net-guardia/src/model/log/mod.rs +++ b/net-guardia/src/model/log/mod.rs @@ -7,4 +7,5 @@ pub mod http; pub mod misc; pub mod ml; pub mod soar; +pub mod suricata; pub mod system; diff --git a/net-guardia/src/model/log/soar.rs b/net-guardia/src/model/log/soar.rs index 4c11a50..635a19e 100644 --- a/net-guardia/src/model/log/soar.rs +++ b/net-guardia/src/model/log/soar.rs @@ -69,8 +69,8 @@ loggable! { #[error("TTL sweep: {removed} blocks removed, {skipped} kept (manual ACL conflict)")] TtlSweepComplete { removed: u32, skipped: u32 } => tracing::Level::DEBUG, - #[error("SOAR log action [{level}]: threat from {source_ip} — {attack_type} (confidence: {confidence})")] - ActionLog { level: String, source_ip: String, attack_type: String, confidence: String } => tracing::Level::INFO, + #[error("SOAR log action [{level}]: threat from {source_ip} — {attack_type} (confidence: {confidence}, ae: {ae_score}, anomaly: {anomaly_score}, c2: {c2_score})")] + ActionLog { level: String, source_ip: String, attack_type: String, confidence: String, ae_score: String, anomaly_score: String, c2_score: String } => tracing::Level::INFO, #[error("SOAR cooldown cleanup: {removed} expired entries removed")] CooldownCleanup { removed: u32 } => tracing::Level::DEBUG, @@ -92,5 +92,8 @@ loggable! { #[error("Invalid operator '{operator}' for condition type '{condition_type}' on playbook '{name}', condition skipped")] InvalidConditionOperator { name: String, condition_type: String, operator: String } => tracing::Level::WARN, + + #[error("Playbook '{name}' uses non-canonical trigger_event '{trigger_event}' — cross-source fusion dedup may silently miss this rule")] + NonCanonicalTriggerEvent { name: String, trigger_event: String } => tracing::Level::WARN, } } diff --git a/net-guardia/src/model/log/suricata.rs b/net-guardia/src/model/log/suricata.rs new file mode 100644 index 0000000..7cc2159 --- /dev/null +++ b/net-guardia/src/model/log/suricata.rs @@ -0,0 +1,36 @@ +use macros::loggable; +use tracing; + +loggable! { + SuricataLog { + #[error("Suricata bridge disabled by config")] + Disabled => tracing::Level::INFO, + + #[error("Spawning Suricata: {binary} -c {config} -i {iface}")] + Spawning { binary: String, config: String, iface: String } => tracing::Level::INFO, + + #[error("Suricata subprocess started (pid={pid})")] + Started { pid: u32 } => tracing::Level::INFO, + + #[error("Suricata subprocess exited unexpectedly: {reason}. Restart in {backoff}s")] + CrashedRestartPending { reason: String, backoff: u64 } => tracing::Level::WARN, + + #[error("Suricata subprocess stopped: {reason}")] + Stopped { reason: String } => tracing::Level::INFO, + + #[error("Suricata subprocess sent SIGTERM for graceful shutdown")] + ShutdownRequested => tracing::Level::INFO, + + #[error("Suricata eve.json monitor waiting for file: {path}")] + MonitorWaitingForFile { path: String } => tracing::Level::INFO, + + #[error("Suricata eve.json monitor attached to {path}")] + MonitorAttached { path: String } => tracing::Level::INFO, + + #[error("Suricata eve.json rotated — reopening")] + MonitorFileRotated => tracing::Level::INFO, + + #[error("Suricata alert forwarded: sid={sid} {src}->{dst} {signature}")] + AlertForwarded { sid: u32, src: String, dst: String, signature: String } => tracing::Level::DEBUG, + } +} diff --git a/net-guardia/src/model/log/system.rs b/net-guardia/src/model/log/system.rs index f0f498a..41fdbe8 100644 --- a/net-guardia/src/model/log/system.rs +++ b/net-guardia/src/model/log/system.rs @@ -101,5 +101,65 @@ loggable! { #[error("ML drift detected: {count} features drifted, max deviation {deviation:.2}σ")] DriftDetected { count: usize, deviation: f64 } => tracing::Level::WARN, + + #[error("eBPF bring-up failed — continuing without data plane: {details}")] + EbpfBringupFailed { details: String } => tracing::Level::ERROR, + + #[error("Telegram rate limited, retrying after {retry_after}s (attempt {attempt}/{max})")] + TelegramRateLimitedRetry { retry_after: u64, attempt: u32, max: u32 } => tracing::Level::WARN, + + #[error("Telegram not configured, skipping alert")] + TelegramNotConfiguredSkipped => tracing::Level::DEBUG, + + #[error("Telegram rate limit reached ({max_messages} per {window_secs}s), dropping alert for IP {source_ip}")] + TelegramLocalRateLimitDropped { max_messages: u32, window_secs: u32, source_ip: String } => tracing::Level::WARN, + + #[error("Stats aggregator started (1h interval)")] + StatsAggregatorStarted => tracing::Level::INFO, + + #[error("Initial stats aggregation failed: {error}")] + InitialStatsAggregationFailed { error: String } => tracing::Level::ERROR, + + #[error("Stats aggregation failed: {error}")] + StatsAggregationFailed { error: String } => tracing::Level::ERROR, + + #[error("Stats aggregated: {threats} threats, {blocks} blocks, {unblocks} unblocks, {rules} active rules")] + StatsAggregated { threats: u64, blocks: u64, unblocks: u64, rules: u64 } => tracing::Level::INFO, + + #[error("Weekly report scheduler started")] + WeeklyReportSchedulerStarted => tracing::Level::INFO, + + #[error("Weekly report window reached — preparing report")] + WeeklyReportWindowReached => tracing::Level::INFO, + + #[error("SMTP is not configured (missing smtp_host/port/username/password). Skipping weekly report.")] + SmtpNotConfigured => tracing::Level::WARN, + + #[error("Failed to read SMTP settings: {error}")] + SmtpSettingsReadFailed { error: String } => tracing::Level::ERROR, + + #[error("No smtp_recipient configured. Skipping weekly report.")] + SmtpRecipientMissing => tracing::Level::WARN, + + #[error("Failed to generate weekly report: {error}")] + WeeklyReportGenerationFailed { error: String } => tracing::Level::ERROR, + + #[error("Weekly report sent successfully")] + WeeklyReportSent => tracing::Level::INFO, + + #[error("Failed to send weekly report: {error}")] + WeeklyReportSendFailed { error: String } => tracing::Level::ERROR, + + #[error("Send task panicked: {error}")] + WeeklyReportSendPanicked { error: String } => tracing::Level::ERROR, + + #[error("HTML report generated at {path}")] + HtmlReportGenerated { path: String } => tracing::Level::INFO, + + #[error("Cleaned {count} stale model-upload staging directories")] + StagingOrphansCleaned { count: u64 } => tracing::Level::INFO, + + #[error("Staging-orphan sweep failed: {error}")] + StagingOrphansSweepFailed { error: String } => tracing::Level::WARN, } } diff --git a/net-guardia/src/model/mod.rs b/net-guardia/src/model/mod.rs index 0f4dc1c..dfdd007 100644 --- a/net-guardia/src/model/mod.rs +++ b/net-guardia/src/model/mod.rs @@ -1,4 +1,3 @@ -// Bounded Context subdirectories pub mod access_control; pub mod config; pub mod detection; @@ -10,14 +9,3 @@ pub mod monitoring; pub mod report; pub mod soar; pub mod system; - -// Backward-compatible re-exports (existing imports continue to work) -pub use access_control::ip_address; -pub use access_control::list_type; -pub use detection::ml_detection; -pub use identity::auth; -pub use monitoring::direction; -pub use monitoring::drop_event; -pub use monitoring::flow_stats; -pub use monitoring::user_packet; -pub use system::health; diff --git a/net-guardia/src/model/monitoring/direction.rs b/net-guardia/src/model/monitoring/direction.rs index 1b52cc3..e502401 100644 --- a/net-guardia/src/model/monitoring/direction.rs +++ b/net-guardia/src/model/monitoring/direction.rs @@ -1,3 +1,5 @@ +use std::fmt; + use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Copy, Clone, Eq, PartialEq, Hash, Debug)] @@ -7,8 +9,8 @@ pub enum Direction { Egress, } -impl std::fmt::Display for Direction { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +impl fmt::Display for Direction { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Direction::Ingress => write!(f, "Ingress"), Direction::Egress => write!(f, "Egress"), diff --git a/net-guardia/src/model/monitoring/drop_event.rs b/net-guardia/src/model/monitoring/drop_event.rs index 192bbd1..0dac23b 100644 --- a/net-guardia/src/model/monitoring/drop_event.rs +++ b/net-guardia/src/model/monitoring/drop_event.rs @@ -1,3 +1,5 @@ +use std::sync::atomic::{AtomicU64, Ordering}; + use serde::Serialize; #[derive(Debug, Clone, Serialize)] @@ -12,6 +14,8 @@ pub struct DropEventMessage { pub ip_version: u8, } +/// Wire snapshot of drop counts. Returned by `DropMonitor::snapshot` and +/// serialized to JSON for the HTTP stats endpoint. #[derive(Default, Clone, Serialize)] pub struct DropCounters { pub acl_blacklist: u64, @@ -24,3 +28,35 @@ pub struct DropCounters { pub geo_block: u64, pub total: u64, } + +/// Lock-free atomic counters incremented on the drop ring-buffer consumer +/// path. `Relaxed` is sufficient — counters are independent and the +/// snapshot does not require a global consistent ordering across them. +#[derive(Default)] +pub struct DropCountersAtomic { + pub acl_blacklist: AtomicU64, + pub rate_limit_pkt: AtomicU64, + pub rate_limit_syn: AtomicU64, + pub rate_limit_udp: AtomicU64, + pub rate_limit_dns: AtomicU64, + pub protocol_filter: AtomicU64, + pub dns_blacklist: AtomicU64, + pub geo_block: AtomicU64, + pub total: AtomicU64, +} + +impl DropCountersAtomic { + pub fn snapshot(&self) -> DropCounters { + DropCounters { + acl_blacklist: self.acl_blacklist.load(Ordering::Relaxed), + rate_limit_pkt: self.rate_limit_pkt.load(Ordering::Relaxed), + rate_limit_syn: self.rate_limit_syn.load(Ordering::Relaxed), + rate_limit_udp: self.rate_limit_udp.load(Ordering::Relaxed), + rate_limit_dns: self.rate_limit_dns.load(Ordering::Relaxed), + protocol_filter: self.protocol_filter.load(Ordering::Relaxed), + dns_blacklist: self.dns_blacklist.load(Ordering::Relaxed), + geo_block: self.geo_block.load(Ordering::Relaxed), + total: self.total.load(Ordering::Relaxed), + } + } +} diff --git a/net-guardia/src/model/monitoring/flow_stats.rs b/net-guardia/src/model/monitoring/flow_stats.rs index 938606f..e9132aa 100644 --- a/net-guardia/src/model/monitoring/flow_stats.rs +++ b/net-guardia/src/model/monitoring/flow_stats.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -use crate::model::direction::Direction; +use crate::model::monitoring::direction::Direction; #[derive(Debug, Clone, Serialize)] pub struct FlowStatsEntry { @@ -19,8 +19,8 @@ pub struct FlowStatsEntry { pub last_seen_us: u64, } -// NOTE: From<&FlowData> impl moved to core/infrastructure/statistics.rs -// to maintain the dependency rule: model/ must not import core/ +// NOTE: From<&FlowData> impl lives in infrastructure/statistics.rs so +// model/ doesn't need to import core/ (dependency-rule invariant). #[derive(Debug, Clone, Serialize)] pub struct StatsSummary { diff --git a/net-guardia/src/model/report/data.rs b/net-guardia/src/model/report/data.rs index 10198ae..5336155 100644 --- a/net-guardia/src/model/report/data.rs +++ b/net-guardia/src/model/report/data.rs @@ -1,6 +1,7 @@ +use chrono::{Duration as ChronoDuration, Local}; use serde::{Deserialize, Serialize}; -use crate::interface::port::repository::RepositoryPort; +use crate::interface::port::setting::SettingRepo; use crate::model::error::Error; /// Shared report data structure used by both HTML email and PDF report. @@ -62,11 +63,11 @@ pub struct SystemHealthSummary { impl ReportData { /// Build report data from database settings (aggregated by the ML pipeline). - pub fn from_database(db: &dyn RepositoryPort) -> Result { - let now = chrono::Local::now(); + pub fn from_database(db: &dyn SettingRepo) -> Result { + let now = Local::now(); let period = format!( "{} — {}", - (now - chrono::Duration::days(7)).format("%Y-%m-%d"), + (now - ChronoDuration::days(7)).format("%Y-%m-%d"), now.format("%Y-%m-%d") ); diff --git a/net-guardia/src/model/soar/condition.rs b/net-guardia/src/model/soar/condition.rs index ebb810e..a94302c 100644 --- a/net-guardia/src/model/soar/condition.rs +++ b/net-guardia/src/model/soar/condition.rs @@ -16,6 +16,17 @@ pub enum ConditionType { RepeatOffender, /// Frequency: N events from same source_ip within window_secs Frequency, + /// Multi-source agreement: `event.active_source_count >= value`. + MultiSourceMin, + /// Solo high-confidence escape hatch: exactly one contributing source, + /// and that source matches a specific name with confidence ≥ `value2`. + /// Lets a single high-confidence signature-class detection block + /// without waiting on peer agreement. + SingleSourceHigh, + /// Fused confidence above threshold — reads `event.fused_confidence` + /// rather than the per-event `confidence`, so a single high-confidence + /// event doesn't pass a threshold intended for multi-source agreement. + FusedConfidenceAbove, } impl fmt::Display for ConditionType { @@ -26,6 +37,9 @@ impl fmt::Display for ConditionType { Self::IpPattern => write!(f, "ip_pattern"), Self::RepeatOffender => write!(f, "repeat_offender"), Self::Frequency => write!(f, "frequency"), + Self::MultiSourceMin => write!(f, "multi_source_min"), + Self::SingleSourceHigh => write!(f, "single_source_high"), + Self::FusedConfidenceAbove => write!(f, "fused_confidence_above"), } } } @@ -40,10 +54,10 @@ impl FromStr for ConditionType { "ip_pattern" => Ok(Self::IpPattern), "repeat_offender" => Ok(Self::RepeatOffender), "frequency" => Ok(Self::Frequency), - other => Err(SoarError::InvalidCondition { - condition_type: other.to_string(), - reason: "unknown condition type".to_string(), - }), + "multi_source_min" => Ok(Self::MultiSourceMin), + "single_source_high" => Ok(Self::SingleSourceHigh), + "fused_confidence_above" => Ok(Self::FusedConfidenceAbove), + other => Err(SoarError::UnknownConditionType(other)), } } } diff --git a/net-guardia/src/model/soar/dry_run.rs b/net-guardia/src/model/soar/dry_run.rs new file mode 100644 index 0000000..649e79d --- /dev/null +++ b/net-guardia/src/model/soar/dry_run.rs @@ -0,0 +1,50 @@ +//! DTOs for the SOAR dry-run endpoint. Returned to the HTTP layer +//! verbatim, so any rename here is a wire-format change. + +use serde::Serialize; + +/// One playbook's simulated outcome against a synthetic event. Reports +/// what would fire, what conditions passed, and what actions would +/// execute — without actually invoking any of them. +#[derive(Debug, Clone, Serialize)] +pub struct DryRunMatch { + pub playbook_id: i64, + pub playbook_name: String, + pub enabled: bool, + pub trigger_event: String, + /// The playbook's `trigger_event` matched the event's `attack_type`. + pub trigger_matches: bool, + /// The playbook has at least one `frequency` condition whose outcome + /// depends on runtime history — dry-run cannot accurately evaluate + /// it, so the UI should warn the admin that real firing may differ. + pub has_frequency_condition: bool, + /// `trigger_matches` and every non-frequency condition reported met. + /// `has_frequency_condition=true` does NOT force this false — the + /// frequency branch is treated as "passes in dry-run" and flagged + /// for the admin to interpret. + pub would_fire: bool, + pub conditions: Vec, + pub actions: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct DryRunConditionResult { + pub condition_type: String, + pub operator: String, + pub value: String, + pub value2: Option, + pub met: bool, + /// Human-readable explanation for the frontend to surface, populated + /// when the answer is non-obvious (e.g. "skipped — requires history" + /// for Frequency, or "sources=[ML] confidence=0.90 target=Suricata" + /// for a mismatching SingleSourceHigh). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub note: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct DryRunAction { + pub action_order: i64, + pub action_type: String, + pub params: serde_json::Value, +} diff --git a/net-guardia/src/model/soar/mod.rs b/net-guardia/src/model/soar/mod.rs index 25d41b9..6e1bf40 100644 --- a/net-guardia/src/model/soar/mod.rs +++ b/net-guardia/src/model/soar/mod.rs @@ -1,3 +1,4 @@ pub mod condition; +pub mod dry_run; pub mod playbook; pub mod playbook_data; diff --git a/net-guardia/src/model/soar/playbook.rs b/net-guardia/src/model/soar/playbook.rs index 7d2d811..bfa25d6 100644 --- a/net-guardia/src/model/soar/playbook.rs +++ b/net-guardia/src/model/soar/playbook.rs @@ -7,16 +7,13 @@ pub struct Playbook { pub name: String, pub enabled: bool, pub trigger_event: String, - pub condition_threshold: Option, pub cooldown_secs: i64, pub actions: Vec, - /// Multi-condition rules (AND logic). Empty = legacy threshold-only mode. pub conditions: Vec, } #[derive(Debug, Clone)] pub struct PlaybookAction { - #[allow(dead_code)] // Kept for domain completeness; ordering handled by SQL ORDER BY pub action_order: i64, pub action_type: String, pub params: serde_json::Value, diff --git a/net-guardia/src/model/system/config.rs b/net-guardia/src/model/system/config.rs index 23f4ff9..b95805b 100644 --- a/net-guardia/src/model/system/config.rs +++ b/net-guardia/src/model/system/config.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; -use crate::model::ml_detection::ClipParams; +use crate::model::detection::ml_detection::ClipParams; #[derive(Serialize, Deserialize, Debug, Clone)] pub struct HttpConfig { @@ -56,6 +56,58 @@ pub struct InferenceConfig { pub inference_batch_size: usize, pub traffic_logging_mode: bool, pub traffic_log_csv_path: String, + /// Rotation: close the current CSV when it reaches this many bytes + /// and open a fresh one. 500MB default — large enough that dropdown + /// analysis tools can eat a shard in one gulp, small enough that + /// a browser download finishes in reasonable time. + #[serde(default = "default_flow_trace_max_file_bytes")] + pub flow_trace_max_file_bytes: u64, + /// Rotation: also roll when the active file crosses this age in + /// seconds, so analysts always have bounded-age shards regardless + /// of traffic volume. 1h default. + #[serde(default = "default_flow_trace_max_file_age_secs")] + pub flow_trace_max_file_age_secs: u64, + /// FIFO budget: total bytes across every rotated shard in the + /// directory. When exceeded, oldest files are deleted until the + /// sum is back under budget. 10GB default keeps a few days of + /// recording on a typical office link. + #[serde(default = "default_flow_trace_total_budget_bytes")] + pub flow_trace_total_budget_bytes: u64, + /// Hard ceiling on the multipart `.onnx` stream. 100MB default fits + /// every shipped shape of netguardia's own model plus headroom for + /// medium BYO networks; very large models (modern transformers) + /// can raise this, at the cost of a wider DoS surface. + #[serde(default = "default_model_upload_max_onnx_bytes")] + pub model_upload_max_onnx_bytes: usize, + /// Hard ceiling on the multipart `manifest` YAML stream. 64KB + /// default is ~100× the largest realistic manifest. + #[serde(default = "default_model_upload_max_manifest_bytes")] + pub model_upload_max_manifest_bytes: usize, + /// Hard ceiling on the optional `scaler` JSON sidecar stream. + /// Shares the 64KB default with the manifest cap — sidecars are + /// numeric arrays whose size scales with feature count, so even a + /// generous feature set stays well under. + #[serde(default = "default_model_upload_max_scaler_bytes")] + pub model_upload_max_scaler_bytes: usize, +} + +fn default_flow_trace_max_file_bytes() -> u64 { + 500 * 1024 * 1024 +} +fn default_flow_trace_max_file_age_secs() -> u64 { + 3600 +} +fn default_flow_trace_total_budget_bytes() -> u64 { + 10 * 1024 * 1024 * 1024 +} +fn default_model_upload_max_onnx_bytes() -> usize { + 100 * 1024 * 1024 +} +fn default_model_upload_max_manifest_bytes() -> usize { + 64 * 1024 +} +fn default_model_upload_max_scaler_bytes() -> usize { + 64 * 1024 } #[derive(Serialize, Deserialize, Debug, Clone)] @@ -75,6 +127,16 @@ pub struct PipelineConfig { pub egress: Vec, } +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct SuricataConfig { + pub enabled: bool, + pub binary_path: String, + pub config_path: String, + pub eve_log_path: String, + pub auto_restart_on_crash: bool, + pub restart_backoff_secs: u64, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MLInferenceConfig { pub ae_feature_names: Vec, @@ -86,6 +148,26 @@ pub struct MLInferenceConfig { pub ae_threshold: f32, pub classifier_feature_names: Vec, pub attack_labels: HashMap, + pub anomaly_threshold: f32, + pub c2_threshold: f32, + #[serde(default = "default_class_min_confidence")] + pub class_min_confidence: f32, + /// Multiplier applied to the confidence threshold before the aggregator + /// fires an alert. The manifest can override this via + /// `thresholds.alert_multiplier`. + #[serde(default = "default_alert_threshold_multiplier")] + pub alert_threshold_multiplier: f32, + pub model_type: String, + pub output_names: Vec, + pub ae_feature_weights: HashMap, +} + +fn default_class_min_confidence() -> f32 { + 0.4 +} + +fn default_alert_threshold_multiplier() -> f32 { + 1.2 } impl MLInferenceConfig { diff --git a/net-guardia/src/model/system/health.rs b/net-guardia/src/model/system/health.rs index 1b0db2d..4a798f1 100644 --- a/net-guardia/src/model/system/health.rs +++ b/net-guardia/src/model/system/health.rs @@ -11,6 +11,69 @@ pub struct SystemHealthMetrics { pub network_stats: ConfiguredNetworkStats, pub load_average: Option, pub temperature: Option, + pub ebpf: EbpfHealth, +} + +/// Runtime health of the eBPF/XDP data plane. +/// +/// `Healthy` means both ingress and egress XDP programs are attached and AF_XDP +/// sockets are bound. `Unavailable` means one of the eBPF setup stages failed; +/// the rest of the system continues to run but any eBPF-backed operation +/// (access control rules, geo block, rate limit, DNS filter, packet capture) +/// will return `EbpfError::NotLoaded` when invoked. +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum EbpfHealth { + Healthy, + Unavailable { + stage: EbpfFailStage, + category: EbpfFailCategory, + /// Human-readable explanation, including interface, kernel version, + /// driver name, and the raw error from the kernel where available. + reason: String, + }, +} + +/// Which stage of eBPF bring-up failed. +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum EbpfFailStage { + /// `aya::Ebpf::load(...)` — reading the compiled BPF object file. + Load, + /// `aya_log::EbpfLogger::init(...)` — wiring the kernel-to-userspace log channel. + LoggerInit, + /// Pipeline program array setup (tail-call dispatch table). + PipelineSetup, + /// `EbpfServices::new(...)` — taking map handles for the userspace services. + MapsBind, + /// `xdp.attach(ifname, ...)` — attaching the XDP program to the NIC. + XdpAttach, + /// AF_XDP socket bind for packet capture. + AfXdpBind, +} + +/// Category of why eBPF bring-up failed. Used by frontend to render +/// targeted guidance (permission vs. driver vs. interface). +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum EbpfFailCategory { + /// EPERM / EACCES — process lacks CAP_BPF / CAP_NET_ADMIN / CAP_SYS_ADMIN. + Permission, + /// ENODEV / interface name does not resolve. + InterfaceNotFound, + /// Interface exists but XDP native/SKB attach refused by driver. + XdpUnsupported, + /// AF_XDP bind rejected — driver does not implement AF_XDP on this kernel. + /// Common case: Intel i350 (igb) on kernel < 6.17. + AfXdpUnsupported, + /// ENOMEM / RLIMIT_MEMLOCK exhausted. + MemlockExhausted, + /// BPF verifier rejected the program (kernel feature missing or bug). + VerifierRejected, + /// BPF object file missing or malformed. + ObjectNotFound, + /// Catch-all for errors we could not classify. + Unknown, } #[derive(Debug, Clone, Serialize)] diff --git a/net-guardia/src/model/system/mod.rs b/net-guardia/src/model/system/mod.rs index 55dcb9a..6c0d46b 100644 --- a/net-guardia/src/model/system/mod.rs +++ b/net-guardia/src/model/system/mod.rs @@ -2,3 +2,4 @@ pub mod config; pub mod health; pub mod rate_limit_settings; pub mod readiness; +pub mod suricata; diff --git a/net-guardia/src/model/system/readiness.rs b/net-guardia/src/model/system/readiness.rs index 7312fcc..f5c801e 100644 --- a/net-guardia/src/model/system/readiness.rs +++ b/net-guardia/src/model/system/readiness.rs @@ -1,20 +1,23 @@ +use std::sync::atomic::AtomicBool; +use std::time::Instant; + /// Per-subsystem readiness state exposed by `/health/ready`. pub struct ReadinessState { - pub db_connected: std::sync::atomic::AtomicBool, - pub ml_model_loaded: std::sync::atomic::AtomicBool, - pub soar_engine_running: std::sync::atomic::AtomicBool, - pub ebpf_attached: std::sync::atomic::AtomicBool, - pub started_at: std::time::Instant, + pub db_connected: AtomicBool, + pub ml_model_loaded: AtomicBool, + pub soar_engine_running: AtomicBool, + pub ebpf_attached: AtomicBool, + pub started_at: Instant, } impl ReadinessState { pub fn new() -> Self { Self { - db_connected: std::sync::atomic::AtomicBool::new(false), - ml_model_loaded: std::sync::atomic::AtomicBool::new(false), - soar_engine_running: std::sync::atomic::AtomicBool::new(false), - ebpf_attached: std::sync::atomic::AtomicBool::new(false), - started_at: std::time::Instant::now(), + db_connected: AtomicBool::new(false), + ml_model_loaded: AtomicBool::new(false), + soar_engine_running: AtomicBool::new(false), + ebpf_attached: AtomicBool::new(false), + started_at: Instant::now(), } } } diff --git a/net-guardia/src/model/system/suricata.rs b/net-guardia/src/model/system/suricata.rs new file mode 100644 index 0000000..403ee9f --- /dev/null +++ b/net-guardia/src/model/system/suricata.rs @@ -0,0 +1,27 @@ +use serde::Serialize; + +/// Runtime health of the Suricata subprocess bridge. +/// +/// `Disabled`: Suricata bridge is off by config — no process is launched. +/// `Running`: subprocess is alive and eve.json tail is active. +/// `Stopped`: subprocess exited (crashed or graceful) and no auto-restart +/// is pending, or the bridge was shut down. `reason` carries context. +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum SuricataHealth { + Disabled, + Running { + /// OS process id of the Suricata child. Useful for operator debugging. + pid: u32, + }, + Stopped { + reason: String, + }, +} + +impl SuricataHealth { + #[allow(dead_code)] + pub fn is_running(&self) -> bool { + matches!(self, SuricataHealth::Running { .. }) + } +} diff --git a/net-guardia/src/utils/logging.rs b/net-guardia/src/utils/logging.rs index c3829ba..cc6b2f5 100644 --- a/net-guardia/src/utils/logging.rs +++ b/net-guardia/src/utils/logging.rs @@ -1,13 +1,17 @@ +use std::env; use std::fs; use std::sync::OnceLock; use tracing::Level; use tracing_appender::rolling::{RollingFileAppender, Rotation}; +use tracing_subscriber::filter::Directive; use tracing_subscriber::filter::EnvFilter; +use tracing_subscriber::fmt::layer as fmt_layer; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::reload; use tracing_subscriber::util::SubscriberInitExt; +use crate::core::observability::log_buffer::LogBufferLayer; use crate::model::error::Error; use crate::model::error::io::IOError; @@ -15,6 +19,12 @@ use crate::model::error::io::IOError; /// We erase the complex layered type by boxing the modify closure. static FILTER_HANDLE: OnceLock> = OnceLock::new(); +/// Snapshot of the per-target directives (e.g. `maxminddb=warn`) in effect +/// at `initialize()` time. `set_level` rebuilds the filter from scratch +/// around a new root level; reapplying these keeps any RUST_LOG overrides +/// the operator configured for specific crates from being silently lost. +static PRESERVED_DIRECTIVES: OnceLock> = OnceLock::new(); + /// Trait to erase the complex generic type of reload::Handle. trait FilterControl: Send + Sync { fn reload_filter(&self, filter: EnvFilter) -> Result<(), String>; @@ -41,14 +51,14 @@ impl Logging { let file_appender = RollingFileAppender::new(Rotation::DAILY, log_directory, "NetGuardia"); - let stdout_layer = tracing_subscriber::fmt::layer() + let stdout_layer = fmt_layer() .with_file(true) .with_line_number(true) .with_thread_ids(true) .with_target(false) .with_ansi(true); - let file_layer = tracing_subscriber::fmt::layer() + let file_layer = fmt_layer() .with_file(false) .with_line_number(false) .with_thread_ids(false) @@ -56,7 +66,7 @@ impl Logging { .with_ansi(false) .with_writer(file_appender); - let level = std::env::var("RUST_LOG") + let level = env::var("RUST_LOG") .ok() .and_then(|s| s.parse::().ok()) .unwrap_or(if cfg!(debug_assertions) { @@ -65,10 +75,31 @@ impl Logging { Level::INFO }); - let filter = EnvFilter::from_default_env() - .add_directive(level.into()) - // SAFETY: "maxminddb=warn" is a valid tracing directive literal - .add_directive("maxminddb=warn".parse().unwrap_or_else(|_| unreachable!())); + // Collect per-target directives from RUST_LOG plus our hardcoded + // `maxminddb=warn` so `set_level` can reapply them on each rebuild + // instead of losing them to `EnvFilter::new(level)`. + let mut preserved: Vec = env::var("RUST_LOG") + .ok() + .map(|raw| { + raw.split(',') + .map(|s| s.trim().to_string()) + .filter(|d| !d.is_empty() && d.contains('=')) + .collect() + }) + .unwrap_or_default(); + if !preserved.iter().any(|d| d == "maxminddb=warn") { + preserved.push("maxminddb=warn".to_string()); + } + let _ = PRESERVED_DIRECTIVES.set(preserved); + + let mut filter = EnvFilter::from_default_env().add_directive(level.into()); + if let Some(directives) = PRESERVED_DIRECTIVES.get() { + for d in directives { + if let Ok(parsed) = d.parse::() { + filter = filter.add_directive(parsed); + } + } + } let (filter_layer, reload_handle) = reload::Layer::new(filter); @@ -76,6 +107,7 @@ impl Logging { .with(filter_layer) .with(stdout_layer) .with(file_layer) + .with(LogBufferLayer::new()) .init(); // Store type-erased handle for runtime log level changes @@ -95,19 +127,58 @@ impl Logging { ) })?; - let new_filter = EnvFilter::new(parsed_level.to_string()) - .add_directive("maxminddb=warn".parse().unwrap_or_else(|_| unreachable!())); + let mut new_filter = EnvFilter::new(parsed_level.to_string()); + if let Some(directives) = PRESERVED_DIRECTIVES.get() { + for d in directives { + if let Ok(parsed) = d.parse::() { + new_filter = new_filter.add_directive(parsed); + } + } + } handle.reload_filter(new_filter)?; - Ok(parsed_level.to_string()) + Ok(parsed_level.to_string().to_lowercase()) } - /// Get the current log level filter string. + /// Get the current global log level as a bare lowercase directive — + /// e.g. `"info"`, not the full `"maxminddb=warn,info"` EnvFilter string. + /// Per-target overrides (like `maxminddb=warn`) are internal tuning and + /// would break the frontend `