From 3012959163a0d66bce267194c6fb7491f16a19cc Mon Sep 17 00:00:00 2001 From: DaLaw2 Date: Sun, 10 May 2026 01:17:18 +0800 Subject: [PATCH] refactor: simplify trainer code --- Cargo.lock | 536 ++++-------- Cargo.toml | 3 +- deploy/compose/podman-compose.yml | 3 + macros/src/fallible.rs | 187 ++++ macros/src/lib.rs | 6 + models/inference_config.json | 2 +- models/manifest.yaml | 162 ++-- net-guardia-frontend | 2 +- net-guardia-trainer | 2 +- net-guardia/Cargo.toml | 3 +- net-guardia/build.rs | 2 +- net-guardia/src/adapter/ebpf/drop_monitor.rs | 36 +- net-guardia/src/adapter/ebpf/geo_block.rs | 57 +- net-guardia/src/adapter/ebpf/mod.rs | 6 +- .../src/adapter/ebpf/protocol_filter.rs | 7 +- net-guardia/src/adapter/http/audit.rs | 16 +- .../src/adapter/http/data_plane/filter.rs | 33 +- net-guardia/src/adapter/http/detection/ml.rs | 97 ++- .../adapter/http/detection/model_upload.rs | 236 ++++-- .../src/adapter/http/identity/api_keys.rs | 4 +- net-guardia/src/adapter/http/identity/auth.rs | 222 ++--- net-guardia/src/adapter/http/logs.rs | 13 +- .../src/adapter/http/middleware/auth.rs | 34 +- .../src/adapter/http/response/report.rs | 28 +- net-guardia/src/adapter/http/response/soar.rs | 193 +---- .../src/adapter/identity/api_key_hasher.rs | 59 ++ net-guardia/src/adapter/identity/mod.rs | 1 + .../src/adapter/model_change_source.rs | 2 +- .../model_loading/artifact_resolver.rs | 2 +- .../adapter/model_loading/config_loader.rs | 570 ++++++------- .../src/adapter/model_promotion_store.rs | 7 +- net-guardia/src/adapter/persistence/acl.rs | 4 + .../src/adapter/persistence/api_key.rs | 62 +- net-guardia/src/adapter/persistence/config.rs | 46 +- .../src/adapter/persistence/enforcement.rs | 24 +- net-guardia/src/adapter/persistence/mod.rs | 9 +- net-guardia/src/adapter/persistence/soar.rs | 119 +-- .../src/adapter/persistence/soar_block.rs | 26 - net-guardia/src/adapter/suricata_monitor.rs | 18 +- net-guardia/src/adapter/telegram.rs | 10 +- .../src/adapter/websocket/health_websocket.rs | 10 +- net-guardia/src/adapter/websocket/routes.rs | 10 +- .../src/adapter/websocket/ws_bridge.rs | 2 +- net-guardia/src/common/error/mod.rs | 3 +- net-guardia/src/common/error/suricata.rs | 19 + net-guardia/src/common/error/system.rs | 4 - net-guardia/src/common/log/audit.rs | 4 +- net-guardia/src/common/log/mod.rs | 1 + net-guardia/src/common/log/suricata.rs | 48 ++ net-guardia/src/common/utils/log_level.rs | 9 + net-guardia/src/common/utils/mod.rs | 1 + net-guardia/src/core/correlation/botnet.rs | 3 +- net-guardia/src/core/correlation/engine.rs | 41 +- net-guardia/src/core/correlation/lateral.rs | 3 +- net-guardia/src/core/correlation/scan.rs | 3 +- .../src/core/data_plane/acl_service.rs | 73 +- .../src/core/data_plane/dns_filter_service.rs | 35 +- .../src/core/data_plane/rate_limit_service.rs | 43 +- net-guardia/src/core/detection/beaconing.rs | 15 +- net-guardia/src/core/detection/mod.rs | 21 + .../src/core/detection/orchestrator.rs | 28 +- net-guardia/src/core/identity/auth_service.rs | 656 +-------------- .../src/core/identity/group_service.rs | 305 +++++++ net-guardia/src/core/identity/mod.rs | 2 + .../src/core/identity/session_service.rs | 13 +- net-guardia/src/core/identity/user_service.rs | 403 +++++++++ net-guardia/src/core/inference/engine.rs | 5 +- .../src/core/inference/flow_tracker.rs | 50 +- .../src/core/inference/inference_runtime.rs | 15 +- .../src/core/inference/model_loader.rs | 94 ++- .../src/core/inference/model_promotion.rs | 178 +--- .../src/core/inference/model_watcher.rs | 6 +- net-guardia/src/core/inference/runner.rs | 170 ++-- net-guardia/src/core/response/actions.rs | 2 +- .../src/core/response/engine/execution.rs | 1 + net-guardia/src/core/response/engine/tests.rs | 2 +- net-guardia/src/core/response/frequency.rs | 16 +- net-guardia/src/domain/common/audit.rs | 4 +- .../src/domain/common/config/constants.rs | 10 + net-guardia/src/domain/common/event.rs | 10 - net-guardia/src/domain/common/notification.rs | 2 +- net-guardia/src/domain/common/system/mod.rs | 1 - .../common/system/rate_limit_settings.rs | 2 +- .../src/domain/detection/attack_type.rs | 11 +- net-guardia/src/domain/detection/error.rs | 18 - .../src/domain/detection/feature_extractor.rs | 2 +- .../src/domain/detection/flow_features.rs | 32 +- .../src/domain/detection/flow_observation.rs | 9 + .../src/domain/detection/flow_tracker.rs | 12 +- net-guardia/src/domain/detection/log.rs | 49 +- net-guardia/src/domain/detection/manifest.rs | 795 ++++++++++-------- net-guardia/src/domain/detection/mod.rs | 3 + .../detection/model_files.rs | 0 .../src/domain/detection/model_source.rs | 30 +- .../suricata_health.rs} | 0 net-guardia/src/domain/identity/error.rs | 96 ++- net-guardia/src/domain/response/error.rs | 4 + net-guardia/src/domain/response/log.rs | 3 + net-guardia/src/domain/response/mod.rs | 1 + .../src/domain/response/playbook_validator.rs | 377 +++++++++ .../src/infrastructure/audit_logger.rs | 4 +- .../src/infrastructure/flow_trace_logger.rs | 21 +- net-guardia/src/infrastructure/health.rs | 26 +- .../src/infrastructure/http_server/mod.rs | 87 +- net-guardia/src/infrastructure/log_buffer.rs | 60 +- net-guardia/src/infrastructure/logger.rs | 6 +- .../src/infrastructure/startup/data_plane.rs | 111 +-- .../src/infrastructure/startup/detection.rs | 23 +- .../src/infrastructure/startup/foundation.rs | 14 + .../src/infrastructure/startup/identity.rs | 22 +- net-guardia/src/infrastructure/startup/mod.rs | 21 +- .../src/infrastructure/startup/response.rs | 14 +- .../src/infrastructure/suricata_manager.rs | 6 +- net-guardia/src/infrastructure/system/mod.rs | 17 +- .../infrastructure/system/runtime_start.rs | 9 +- .../src/infrastructure/system/setup.rs | 18 +- net-guardia/src/interface/app_repo.rs | 50 -- net-guardia/src/interface/data_plane/acl.rs | 2 + .../src/interface/data_plane/enforcement.rs | 11 +- .../interface/data_plane/protocol_filter.rs | 4 +- .../interface/detection/flow_trace_sink.rs | 2 +- net-guardia/src/interface/detection/mod.rs | 1 - .../detection/model_promotion_store.rs | 2 +- net-guardia/src/interface/identity/api_key.rs | 3 +- .../src/interface/identity/api_key_hasher.rs | 3 + net-guardia/src/interface/identity/mod.rs | 1 + net-guardia/src/interface/mod.rs | 1 - net-guardia/src/interface/response/soar.rs | 56 +- net-guardia/src/interface/system/db_admin.rs | 21 - .../src/interface/system/health_query.rs | 6 +- net-guardia/src/interface/system/live_logs.rs | 10 - net-guardia/src/interface/system/mod.rs | 1 - 132 files changed, 4004 insertions(+), 3243 deletions(-) create mode 100644 macros/src/fallible.rs create mode 100644 net-guardia/src/adapter/identity/api_key_hasher.rs create mode 100644 net-guardia/src/common/error/suricata.rs create mode 100644 net-guardia/src/common/log/suricata.rs create mode 100644 net-guardia/src/common/utils/log_level.rs create mode 100644 net-guardia/src/core/identity/group_service.rs create mode 100644 net-guardia/src/core/identity/user_service.rs create mode 100644 net-guardia/src/domain/detection/flow_observation.rs rename net-guardia/src/{interface => domain}/detection/model_files.rs (100%) rename net-guardia/src/domain/{common/system/suricata.rs => detection/suricata_health.rs} (100%) create mode 100644 net-guardia/src/domain/response/playbook_validator.rs delete mode 100644 net-guardia/src/interface/app_repo.rs create mode 100644 net-guardia/src/interface/identity/api_key_hasher.rs delete mode 100644 net-guardia/src/interface/system/db_admin.rs diff --git a/Cargo.lock b/Cargo.lock index 4b73b17..9efdb33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -336,10 +336,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", - "cipher", + "cipher 0.4.4", "cpufeatures 0.2.17", ] +[[package]] +name = "aes" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66bd29a732b644c0431c6140f370d097879203d79b80c94a6747ba0872adaef8" +dependencies = [ + "cipher 0.5.1", + "cpubits", + "cpufeatures 0.3.0", +] + [[package]] name = "aes-gcm" version = "0.10.3" @@ -347,8 +358,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" dependencies = [ "aead", - "aes", - "cipher", + "aes 0.8.4", + "cipher 0.4.4", "ctr", "ghash", "subtle", @@ -684,12 +695,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "base16ct" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" - [[package]] name = "base64" version = "0.22.1" @@ -768,6 +773,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" dependencies = [ "hybrid-array", + "zeroize", ] [[package]] @@ -818,6 +824,15 @@ dependencies = [ "bytes", ] +[[package]] +name = "bzip2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" +dependencies = [ + "libbz2-rs-sys", +] + [[package]] name = "camino" version = "1.2.2" @@ -913,7 +928,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common 0.1.6", - "inout", + "inout 0.1.4", +] + +[[package]] +name = "cipher" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34d8227fe1ba289043aeb13792056ff80fd6de1a9f49137a5f499de8e8c78ea" +dependencies = [ + "crypto-common 0.2.1", + "inout 0.2.2", ] [[package]] @@ -998,18 +1023,18 @@ dependencies = [ "memchr", ] -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - [[package]] name = "const-oid" version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "convert_case" version = "0.4.0" @@ -1061,6 +1086,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1150,18 +1181,6 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" -[[package]] -name = "crypto-bigint" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" -dependencies = [ - "generic-array", - "rand_core 0.6.4", - "subtle", - "zeroize", -] - [[package]] name = "crypto-common" version = "0.1.6" @@ -1188,7 +1207,7 @@ version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" dependencies = [ - "cipher", + "cipher 0.4.4", ] [[package]] @@ -1200,33 +1219,6 @@ dependencies = [ "cmov", ] -[[package]] -name = "curve25519-dalek" -version = "4.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "curve25519-dalek-derive", - "digest 0.10.7", - "fiat-crypto", - "rustc_version", - "subtle", - "zeroize", -] - -[[package]] -name = "curve25519-dalek-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "darling" version = "0.20.11" @@ -1277,15 +1269,10 @@ dependencies = [ ] [[package]] -name = "der" -version = "0.7.10" +name = "deflate64" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid 0.9.6", - "pem-rfc7468", - "zeroize", -] +checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" [[package]] name = "deranged" @@ -1350,7 +1337,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", - "const-oid 0.9.6", "crypto-common 0.1.6", "subtle", ] @@ -1362,9 +1348,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.0", - "const-oid 0.10.2", + "const-oid", "crypto-common 0.2.1", "ctutils", + "zeroize", ] [[package]] @@ -1418,44 +1405,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15401da73a9ed8c80e3b2d4dc05fe10e7b72d7243b9f614e516a44fa99986e88" -[[package]] -name = "ecdsa" -version = "0.16.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" -dependencies = [ - "der", - "digest 0.10.7", - "elliptic-curve", - "rfc6979", - "signature", - "spki", -] - -[[package]] -name = "ed25519" -version = "2.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" -dependencies = [ - "pkcs8", - "signature", -] - -[[package]] -name = "ed25519-dalek" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" -dependencies = [ - "curve25519-dalek", - "ed25519", - "serde", - "sha2 0.10.9", - "subtle", - "zeroize", -] - [[package]] name = "egress-ebpf" version = "1.0.0" @@ -1472,27 +1421,6 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" -[[package]] -name = "elliptic-curve" -version = "0.13.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" -dependencies = [ - "base16ct", - "crypto-bigint", - "digest 0.10.7", - "ff", - "generic-array", - "group", - "hkdf 0.12.4", - "pem-rfc7468", - "pkcs8", - "rand_core 0.6.4", - "sec1", - "subtle", - "zeroize", -] - [[package]] name = "email-encoding" version = "0.4.1" @@ -1552,22 +1480,6 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" -[[package]] -name = "ff" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" -dependencies = [ - "rand_core 0.6.4", - "subtle", -] - -[[package]] -name = "fiat-crypto" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" - [[package]] name = "filetime" version = "0.2.27" @@ -1593,6 +1505,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -1704,7 +1617,6 @@ checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" dependencies = [ "typenum", "version_check", - "zeroize", ] [[package]] @@ -1741,11 +1653,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] @@ -1764,17 +1678,6 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" -[[package]] -name = "group" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" -dependencies = [ - "ff", - "rand_core 0.6.4", - "subtle", -] - [[package]] name = "h2" version = "0.3.27" @@ -1861,31 +1764,13 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hkdf" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" -dependencies = [ - "hmac 0.12.1", -] - [[package]] name = "hkdf" version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" dependencies = [ - "hmac 0.13.0", -] - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest 0.10.7", + "hmac", ] [[package]] @@ -2234,6 +2119,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -2365,29 +2259,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "jsonwebtoken" -version = "10.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" -dependencies = [ - "base64", - "ed25519-dalek", - "getrandom 0.2.17", - "hmac 0.12.1", - "js-sys", - "p256", - "p384", - "pem", - "rand 0.8.6", - "rsa", - "serde", - "serde_json", - "sha2 0.10.9", - "signature", - "simple_asn1", -] - [[package]] name = "kqueue" version = "1.1.1" @@ -2429,9 +2300,6 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -dependencies = [ - "spin", -] [[package]] name = "leb128fmt" @@ -2478,6 +2346,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libbz2-rs-sys" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a6a8c165077efc8f3a971534c50ea6a1a18b329ef4a66e897a7e3a1494565f" + [[package]] name = "libc" version = "0.2.186" @@ -2647,6 +2521,15 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lzma-rust2" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47bb1e988e6fb779cf720ad431242d3f03167c1b3f2b1aae7f1a94b2495b36ae" +dependencies = [ + "sha2 0.10.9", +] + [[package]] name = "macros" version = "1.0.0" @@ -2810,10 +2693,9 @@ dependencies = [ "dashmap", "dotenvy", "futures-util", - "hkdf 0.13.0", - "hmac 0.13.0", + "hkdf", + "hmac", "ipnetwork", - "jsonwebtoken", "lettre", "libc", "libxdp-sys", @@ -2837,6 +2719,7 @@ dependencies = [ "sysinfo", "thiserror 2.0.18", "tokio", + "tokio-util", "tracing", "tracing-appender", "tracing-subscriber", @@ -2845,6 +2728,7 @@ dependencies = [ "uuid", "which", "xsk-rs", + "zip", ] [[package]] @@ -2963,32 +2847,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "num-bigint" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-bigint-dig" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" -dependencies = [ - "lazy_static", - "libm", - "num-integer", - "num-iter", - "num-traits", - "rand 0.8.6", - "smallvec", - "zeroize", -] - [[package]] name = "num-complex" version = "0.4.6" @@ -3024,17 +2882,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-iter" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" -dependencies = [ - "autocfg", - "num-integer", - "num-traits", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -3159,30 +3006,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" -[[package]] -name = "p256" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" -dependencies = [ - "ecdsa", - "elliptic-curve", - "primeorder", - "sha2 0.10.9", -] - -[[package]] -name = "p384" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" -dependencies = [ - "ecdsa", - "elliptic-curve", - "primeorder", - "sha2 0.10.9", -] - [[package]] name = "parking_lot" version = "0.12.5" @@ -3230,22 +3053,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" [[package]] -name = "pem" -version = "3.0.6" +name = "pbkdf2" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" dependencies = [ - "base64", - "serde_core", -] - -[[package]] -name = "pem-rfc7468" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" -dependencies = [ - "base64ct", + "digest 0.11.3", + "hmac", ] [[package]] @@ -3303,27 +3117,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pkcs1" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" -dependencies = [ - "der", - "pkcs8", - "spki", -] - -[[package]] -name = "pkcs8" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" -dependencies = [ - "der", - "spki", -] - [[package]] name = "pkg-config" version = "0.3.33" @@ -3378,6 +3171,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppmd-rust" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -3406,15 +3205,6 @@ dependencies = [ "num-integer", ] -[[package]] -name = "primeorder" -version = "0.13.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" -dependencies = [ - "elliptic-curve", -] - [[package]] name = "proc-macro2" version = "1.0.106" @@ -3724,16 +3514,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "rfc6979" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" -dependencies = [ - "hmac 0.12.1", - "subtle", -] - [[package]] name = "ring" version = "0.17.14" @@ -3748,26 +3528,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rsa" -version = "0.9.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" -dependencies = [ - "const-oid 0.9.6", - "digest 0.10.7", - "num-bigint-dig", - "num-integer", - "num-traits", - "pkcs1", - "pkcs8", - "rand_core 0.6.4", - "signature", - "spki", - "subtle", - "zeroize", -] - [[package]] name = "rsqlite-vfs" version = "0.1.0" @@ -4010,20 +3770,6 @@ dependencies = [ "libc", ] -[[package]] -name = "sec1" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" -dependencies = [ - "base16ct", - "der", - "generic-array", - "pkcs8", - "subtle", - "zeroize", -] - [[package]] name = "security-framework" version = "3.7.0" @@ -4192,16 +3938,6 @@ dependencies = [ "libc", ] -[[package]] -name = "signature" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" -dependencies = [ - "digest 0.10.7", - "rand_core 0.6.4", -] - [[package]] name = "simd-adler32" version = "0.3.9" @@ -4224,18 +3960,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" -[[package]] -name = "simple_asn1" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" -dependencies = [ - "num-bigint", - "num-traits", - "thiserror 2.0.18", - "time", -] - [[package]] name = "slab" version = "0.4.12" @@ -4268,22 +3992,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "spin" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" - -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der", -] - [[package]] name = "sqlite-wasm-rs" version = "0.5.3" @@ -4487,6 +4195,7 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", + "js-sys", "num-conv", "powerfmt", "serde_core", @@ -4866,6 +4575,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "typenum" version = "1.20.0" @@ -5694,12 +5409,57 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "aes 0.9.0", + "bzip2", + "constant_time_eq", + "crc32fast", + "deflate64", + "flate2", + "getrandom 0.4.2", + "hmac", + "indexmap", + "lzma-rust2", + "memchr", + "pbkdf2", + "ppmd-rust", + "sha1", + "time", + "typed-path", + "zeroize", + "zopfli", + "zstd", +] + +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zstd" version = "0.13.3" diff --git a/Cargo.toml b/Cargo.toml index 1fb9faa..3f4fbb0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,7 @@ serde_yaml_ng = "0.10.0" # Async runtime tokio = { version = "1.50.0", features = ["rt-multi-thread", "macros", "sync", "time", "process", "io-util", "io-std", "fs", "signal"] } +tokio-util = { version = "0.7", features = ["io"] } # Web framework actix = "0.13.5" @@ -37,6 +38,7 @@ actix-ws = "0.4.0" actix-multipart = "0.7" actix-files = "0.6" tokio-tungstenite = "0.29.0" +zip = "8.6.0" # Logging / tracing tracing = "0.1.44" @@ -60,7 +62,6 @@ ipnetwork = "0.21.1" lru = "0.18.0" rusqlite = { version = "0.39", features = ["bundled-sqlcipher"] } async-sqlite = { version = "0.5.7", default-features = false, features = ["bundled-sqlcipher"] } -jsonwebtoken = { version = "10.3.0", features = ["rust_crypto"] } argon2 = "0.5" rand = "0.10.1" ed25519-dalek = { version = "2", features = ["std", "rand_core"] } diff --git a/deploy/compose/podman-compose.yml b/deploy/compose/podman-compose.yml index 27cb924..f5072da 100644 --- a/deploy/compose/podman-compose.yml +++ b/deploy/compose/podman-compose.yml @@ -15,6 +15,9 @@ services: memlock: soft: -1 hard: -1 + environment: + NETGUARDIA_DB_KEY: netguardia-dev-db-key + NETGUARDIA_SECRETS_KEY: netguardia-dev-secrets-key dns: - 10.10.3.1 - 8.8.8.8 diff --git a/macros/src/fallible.rs b/macros/src/fallible.rs new file mode 100644 index 0000000..4136107 --- /dev/null +++ b/macros/src/fallible.rs @@ -0,0 +1,187 @@ +use proc_macro::TokenStream; +use quote::quote; +use syn::parse::{Parse, ParseStream}; +use syn::spanned::Spanned; +use syn::{Attribute, Error, Ident, LitStr, Result, Token, Type}; + +struct FallibleVariant { + attributes: Vec, + error_msg: LitStr, + name: Ident, + fields: Vec<(Ident, Type)>, +} + +impl FallibleVariant { + fn has_no_source(&self) -> bool { + self.attributes.iter().any(|attr| attr.path().is_ident("no_source")) + } + + fn should_generate_constructor(&self) -> bool { + if self.has_no_source() { + !self.fields.is_empty() + } else { + true + } + } +} + +struct FallibleInput { + enum_name: Ident, + variants: Vec, +} + +impl Parse for FallibleInput { + fn parse(input: ParseStream) -> Result { + let enum_name = input.parse::()?; + + let content; + syn::braced!(content in input); + + let mut variants = Vec::new(); + + while !content.is_empty() { + let mut attributes = Vec::new(); + + while content.peek(Token![#]) { + attributes.push(content.call(Attribute::parse_outer)?); + } + + let attributes: Vec<_> = attributes.into_iter().flatten().collect(); + + let error_attr = attributes + .iter() + .find(|attr| attr.path().is_ident("error")) + .ok_or_else(|| Error::new(content.span(), "Missing #[error] attribute"))?; + + let error_msg = match &error_attr.meta { + syn::Meta::List(list) => syn::parse2::(list.tokens.clone())?, + _ => { + return Err(Error::new(error_attr.span(), "Invalid error attribute format")); + } + }; + + let name = content.parse::()?; + + let mut fields = Vec::new(); + if content.peek(syn::token::Brace) { + let fields_content; + syn::braced!(fields_content in content); + + while !fields_content.is_empty() { + let field_name = fields_content.parse::()?; + fields_content.parse::()?; + let field_type = fields_content.parse::()?; + fields.push((field_name, field_type)); + + if !fields_content.is_empty() { + fields_content.parse::()?; + } + } + } + + if !content.is_empty() { + content.parse::()?; + } + + variants.push(FallibleVariant { + attributes, + error_msg, + name, + fields, + }); + } + + Ok(FallibleInput { enum_name, variants }) + } +} + +pub fn fallible_impl(input: TokenStream) -> TokenStream { + let input = syn::parse_macro_input!(input as FallibleInput); + + let enum_name = &input.enum_name; + let variants = &input.variants; + + let enum_variants = variants.iter().map(|variant| { + let name = &variant.name; + let error_msg = &variant.error_msg; + let fields = &variant.fields; + + let field_definitions = fields.iter().map(|(name, ty)| { + quote! { #name: #ty } + }); + + if variant.has_no_source() { + if variant.fields.is_empty() { + quote! { + #[error(#error_msg)] + #name + } + } else { + quote! { + #[error(#error_msg)] + #name { #(#field_definitions,)* } + } + } + } else { + quote! { + #[error(#error_msg)] + #name { + #(#field_definitions,)* + err: String + } + } + } + }); + + let constructors = variants.iter().filter_map(|variant| { + if !variant.should_generate_constructor() { + return None; + } + + let name = &variant.name; + let fields = &variant.fields; + + let params = fields.iter().map(|(field_name, field_type)| { + quote! { #field_name: impl Into<#field_type> } + }); + + let field_assignments = fields.iter().map(|(field_name, _)| { + quote! { #field_name: #field_name.into() } + }); + + if variant.has_no_source() { + Some(quote! { + #[allow(non_snake_case)] + pub fn #name(#(#params),*) -> Self { + Self::#name { + #(#field_assignments,)* + } + } + }) + } else { + Some(quote! { + #[allow(non_snake_case)] + pub fn #name(#(#params,)* source: impl std::fmt::Display) -> Self { + Self::#name { + #(#field_assignments,)* + err: source.to_string() + } + } + }) + } + }); + + let expanded = quote! { + #[allow(dead_code, clippy::enum_variant_names)] + #[derive(Debug, Clone, thiserror::Error)] + pub enum #enum_name { + #(#enum_variants,)* + } + + impl #enum_name { + #(#constructors)* + } + }; + + TokenStream::from(expanded) +} diff --git a/macros/src/lib.rs b/macros/src/lib.rs index 3649b78..c438aec 100644 --- a/macros/src/lib.rs +++ b/macros/src/lib.rs @@ -1,5 +1,6 @@ mod config; mod error_enum; +mod fallible; mod log; mod loggable; mod traceable; @@ -11,6 +12,11 @@ pub fn config_settings(attr: TokenStream, item: TokenStream) -> TokenStream { config::config_settings_impl(attr, item) } +#[proc_macro] +pub fn fallible(input: TokenStream) -> TokenStream { + fallible::fallible_impl(input) +} + #[proc_macro] pub fn log(input: TokenStream) -> TokenStream { log::log_impl(input) diff --git a/models/inference_config.json b/models/inference_config.json index 9204c8e..198c9a5 100644 --- a/models/inference_config.json +++ b/models/inference_config.json @@ -275,7 +275,7 @@ }, "anomaly_threshold": 0.9179317355155945, "c2_threshold": 0.9085615873336792, - "model_type": "MultiTaskModel", + "model_type": "multi_task", "output_names": [ "anomaly", "class_probs", diff --git a/models/manifest.yaml b/models/manifest.yaml index 0ff4a93..ba64c05 100644 --- a/models/manifest.yaml +++ b/models/manifest.yaml @@ -1,54 +1,106 @@ -# 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 +name: netguardia-v2 +version: 2 models: - autoencoder: deep_autoencoder.onnx - classifier: classifier.onnx + - id: anomaly_detector + file: deep_autoencoder.onnx + input_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 + preprocessing: + - type: standard_scaler + sidecar: inference_config.json + - type: clip + min: -5.0 + max: 5.0 + outputs: + - name: ae_anomaly_score + shape: [1] + semantic: anomaly_score + threshold: 0.23011694848537445 -# 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 + - id: classifier + file: classifier.onnx + input_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 + - ae_anomaly_score + outputs: + - name: anomaly + shape: [1] + semantic: binary + threshold: 0.9179317355155945 + - name: class_probs + shape: [10] + semantic: multiclass + min_confidence: 0.4 + - name: c2_score + shape: [1] + semantic: binary + threshold: 0.9085615873336792 + +pipeline: + - anomaly_detector + - classifier -# `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 } @@ -61,14 +113,8 @@ labels: "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 +alert_rules: + - condition: "anomaly > threshold" + source_label: anomaly + - condition: "class_probs.argmax != Normal AND class_probs.max > min_confidence" + source_label: class_probs diff --git a/net-guardia-frontend b/net-guardia-frontend index 9d32885..bb44252 160000 --- a/net-guardia-frontend +++ b/net-guardia-frontend @@ -1 +1 @@ -Subproject commit 9d32885d4cf3781b861e8f3e582dd46fe3bb187c +Subproject commit bb44252915e0a8e0ae56508b2695c8dbf748c404 diff --git a/net-guardia-trainer b/net-guardia-trainer index 283a44b..2fc51b6 160000 --- a/net-guardia-trainer +++ b/net-guardia-trainer @@ -1 +1 @@ -Subproject commit 283a44ba4357b9a509400f62a59361da9025b8d9 +Subproject commit 2fc51b61fbbe463d88bc72ee0f792452a0ef2c3f diff --git a/net-guardia/Cargo.toml b/net-guardia/Cargo.toml index 632ca2a..3358e10 100644 --- a/net-guardia/Cargo.toml +++ b/net-guardia/Cargo.toml @@ -25,6 +25,7 @@ uuid = { workspace = true } rust-embed = { workspace = true } mime_guess = { workspace = true } url = { workspace = true } +zip = { workspace = true } # Serialization serde = { workspace = true } @@ -33,6 +34,7 @@ serde_yaml_ng = { workspace = true } # Async tokio = { workspace = true } +tokio-util = { workspace = true } futures-util = { workspace = true } crossbeam = { workspace = true } @@ -70,7 +72,6 @@ ipnetwork = { workspace = true } lru = { workspace = true } rusqlite = { workspace = true } async-sqlite = { workspace = true } -jsonwebtoken = { workspace = true } argon2 = { workspace = true } sha2 = { workspace = true } hmac = { workspace = true } diff --git a/net-guardia/build.rs b/net-guardia/build.rs index 19dcd88..fba5093 100644 --- a/net-guardia/build.rs +++ b/net-guardia/build.rs @@ -62,7 +62,7 @@ fn build_ebpf_package(package_name: &str, target_subdir: &str) { let ebpf_dir = manifest_path.parent().unwrap(); println!("cargo:rerun-if-changed={}", ebpf_dir.as_str()); - println!("cargo:rerun-if-changed=../common/src"); + println!("cargo:rerun-if-changed=../netguardia-abi/src"); let mut cmd = Command::new("cargo"); cmd.args([ diff --git a/net-guardia/src/adapter/ebpf/drop_monitor.rs b/net-guardia/src/adapter/ebpf/drop_monitor.rs index 7177b9f..34813b4 100644 --- a/net-guardia/src/adapter/ebpf/drop_monitor.rs +++ b/net-guardia/src/adapter/ebpf/drop_monitor.rs @@ -27,21 +27,36 @@ pub struct DropCountersAtomic { protocol_filter: AtomicU64, dns_blacklist: AtomicU64, geo_block: AtomicU64, - total: AtomicU64, } impl DropCountersAtomic { pub fn snapshot(&self) -> DropCounters { + let acl_blacklist = self.acl_blacklist.load(Ordering::Relaxed); + let rate_limit_pkt = self.rate_limit_pkt.load(Ordering::Relaxed); + let rate_limit_syn = self.rate_limit_syn.load(Ordering::Relaxed); + let rate_limit_udp = self.rate_limit_udp.load(Ordering::Relaxed); + let rate_limit_dns = self.rate_limit_dns.load(Ordering::Relaxed); + let protocol_filter = self.protocol_filter.load(Ordering::Relaxed); + let dns_blacklist = self.dns_blacklist.load(Ordering::Relaxed); + let geo_block = self.geo_block.load(Ordering::Relaxed); + let total = acl_blacklist + + rate_limit_pkt + + rate_limit_syn + + rate_limit_udp + + rate_limit_dns + + protocol_filter + + dns_blacklist + + geo_block; 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), + acl_blacklist, + rate_limit_pkt, + rate_limit_syn, + rate_limit_udp, + rate_limit_dns, + protocol_filter, + dns_blacklist, + geo_block, + total, } } } @@ -79,7 +94,6 @@ impl DropMonitor { } pub fn record_drop_count(&self, reason: u8) { - self.counters.total.fetch_add(1, Ordering::Relaxed); if let Some(counter) = self.bucket_for(reason) { counter.fetch_add(1, Ordering::Relaxed); } diff --git a/net-guardia/src/adapter/ebpf/geo_block.rs b/net-guardia/src/adapter/ebpf/geo_block.rs index 5017594..911baf6 100644 --- a/net-guardia/src/adapter/ebpf/geo_block.rs +++ b/net-guardia/src/adapter/ebpf/geo_block.rs @@ -171,20 +171,20 @@ impl GeoBlock { } } - let mut v4_entries: Vec<(Key, u8)> = Vec::new(); - let mut v6_entries: Vec<(Key, u8)> = Vec::new(); + let mut v4_entries: HashSet<(u32, u32)> = HashSet::new(); + let mut v6_entries: HashSet<(u128, u32)> = HashSet::new(); if !countries.is_empty() { let index = self.index()?; for code in countries { if let Some(prefixes) = index.v4.get(code) { - for &(ip_be, prefix_len) in prefixes { - v4_entries.push((Key::new(prefix_len, ip_be), 1u8)); + for &entry in prefixes { + v4_entries.insert(entry); } } if let Some(prefixes) = index.v6.get(code) { - for &(ip_be, prefix_len) in prefixes { - v6_entries.push((Key::new(prefix_len, ip_be), 1u8)); + for &entry in prefixes { + v6_entries.insert(entry); } } } @@ -196,44 +196,49 @@ impl GeoBlock { (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 { - if v4_trie.insert(key, *val, 0).is_ok() { + for &(ip_be, prefix_len) in &v4_entries { + let key = Key::new(prefix_len, ip_be); + if v4_trie.insert(&key, 1u8, 0).is_ok() { count += 1; } } - for (key, val) in &v6_entries { - if v6_trie.insert(key, *val, 0).is_ok() { + for &(ip_be, prefix_len) in &v6_entries { + let key = Key::new(prefix_len, ip_be); + if v6_trie.insert(&key, 1u8, 0).is_ok() { count += 1; } } + Self::remove_stale_v4(v4_trie, &v4_entries); + Self::remove_stale_v6(v6_trie, &v6_entries); + Ok(count) } - fn clear_trie_v4(trie: &mut LpmTrie) -> Result<(), Error> { - let keys: Vec> = trie + fn remove_stale_v4(trie: &mut LpmTrie, desired: &HashSet<(u32, u32)>) { + let stale: Vec> = trie .iter() - .map(|entry| entry.map(|(key, _)| key).map_err(EbpfError::MapOperationError)) - .collect::, _>>()?; - for key in keys { - trie.remove(&key).map_err(EbpfError::MapOperationError)?; + .filter_map(|entry| entry.ok()) + .map(|(key, _)| key) + .filter(|key| !desired.contains(&(key.data(), key.prefix_len()))) + .collect(); + for key in stale { + let _ = trie.remove(&key); } - Ok(()) } - fn clear_trie_v6(trie: &mut LpmTrie) -> Result<(), Error> { - let keys: Vec> = trie + fn remove_stale_v6(trie: &mut LpmTrie, desired: &HashSet<(u128, u32)>) { + let stale: Vec> = trie .iter() - .map(|entry| entry.map(|(key, _)| key).map_err(EbpfError::MapOperationError)) - .collect::, _>>()?; - for key in keys { - trie.remove(&key).map_err(EbpfError::MapOperationError)?; + .filter_map(|entry| entry.ok()) + .map(|(key, _)| key) + .filter(|key| !desired.contains(&(key.data(), key.prefix_len()))) + .collect(); + for key in stale { + let _ = trie.remove(&key); } - Ok(()) } } diff --git a/net-guardia/src/adapter/ebpf/mod.rs b/net-guardia/src/adapter/ebpf/mod.rs index 610b978..b95ea09 100644 --- a/net-guardia/src/adapter/ebpf/mod.rs +++ b/net-guardia/src/adapter/ebpf/mod.rs @@ -11,7 +11,6 @@ use arc_swap::ArcSwap; use aya::Ebpf; use aya::maps::{MapData, RingBuf}; use crossbeam::queue::SegQueue; -use macros::log; use parking_lot::Mutex; use tokio::sync::oneshot; @@ -22,7 +21,6 @@ use crate::adapter::ebpf::protocol_filter::ProtocolFilter; use crate::adapter::ebpf::rate_limit::RateLimitConfig; use crate::adapter::ebpf::xsk_manager::XskManager; use crate::common::error::Error; -use crate::common::error::system::SystemError; use crate::domain::common::config::AppConfig; use crate::domain::data_plane::error::EbpfError; use crate::interface::data_plane::dns_query_filter::DnsQueryFilter; @@ -105,9 +103,7 @@ impl EbpfServices { pub fn terminate(self: Arc) { while let Some(shutdown) = self.shutdowns.pop() { - if shutdown.send(()).is_err() { - log!(SystemError::ShutdownSignalFailed); - } + let _ = shutdown.send(()); } } } diff --git a/net-guardia/src/adapter/ebpf/protocol_filter.rs b/net-guardia/src/adapter/ebpf/protocol_filter.rs index 06dae2b..927177c 100644 --- a/net-guardia/src/adapter/ebpf/protocol_filter.rs +++ b/net-guardia/src/adapter/ebpf/protocol_filter.rs @@ -9,7 +9,8 @@ use crate::common::error::Error; use crate::domain::data_plane::error::EbpfError; use crate::domain::data_plane::ip_address::NativeConvert; use crate::domain::data_plane::ip_version::IpVersion; -use crate::interface::data_plane::protocol_filter::ProtocolFilterPort; +use crate::interface::data_plane::protocol_filter::HttpFilterPort; +use crate::interface::data_plane::protocol_filter::SshFilterPort; use netguardia_abi::model::empty::EmptyMapValue; use netguardia_abi::model::http_method::{HttpMethod, HttpMethodBitmap}; use netguardia_abi::model::ip_address::{AddrPortV4, AddrPortV6, IPv4, IPv6}; @@ -85,7 +86,7 @@ fn require_v6_ip(ip: IpAddr) -> Result { } } -impl ProtocolFilterPort for ProtocolFilter { +impl HttpFilterPort for ProtocolFilter { fn get_http_service(&self, version: IpVersion) -> HashMap> { match version { IpVersion::V4 => self @@ -135,7 +136,9 @@ impl ProtocolFilterPort for ProtocolFilter { .remove_http_service(require_v6_socket(address)?, methods), } } +} +impl SshFilterPort for ProtocolFilter { fn is_ssh_white_list_enable(&self) -> bool { self.ssh_white_list_enable.read().is_white_list_enable() } diff --git a/net-guardia/src/adapter/http/audit.rs b/net-guardia/src/adapter/http/audit.rs index 732a3c5..fc2bfa5 100644 --- a/net-guardia/src/adapter/http/audit.rs +++ b/net-guardia/src/adapter/http/audit.rs @@ -14,21 +14,7 @@ pub fn initialize() -> Scope { async fn list_audit_logs(_auth: AuthClaims, audit: web::Data) -> HttpResponse { match audit.list_audit_logs().await { - Ok(entries) => { - let json: Vec = entries - .into_iter() - .map(|e| { - serde_json::json!({ - "id": e.id, - "actor": e.actor, - "action": e.action, - "detail": e.detail, - "created_at": e.created_at, - }) - }) - .collect(); - HttpResponse::Ok().json(json) - } + Ok(entries) => HttpResponse::Ok().json(entries), Err(e) => internal_error(e), } } diff --git a/net-guardia/src/adapter/http/data_plane/filter.rs b/net-guardia/src/adapter/http/data_plane/filter.rs index e89f333..c758a83 100644 --- a/net-guardia/src/adapter/http/data_plane/filter.rs +++ b/net-guardia/src/adapter/http/data_plane/filter.rs @@ -8,7 +8,8 @@ use crate::common::error::Error; use crate::core::data_plane::dns_filter_service::DnsFilterService; use crate::domain::data_plane::error::EbpfError; use crate::domain::data_plane::ip_version::IpVersion; -use crate::interface::data_plane::protocol_filter::ProtocolFilterPort; +use crate::interface::data_plane::protocol_filter::HttpFilterPort; +use crate::interface::data_plane::protocol_filter::SshFilterPort; use netguardia_abi::model::http_method::HttpMethod; pub fn initialize() -> Scope { @@ -125,7 +126,7 @@ fn ssh_blacklist_scope() -> Scope { .route("/{version}", web::delete().to(remove_ssh_black_list)) } -async fn get_http_service(path: web::Path, service: web::Data) -> impl Responder { +async fn get_http_service(path: web::Path, service: web::Data) -> impl Responder { let Some(version) = parse_ip_version(&path) else { return bad_request("invalid IP version"); }; @@ -135,7 +136,7 @@ async fn get_http_service(path: web::Path, service: web::Data, payload: web::Json<(SocketAddr, Vec)>, - service: web::Data, + service: web::Data, ) -> impl Responder { let Some(version) = parse_ip_version(&path) else { return bad_request("invalid IP version"); @@ -147,7 +148,7 @@ async fn add_http_service( async fn remove_http_service( path: web::Path, payload: web::Json<(SocketAddr, Vec)>, - service: web::Data, + service: web::Data, ) -> impl Responder { let Some(version) = parse_ip_version(&path) else { return bad_request("invalid IP version"); @@ -156,7 +157,7 @@ async fn remove_http_service( protocol_filter_result(service.remove_http_service(version, addr, methods)) } -async fn get_ssh_service(path: web::Path, service: web::Data) -> impl Responder { +async fn get_ssh_service(path: web::Path, service: web::Data) -> impl Responder { let Some(version) = parse_ip_version(&path) else { return bad_request("invalid IP version"); }; @@ -166,7 +167,7 @@ async fn get_ssh_service(path: web::Path, service: web::Data, payload: web::Json, - service: web::Data, + service: web::Data, ) -> impl Responder { let Some(version) = parse_ip_version(&path) else { return bad_request("invalid IP version"); @@ -177,7 +178,7 @@ async fn add_ssh_service( async fn remove_ssh_service( path: web::Path, payload: web::Json, - service: web::Data, + service: web::Data, ) -> impl Responder { let Some(version) = parse_ip_version(&path) else { return bad_request("invalid IP version"); @@ -185,21 +186,21 @@ async fn remove_ssh_service( protocol_filter_result(service.remove_ssh_service(version, payload.into_inner())) } -async fn is_ssh_white_list_enable(service: web::Data) -> impl Responder { +async fn is_ssh_white_list_enable(service: web::Data) -> impl Responder { HttpResponse::Ok().json(serde_json::json!({ "enabled": service.is_ssh_white_list_enable(), })) } -async fn enable_ssh_white_list(service: web::Data) -> impl Responder { +async fn enable_ssh_white_list(service: web::Data) -> impl Responder { protocol_filter_result(service.enable_ssh_white_list()) } -async fn disable_ssh_white_list(service: web::Data) -> impl Responder { +async fn disable_ssh_white_list(service: web::Data) -> impl Responder { protocol_filter_result(service.disable_ssh_white_list()) } -async fn get_ssh_white_list(path: web::Path, service: web::Data) -> impl Responder { +async fn get_ssh_white_list(path: web::Path, service: web::Data) -> impl Responder { let Some(version) = parse_ip_version(&path) else { return bad_request("invalid IP version"); }; @@ -209,7 +210,7 @@ async fn get_ssh_white_list(path: web::Path, service: web::Data, payload: web::Json, - service: web::Data, + service: web::Data, ) -> impl Responder { let Some(version) = parse_ip_version(&path) else { return bad_request("invalid IP version"); @@ -220,7 +221,7 @@ async fn add_ssh_white_list( async fn remove_ssh_white_list( path: web::Path, payload: web::Json, - service: web::Data, + service: web::Data, ) -> impl Responder { let Some(version) = parse_ip_version(&path) else { return bad_request("invalid IP version"); @@ -228,7 +229,7 @@ async fn remove_ssh_white_list( protocol_filter_result(service.remove_ssh_white_list(version, payload.into_inner())) } -async fn get_ssh_black_list(path: web::Path, service: web::Data) -> impl Responder { +async fn get_ssh_black_list(path: web::Path, service: web::Data) -> impl Responder { let Some(version) = parse_ip_version(&path) else { return bad_request("invalid IP version"); }; @@ -238,7 +239,7 @@ async fn get_ssh_black_list(path: web::Path, service: web::Data, payload: web::Json, - service: web::Data, + service: web::Data, ) -> impl Responder { let Some(version) = parse_ip_version(&path) else { return bad_request("invalid IP version"); @@ -249,7 +250,7 @@ async fn add_ssh_black_list( async fn remove_ssh_black_list( path: web::Path, payload: web::Json, - service: web::Data, + service: web::Data, ) -> impl Responder { let Some(version) = parse_ip_version(&path) else { return bad_request("invalid IP version"); diff --git a/net-guardia/src/adapter/http/detection/ml.rs b/net-guardia/src/adapter/http/detection/ml.rs index a4c91af..9c88d7a 100644 --- a/net-guardia/src/adapter/http/detection/ml.rs +++ b/net-guardia/src/adapter/http/detection/ml.rs @@ -13,11 +13,11 @@ use crate::core::inference::model_adapter::ModelSourceState; use crate::core::inference::model_watcher::{ModelReloadOutcome, reload_model_from_disk}; use crate::core::inference::runner::Inference; use crate::domain::common::config::AppConfig; -use crate::domain::common::config::constants::AUDIT_ACTOR_SECURITY_ADMIN_PREFIX; +use crate::domain::common::config::constants::{AUDIT_ACTOR_SECURITY_ADMIN_PREFIX, PERMISSION_USERS_ADMIN}; use crate::infrastructure::model_promotion_deps::ModelPromotionDeps; use crate::interface::system::audit::AuditRepo; -const MODEL_LIFECYCLE_REQUIRED_PERMISSION: &str = "users:admin"; +const MODEL_LIFECYCLE_REQUIRED_PERMISSION: &str = PERMISSION_USERS_ADMIN; const AUDIT_ACTION_MODEL_DORMANT: &str = "model_dormant"; const AUDIT_ACTION_MODEL_ENABLE: &str = "model_enable"; @@ -183,10 +183,10 @@ async fn enable_current_model( let audit_detail = serde_json::json!({ "before": before_json, "after": { - "name": info.name, - "adapter_kind": info.adapter_kind, + "name": info.name(), + "adapter_kind": info.adapter_kind(), "loaded_at_secs": info.loaded_at_secs, - "features_count": info.features_count, + "features_count": info.features_count(), }, }) .to_string(); @@ -234,7 +234,7 @@ async fn enable_current_model( #[cfg(test)] mod tests { - use std::collections::HashMap; + use std::collections::{BTreeMap, HashMap}; use std::fs; use std::path::Path; use std::path::PathBuf; @@ -254,7 +254,11 @@ mod tests { use crate::domain::detection::ml_inference_config::MLInferenceConfig; use crate::domain::detection::model_source::ModelSourceStatus; use crate::domain::detection::{ - error::MLError, manifest::AdapterKind, manifest::ModelManifest, manifest::ModelPaths, + error::MLError, + manifest::{ + AdapterKind, AlertRuleSpec, LabelSpec, ModelManifest, ModelSpec, OutputHeadSpec, OutputSemantic, + PreprocessingStep, + }, }; use crate::domain::identity::auth::Claims; use crate::interface::detection::model_artifact_resolver::ModelArtifactResolver; @@ -318,6 +322,56 @@ mod tests { } } + fn test_manifest() -> ModelManifest { + ModelManifest { + name: "test-model".to_string(), + version: 2, + models: vec![ + ModelSpec { + id: "anomaly_detector".to_string(), + file: "deep_autoencoder.onnx".to_string(), + input_features: vec!["Destination Port".to_string()], + preprocessing: vec![PreprocessingStep::StandardScaler { + sidecar: "sidecar.json".to_string(), + }], + outputs: vec![OutputHeadSpec { + name: "ae_anomaly_score".to_string(), + shape: vec!["1".to_string()], + semantic: OutputSemantic::AnomalyScore, + threshold: Some(0.5), + min_confidence: None, + }], + }, + ModelSpec { + id: "classifier".to_string(), + file: "classifier.onnx".to_string(), + input_features: vec!["Destination Port".to_string(), "ae_anomaly_score".to_string()], + preprocessing: vec![], + outputs: vec![OutputHeadSpec { + name: "class_probs".to_string(), + shape: vec!["2".to_string()], + semantic: OutputSemantic::Multiclass, + threshold: None, + min_confidence: Some(0.4), + }], + }, + ], + pipeline: vec!["anomaly_detector".to_string(), "classifier".to_string()], + labels: BTreeMap::from([( + "0".to_string(), + LabelSpec { + name: "Bot".to_string(), + confirmations: Some(1), + playbook: None, + }, + )]), + alert_rules: vec![AlertRuleSpec { + condition: "class_probs.max > min_confidence".to_string(), + source_label: "class_probs".to_string(), + }], + } + } + fn inference_with_error_state() -> web::Data { web::Data::new(Inference::new( ModelSourceState::Error { @@ -388,39 +442,14 @@ mod tests { impl ModelConfigLoader for FakeConfigLoader { fn load_manifest(&self, _manifest_path: &Path) -> Result { - Ok(ModelManifest { - name: "test-model".to_string(), - adapter: AdapterKind::ClassifierOnly, - models: ModelPaths { - model: Some("classifier.onnx".to_string()), - ..Default::default() - }, - features: vec!["Destination Port".to_string()], - labels: Default::default(), - thresholds: Default::default(), - preprocessing: None, - }) + Ok(test_manifest()) } fn load_manifest_with_sidecar( &self, _manifest_path: &Path, ) -> Result<(MLInferenceConfig, ModelManifest), MLError> { - Ok(( - test_inference_config(), - ModelManifest { - name: "test-model".to_string(), - adapter: AdapterKind::ClassifierOnly, - models: ModelPaths { - model: Some("classifier.onnx".to_string()), - ..Default::default() - }, - features: vec!["Destination Port".to_string()], - labels: Default::default(), - thresholds: Default::default(), - preprocessing: None, - }, - )) + Ok((test_inference_config(), test_manifest())) } } diff --git a/net-guardia/src/adapter/http/detection/model_upload.rs b/net-guardia/src/adapter/http/detection/model_upload.rs index 54cc65f..51dfb88 100644 --- a/net-guardia/src/adapter/http/detection/model_upload.rs +++ b/net-guardia/src/adapter/http/detection/model_upload.rs @@ -7,24 +7,27 @@ use actix_web::{HttpResponse, Responder, Scope, web}; use arc_swap::ArcSwap; use futures_util::TryStreamExt; use macros::log; +use std::io::{self, Read}; use tokio::fs; use tokio::io::AsyncWriteExt; use uuid::Uuid; +use zip::ZipArchive; use crate::adapter::http::middleware::extractor::AuthClaims; use crate::core::inference::model_promotion::{PromoteError, PromoteGate, StagedModelPromotion, validate_and_promote}; use crate::core::inference::runner::Inference; use crate::domain::common::config::AppConfig; +use crate::domain::common::config::constants::PERMISSION_USERS_ADMIN; use crate::domain::detection::log::MLLog; +use crate::domain::detection::model_files::{MANIFEST_FILENAME, MODELS_DIR, STAGING_SUBDIR}; use crate::infrastructure::model_promotion_deps::ModelPromotionDeps; -use crate::interface::detection::model_files::{MANIFEST_FILENAME, MODELS_DIR, STAGING_SUBDIR}; use crate::interface::system::audit::AuditRepo; -const FIELD_MANIFEST: &str = "manifest"; -const FIELD_ONNX: &str = "onnx"; -const FIELD_SCALER: &str = "scaler"; +const FIELD_BUNDLE: &str = "bundle"; +const BUNDLE_FILENAME: &str = "model_bundle.zip"; +#[cfg(test)] const ONNX_SNIFF_BYTES: usize = 16; -const PROMOTE_REQUIRED_PERMISSION: &str = "users:admin"; +const PROMOTE_REQUIRED_PERMISSION: &str = PERMISSION_USERS_ADMIN; pub fn initialize() -> Scope { web::scope("/ml/models").route("/upload", web::post().to(upload)) @@ -51,6 +54,12 @@ async fn upload( let config = app_config.load(); let caps = UploadCaps { + bundle: config + .ml + .model_upload + .max_manifest_bytes + .saturating_add(config.ml.model_upload.max_onnx_bytes) + .saturating_add(config.ml.model_upload.max_scaler_bytes), manifest: config.ml.model_upload.max_manifest_bytes, onnx: config.ml.model_upload.max_onnx_bytes, scaler: config.ml.model_upload.max_scaler_bytes, @@ -67,8 +76,6 @@ async fn upload( }; let outcome = validate_and_promote(&StagedModelPromotion { staging_dir: &staging_dir, - uploaded_onnx_filename: &summary.onnx_filename, - uploaded_scaler_filename: summary.scaler_filename.as_deref(), inference: inference.get_ref(), audit_repo: audit_repo.get_ref(), promote_gate: promote_lock.get_ref(), @@ -87,6 +94,7 @@ async fn upload( Ok(report) => HttpResponse::Ok().json(serde_json::json!({ "promoted": true, "staging_id": staging_id, + "bundle_bytes": summary.bundle_bytes, "manifest_bytes": summary.manifest_bytes, "onnx_bytes": summary.onnx_bytes, "scaler_bytes": summary.scaler_bytes, @@ -113,15 +121,15 @@ async fn cleanup_staging_dir(staging_dir: &Path) { #[derive(Debug)] struct UploadSummary { + bundle_bytes: usize, manifest_bytes: usize, onnx_bytes: usize, - onnx_filename: String, - scaler_filename: Option, scaler_bytes: Option, } #[derive(Debug, Clone, Copy)] struct UploadCaps { + bundle: usize, manifest: usize, onnx: usize, scaler: usize, @@ -133,10 +141,13 @@ enum UploadError { DuplicateField(&'static str), FilenameConflict(String), UnknownField(String), + BundleTooLarge(usize), + BundleNotZip, + BundleEntryInvalid(String), + BundleExtractionFailed(String), ManifestTooLarge(usize), OnnxTooLarge(usize), ScalerTooLarge(usize), - OnnxNotBinary, StreamFailure(String), StagingSetupFailure(String), } @@ -151,13 +162,13 @@ impl UploadError { format!("multipart filename conflicts with another upload file: {name}"), ), Self::UnknownField(name) => (400, format!("unexpected multipart field: {name}")), + Self::BundleTooLarge(max_bytes) => (413, format!("bundle exceeds {max_bytes} bytes")), + Self::BundleNotZip => (400, "bundle field does not look like a zip archive".to_string()), + Self::BundleEntryInvalid(err) => (422, format!("invalid bundle entry: {err}")), + Self::BundleExtractionFailed(err) => (422, format!("bundle extraction failed: {err}")), 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}")), }; @@ -179,10 +190,7 @@ async fn ingest_multipart( .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; - let mut upload_filenames = vec![MANIFEST_FILENAME.to_string()]; + let mut bundle_summary: Option = None; while let Some(mut field) = payload .try_next() @@ -195,41 +203,19 @@ async fn ingest_multipart( .unwrap_or("") .to_string(); match field_name.as_str() { - FIELD_MANIFEST => { - if manifest_written.is_some() { - return Err(UploadError::DuplicateField(FIELD_MANIFEST)); + FIELD_BUNDLE => { + if bundle_summary.is_some() { + return Err(UploadError::DuplicateField(FIELD_BUNDLE)); } - 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 + let _uploaded_name = field .content_disposition() .and_then(|cd| cd.get_filename()) .map(sanitize_filename) - .unwrap_or_else(|| "model.onnx".to_string()); - reserve_upload_filename(&mut upload_filenames, &onnx_filename)?; - 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()); - reserve_upload_filename(&mut upload_filenames, &scaler_filename)?; - 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)); + .unwrap_or_else(|| BUNDLE_FILENAME.to_string()); + let bundle_filename = BUNDLE_FILENAME.to_string(); + let dest = staging_dir.join(&bundle_filename); + let written = stream_field_to_file(&mut field, &dest, caps.bundle).await?; + bundle_summary = Some(written); } other => { return Err(UploadError::UnknownField(other.to_string())); @@ -237,40 +223,28 @@ async fn ingest_multipart( } } - 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_filename, scaler_bytes) = match scaler_summary { - Some((name, n)) => (Some(name), Some(n)), - None => (None, None), - }; + let bundle_bytes = bundle_summary.ok_or(UploadError::MissingField(FIELD_BUNDLE))?; + let bundle_path = staging_dir.join(BUNDLE_FILENAME); + let extracted = extract_bundle_zip(&bundle_path, staging_dir, caps)?; Ok(UploadSummary { - manifest_bytes, - onnx_bytes, - onnx_filename, - scaler_filename, - scaler_bytes, + bundle_bytes, + manifest_bytes: extracted.manifest_bytes, + onnx_bytes: extracted.onnx_bytes, + scaler_bytes: extracted.scaler_bytes, }) } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum FieldKind { - Manifest, - Onnx, - Scaler, -} - 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; + let mut sniffed = false; while let Some(chunk) = field .try_next() @@ -278,18 +252,14 @@ async fn stream_field_to_file( .map_err(|e| UploadError::StreamFailure(e.to_string()))? { if !sniffed { - if !looks_like_onnx(&chunk) { - return Err(UploadError::OnnxNotBinary); + if !looks_like_zip(&chunk) { + return Err(UploadError::BundleNotZip); } 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), - }); + return Err(UploadError::BundleTooLarge(max_bytes)); } file.write_all(&chunk) .await @@ -309,6 +279,105 @@ fn reserve_upload_filename(used: &mut Vec, filename: &str) -> Result<(), Ok(()) } +#[derive(Debug)] +struct BundleExtractionSummary { + manifest_bytes: usize, + onnx_bytes: usize, + scaler_bytes: Option, +} + +fn extract_bundle_zip( + bundle_path: &Path, + staging_dir: &Path, + caps: UploadCaps, +) -> Result { + let file = std::fs::File::open(bundle_path).map_err(|e| UploadError::BundleExtractionFailed(e.to_string()))?; + let mut archive = ZipArchive::new(file).map_err(|e| UploadError::BundleExtractionFailed(e.to_string()))?; + + let mut seen_files: Vec = Vec::with_capacity(archive.len()); + let mut manifest_bytes = None; + let mut onnx_bytes = 0usize; + let mut scaler_bytes = None; + let mut total_uncompressed = 0usize; + + for idx in 0..archive.len() { + let entry = archive + .by_index(idx) + .map_err(|e| UploadError::BundleExtractionFailed(e.to_string()))?; + if entry.is_dir() { + continue; + } + let enclosed = entry + .enclosed_name() + .ok_or_else(|| UploadError::BundleEntryInvalid(entry.name().to_string()))?; + if enclosed.components().count() != 1 { + return Err(UploadError::BundleEntryInvalid(format!( + "nested archive paths are not allowed: {}", + enclosed.display() + ))); + } + let filename = enclosed + .file_name() + .and_then(|value| value.to_str()) + .ok_or_else(|| UploadError::BundleEntryInvalid(entry.name().to_string()))?; + reserve_upload_filename(&mut seen_files, filename)?; + + let dest = staging_dir.join(filename); + let mut out = std::fs::File::create(&dest).map_err(|e| UploadError::BundleExtractionFailed(e.to_string()))?; + let max_for_file = match filename { + MANIFEST_FILENAME => caps.manifest, + _ if filename.ends_with(".onnx") => caps.onnx, + _ if filename.ends_with(".json") => caps.scaler, + _ => caps.bundle, + }; + let copied = io::copy(&mut entry.take((max_for_file as u64).saturating_add(1)), &mut out) + .map_err(|e| UploadError::BundleExtractionFailed(e.to_string()))?; + let copied = usize::try_from(copied) + .map_err(|_| UploadError::BundleExtractionFailed("copied size overflow".to_string()))?; + if copied > max_for_file { + return Err(match filename { + MANIFEST_FILENAME => UploadError::ManifestTooLarge(caps.manifest), + _ if filename.ends_with(".onnx") => UploadError::OnnxTooLarge(caps.onnx), + _ if filename.ends_with(".json") => UploadError::ScalerTooLarge(caps.scaler), + _ => UploadError::BundleExtractionFailed(format!("entry '{filename}' exceeds extraction cap")), + }); + } + total_uncompressed = total_uncompressed.saturating_add(copied); + if total_uncompressed > caps.bundle { + return Err(UploadError::BundleExtractionFailed( + "bundle expands beyond configured upload limits".to_string(), + )); + } + + match filename { + MANIFEST_FILENAME => { + manifest_bytes = Some(copied); + } + _ if filename.ends_with(".onnx") => { + onnx_bytes = onnx_bytes.saturating_add(copied); + } + _ if filename.ends_with(".json") => { + scaler_bytes = Some(copied); + } + _ => {} + } + } + + let manifest_bytes = manifest_bytes + .ok_or_else(|| UploadError::BundleExtractionFailed("manifest.yaml missing from bundle".to_string()))?; + if onnx_bytes == 0 { + return Err(UploadError::BundleExtractionFailed( + "no onnx model files found in bundle".to_string(), + )); + } + + Ok(BundleExtractionSummary { + manifest_bytes, + onnx_bytes, + scaler_bytes, + }) +} + pub fn sanitize_filename(raw: impl AsRef) -> String { let raw = raw.as_ref(); let trimmed = raw.rsplit(['/', '\\']).next().unwrap_or("model.onnx"); @@ -319,6 +388,7 @@ pub fn sanitize_filename(raw: impl AsRef) -> String { } } +#[cfg(test)] pub fn looks_like_onnx(first_chunk: &[u8]) -> bool { if first_chunk.is_empty() { return false; @@ -341,15 +411,17 @@ pub fn looks_like_onnx(first_chunk: &[u8]) -> bool { true } +pub fn looks_like_zip(first_chunk: &[u8]) -> bool { + first_chunk.starts_with(b"PK") +} + fn promote_error_response(error: PromoteError) -> HttpResponse { let (status, message) = match error { PromoteError::ManifestInvalid { err } => (422, format!("manifest invalid: {err}")), PromoteError::ValidationFailed { err } => (422, format!("model failed validation: {err}")), PromoteError::UnsupportedAdapter => ( 422, - "multi_task adapter is not supported by the v1 upload flow — \ - submit an autoencoder_only or classifier_only manifest" - .to_string(), + "unsupported adapter kind for the current model promotion flow".to_string(), ), PromoteError::StagingIo { operation, err } => (500, format!("staging io error during {operation}: {err}")), PromoteError::PromoteIo { operation, err } => (500, format!("promote io error during {operation}: {err}")), @@ -405,6 +477,12 @@ mod tests { assert!(looks_like_onnx(&buf)); } + #[test] + fn zip_sniff_accepts_local_file_header() { + assert!(looks_like_zip(b"PK\x03\x04")); + assert!(!looks_like_zip(b"name: not-a-zip")); + } + #[test] fn sanitize_filename_strips_directory_components() { assert_eq!(sanitize_filename("../../etc/passwd"), "passwd"); diff --git a/net-guardia/src/adapter/http/identity/api_keys.rs b/net-guardia/src/adapter/http/identity/api_keys.rs index 9c341e9..c5949ef 100644 --- a/net-guardia/src/adapter/http/identity/api_keys.rs +++ b/net-guardia/src/adapter/http/identity/api_keys.rs @@ -8,6 +8,7 @@ use crate::adapter::http::middleware::extractor::AuthClaims; use crate::domain::identity::auth::PermissionLevel; use crate::domain::identity::validation::validate_api_key_name; use crate::interface::identity::api_key::ApiKeyRepo; +use crate::interface::identity::api_key_hasher::ApiKeyHasher; pub fn initialize() -> Scope { web::scope("/api-keys") @@ -46,6 +47,7 @@ struct GenerateKeyRequest { async fn generate_key( _auth: AuthClaims, db: web::Data, + hasher: web::Data, body: web::Json, ) -> HttpResponse { let raw_key: String = rand::rng() @@ -54,7 +56,7 @@ async fn generate_key( .map(char::from) .collect(); - let key_hash = db.hmac_api_key(&raw_key); + let key_hash = hasher.hash_api_key(&raw_key); let raw_level = body.level.as_deref().unwrap_or("read_only"); let Some(level) = PermissionLevel::from_str(raw_level) else { diff --git a/net-guardia/src/adapter/http/identity/auth.rs b/net-guardia/src/adapter/http/identity/auth.rs index 8a2a245..03e2b57 100644 --- a/net-guardia/src/adapter/http/identity/auth.rs +++ b/net-guardia/src/adapter/http/identity/auth.rs @@ -5,9 +5,12 @@ use serde::{Deserialize, Serialize}; use crate::adapter::http::helpers::{bad_request, conflict, forbidden, internal_error, json_error, not_found}; use crate::adapter::http::middleware::extractor::AuthClaims; use crate::adapter::http::session::SessionCookieService; -use crate::core::identity::auth_service::{AuthService, IdentityAdminError, LoginError, RegisterError, UserProfile}; +use crate::core::identity::auth_service::AuthService; +use crate::core::identity::group_service::GroupService; use crate::core::identity::session_service::SessionService; +use crate::core::identity::user_service::{UserProfile, UserService}; use crate::domain::identity::auth::{Claims, ROLE_ADMIN, ROLE_VIEWER}; +use crate::domain::identity::error::{GroupError, LoginError, RegisterError, UserError}; #[derive(Deserialize)] struct LoginRequest { @@ -28,6 +31,36 @@ struct ChangePasswordRequest { new_password: String, } +#[derive(Deserialize)] +struct UpdateRoleRequest { + role: String, +} + +#[derive(Deserialize)] +struct ResetPasswordRequest { + new_password: Option, + password: Option, +} + +#[derive(Deserialize)] +struct CreateGroupRequest { + name: Option, + description: Option, + permissions: Option, +} + +#[derive(Deserialize)] +struct UpdateGroupRequest { + name: Option, + description: Option, + permissions: Option, +} + +#[derive(Deserialize)] +struct SetUserGroupsRequest { + group_ids: Vec, +} + #[derive(Serialize)] struct MeResponse { id: i64, @@ -57,16 +90,16 @@ fn append_session_removal_cookies(response: &mut HttpResponseBuilder, cookie_ser } } -async fn invalidate_group_member_sessions(auth_svc: &AuthService, session_service: &SessionService, group_id: i64) { - if let Ok(Some(group)) = auth_svc.get_group(group_id).await { +async fn invalidate_group_member_sessions(group_svc: &GroupService, session_service: &SessionService, group_id: i64) { + if let Ok(Some(group)) = group_svc.get_group(group_id).await { for user_id in group.members { session_service.remove_sessions_for_user(user_id); } } } -async fn group_member_ids(auth_svc: &AuthService, group_id: i64) -> Vec { - match auth_svc.get_group(group_id).await { +async fn group_member_ids(group_svc: &GroupService, group_id: i64) -> Vec { + match group_svc.get_group(group_id).await { Ok(Some(group)) => group.members, _ => Vec::new(), } @@ -149,23 +182,23 @@ async fn register( .await { Ok(_) => HttpResponse::Created().json(serde_json::json!({"username": reg.username, "role": reg.role})), - Err(RegisterError::Validation(msg)) => bad_request(msg), + Err(e @ RegisterError::Validation { .. }) => bad_request(e.to_string()), Err(RegisterError::InvalidRole) => bad_request("Role must be 'admin' or 'viewer'"), Err(RegisterError::Forbidden) => forbidden("Only administrators can create admin accounts"), Err(RegisterError::HashFailed) => internal_error("Failed to hash password"), - Err(RegisterError::Conflict(e)) => conflict(e), - Err(RegisterError::Internal(e)) => internal_error(e), + Err(e @ RegisterError::Conflict { .. }) => conflict(e.to_string()), + Err(e @ RegisterError::Internal { .. }) => internal_error(e.to_string()), } } async fn me( req: HttpRequest, auth: AuthClaims, - auth_svc: web::Data, + user_svc: web::Data, session_service: web::Data, cookie_service: web::Data, ) -> impl Responder { - match auth_svc.user_profile(auth.sub, &auth.username).await { + match user_svc.user_profile(auth.sub, &auth.username).await { Ok(profile) => { let csrf_token = req .cookie(cookie_service.cookie_name()) @@ -179,13 +212,13 @@ async fn me( async fn change_password( auth: AuthClaims, body: web::Json, - auth_svc: web::Data, + user_svc: web::Data, session_service: web::Data, cookie_service: web::Data, ) -> impl Responder { let change_req = body.into_inner(); - match auth_svc + match user_svc .change_password(auth.sub, &change_req.current_password, &change_req.new_password) .await { @@ -195,12 +228,12 @@ async fn change_password( append_session_removal_cookies(&mut response, &cookie_service); response.json(serde_json::json!({"message": "Password changed successfully"})) } - Err(e) => identity_admin_error(e), + Err(e) => user_error(e), } } -async fn list_users(_auth: AuthClaims, auth_svc: web::Data) -> impl Responder { - match auth_svc.list_users().await { +async fn list_users(_auth: AuthClaims, user_svc: web::Data) -> impl Responder { + match user_svc.list_users().await { Ok(users) => HttpResponse::Ok().json(users), Err(e) => internal_error(e), } @@ -209,68 +242,63 @@ async fn list_users(_auth: AuthClaims, auth_svc: web::Data) -> impl async fn delete_user( auth: AuthClaims, path: web::Path, - auth_svc: web::Data, + user_svc: web::Data, session_service: web::Data, ) -> impl Responder { let user_id = path.into_inner(); - match auth_svc.delete_user(auth.sub, user_id).await { + match user_svc.delete_user(auth.sub, user_id).await { Ok(true) => { session_service.remove_sessions_for_user(user_id); HttpResponse::Ok().json(serde_json::json!({"message": "User deleted successfully"})) } Ok(false) => not_found("User not found"), - Err(e) => identity_admin_error(e), + Err(e) => user_error(e), } } async fn update_role( _auth: AuthClaims, path: web::Path, - body: web::Json, - auth_svc: web::Data, + body: web::Json, + user_svc: web::Data, session_service: web::Data, ) -> impl Responder { let user_id = path.into_inner(); + let req = body.into_inner(); - let role = match body.get("role").and_then(|v| v.as_str()) { - Some(r) if r == ROLE_ADMIN || r == ROLE_VIEWER => r, - _ => { - return bad_request("Role must be 'admin' or 'viewer'"); - } - }; + if req.role != ROLE_ADMIN && req.role != ROLE_VIEWER { + return bad_request("Role must be 'admin' or 'viewer'"); + } - match auth_svc.update_role(_auth.sub, user_id, role).await { + match user_svc.update_role(_auth.sub, user_id, &req.role).await { Ok(_) => { session_service.remove_sessions_for_user(user_id); - HttpResponse::Ok().json(serde_json::json!({"message": "Role updated successfully", "role": role})) + HttpResponse::Ok().json(serde_json::json!({"message": "Role updated successfully", "role": req.role})) } - Err(e) => identity_admin_error(e), + Err(e) => user_error(e), } } async fn reset_password( _auth: AuthClaims, path: web::Path, - body: web::Json, - auth_svc: web::Data, + body: web::Json, + user_svc: web::Data, session_service: web::Data, cookie_service: web::Data, ) -> impl Responder { let user_id = path.into_inner(); + let req = body.into_inner(); - let new_password = match body - .get("new_password") - .or_else(|| body.get("password")) - .and_then(|v| v.as_str()) - { + let new_password = match req.new_password.as_deref().or(req.password.as_deref()) { Some(p) => p, None => { return bad_request("Password is required"); } }; - match auth_svc.reset_password(user_id, new_password).await { + match user_svc.reset_password(user_id, new_password).await { Ok(()) => { session_service.remove_sessions_for_user(user_id); let mut response = HttpResponse::Ok(); @@ -279,24 +307,34 @@ async fn reset_password( } response.finish() } - Err(e) => identity_admin_error(e), + Err(e) => user_error(e), } } -fn identity_admin_error(err: IdentityAdminError) -> HttpResponse { +fn user_error(err: UserError) -> HttpResponse { match err { - IdentityAdminError::Validation(message) => bad_request(message), - IdentityAdminError::Unauthorized => json_error(StatusCode::UNAUTHORIZED, "Current password is incorrect"), - IdentityAdminError::Forbidden(message) => forbidden(message), - IdentityAdminError::NotFound(message) => not_found(message), - IdentityAdminError::HashFailed => internal_error("Failed to hash password"), - IdentityAdminError::Conflict(e) => conflict(e), - IdentityAdminError::Internal(e) => internal_error(e), + UserError::Validation { .. } => bad_request(err.to_string()), + UserError::Unauthorized => json_error(StatusCode::UNAUTHORIZED, "Current password is incorrect"), + UserError::Forbidden { .. } => forbidden(err.to_string()), + UserError::NotFound { .. } => not_found(err.to_string()), + UserError::HashFailed => internal_error("Failed to hash password"), + UserError::Conflict { .. } => conflict(err.to_string()), + UserError::Internal { .. } => internal_error(err.to_string()), } } -async fn list_groups(_auth: AuthClaims, auth_svc: web::Data) -> impl Responder { - match auth_svc.list_groups().await { +fn group_error(err: GroupError) -> HttpResponse { + match err { + GroupError::Validation { .. } => bad_request(err.to_string()), + GroupError::Forbidden { .. } => forbidden(err.to_string()), + GroupError::NotFound { .. } => not_found(err.to_string()), + GroupError::Conflict { .. } => conflict(err.to_string()), + GroupError::Internal { .. } => internal_error(err.to_string()), + } +} + +async fn list_groups(_auth: AuthClaims, group_svc: web::Data) -> impl Responder { + match group_svc.list_groups().await { Ok(groups) => HttpResponse::Ok().json(groups), Err(e) => internal_error(e), } @@ -304,26 +342,27 @@ async fn list_groups(_auth: AuthClaims, auth_svc: web::Data) -> imp async fn create_group( _auth: AuthClaims, - body: web::Json, - auth_svc: web::Data, + body: web::Json, + group_svc: web::Data, ) -> impl Responder { - match auth_svc + let req = body.into_inner(); + match group_svc .create_group( - body.get("name").and_then(|v| v.as_str()), - body.get("description").and_then(|v| v.as_str()), - body.get("permissions"), + req.name.as_deref(), + req.description.as_deref(), + req.permissions.as_ref(), ) .await { Ok(group) => HttpResponse::Created().json(group), - Err(e) => identity_admin_error(e), + Err(e) => group_error(e), } } -async fn get_group(_auth: AuthClaims, path: web::Path, auth_svc: web::Data) -> impl Responder { +async fn get_group(_auth: AuthClaims, path: web::Path, group_svc: web::Data) -> impl Responder { let group_id = path.into_inner(); - match auth_svc.get_group(group_id).await { + match group_svc.get_group(group_id).await { Ok(Some(group)) => HttpResponse::Ok().json(group), Ok(None) => not_found("Group not found"), Err(e) => internal_error(e), @@ -333,39 +372,40 @@ async fn get_group(_auth: AuthClaims, path: web::Path, auth_svc: web::Data< async fn update_group( _auth: AuthClaims, path: web::Path, - body: web::Json, - auth_svc: web::Data, + body: web::Json, + group_svc: web::Data, session_service: web::Data, ) -> impl Responder { let group_id = path.into_inner(); + let req = body.into_inner(); - match auth_svc + match group_svc .update_group( group_id, - body.get("name").and_then(|v| v.as_str()), - body.get("description").and_then(|v| v.as_str()), - body.get("permissions"), + req.name.as_deref(), + req.description.as_deref(), + req.permissions.as_ref(), ) .await { Ok(group) => { - invalidate_group_member_sessions(&auth_svc, &session_service, group_id).await; + invalidate_group_member_sessions(&group_svc, &session_service, group_id).await; HttpResponse::Ok().json(group) } - Err(e) => identity_admin_error(e), + Err(e) => group_error(e), } } async fn delete_group( _auth: AuthClaims, path: web::Path, - auth_svc: web::Data, + group_svc: web::Data, session_service: web::Data, ) -> impl Responder { let group_id = path.into_inner(); - let member_ids = group_member_ids(&auth_svc, group_id).await; + let member_ids = group_member_ids(&group_svc, group_id).await; - match auth_svc.delete_group(group_id).await { + match group_svc.delete_group(group_id).await { Ok(true) => { for user_id in member_ids { session_service.remove_sessions_for_user(user_id); @@ -373,50 +413,34 @@ async fn delete_group( HttpResponse::Ok().json(serde_json::json!({"message": "Group deleted successfully"})) } Ok(false) => not_found("Group not found"), - Err(e) => identity_admin_error(e), + Err(e) => group_error(e), } } async fn set_user_groups( _auth: AuthClaims, path: web::Path, - body: web::Json, - auth_svc: web::Data, + body: web::Json, + user_svc: web::Data, session_service: web::Data, ) -> impl Responder { let user_id = path.into_inner(); + let req = body.into_inner(); - let group_ids = match parse_group_ids(&body) { - Ok(group_ids) => group_ids, - Err(message) => return bad_request(message), - }; - - match auth_svc.set_user_groups(_auth.sub, user_id, &group_ids).await { + match user_svc.set_user_groups(_auth.sub, user_id, &req.group_ids).await { Ok(_) => { session_service.remove_sessions_for_user(user_id); HttpResponse::Ok() - .json(serde_json::json!({"message": "User groups updated successfully", "group_ids": group_ids})) + .json(serde_json::json!({"message": "User groups updated successfully", "group_ids": req.group_ids})) } - Err(e) => identity_admin_error(e), + Err(e) => user_error(e), } } -fn parse_group_ids(body: &serde_json::Value) -> Result, &'static str> { - let group_ids = body - .get("group_ids") - .and_then(|value| value.as_array()) - .ok_or("group_ids array is required")?; - - group_ids - .iter() - .map(|value| value.as_i64().ok_or("group_ids must contain only integer ids")) - .collect() -} - #[cfg(test)] mod tests { - use super::parse_group_ids; - use crate::core::identity::auth_service::parse_permissions; + use super::SetUserGroupsRequest; + use crate::core::identity::user_service::parse_permissions; use crate::domain::identity::validation::{validate_password, validate_username}; #[test] @@ -451,24 +475,24 @@ mod tests { } #[test] - fn parse_group_ids_rejects_malformed_entries() { + fn set_user_groups_request_rejects_malformed_entries() { let body = serde_json::json!({ "group_ids": [1, "2", null] }); - let err = parse_group_ids(&body).expect_err("mixed group ids should fail"); + let result: Result = serde_json::from_value(body); - assert_eq!(err, "group_ids must contain only integer ids"); + assert!(result.is_err()); } #[test] - fn parse_group_ids_accepts_integer_entries() { + fn set_user_groups_request_accepts_integer_entries() { let body = serde_json::json!({ "group_ids": [1, 2, 3] }); - let ids = parse_group_ids(&body).expect("valid group ids"); + let req: SetUserGroupsRequest = serde_json::from_value(body).expect("valid group ids"); - assert_eq!(ids, vec![1, 2, 3]); + assert_eq!(req.group_ids, vec![1, 2, 3]); } } diff --git a/net-guardia/src/adapter/http/logs.rs b/net-guardia/src/adapter/http/logs.rs index 0849803..3539fa2 100644 --- a/net-guardia/src/adapter/http/logs.rs +++ b/net-guardia/src/adapter/http/logs.rs @@ -7,10 +7,12 @@ use actix_web::http::StatusCode; use actix_web::{HttpResponse, Scope, web}; use arc_swap::ArcSwap; use serde::{Deserialize, Serialize}; +use tokio_util::io::ReaderStream; use crate::adapter::http::helpers::{bad_request, forbidden, internal_error, json_error, not_found}; +use crate::common::utils::log_level::level_severity; use crate::domain::common::config::AppConfig; -use crate::interface::system::live_logs::{LiveLogQuery, LogEntry, level_severity}; +use crate::interface::system::live_logs::{LiveLogQuery, LogEntry}; fn is_valid_log_filename(name: &str) -> bool { !name.is_empty() @@ -151,15 +153,16 @@ async fn download_log(path: web::Path, app_config: web::Data {} } - let content = match fs::read(&canonical) { - Ok(bytes) => bytes, - Err(e) => return internal_error(format!("Failed to read log file: {}", e)), + let file = match tokio::fs::File::open(&canonical).await { + Ok(f) => f, + Err(e) => return internal_error(format!("Failed to open log file: {}", e)), }; + let stream = ReaderStream::new(file); HttpResponse::Ok() .insert_header(("Content-Type", "application/octet-stream")) .insert_header(("Content-Disposition", format!("attachment; filename=\"{}\"", filename))) - .body(content) + .streaming(stream) } #[cfg(test)] diff --git a/net-guardia/src/adapter/http/middleware/auth.rs b/net-guardia/src/adapter/http/middleware/auth.rs index 4a96114..6c728d3 100644 --- a/net-guardia/src/adapter/http/middleware/auth.rs +++ b/net-guardia/src/adapter/http/middleware/auth.rs @@ -12,9 +12,13 @@ use macros::log; use crate::adapter::http::helpers::json_error; use crate::adapter::http::session::SessionCookieService; use crate::core::identity::session_service::SessionService; +use crate::domain::common::config::constants::{ + PERMISSION_ACCESS_CONTROL_WRITE, PERMISSION_API_KEYS_ADMIN, PERMISSION_USERS_ADMIN, +}; use crate::domain::identity::error::AuthError; -use crate::interface::app_repo::AppRepo; use crate::interface::identity::api_key::ApiKeyRepo; +use crate::interface::identity::api_key_hasher::ApiKeyHasher; +use crate::interface::identity::auth_repo::LoginAttemptRepo; pub struct AuthMiddleware; @@ -48,7 +52,7 @@ fn required_permission(path: &str, method: &Method) -> Option { { return None; } else if path.starts_with("/api/auth/") { - return Some("users:admin".to_string()); + return Some(PERMISSION_USERS_ADMIN.to_string()); } else if path.starts_with("/api/health/") { "dashboard" } else if path.starts_with("/api/stats/drops") { @@ -76,9 +80,9 @@ fn required_permission(path: &str, method: &Method) -> Option { } else if path.starts_with("/api/system/") { "system" } else if path == "/api/api-keys" || path.starts_with("/api/api-keys/") { - return Some("api_keys:admin".to_string()); + return Some(PERMISSION_API_KEYS_ADMIN.to_string()); } else if path.contains("/soar/blocks/") && path.ends_with("/unblock") { - return Some("access_control:write".to_string()); + return Some(PERMISSION_ACCESS_CONTROL_WRITE.to_string()); } else if path.starts_with("/api/soar/") || path.starts_with("/api/notifications/") || path == "/api/report" @@ -141,10 +145,17 @@ where return Ok(req.into_response(resp).map_into_right_body()); } }; - let repo = match req.app_data::>() { + let api_key_hasher = match req.app_data::>() { Some(d) => d.clone(), None => { - let resp = json_error(StatusCode::INTERNAL_SERVER_ERROR, "AppRepo not configured"); + let resp = json_error(StatusCode::INTERNAL_SERVER_ERROR, "ApiKeyHasher not configured"); + return Ok(req.into_response(resp).map_into_right_body()); + } + }; + let login_attempt_repo = match req.app_data::>() { + Some(d) => d.clone(), + None => { + let resp = json_error(StatusCode::INTERNAL_SERVER_ERROR, "LoginAttemptRepo not configured"); return Ok(req.into_response(resp).map_into_right_body()); } }; @@ -153,7 +164,7 @@ where "apikey:{}", req.peer_addr().map(|a| a.ip().to_string()).unwrap_or_default() ); - match repo.get_remaining_lock_secs(&rate_key).await { + match login_attempt_repo.get_remaining_lock_secs(&rate_key).await { Ok(Some(remaining)) => { let resp = HttpResponse::TooManyRequests().json(serde_json::json!({ "error": "Too many failed API key attempts", @@ -162,7 +173,7 @@ where return Ok(req.into_response(resp).map_into_right_body()); } Ok(None) => { - if let Err(e) = repo.clear_expired_login_lock(&rate_key).await { + if let Err(e) = login_attempt_repo.clear_expired_login_lock(&rate_key).await { log!(AuthError::LoginLockoutLookupFailed(e)); let resp = json_error(StatusCode::INTERNAL_SERVER_ERROR, "API key lockout cleanup failed"); return Ok(req.into_response(resp).map_into_right_body()); @@ -175,15 +186,16 @@ where } } - match api_key_port.validate_api_key(api_key).await { + let key_hash = api_key_hasher.hash_api_key(api_key); + match api_key_port.validate_api_key(&key_hash).await { Ok(Some(key_claims)) => { - if let Err(e) = repo.clear_login_failures(&rate_key).await { + if let Err(e) = login_attempt_repo.clear_login_failures(&rate_key).await { log!(AuthError::LoginClearError(e)); } key_claims } Ok(None) => { - if let Err(e) = repo.record_login_failure(&rate_key).await { + if let Err(e) = login_attempt_repo.record_login_failure(&rate_key).await { log!(AuthError::LoginFailureTrackingError(e)); } let resp = json_error(StatusCode::UNAUTHORIZED, "Invalid or revoked API key"); diff --git a/net-guardia/src/adapter/http/response/report.rs b/net-guardia/src/adapter/http/response/report.rs index 8c6ae95..d47b975 100644 --- a/net-guardia/src/adapter/http/response/report.rs +++ b/net-guardia/src/adapter/http/response/report.rs @@ -1,6 +1,5 @@ -use std::fs; - use actix_web::{HttpResponse, Scope, web}; +use tokio_util::io::ReaderStream; use crate::adapter::http::helpers::{internal_error, ok_json_or_error}; use crate::adapter::http::middleware::extractor::AuthClaims; @@ -17,19 +16,18 @@ pub fn initialize() -> Scope { async fn generate_report(_auth: AuthClaims, reports: web::Data) -> HttpResponse { match reports.generate_html_report().await { - Ok(path) => match fs::read(&path) { - Ok(content) => HttpResponse::Ok() - .content_type("text/html; charset=utf-8") - .insert_header(( - "Content-Disposition", - format!( - "attachment; filename=\"{}\"", - path.file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_else(|| "report.html".into()) - ), - )) - .body(content), + Ok(path) => match tokio::fs::File::open(&path).await { + Ok(file) => { + let filename = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "report.html".into()); + let stream = ReaderStream::new(file); + HttpResponse::Ok() + .content_type("text/html; charset=utf-8") + .insert_header(("Content-Disposition", format!("attachment; filename=\"{}\"", filename))) + .streaming(stream) + } Err(_) => HttpResponse::Ok().json(serde_json::json!({ "success": true, "path": path.to_string_lossy(), diff --git a/net-guardia/src/adapter/http/response/soar.rs b/net-guardia/src/adapter/http/response/soar.rs index a35f666..4878e87 100644 --- a/net-guardia/src/adapter/http/response/soar.rs +++ b/net-guardia/src/adapter/http/response/soar.rs @@ -13,16 +13,13 @@ use crate::core::response::playbook_service::PlaybookService; use crate::domain::common::config::AppConfig; use crate::domain::common::event::{DetectionSource, ThreatDetectedEvent}; use crate::domain::data_plane::error::EbpfError; -use crate::domain::response::condition::{ConditionType, is_valid_ip_pattern, is_valid_operator}; -use crate::domain::response::playbook::ActionType; +use crate::domain::response::playbook_validator; use crate::interface::response::playbook_data::{ActionInput, CreateConditionInput, CreatePlaybookInput}; const DEFAULT_DRY_RUN_DEST_IP: &str = "0.0.0.0"; const DEFAULT_DRY_RUN_FLOW_COUNT: u32 = 1; const DEFAULT_DRY_RUN_PACKET_RATE: f64 = 0.0; const DEFAULT_DRY_RUN_PROTOCOL: u8 = 6; -const RATE_LIMIT_FACTOR_MIN: f64 = 0.01; -const RATE_LIMIT_FACTOR_MAX: f64 = 1.0; #[derive(Deserialize)] struct CreatePlaybookRequest { @@ -55,19 +52,20 @@ fn map_request_to_input( fallback_cooldown: i64, max_ttl_secs: u64, ) -> Result { - validate_optional_positive_i64("condition_count", body.condition_count)?; - validate_optional_positive_i64("condition_window_secs", body.condition_window_secs)?; + playbook_validator::validate_optional_positive_i64("condition_count", body.condition_count) + .map_err(|e| e.to_string())?; + playbook_validator::validate_optional_positive_i64("condition_window_secs", body.condition_window_secs) + .map_err(|e| e.to_string())?; let cooldown_secs = body.cooldown_secs.unwrap_or(fallback_cooldown); - if cooldown_secs < 0 { - return Err("cooldown_secs must be greater than or equal to 0".to_string()); - } + playbook_validator::validate_cooldown_secs(cooldown_secs).map_err(|e| e.to_string())?; let actions = body .actions .iter() .enumerate() .map(|(index, a)| { - validate_action_request(a, max_ttl_secs)?; + playbook_validator::validate_action(&a.action_type, a.params.as_ref(), max_ttl_secs) + .map_err(|e| e.to_string())?; let params_str = a .params .as_ref() @@ -95,7 +93,7 @@ fn map_request_to_input( c.value2.clone(), ) .map_err(|_| format!("unknown condition_type: {}", c.condition_type))?; - validate_condition_input(&input)?; + playbook_validator::validate_condition_input(&input).map_err(|e| e.to_string())?; Ok(input) }) .collect::, String>>()?; @@ -112,179 +110,6 @@ fn map_request_to_input( }) } -fn validate_condition_input(condition: &CreateConditionInput) -> Result<(), String> { - let condition_type = condition - .condition_type - .parse::() - .map_err(|_| format!("unknown condition_type: {}", condition.condition_type))?; - if !is_valid_operator(&condition_type, &condition.operator) { - return Err(format!( - "invalid operator '{}' for condition_type '{}'", - condition.operator, condition.condition_type - )); - } - validate_condition_value(&condition_type, condition)?; - Ok(()) -} - -fn validate_condition_value(condition_type: &ConditionType, condition: &CreateConditionInput) -> Result<(), String> { - match condition_type { - ConditionType::Threshold | ConditionType::FusedConfidenceAbove => { - parse_finite_f64(&condition.value, &condition.condition_type)?; - } - ConditionType::Frequency | ConditionType::MultiSourceMin => { - parse_positive_usize(&condition.value, &condition.condition_type)?; - if let Some(value2) = &condition.value2 { - parse_positive_u64(value2, "value2")?; - } - } - ConditionType::SourceCountry => { - if condition.value.split(',').all(|part| part.trim().is_empty()) { - return Err("source_country value must contain at least one country code".to_string()); - } - } - ConditionType::IpPattern => { - if !is_valid_ip_pattern(&condition.value) { - return Err(format!("ip_pattern value must be a valid CIDR: {}", condition.value)); - } - } - ConditionType::RepeatOffender => { - parse_bool_literal(&condition.value, &condition.condition_type)?; - } - ConditionType::SingleSourceHigh => { - DetectionSource::from_str(&condition.value).map_err(|_| { - format!( - "single_source_high value must be a valid DetectionSource: {}", - condition.value - ) - })?; - if let Some(value2) = &condition.value2 { - parse_finite_f64(value2, "value2")?; - } - } - } - Ok(()) -} - -fn parse_finite_f64(value: &str, field: &str) -> Result { - match value.parse::() { - Ok(parsed) if parsed.is_finite() => Ok(parsed), - _ => Err(format!("{field} value must be a finite number")), - } -} - -fn parse_positive_usize(value: &str, field: &str) -> Result { - match value.parse::() { - Ok(parsed) if parsed > 0 => Ok(parsed), - _ => Err(format!("{field} value must be a positive integer")), - } -} - -fn parse_positive_u64(value: &str, field: &str) -> Result { - match value.parse::() { - Ok(parsed) if parsed > 0 => Ok(parsed), - _ => Err(format!("{field} must be a positive integer")), - } -} - -fn parse_bool_literal(value: &str, field: &str) -> Result { - match value.to_ascii_lowercase().as_str() { - "true" => Ok(true), - "false" => Ok(false), - _ => Err(format!("{field} value must be 'true' or 'false'")), - } -} - -fn validate_optional_positive_i64(field: &str, value: Option) -> Result<(), String> { - if value.is_some_and(|value| value <= 0) { - return Err(format!("{field} must be greater than 0")); - } - Ok(()) -} - -fn validate_action_request(action: &CreateActionRequest, max_ttl_secs: u64) -> Result<(), String> { - let params = action.params.as_ref(); - if let Some(params) = params - && !params.is_object() - { - return Err(format!("action '{}' params must be a JSON object", action.action_type)); - } - - match action.action_type.parse::() { - Ok(ActionType::BlockIp) => { - let ttl_secs = params.and_then(|p| p.get("ttl_secs")); - validate_optional_positive_u64("ttl_secs", ttl_secs)?; - validate_optional_max_u64("ttl_secs", ttl_secs, max_ttl_secs) - } - Ok(ActionType::AdjustRateLimit) => { - let ttl_secs = params.and_then(|p| p.get("ttl_secs")); - validate_optional_positive_u64("ttl_secs", ttl_secs)?; - validate_optional_max_u64("ttl_secs", ttl_secs, max_ttl_secs)?; - validate_optional_rate_limit_factor(params.and_then(|p| p.get("factor"))) - } - Ok(ActionType::SendTelegram | ActionType::SendEmail) => Ok(()), - Ok(ActionType::Webhook) => { - validate_required_non_empty_string("url", params.and_then(|p| p.get("url")))?; - validate_optional_positive_u64("timeout_secs", params.and_then(|p| p.get("timeout_secs"))) - } - Ok(ActionType::Log) => validate_optional_string("level", params.and_then(|p| p.get("level"))), - Err(_) => Err(format!("unknown action_type: {}", action.action_type)), - } -} - -fn validate_optional_positive_u64(field: &str, value: Option<&serde_json::Value>) -> Result<(), String> { - let Some(value) = value else { - return Ok(()); - }; - match value.as_u64() { - Some(value) if value > 0 => Ok(()), - Some(_) => Err(format!("{field} must be greater than 0")), - None => Err(format!("{field} must be a positive integer")), - } -} - -fn validate_optional_max_u64(field: &str, value: Option<&serde_json::Value>, max: u64) -> Result<(), String> { - let Some(value) = value.and_then(|value| value.as_u64()) else { - return Ok(()); - }; - if value > max { - return Err(format!("{field} must be less than or equal to {max}")); - } - Ok(()) -} - -fn validate_optional_rate_limit_factor(value: Option<&serde_json::Value>) -> Result<(), String> { - let Some(value) = value else { - return Ok(()); - }; - match value.as_f64() { - Some(value) if (RATE_LIMIT_FACTOR_MIN..=RATE_LIMIT_FACTOR_MAX).contains(&value) => Ok(()), - Some(_) => Err(format!( - "factor must be between {RATE_LIMIT_FACTOR_MIN} and {RATE_LIMIT_FACTOR_MAX}" - )), - None => Err("factor must be a number".to_string()), - } -} - -fn validate_required_non_empty_string(field: &str, value: Option<&serde_json::Value>) -> Result<(), String> { - match value.and_then(|value| value.as_str()) { - Some(value) if !value.trim().is_empty() => Ok(()), - Some(_) => Err(format!("{field} must not be empty")), - None => Err(format!("{field} is required")), - } -} - -fn validate_optional_string(field: &str, value: Option<&serde_json::Value>) -> Result<(), String> { - let Some(value) = value else { - return Ok(()); - }; - if value.is_string() { - Ok(()) - } else { - Err(format!("{field} must be a string")) - } -} - pub fn initialize() -> Scope { web::scope("/soar") .route("/playbooks", web::get().to(list_playbooks)) diff --git a/net-guardia/src/adapter/identity/api_key_hasher.rs b/net-guardia/src/adapter/identity/api_key_hasher.rs new file mode 100644 index 0000000..8730b2e --- /dev/null +++ b/net-guardia/src/adapter/identity/api_key_hasher.rs @@ -0,0 +1,59 @@ +use std::fmt::Write; + +use hmac::{Hmac, KeyInit, Mac}; +use sha2::Sha256; + +use crate::interface::identity::api_key_hasher::ApiKeyHasher; + +type HmacSha256 = Hmac; + +pub struct HmacApiKeyHasher { + key: [u8; 32], +} + +impl HmacApiKeyHasher { + pub fn new(key: [u8; 32]) -> Self { + Self { key } + } +} + +impl ApiKeyHasher for HmacApiKeyHasher { + fn hash_api_key(&self, raw_key: &str) -> String { + let mut mac = match HmacSha256::new_from_slice(&self.key) { + Ok(mac) => mac, + // SAFETY: HMAC accepts keys of any length; `key` is a fixed 32-byte array. + Err(_) => unreachable!("HMAC-SHA256 accepts fixed 32-byte keys"), + }; + mac.update(raw_key.as_bytes()); + let result = mac.finalize().into_bytes(); + + let mut hex = String::with_capacity(64); + for byte in result { + // SAFETY: write! on a String is infallible. + let _ = write!(&mut hex, "{:02x}", byte); + } + hex + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deterministic_hash() { + let hasher = HmacApiKeyHasher::new([0xAB; 32]); + let h1 = hasher.hash_api_key("test-key"); + let h2 = hasher.hash_api_key("test-key"); + assert_eq!(h1, h2); + assert_eq!(h1.len(), 64); + } + + #[test] + fn different_keys_produce_different_hashes() { + let hasher = HmacApiKeyHasher::new([0xAB; 32]); + let h1 = hasher.hash_api_key("key-a"); + let h2 = hasher.hash_api_key("key-b"); + assert_ne!(h1, h2); + } +} diff --git a/net-guardia/src/adapter/identity/mod.rs b/net-guardia/src/adapter/identity/mod.rs index 2cf51be..cdec0a1 100644 --- a/net-guardia/src/adapter/identity/mod.rs +++ b/net-guardia/src/adapter/identity/mod.rs @@ -1 +1,2 @@ +pub mod api_key_hasher; pub mod password_hasher; diff --git a/net-guardia/src/adapter/model_change_source.rs b/net-guardia/src/adapter/model_change_source.rs index f8b959b..9490f20 100644 --- a/net-guardia/src/adapter/model_change_source.rs +++ b/net-guardia/src/adapter/model_change_source.rs @@ -7,8 +7,8 @@ use tokio::sync::mpsc::error::TrySendError; use crate::domain::detection::error::MLError; use crate::domain::detection::log::MLLog; +use crate::domain::detection::model_files::STAGING_SUBDIR; use crate::interface::detection::model_change_source::{ModelChangeSource, ModelChangeSubscription}; -use crate::interface::detection::model_files::STAGING_SUBDIR; pub struct NotifyModelChangeSource { models_dir: PathBuf, diff --git a/net-guardia/src/adapter/model_loading/artifact_resolver.rs b/net-guardia/src/adapter/model_loading/artifact_resolver.rs index dbea8d0..6ab5cb7 100644 --- a/net-guardia/src/adapter/model_loading/artifact_resolver.rs +++ b/net-guardia/src/adapter/model_loading/artifact_resolver.rs @@ -1,8 +1,8 @@ use std::path::{Path, PathBuf}; use crate::domain::detection::manifest::ModelManifest; +use crate::domain::detection::model_files::MODELS_DIR; use crate::interface::detection::model_artifact_resolver::ModelArtifactResolver; -use crate::interface::detection::model_files::MODELS_DIR; #[derive(Default)] pub struct FsModelArtifactResolver; diff --git a/net-guardia/src/adapter/model_loading/config_loader.rs b/net-guardia/src/adapter/model_loading/config_loader.rs index ecf519f..f0e3bf8 100644 --- a/net-guardia/src/adapter/model_loading/config_loader.rs +++ b/net-guardia/src/adapter/model_loading/config_loader.rs @@ -5,12 +5,10 @@ use std::path::{Path, PathBuf}; use crate::adapter::model_loading::manifest::load_model_manifest; use crate::domain::detection::error::MLError; use crate::domain::detection::feature_extractor::feature_is_known; -use crate::domain::detection::manifest::{AdapterKind, LabelSpec, ModelManifest}; +use crate::domain::detection::manifest::{LabelSpec, ModelManifest, OutputSemantic}; use crate::domain::detection::ml_inference_config::MLInferenceConfig; use crate::interface::detection::model_config_loader::ModelConfigLoader; -const AE_ANOMALY_SCORE_FEATURE: &str = "ae_anomaly_score"; - #[derive(Default)] pub struct FsModelConfigLoader; @@ -23,7 +21,7 @@ impl FsModelConfigLoader { fn load_file_at(path: &Path) -> Result { let content = fs::read_to_string(path).map_err(|e| MLError::ConfigLoadFailed(path.to_path_buf(), e))?; let config: MLInferenceConfig = serde_json::from_str(&content).map_err(MLError::ConfigParseFailed)?; - validate(&config)?; + validate_config(&config)?; Ok(config) } @@ -32,22 +30,18 @@ impl FsModelConfigLoader { manifest_path: &Path, ) -> Result<(MLInferenceConfig, ModelManifest), MLError> { let manifest = load_model_manifest(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_rel = manifest.primary_scaler_sidecar().ok_or_else(|| { + MLError::ManifestInvalid( + manifest_path.to_path_buf(), + "primary model requires a preprocessing.standard_scaler sidecar".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)?; + validate_manifest_alignment(&manifest, &config, manifest_path)?; apply_manifest_overrides(&manifest, &mut config); - validate_for_adapter(&config, manifest.adapter)?; + validate_manifest_backed_config(&config, &manifest, manifest_path)?; Ok((config, manifest)) } @@ -63,7 +57,7 @@ impl ModelConfigLoader for FsModelConfigLoader { } } -fn validate(config: &MLInferenceConfig) -> Result<(), MLError> { +fn validate_config(config: &MLInferenceConfig) -> Result<(), MLError> { if config.ae_feature_names.is_empty() { return Err(MLError::ConfigInvalid("ae_feature_names is empty")); } @@ -94,6 +88,158 @@ fn validate(config: &MLInferenceConfig) -> Result<(), MLError> { for (name, params) in &config.ae_clip_params { validate_clip_range(&format!("ae_clip_params.{name}"), params.lower, params.upper)?; } + for name in &config.ae_feature_names { + if !feature_is_known(name) { + return Err(MLError::ConfigInvalid(format!( + "ae_feature_names contains unknown feature '{name}'" + ))); + } + } + Ok(()) +} + +fn validate_manifest_alignment( + manifest: &ModelManifest, + config: &MLInferenceConfig, + manifest_path: &Path, +) -> Result<(), MLError> { + let Some(primary) = manifest.primary_model() else { + return Err(MLError::ManifestInvalid( + manifest_path.to_path_buf(), + "pipeline does not resolve to a primary model".to_string(), + )); + }; + + if config.ae_feature_names != primary.input_features { + return Err(MLError::ManifestInvalid( + manifest_path.to_path_buf(), + "primary model input_features must match ae_feature_names in the runtime sidecar".to_string(), + )); + } + + let Some(classifier) = manifest.classifier_model() else { + return Ok(()); + }; + + if config.classifier_feature_names != classifier.input_features { + return Err(MLError::ManifestInvalid( + manifest_path.to_path_buf(), + "classifier model input_features must match classifier_feature_names in the runtime sidecar".to_string(), + )); + } + Ok(()) +} + +fn apply_manifest_overrides(manifest: &ModelManifest, config: &mut MLInferenceConfig) { + config.attack_labels = manifest_labels_to_map(&manifest.labels); + config.model_type = manifest.runtime_kind(); + + if let Some(primary) = manifest.primary_model() + && let Some(output) = primary + .outputs + .iter() + .find(|output| output.semantic == OutputSemantic::AnomalyScore) + && let Some(threshold) = output.threshold + { + config.ae_threshold = threshold; + } + + if let Some(classifier) = manifest.classifier_model() + && let Some(output) = classifier + .outputs + .iter() + .find(|output| output.semantic == OutputSemantic::Binary || output.name == "anomaly") + && let Some(threshold) = output.threshold + { + config.anomaly_threshold = threshold; + } + if let Some(classifier) = manifest.classifier_model() + && let Some(output) = classifier + .outputs + .iter() + .find(|output| output.semantic == OutputSemantic::Multiclass) + && let Some(min_confidence) = output.min_confidence + { + config.class_min_confidence = min_confidence; + } + if let Some(classifier) = manifest.classifier_model() + && let Some(output) = classifier.outputs.iter().find(|output| output.name == "c2_score") + && let Some(threshold) = output.threshold + { + config.c2_threshold = threshold; + } +} + +fn validate_manifest_backed_config( + config: &MLInferenceConfig, + manifest: &ModelManifest, + manifest_path: &Path, +) -> Result<(), MLError> { + let Some(primary) = manifest.primary_model() else { + return Err(MLError::ManifestInvalid( + manifest_path.to_path_buf(), + "manifest primary model is missing".to_string(), + )); + }; + + if config.ae_feature_names.len() != primary.input_features.len() { + return Err(MLError::ManifestInvalid( + manifest_path.to_path_buf(), + format!( + "ae_feature_names length mismatch: manifest has {}, sidecar has {}", + primary.input_features.len(), + config.ae_feature_names.len() + ), + )); + } + if config + .ae_feature_names + .iter() + .zip(primary.input_features.iter()) + .any(|(lhs, rhs)| lhs != rhs) + { + return Err(MLError::ManifestInvalid( + manifest_path.to_path_buf(), + "ae_feature_names order does not match primary model input_features".to_string(), + )); + } + + if let Some(classifier) = manifest.classifier_model() { + if config.classifier_feature_names.len() != classifier.input_features.len() { + return Err(MLError::ManifestInvalid( + manifest_path.to_path_buf(), + format!( + "classifier_feature_names length mismatch: manifest has {}, sidecar has {}", + classifier.input_features.len(), + config.classifier_feature_names.len() + ), + )); + } + if config + .classifier_feature_names + .iter() + .zip(classifier.input_features.iter()) + .any(|(lhs, rhs)| lhs != rhs) + { + return Err(MLError::ManifestInvalid( + manifest_path.to_path_buf(), + "classifier_feature_names order does not match classifier model input_features".to_string(), + )); + } + } + + if let Some(primary_output) = primary + .outputs + .iter() + .find(|output| output.semantic == OutputSemantic::AnomalyScore) + && let Some(threshold) = primary_output.threshold + && (config.ae_threshold - threshold).abs() > f32::EPSILON + { + return Err(MLError::ManifestInvalid( + manifest_path.to_path_buf(), + "ae_threshold does not match manifest output threshold".to_string(), + )); + } Ok(()) } @@ -133,134 +279,6 @@ fn validate_clip_range(field: &str, lower: f64, upper: f64) -> Result<(), MLErro Ok(()) } -fn validate_for_adapter(config: &MLInferenceConfig, adapter: AdapterKind) -> Result<(), MLError> { - if matches!(adapter, AdapterKind::MultiTask | AdapterKind::ClassifierOnly) - && config.classifier_feature_names.is_empty() - { - return Err(MLError::ConfigInvalid( - "classifier adapters require classifier_feature_names", - )); - } - - match adapter { - AdapterKind::MultiTask => { - validate_output_names(config, &["anomaly", "class_probs", "c2_score"], "MultiTaskModel")?; - validate_multitask_classifier_features(config)?; - } - AdapterKind::ClassifierOnly => { - validate_output_names(config, &["class_probs"], "ClassifierOnly adapter")?; - validate_feature_names("classifier_feature_names", &config.classifier_feature_names)?; - } - AdapterKind::AutoencoderOnly => { - validate_output_names(config, &["reconstruction"], "AutoencoderOnly adapter")?; - } - } - Ok(()) -} - -fn validate_output_names( - config: &MLInferenceConfig, - expected: &[&str], - adapter_name: &'static str, -) -> Result<(), MLError> { - if config - .output_names - .iter() - .map(String::as_str) - .eq(expected.iter().copied()) - { - return Ok(()); - } - Err(MLError::ConfigInvalid(format!( - "{adapter_name} requires output_names {:?} in that order", - expected - ))) -} - -fn validate_multitask_classifier_features(config: &MLInferenceConfig) -> Result<(), MLError> { - let expected_len = config.ae_feature_names.len() + 1; - if config.classifier_feature_names.len() != expected_len { - return Err(MLError::ConfigInvalid(format!( - "MultiTaskModel classifier_feature_names must contain AE features plus {AE_ANOMALY_SCORE_FEATURE} \ - (expected {expected_len}, got {})", - config.classifier_feature_names.len() - ))); - } - if !config - .classifier_feature_names - .iter() - .take(config.ae_feature_names.len()) - .zip(config.ae_feature_names.iter()) - .all(|(classifier_name, ae_name)| classifier_name == ae_name) - { - return Err(MLError::ConfigInvalid( - "MultiTaskModel classifier_feature_names must start with ae_feature_names in the same order", - )); - } - if config.classifier_feature_names.last().map(String::as_str) != Some(AE_ANOMALY_SCORE_FEATURE) { - return Err(MLError::ConfigInvalid(format!( - "MultiTaskModel classifier_feature_names must end with {AE_ANOMALY_SCORE_FEATURE}" - ))); - } - Ok(()) -} - -fn validate_feature_names(field: &str, feature_names: &[String]) -> Result<(), MLError> { - if let Some(name) = feature_names.iter().find(|name| !feature_is_known(name.trim())) { - return Err(MLError::ConfigInvalid(format!( - "{field} contains unknown feature '{name}'" - ))); - } - 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() } @@ -271,150 +289,92 @@ mod tests { use std::io::Write; use super::*; + use crate::domain::detection::manifest::{ + AdapterKind, ModelSpec, OutputHeadSpec, OutputSemantic, PreprocessingStep, + }; use crate::domain::detection::ml_detection::ClipParams; - #[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; + fn test_manifest() -> ModelManifest { + ModelManifest { + name: "test".into(), + version: 2, + models: vec![ + ModelSpec { + id: "anomaly_detector".into(), + file: "deep_autoencoder.onnx".into(), + input_features: vec!["flow_duration".into(), "fwd_packets".into()], + preprocessing: vec![ + PreprocessingStep::StandardScaler { + sidecar: "sidecar.json".into(), + }, + PreprocessingStep::Clip { min: -5.0, max: 5.0 }, + ], + outputs: vec![OutputHeadSpec { + name: "ae_anomaly_score".into(), + shape: vec!["1".into()], + semantic: OutputSemantic::AnomalyScore, + threshold: Some(0.23), + min_confidence: None, + }], + }, + ModelSpec { + id: "classifier".into(), + file: "classifier.onnx".into(), + input_features: vec!["flow_duration".into(), "fwd_packets".into(), "ae_anomaly_score".into()], + preprocessing: vec![], + outputs: vec![ + OutputHeadSpec { + name: "anomaly".into(), + shape: vec!["1".into()], + semantic: OutputSemantic::Binary, + threshold: Some(0.91), + min_confidence: None, + }, + OutputHeadSpec { + name: "class_probs".into(), + shape: vec!["10".into()], + semantic: OutputSemantic::Multiclass, + threshold: None, + min_confidence: Some(0.4), + }, + ], + }, + ], + pipeline: vec!["anomaly_detector".into(), "classifier".into()], + labels: BTreeMap::from([ + ( + "0".into(), + LabelSpec { + name: "Bot".into(), + confirmations: Some(1), + playbook: None, + }, + ), + ( + "7".into(), + LabelSpec { + name: "Normal".into(), + confirmations: None, + playbook: None, + }, + ), + ]), + alert_rules: vec![crate::domain::detection::manifest::AlertRuleSpec { + condition: "anomaly > threshold".into(), + source_label: "anomaly".into(), + }], } - let loader = FsModelConfigLoader; - let (cfg, manifest) = loader - .load_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); - 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() { - let sidecar = test_config(); - - let tmp = env::temp_dir().join("netguardia-m1-mismatch-test"); + fn load_config_accepts_manifest_sidecar_pair() { + let manifest = test_manifest(); + let tmp = env::temp_dir().join("netguardia-v2-config-loader"); fs::create_dir_all(&tmp).unwrap(); - let sidecar_path = tmp.join("sidecar.json"); let manifest_path = tmp.join("manifest.yaml"); + let sidecar_path = tmp.join("sidecar.json"); let mut f = fs::File::create(&sidecar_path).unwrap(); - f.write_all(serde_json::to_string(&sidecar).unwrap().as_bytes()) - .unwrap(); - 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 loader = FsModelConfigLoader; - let err = loader - .load_manifest_with_sidecar(&manifest_path) - .expect_err("should reject count mismatch"); - assert!(matches!(err, MLError::ManifestInvalid { .. }), "got {err:?}"); - } - - #[test] - fn invalid_sidecar_numeric_values_are_rejected() { - for mutate in [ - |cfg: &mut MLInferenceConfig| cfg.ae_scaler_mean[0] = f64::NAN, - |cfg: &mut MLInferenceConfig| cfg.ae_scaler_std[0] = -1.0, - |cfg: &mut MLInferenceConfig| cfg.ae_post_clip_min = 10.0, - |cfg: &mut MLInferenceConfig| cfg.anomaly_threshold = f32::NAN, - |cfg: &mut MLInferenceConfig| cfg.c2_threshold = -0.1, - |cfg: &mut MLInferenceConfig| cfg.class_min_confidence = 1.1, - |cfg: &mut MLInferenceConfig| cfg.alert_threshold_multiplier = 0.0, - ] { - let mut cfg = test_config(); - mutate(&mut cfg); - - let result = validate(&cfg); - - assert!(result.is_err()); - } - } - - #[test] - fn invalid_sidecar_clip_params_are_rejected() { - let mut cfg = test_config(); - cfg.ae_clip_params.insert( - "flow_duration".into(), - ClipParams { - lower: 10.0, - upper: 1.0, - }, - ); - - let result = validate(&cfg); - - assert!(result.is_err()); - } - - #[test] - fn classifier_adapters_require_classifier_features() { - let mut cfg = test_config(); - cfg.classifier_feature_names.clear(); - - assert!(validate_for_adapter(&cfg, AdapterKind::MultiTask).is_err()); - - cfg.output_names = vec!["class_probs".into()]; - assert!(validate_for_adapter(&cfg, AdapterKind::ClassifierOnly).is_err()); - cfg.output_names = vec!["reconstruction".into()]; - assert!(validate_for_adapter(&cfg, AdapterKind::AutoencoderOnly).is_ok()); - } - - #[test] - fn multitask_classifier_features_must_match_runner_layout() { - let mut cfg = test_config(); - cfg.classifier_feature_names.swap(0, 1); - - let err = validate_for_adapter(&cfg, AdapterKind::MultiTask).expect_err("should reject reordered features"); - - assert!(err.to_string().contains("same order"), "got {err:?}"); - - let mut cfg = test_config(); - cfg.classifier_feature_names.pop(); - let err = validate_for_adapter(&cfg, AdapterKind::MultiTask).expect_err("should reject missing AE score"); - assert!(err.to_string().contains("expected 3, got 2"), "got {err:?}"); - - let mut cfg = test_config(); - *cfg.classifier_feature_names.last_mut().unwrap() = "fwd_packets".into(); - let err = validate_for_adapter(&cfg, AdapterKind::MultiTask).expect_err("should reject wrong final feature"); - assert!(err.to_string().contains("ae_anomaly_score"), "got {err:?}"); - } - - #[test] - fn classifier_only_rejects_unknown_features() { - let mut cfg = test_config(); - cfg.output_names = vec!["class_probs".into()]; - cfg.classifier_feature_names = vec!["flow_duration".into(), "not_a_feature".into()]; - - let err = validate_for_adapter(&cfg, AdapterKind::ClassifierOnly).expect_err("should reject unknown feature"); - - assert!(err.to_string().contains("not_a_feature"), "got {err:?}"); - } - - #[test] - fn adapter_output_names_must_match_runtime_order() { - let mut cfg = test_config(); - cfg.output_names = vec!["class_probs".into(), "anomaly".into(), "c2_score".into()]; - - let err = validate_for_adapter(&cfg, AdapterKind::MultiTask).expect_err("should reject reordered outputs"); - - assert!(err.to_string().contains("output_names"), "got {err:?}"); - } - - fn test_config() -> MLInferenceConfig { - MLInferenceConfig { + 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 }), @@ -424,16 +384,58 @@ preprocessing: ae_scaler_std: vec![1.0, 1.0], ae_post_clip_min: -5.0, ae_post_clip_max: 5.0, - ae_threshold: 0.5, + ae_threshold: 0.23, classifier_feature_names: vec!["flow_duration".into(), "fwd_packets".into(), "ae_anomaly_score".into()], attack_labels: HashMap::new(), + anomaly_threshold: 0.91, + c2_threshold: 0.5, + class_min_confidence: 0.4, + alert_threshold_multiplier: 1.2, + model_type: AdapterKind::MultiTask, + output_names: vec!["anomaly".into(), "class_probs".into()], + ae_feature_weights: HashMap::new(), + }; + f.write_all(serde_json::to_string(&sidecar).unwrap().as_bytes()) + .unwrap(); + fs::write(&manifest_path, serde_yaml_ng::to_string(&manifest).unwrap()).unwrap(); + let loader = FsModelConfigLoader; + let (cfg, loaded) = loader + .load_manifest_with_sidecar(&manifest_path) + .expect("load v2 manifest + sidecar"); + assert_eq!(loaded.name, "test"); + assert_eq!( + cfg.ae_feature_names, + vec!["flow_duration".to_string(), "fwd_packets".to_string()] + ); + assert_eq!( + cfg.classifier_feature_names.last().map(String::as_str), + Some("ae_anomaly_score") + ); + assert_eq!(cfg.attack_labels.get("0").map(String::as_str), Some("Bot")); + assert_eq!(cfg.model_type, AdapterKind::MultiTask); + } + + #[test] + fn invalid_sidecar_numeric_values_are_rejected() { + let mut cfg = MLInferenceConfig { + ae_feature_names: vec!["flow_duration".into()], + ae_clip_params: HashMap::from([("flow_duration".into(), ClipParams { lower: 0.0, upper: 1.0 })]), + ae_scaler_mean: vec![0.0], + ae_scaler_std: vec![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()], + attack_labels: HashMap::new(), anomaly_threshold: 0.5, c2_threshold: 0.5, class_min_confidence: 0.4, alert_threshold_multiplier: 1.2, - model_type: AdapterKind::MultiTask, - output_names: vec!["anomaly".into(), "class_probs".into(), "c2_score".into()], + model_type: AdapterKind::ClassifierOnly, + output_names: vec!["class_probs".into()], ae_feature_weights: HashMap::new(), - } + }; + cfg.ae_scaler_std[0] = -1.0; + assert!(validate_config(&cfg).is_err()); } } diff --git a/net-guardia/src/adapter/model_promotion_store.rs b/net-guardia/src/adapter/model_promotion_store.rs index 4fc4e63..a98c176 100644 --- a/net-guardia/src/adapter/model_promotion_store.rs +++ b/net-guardia/src/adapter/model_promotion_store.rs @@ -15,8 +15,11 @@ pub struct FsModelPromotionStore; #[async_trait::async_trait] impl ModelPromotionStore for FsModelPromotionStore { - fn exists(&self, path: &Path) -> io::Result { - path.try_exists() + async fn exists(&self, path: &Path) -> io::Result { + let path = path.to_path_buf(); + task::spawn_blocking(move || path.try_exists()) + .await + .unwrap_or_else(|e| Err(io::Error::other(format!("exists join: {e}")))) } async fn create_dir_all(&self, path: &Path) -> io::Result<()> { diff --git a/net-guardia/src/adapter/persistence/acl.rs b/net-guardia/src/adapter/persistence/acl.rs index 1ea9995..5a450e8 100644 --- a/net-guardia/src/adapter/persistence/acl.rs +++ b/net-guardia/src/adapter/persistence/acl.rs @@ -199,6 +199,10 @@ fn decode_acl_list_type(raw_list_type: String) -> Result Result, Error> { + self.list_acl_rules().await + } + async fn has_manual_acl_rule(&self, ip_address: &str) -> Result { self.has_manual_acl_rule(ip_address).await } diff --git a/net-guardia/src/adapter/persistence/api_key.rs b/net-guardia/src/adapter/persistence/api_key.rs index 0a534b0..e10448c 100644 --- a/net-guardia/src/adapter/persistence/api_key.rs +++ b/net-guardia/src/adapter/persistence/api_key.rs @@ -1,9 +1,5 @@ -use std::fmt::Write; - use async_trait::async_trait; -use hmac::{Hmac, KeyInit, Mac}; use rusqlite::{Error as RusqliteError, params}; -use sha2::Sha256; use super::Database; use crate::common::error::Error; @@ -12,31 +8,14 @@ use crate::domain::identity::auth::{Claims, PermissionLevel}; use crate::domain::identity::user::ApiKeyView; use crate::interface::identity::api_key::ApiKeyRepo; -type HmacSha256 = Hmac; - impl Database { - pub fn hmac_api_key(&self, raw_key: &str) -> String { - let mut mac = match HmacSha256::new_from_slice(&self.api_key_hmac) { - Ok(mac) => mac, - // SAFETY: HMAC accepts keys of any length; `api_key_hmac` is a fixed 32-byte key. - Err(_) => unreachable!("HMAC-SHA256 accepts fixed 32-byte keys"), - }; - mac.update(raw_key.as_bytes()); - let result = mac.finalize().into_bytes(); - - let mut hex = String::with_capacity(64); - for byte in result { - // SAFETY: write! on a String is infallible. - let _ = write!(&mut hex, "{:02x}", byte); - } - hex - } - - pub async fn validate_api_key(&self, api_key: &str) -> Result, Error> { - let digest = self.hmac_api_key(api_key); + pub async fn validate_api_key(&self, key_hash: &str) -> Result, Error> { + let digest = key_hash.to_string(); self.pool - .conn_and_then(move |conn| { - let result = conn.query_row( + .conn_mut_and_then(move |conn| { + let tx = conn.transaction()?; + + let result = tx.query_row( "SELECT id, name, permission_level FROM api_keys WHERE key_hash = ?1", params![digest], |row| { @@ -50,10 +29,11 @@ impl Database { match result { Ok((id, name, level)) => { - conn.execute( + tx.execute( "UPDATE api_keys SET last_used_at = datetime('now') WHERE id = ?1", params![id], )?; + tx.commit()?; let perm_level = PermissionLevel::from_str(&level).ok_or_else(|| { DatabaseError::PersistedValueInvalid("api_keys", "permission_level", level.clone()) @@ -121,12 +101,8 @@ impl Database { #[async_trait] impl ApiKeyRepo for Database { - async fn validate_api_key(&self, api_key: &str) -> Result, Error> { - self.validate_api_key(api_key).await - } - - fn hmac_api_key(&self, raw_key: &str) -> String { - self.hmac_api_key(raw_key) + async fn validate_api_key(&self, key_hash: &str) -> Result, Error> { + self.validate_api_key(key_hash).await } async fn list_api_keys(&self) -> Result, Error> { @@ -144,17 +120,21 @@ impl ApiKeyRepo for Database { #[cfg(test)] mod tests { + use crate::adapter::identity::api_key_hasher::HmacApiKeyHasher; + use crate::interface::identity::api_key_hasher::ApiKeyHasher; + use super::Database; #[tokio::test] async fn validate_full_access_api_key_grants_admin_permissions() { let db = Database::new(":memory:").await.expect("test db"); + let hasher = HmacApiKeyHasher::new([0xAB; 32]); let raw_key = "ng-test-full-access"; - let digest = db.hmac_api_key(raw_key); + let digest = hasher.hash_api_key(raw_key); db.insert_api_key(&digest, "automation", "full_access").await.unwrap(); - let claims = db.validate_api_key(raw_key).await.unwrap().expect("claims"); + let claims = db.validate_api_key(&digest).await.unwrap().expect("claims"); assert!(claims.permissions.contains(&"api_keys:admin".to_string())); assert!(claims.permissions.contains(&"system:admin".to_string())); assert!(claims.permissions.contains(&"users:admin".to_string())); @@ -163,12 +143,13 @@ mod tests { #[tokio::test] async fn validate_read_write_api_key_does_not_grant_admin_permissions() { let db = Database::new(":memory:").await.expect("test db"); + let hasher = HmacApiKeyHasher::new([0xAB; 32]); let raw_key = "ng-test-read-write"; - let digest = db.hmac_api_key(raw_key); + let digest = hasher.hash_api_key(raw_key); db.insert_api_key(&digest, "automation", "read_write").await.unwrap(); - let claims = db.validate_api_key(raw_key).await.unwrap().expect("claims"); + let claims = db.validate_api_key(&digest).await.unwrap().expect("claims"); assert!(!claims.permissions.contains(&"api_keys:admin".to_string())); assert!(!claims.permissions.contains(&"system:admin".to_string())); assert!(!claims.permissions.contains(&"users:admin".to_string())); @@ -177,11 +158,12 @@ mod tests { #[tokio::test] async fn validate_api_key_rejects_invalid_persisted_permission_level() { let db = Database::new(":memory:").await.expect("test db"); + let hasher = HmacApiKeyHasher::new([0xAB; 32]); let raw_key = "ng-test-invalid-level"; - let digest = db.hmac_api_key(raw_key); + let digest = hasher.hash_api_key(raw_key); db.insert_api_key(&digest, "automation", "owner").await.unwrap(); - assert!(db.validate_api_key(raw_key).await.is_err()); + assert!(db.validate_api_key(&digest).await.is_err()); } } diff --git a/net-guardia/src/adapter/persistence/config.rs b/net-guardia/src/adapter/persistence/config.rs index 2a005a2..0b52fea 100644 --- a/net-guardia/src/adapter/persistence/config.rs +++ b/net-guardia/src/adapter/persistence/config.rs @@ -4,6 +4,7 @@ use rusqlite::{Error as RusqliteError, params}; use super::Database; use crate::common::error::Error; use crate::common::error::system::SystemError; +use crate::domain::identity::auth::{GROUP_ADMIN, ROLE_ADMIN}; use crate::interface::system::config_repo::ConfigRepo; use crate::interface::system::setup::SetupRepo; @@ -142,14 +143,25 @@ impl Database { params![channel, config_json], )?; } + tx.execute( + "INSERT INTO users (username, password_hash, role, force_password_change) \ + VALUES (?1, ?2, ?3, 0) \ + ON CONFLICT(username) DO UPDATE SET password_hash = ?2, force_password_change = 0", + params![admin_username, password_hash, ROLE_ADMIN], + )?; let admin_id = tx.query_row( "SELECT id FROM users WHERE username = ?1", params![admin_username], |row| row.get::<_, i64>(0), )?; + let admin_group_id = tx.query_row( + "SELECT id FROM user_groups WHERE name = ?1", + params![GROUP_ADMIN], + |row| row.get::<_, i64>(0), + )?; tx.execute( - "UPDATE users SET password_hash = ?1, force_password_change = 0 WHERE id = ?2", - params![password_hash, admin_id], + "INSERT OR IGNORE INTO user_group_members (user_id, group_id) VALUES (?1, ?2)", + params![admin_id, admin_group_id], )?; tx.execute( "INSERT OR REPLACE INTO system_state (key, value) VALUES ('setup_complete', 'true')", @@ -242,39 +254,9 @@ mod tests { use crate::common::error::Error; use crate::domain::identity::auth::{DEFAULT_ADMIN_USERNAME, ROLE_ADMIN}; - #[tokio::test] - async fn complete_setup_atomically_rolls_back_when_admin_missing() { - let db = Database::new(":memory:").await.expect("database"); - let admin_id = db - .insert_user(DEFAULT_ADMIN_USERNAME, "old-hash", ROLE_ADMIN, true) - .await - .expect("insert admin"); - db.delete_user(admin_id).await.expect("delete admin"); - - let err = db - .complete_setup_atomically( - vec![("ingress_interface".to_string(), "eth0".to_string())], - vec![("smtp_password".to_string(), "encrypted-secret".to_string())], - vec![("telegram".to_string(), "{}".to_string())], - DEFAULT_ADMIN_USERNAME, - "new-hash", - ) - .await - .expect_err("missing admin should abort transaction"); - - assert!(err.to_string().contains("no rows")); - assert_eq!(db.get_config_value("ingress_interface").await.unwrap(), None); - assert_eq!(db.get_app_secret("smtp_password").await.unwrap(), None); - assert_eq!(db.get_notification_config("telegram").await.unwrap(), None); - assert_eq!(db.get_system_state("setup_complete").await.unwrap(), None); - } - #[tokio::test] async fn complete_setup_atomically_commits_all_setup_state() { let db = Database::new(":memory:").await.expect("database"); - db.insert_user(DEFAULT_ADMIN_USERNAME, "old-hash", ROLE_ADMIN, true) - .await - .expect("insert admin"); db.complete_setup_atomically( vec![("ingress_interface".to_string(), "eth0".to_string())], diff --git a/net-guardia/src/adapter/persistence/enforcement.rs b/net-guardia/src/adapter/persistence/enforcement.rs index a1f4731..1a23cba 100644 --- a/net-guardia/src/adapter/persistence/enforcement.rs +++ b/net-guardia/src/adapter/persistence/enforcement.rs @@ -6,7 +6,9 @@ use rusqlite::types::Type; use super::Database; use crate::common::error::Error; use crate::common::error::database::DatabaseError; -use crate::interface::data_plane::enforcement::EnforcementRepo; +use crate::interface::data_plane::enforcement::DnsEnforcementPort; +use crate::interface::data_plane::enforcement::GeoEnforcementPort; +use crate::interface::data_plane::enforcement::RateLimitWritePort; impl Database { pub async fn set_rate_limits(&self, values: &[(String, u64)]) -> Result<(), Error> { @@ -150,10 +152,21 @@ fn decode_rate_limit_value(raw_value: i64) -> Result { } #[async_trait] -impl EnforcementRepo for Database { +impl RateLimitWritePort for Database { + async fn load_rate_limit_config(&self) -> Result, Error> { + self.load_rate_limit_config().await + } + async fn set_rate_limits(&self, values: &[(String, u64)]) -> Result<(), Error> { self.set_rate_limits(values).await } +} + +#[async_trait] +impl DnsEnforcementPort for Database { + async fn load_dns_domains(&self) -> Result, Error> { + self.load_dns_domains().await + } async fn insert_dns_domains(&self, domains: &[String]) -> Result<(), Error> { self.insert_dns_domains(domains).await @@ -162,6 +175,13 @@ impl EnforcementRepo for Database { async fn delete_dns_domains(&self, domains: &[String]) -> Result<(), Error> { self.delete_dns_domains(domains).await } +} + +#[async_trait] +impl GeoEnforcementPort for Database { + async fn load_geo_countries(&self) -> Result, Error> { + self.load_geo_countries().await + } async fn insert_geo_countries(&self, codes: &[String]) -> Result<(), Error> { self.insert_geo_countries(codes).await diff --git a/net-guardia/src/adapter/persistence/mod.rs b/net-guardia/src/adapter/persistence/mod.rs index b16baac..bb77a87 100644 --- a/net-guardia/src/adapter/persistence/mod.rs +++ b/net-guardia/src/adapter/persistence/mod.rs @@ -52,7 +52,6 @@ fn db_encryption_key() -> Option { pub struct Database { pool: Client, - api_key_hmac: [u8; 32], } impl Database { @@ -89,14 +88,14 @@ impl Database { .await .map_err(|_| DatabaseError::EncryptionKeyInvalid)?; - let api_key_hmac = Self::derive_api_key_hmac(path, encryption_key.as_deref())?; - let db = Self { pool, api_key_hmac }; + let db = Self { pool }; db.create_tables().await?; Ok(db) } - fn derive_api_key_hmac(path: &str, encryption_key: Option<&str>) -> Result<[u8; 32], Error> { - let root_key = api_key_hmac_root_key(path, encryption_key)?; + pub fn derive_api_key_hmac(path: &str) -> Result<[u8; 32], Error> { + let encryption_key = db_encryption_key(); + let root_key = api_key_hmac_root_key(path, encryption_key.as_deref())?; let hk = Hkdf::::new(Some(b"netguardia-v1-salt"), root_key.as_bytes()); let mut okm = [0u8; 32]; if hk.expand(b"netguardia-apikey-hmac-v1", &mut okm).is_err() { diff --git a/net-guardia/src/adapter/persistence/soar.rs b/net-guardia/src/adapter/persistence/soar.rs index cb17cee..82551cf 100644 --- a/net-guardia/src/adapter/persistence/soar.rs +++ b/net-guardia/src/adapter/persistence/soar.rs @@ -4,12 +4,13 @@ use rusqlite::{Transaction, params}; use super::Database; use crate::common::error::Error; use crate::common::error::database::DatabaseError; +use crate::domain::data_plane::ip_version::IpVersion; use crate::domain::response::defaults::{DEFAULT_PLAYBOOKS, DefaultPlaybook}; use crate::interface::response::playbook_data::{ ActionInput, ActionView, ActiveBlockView, ConditionView, CreateConditionInput, CreatePlaybookInput, ExecutionView, PendingUnblock, PlaybookView, UpdatePlaybookInput, }; -use crate::interface::response::soar::SoarRepo; +use crate::interface::response::soar::{PlaybookRepo, SoarBlockRepo}; struct PlaybookActionRow { pb_id: i64, @@ -203,7 +204,7 @@ fn seed_default_playbooks_tx(tx: &Transaction<'_>, defaults: &[DefaultPlaybook]) } #[async_trait] -impl SoarRepo for Database { +impl PlaybookRepo for Database { async fn list_playbooks(&self) -> Result, Error> { let rows = self.list_playbooks_with_actions().await?; let mut result: Vec = Vec::new(); @@ -250,49 +251,6 @@ impl SoarRepo for Database { Ok(result) } - async fn count_active_soar_blocks(&self) -> Result { - self.count_active_soar_blocks().await - } - - async fn list_active_soar_blocks(&self) -> Result, Error> { - self.list_active_soar_blocks().await - } - - async fn find_soar_block_by_id(&self, id: i64) -> Result, Error> { - self.find_soar_block_by_id(id).await - } - - async fn list_expired_soar_blocks(&self) -> Result, Error> { - self.list_expired_soar_blocks().await - } - - async fn list_pending_unblocks(&self) -> Result, Error> { - self.list_pending_unblocks().await - } - - async fn list_soar_executions(&self, limit: i64) -> Result, Error> { - self.list_soar_executions(limit).await - } - - async fn seed_default_playbooks(&self) -> Result<(), Error> { - self.seed_default_playbooks().await - } - - async fn insert_pending_unblock(&self, source_ip: &str) -> Result { - self.insert_pending_unblock(source_ip).await - } - - async fn insert_soar_execution( - &self, - playbook_id: i64, - source_ip: Option<&str>, - trigger_event: &str, - actions_json: &str, - ) -> Result { - self.insert_soar_execution(playbook_id, source_ip, trigger_event, actions_json) - .await - } - async fn insert_playbook_atomic( &self, input: &CreatePlaybookInput, @@ -382,6 +340,52 @@ impl SoarRepo for Database { .await } + async fn delete_playbook(&self, id: i64) -> Result { + self.delete_playbook(id).await + } +} + +#[async_trait] +impl SoarBlockRepo for Database { + async fn count_active_soar_blocks(&self) -> Result { + self.count_active_soar_blocks().await + } + + async fn list_active_soar_blocks(&self) -> Result, Error> { + self.list_active_soar_blocks().await + } + + async fn find_soar_block_by_id(&self, id: i64) -> Result, Error> { + self.find_soar_block_by_id(id).await + } + + async fn list_expired_soar_blocks(&self) -> Result, Error> { + self.list_expired_soar_blocks().await + } + + async fn list_pending_unblocks(&self) -> Result, Error> { + self.list_pending_unblocks().await + } + + async fn list_soar_executions(&self, limit: i64) -> Result, Error> { + self.list_soar_executions(limit).await + } + + async fn insert_pending_unblock(&self, source_ip: &str) -> Result { + self.insert_pending_unblock(source_ip).await + } + + async fn insert_soar_execution( + &self, + playbook_id: i64, + source_ip: Option<&str>, + trigger_event: &str, + actions_json: &str, + ) -> Result { + self.insert_soar_execution(playbook_id, source_ip, trigger_event, actions_json) + .await + } + async fn mark_soar_block_unblocked(&self, id: i64) -> Result<(), Error> { self.mark_soar_block_unblocked(id).await } @@ -394,13 +398,30 @@ impl SoarRepo for Database { self.mark_pending_unblock_exhausted(id, last_error).await } - async fn delete_playbook(&self, id: i64) -> Result { - self.delete_playbook(id).await - } - async fn delete_pending_unblock(&self, id: i64) -> Result<(), Error> { self.delete_pending_unblock(id).await } + + async fn commit_soar_block_to_db( + &self, + source_ip: &str, + ip_version: IpVersion, + playbook_id: i64, + expires_at: &str, + ) -> Result { + self.commit_soar_block_to_db(source_ip, ip_version, playbook_id, expires_at) + .await + } + + async fn commit_soar_unblock_to_db( + &self, + soar_block_id: i64, + ip_version: IpVersion, + source_ip: &str, + ) -> Result<(), Error> { + self.commit_soar_unblock_to_db(soar_block_id, ip_version, source_ip) + .await + } } #[cfg(test)] diff --git a/net-guardia/src/adapter/persistence/soar_block.rs b/net-guardia/src/adapter/persistence/soar_block.rs index 3a3c140..13c46af 100644 --- a/net-guardia/src/adapter/persistence/soar_block.rs +++ b/net-guardia/src/adapter/persistence/soar_block.rs @@ -1,11 +1,9 @@ -use async_trait::async_trait; use rusqlite::{Error as RusqliteError, params}; use super::Database; use crate::common::error::Error; use crate::domain::data_plane::ip_version::IpVersion; use crate::interface::response::playbook_data::{ActiveBlockView, PendingUnblock}; -use crate::interface::system::db_admin::DbAdminRepo; fn active_block_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { Ok(ActiveBlockView { @@ -246,30 +244,6 @@ impl Database { } } -#[async_trait] -impl DbAdminRepo for Database { - async fn commit_soar_block_to_db( - &self, - source_ip: &str, - ip_version: IpVersion, - playbook_id: i64, - expires_at: &str, - ) -> Result { - self.commit_soar_block_to_db(source_ip, ip_version, playbook_id, expires_at) - .await - } - - async fn commit_soar_unblock_to_db( - &self, - soar_block_id: i64, - ip_version: IpVersion, - source_ip: &str, - ) -> Result<(), Error> { - self.commit_soar_unblock_to_db(soar_block_id, ip_version, source_ip) - .await - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/net-guardia/src/adapter/suricata_monitor.rs b/net-guardia/src/adapter/suricata_monitor.rs index 0edef78..7bd0c6c 100644 --- a/net-guardia/src/adapter/suricata_monitor.rs +++ b/net-guardia/src/adapter/suricata_monitor.rs @@ -8,14 +8,14 @@ use macros::log; use tokio::fs::{self, File}; use tokio::io::{AsyncBufReadExt, AsyncSeekExt, BufReader}; use tokio::sync::mpsc; -use tokio::sync::mpsc::error::TrySendError; use tokio::task::JoinHandle; use tokio::time::sleep; +use crate::common::log::suricata::SuricataLog; +use crate::core::detection::send_detection_or_log; use crate::domain::common::config::AppConfig; use crate::domain::common::event::{DetectionEvent, DetectionSource}; use crate::domain::detection::attack_type::translate; -use crate::domain::detection::log::{DetectionLog, SuricataLog}; const IANA_PROTO_ICMP: u8 = 1; const IANA_PROTO_TCP: u8 = 6; @@ -190,20 +190,6 @@ fn may_be_alert_event(raw: &str) -> bool { false } -fn send_detection_or_log(tx: &mpsc::Sender, event: DetectionEvent) -> bool { - match tx.try_send(event) { - Ok(()) => true, - Err(TrySendError::Full(dropped) | TrySendError::Closed(dropped)) => { - log!(DetectionLog::DetectionChannelDrop( - format!("{:?}", dropped.source), - dropped.attack_type, - dropped.source_ip, - )); - false - } - } -} - #[cfg(test)] mod tests { use std::sync::Arc; diff --git a/net-guardia/src/adapter/telegram.rs b/net-guardia/src/adapter/telegram.rs index 7261293..cc759ed 100644 --- a/net-guardia/src/adapter/telegram.rs +++ b/net-guardia/src/adapter/telegram.rs @@ -4,6 +4,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use arc_swap::ArcSwap; use async_trait::async_trait; +use chrono::{TimeZone, Utc}; use macros::log; use reqwest::Client; use tokio::time::sleep; @@ -177,7 +178,12 @@ impl TelegramAdapter { let country = telegram_html_escape(country_str); let threat_type = telegram_html_escape(&payload.threat_type); let action_description = telegram_html_escape(&payload.action_description); - let timestamp = telegram_html_escape(&payload.timestamp); + let ts_formatted = Utc + .timestamp_opt(payload.timestamp, 0) + .single() + .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string()) + .unwrap_or_else(|| payload.timestamp.to_string()); + let timestamp = telegram_html_escape(&ts_formatted); format!( "🛡 [NetGuardia] {action}\n\ Source: {src} ({country})\n\ @@ -315,7 +321,7 @@ mod tests { threat_type: "\">owned".to_string(), confidence: 0.9, action_description: "blocked & notified".to_string(), - timestamp: "2026-05-07T00:00:00Z".to_string(), + timestamp: 1778284800, }; let message = TelegramAdapter::format_alert_message(&payload); diff --git a/net-guardia/src/adapter/websocket/health_websocket.rs b/net-guardia/src/adapter/websocket/health_websocket.rs index c32e454..0cd9357 100644 --- a/net-guardia/src/adapter/websocket/health_websocket.rs +++ b/net-guardia/src/adapter/websocket/health_websocket.rs @@ -1,8 +1,11 @@ +use std::sync::Arc; + use actix_web::rt::spawn; use actix_web::{HttpRequest, HttpResponse, Result, web}; use actix_ws::handle; use super::ws_bridge; +use crate::domain::common::system::health::SystemHealthMetrics; use crate::interface::system::health_query::HealthQuery; pub async fn websocket_system_health( @@ -12,6 +15,11 @@ pub async fn websocket_system_health( ) -> Result { let (response, session, msg_stream) = handle(&req, body)?; let rx = health.subscribe_to_metrics(); - spawn(ws_bridge::broadcast_json(session, msg_stream, rx)); + spawn(ws_bridge::broadcast_loop( + session, + msg_stream, + rx, + |event: &Arc| ws_bridge::serialize_json(event.as_ref()), + )); Ok(response) } diff --git a/net-guardia/src/adapter/websocket/routes.rs b/net-guardia/src/adapter/websocket/routes.rs index 15e032d..2971e4f 100644 --- a/net-guardia/src/adapter/websocket/routes.rs +++ b/net-guardia/src/adapter/websocket/routes.rs @@ -9,16 +9,14 @@ use crate::adapter::http::session::SessionCookieService; use crate::core::common::statistics::FlowStatistics; use crate::core::identity::session_service::SessionService; use crate::core::inference::alert::MLAlert; +use crate::domain::common::config::constants::{ + PERMISSION_AI_DETECTION_READ, PERMISSION_DASHBOARD_READ, PERMISSION_DROPS_READ, PERMISSION_FUSION_READ, + PERMISSION_TRAFFIC_MAP_READ, +}; use crate::domain::common::event::ThreatDetectedEvent; use crate::domain::identity::auth::Claims; use crate::interface::system::health_query::HealthQuery; -const PERMISSION_DASHBOARD_READ: &str = "dashboard:read"; -const PERMISSION_AI_DETECTION_READ: &str = "ai_detection:read"; -const PERMISSION_FUSION_READ: &str = "fusion:read"; -const PERMISSION_TRAFFIC_MAP_READ: &str = "traffic_map:read"; -const PERMISSION_DROPS_READ: &str = "drops:read"; - pub fn initialize() -> Scope { web::scope("/ws") .route("/health", web::get().to(health_ws)) diff --git a/net-guardia/src/adapter/websocket/ws_bridge.rs b/net-guardia/src/adapter/websocket/ws_bridge.rs index 41ab1ff..b6adf14 100644 --- a/net-guardia/src/adapter/websocket/ws_bridge.rs +++ b/net-guardia/src/adapter/websocket/ws_bridge.rs @@ -29,7 +29,7 @@ async fn handle_client_message( } } -fn serialize_json(value: &T) -> Option { +pub fn serialize_json(value: &T) -> Option { match serde_json::to_string(value) { Ok(json) => Some(json), Err(err) => { diff --git a/net-guardia/src/common/error/mod.rs b/net-guardia/src/common/error/mod.rs index ad13c09..314331c 100644 --- a/net-guardia/src/common/error/mod.rs +++ b/net-guardia/src/common/error/mod.rs @@ -4,6 +4,7 @@ pub mod database; pub mod http; pub mod io; pub mod notification; +pub mod suricata; pub mod system; use serde::{Deserialize, Serialize}; @@ -14,10 +15,10 @@ use crate::common::error::database::DatabaseError; use crate::common::error::http::HttpError; use crate::common::error::io::IOError; use crate::common::error::notification::NotificationError; +use crate::common::error::suricata::SuricataError; use crate::common::error::system::SystemError; use crate::domain::data_plane::error::EbpfError; use crate::domain::detection::error::MLError; -use crate::domain::detection::error::SuricataError; use crate::domain::identity::error::AuthError; use crate::domain::report::error::ReportError; use crate::domain::response::error::SoarError; diff --git a/net-guardia/src/common/error/suricata.rs b/net-guardia/src/common/error/suricata.rs new file mode 100644 index 0000000..6a169f4 --- /dev/null +++ b/net-guardia/src/common/error/suricata.rs @@ -0,0 +1,19 @@ +use macros::traceable; + +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, + + #[error("Failed to open eve.json stream at '{path}'")] + EveOpenFailed { path: String } => tracing::Level::ERROR, + } +} diff --git a/net-guardia/src/common/error/system.rs b/net-guardia/src/common/error/system.rs index 28b81af..f84a041 100644 --- a/net-guardia/src/common/error/system.rs +++ b/net-guardia/src/common/error/system.rs @@ -29,10 +29,6 @@ traceable! { #[error("Configuration file not found")] ConfigNotFound => tracing::Level::ERROR, - #[no_source] - #[error("Failed to send shutdown signal")] - ShutdownSignalFailed => tracing::Level::ERROR, - #[error("Unexpected error")] UnexpectedError => tracing::Level::ERROR, diff --git a/net-guardia/src/common/log/audit.rs b/net-guardia/src/common/log/audit.rs index 8f6bbcf..769a100 100644 --- a/net-guardia/src/common/log/audit.rs +++ b/net-guardia/src/common/log/audit.rs @@ -18,8 +18,8 @@ loggable! { #[error("AuditLogger: DB write failed for drift event: {error}")] AuditDriftDbWriteFailed { error: String } => tracing::Level::WARN, - #[error("AuditLogger lagged by {count} events")] - AuditLagged { count: u64 } => tracing::Level::WARN, + #[error("AuditLogger [{channel}] lagged by {count} events")] + AuditLagged { channel: String, count: u64 } => tracing::Level::WARN, #[error("AuditLogger: event channel closed")] AuditChannelClosed => tracing::Level::INFO, diff --git a/net-guardia/src/common/log/mod.rs b/net-guardia/src/common/log/mod.rs index 87603a5..32d64a5 100644 --- a/net-guardia/src/common/log/mod.rs +++ b/net-guardia/src/common/log/mod.rs @@ -6,4 +6,5 @@ pub mod health; pub mod http; pub mod notification; pub mod reporting; +pub mod suricata; pub mod system; diff --git a/net-guardia/src/common/log/suricata.rs b/net-guardia/src/common/log/suricata.rs new file mode 100644 index 0000000..1b651a2 --- /dev/null +++ b/net-guardia/src/common/log/suricata.rs @@ -0,0 +1,48 @@ +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 subprocess SIGTERM failed: {error}")] + ShutdownSignalFailed { error: String } => tracing::Level::WARN, + + #[error("Suricata subprocess SIGKILL failed after timeout: {error}")] + ShutdownKillFailed { error: String } => tracing::Level::ERROR, + + #[error("Suricata eve.json monitor waiting for file: {path}")] + MonitorWaitingForFile { path: String } => tracing::Level::INFO, + + #[error("Suricata eve.json monitor failed to open {path}: {error}")] + MonitorOpenFailed { path: String, error: String } => tracing::Level::WARN, + + #[error("Suricata eve.json monitor failed to seek to end of {path}: {error}")] + MonitorSeekFailed { path: String, error: String } => tracing::Level::WARN, + + #[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/common/utils/log_level.rs b/net-guardia/src/common/utils/log_level.rs new file mode 100644 index 0000000..51bb754 --- /dev/null +++ b/net-guardia/src/common/utils/log_level.rs @@ -0,0 +1,9 @@ +pub fn level_severity(level: &str) -> u8 { + match level { + "ERROR" => 1, + "WARN" => 2, + "INFO" => 3, + "DEBUG" => 4, + _ => 5, + } +} diff --git a/net-guardia/src/common/utils/mod.rs b/net-guardia/src/common/utils/mod.rs index 2063c6d..2b3dc26 100644 --- a/net-guardia/src/common/utils/mod.rs +++ b/net-guardia/src/common/utils/mod.rs @@ -1,3 +1,4 @@ pub mod ip_address; +pub mod log_level; pub mod packet_parser; pub mod security; diff --git a/net-guardia/src/core/correlation/botnet.rs b/net-guardia/src/core/correlation/botnet.rs index d5528e5..8d5a169 100644 --- a/net-guardia/src/core/correlation/botnet.rs +++ b/net-guardia/src/core/correlation/botnet.rs @@ -6,8 +6,9 @@ use macros::log; use crate::core::correlation::correlation_cleanup::capped_cleanup; use crate::domain::common::config::correlation::CorrelationDetectorParams; -use crate::domain::common::event::{DetectionEvent, DetectionSource, FlowObservation}; +use crate::domain::common::event::{DetectionEvent, DetectionSource}; use crate::domain::detection::attack_type::CanonicalAttackType; +use crate::domain::detection::flow_observation::FlowObservation; use crate::domain::detection::log::DetectionLog; struct TimedSourceSet { diff --git a/net-guardia/src/core/correlation/engine.rs b/net-guardia/src/core/correlation/engine.rs index ae3259c..b2e57e0 100644 --- a/net-guardia/src/core/correlation/engine.rs +++ b/net-guardia/src/core/correlation/engine.rs @@ -4,15 +4,16 @@ use std::time::Duration; use arc_swap::ArcSwap; use macros::log; use tokio::sync::broadcast::error::RecvError; -use tokio::sync::mpsc::error::TrySendError; use tokio::sync::{broadcast, mpsc}; use tokio::time::interval; use crate::core::correlation::botnet::BotnetDetector; use crate::core::correlation::lateral::LateralMovementDetector; use crate::core::correlation::scan::ScanDetector; +use crate::core::detection::send_detection_or_log; use crate::domain::common::config::AppConfig; -use crate::domain::common::event::{DetectionEvent, FlowObservation}; +use crate::domain::common::event::DetectionEvent; +use crate::domain::detection::flow_observation::FlowObservation; use crate::domain::detection::log::DetectionLog; pub struct CorrelationEngine { @@ -66,13 +67,13 @@ impl CorrelationEngine { fn process_alert(&self, alert: &FlowObservation) { if let Some(event) = self.botnet.process(alert) { - let _ = send_or_log(&self.detection_tx, event); + let _ = send_detection_or_log(&self.detection_tx, event); } if let Some(event) = self.scan.process(alert) { - let _ = send_or_log(&self.detection_tx, event); + let _ = send_detection_or_log(&self.detection_tx, event); } if let Some(event) = self.lateral.process(alert) { - let _ = send_or_log(&self.detection_tx, event); + let _ = send_detection_or_log(&self.detection_tx, event); } } @@ -84,26 +85,12 @@ impl CorrelationEngine { } } -fn send_or_log(tx: &mpsc::Sender, event: DetectionEvent) -> bool { - match tx.try_send(event) { - Ok(()) => true, - Err(TrySendError::Full(dropped) | TrySendError::Closed(dropped)) => { - log!(DetectionLog::DetectionChannelDrop( - format!("{:?}", dropped.source), - dropped.attack_type, - dropped.source_ip, - )); - false - } - } -} - #[cfg(test)] mod tests { use tokio::sync::mpsc; - use super::*; - use crate::domain::common::event::DetectionSource; + use crate::core::detection::send_detection_or_log; + use crate::domain::common::event::{DetectionEvent, DetectionSource}; fn detection_event() -> DetectionEvent { DetectionEvent { @@ -122,25 +109,25 @@ mod tests { } #[test] - fn send_or_log_reports_success() { + fn send_detection_or_log_reports_success() { let (tx, _rx) = mpsc::channel(1); - assert!(send_or_log(&tx, detection_event())); + assert!(send_detection_or_log(&tx, detection_event())); } #[test] - fn send_or_log_reports_full_channel_drop() { + fn send_detection_or_log_reports_full_channel_drop() { let (tx, _rx) = mpsc::channel(1); tx.try_send(detection_event()).unwrap(); - assert!(!send_or_log(&tx, detection_event())); + assert!(!send_detection_or_log(&tx, detection_event())); } #[test] - fn send_or_log_reports_closed_channel_drop() { + fn send_detection_or_log_reports_closed_channel_drop() { let (tx, rx) = mpsc::channel(1); drop(rx); - assert!(!send_or_log(&tx, detection_event())); + assert!(!send_detection_or_log(&tx, detection_event())); } } diff --git a/net-guardia/src/core/correlation/lateral.rs b/net-guardia/src/core/correlation/lateral.rs index 46c4166..56a662a 100644 --- a/net-guardia/src/core/correlation/lateral.rs +++ b/net-guardia/src/core/correlation/lateral.rs @@ -7,8 +7,9 @@ use macros::log; use crate::common::utils::ip_address::is_internal_ip; use crate::core::correlation::correlation_cleanup::capped_cleanup; use crate::domain::common::config::correlation::CorrelationDetectorParams; -use crate::domain::common::event::{DetectionEvent, DetectionSource, FlowObservation}; +use crate::domain::common::event::{DetectionEvent, DetectionSource}; use crate::domain::detection::attack_type::CanonicalAttackType; +use crate::domain::detection::flow_observation::FlowObservation; use crate::domain::detection::log::DetectionLog; struct TimedDestSet { diff --git a/net-guardia/src/core/correlation/scan.rs b/net-guardia/src/core/correlation/scan.rs index 9401341..cdd7568 100644 --- a/net-guardia/src/core/correlation/scan.rs +++ b/net-guardia/src/core/correlation/scan.rs @@ -6,8 +6,9 @@ use macros::log; use crate::core::correlation::correlation_cleanup::capped_cleanup; use crate::domain::common::config::correlation::CorrelationDetectorParams; -use crate::domain::common::event::{DetectionEvent, DetectionSource, FlowObservation}; +use crate::domain::common::event::{DetectionEvent, DetectionSource}; use crate::domain::detection::attack_type::CanonicalAttackType; +use crate::domain::detection::flow_observation::FlowObservation; use crate::domain::detection::log::DetectionLog; struct TimedPortSet { diff --git a/net-guardia/src/core/data_plane/acl_service.rs b/net-guardia/src/core/data_plane/acl_service.rs index 2f5858f..80563b5 100644 --- a/net-guardia/src/core/data_plane/acl_service.rs +++ b/net-guardia/src/core/data_plane/acl_service.rs @@ -1,22 +1,23 @@ use std::collections::HashSet; -use std::net::{SocketAddrV4, SocketAddrV6}; +use std::net::{Ipv4Addr, Ipv6Addr, SocketAddrV4, SocketAddrV6}; use std::sync::Arc; use macros::log; use crate::common::error::Error; +use crate::common::log::data_plane::DataPlaneLog; use crate::domain::data_plane::direction::FlowDirection; use crate::domain::data_plane::error::EbpfError; use crate::domain::data_plane::ip_version::IpVersion; use crate::domain::data_plane::list_type::ListType; use crate::interface::data_plane::access_control_admin::AccessControlAdminPort; use crate::interface::data_plane::acl::AclRepo; -use crate::interface::data_plane::enforcement::EnforcementRepo; +use crate::interface::data_plane::enforcement::GeoEnforcementPort; use crate::interface::data_plane::geo_block_api::GeoBlockPort; pub struct AclService { acl_repo: Arc, - enforcement_repo: Arc, + enforcement_repo: Arc, access_control: Arc, geo_block: Arc, } @@ -24,7 +25,7 @@ pub struct AclService { impl AclService { pub fn new( acl_repo: Arc, - enforcement_repo: Arc, + enforcement_repo: Arc, access_control: Arc, geo_block: Arc, ) -> Self { @@ -36,6 +37,70 @@ impl AclService { } } + pub async fn restore(&self) { + self.restore_geo_countries().await; + self.restore_acl_rules().await; + } + + async fn restore_geo_countries(&self) { + let countries = match self.enforcement_repo.load_geo_countries().await { + Ok(c) if !c.is_empty() => c, + _ => return, + }; + match self.geo_block.block_countries(&countries) { + Ok(_) => log!(DataPlaneLog::GeoCountriesRestored(countries.len())), + Err(e) => log!(DataPlaneLog::GeoRestoreFailed(e.to_string())), + } + } + + async fn restore_acl_rules(&self) { + let rules = match self.acl_repo.list_acl_rules().await { + Ok(r) => r, + Err(_) => return, + }; + let mut restored = 0u32; + for rule in &rules { + let result = match rule.ip_version { + IpVersion::V4 => match rule.ip_address.parse::() { + Ok(addr) => self.access_control.add_ipv4_list( + rule.direction, + rule.list_type, + SocketAddrV4::new(addr, rule.port), + ), + Err(e) => { + log!(DataPlaneLog::AclIpv4ParseFailed(rule.ip_address.clone(), e.to_string())); + continue; + } + }, + IpVersion::V6 => match rule.ip_address.parse::() { + Ok(addr) => self.access_control.add_ipv6_list( + rule.direction, + rule.list_type, + SocketAddrV6::new(addr, rule.port, 0, 0), + ), + Err(e) => { + log!(DataPlaneLog::AclIpv6ParseFailed(rule.ip_address.clone(), e.to_string())); + continue; + } + }, + }; + if let Err(e) = result { + log!(DataPlaneLog::AclRuleRestoreFailed( + rule.direction.as_str().to_string(), + rule.list_type.as_str().to_string(), + rule.ip_address.clone(), + rule.port, + e.to_string() + )); + } else { + restored += 1; + } + } + if restored > 0 { + log!(DataPlaneLog::AclRulesRestored(restored as usize)); + } + } + pub async fn add_ipv4( &self, direction: FlowDirection, diff --git a/net-guardia/src/core/data_plane/dns_filter_service.rs b/net-guardia/src/core/data_plane/dns_filter_service.rs index df9791e..a5be305 100644 --- a/net-guardia/src/core/data_plane/dns_filter_service.rs +++ b/net-guardia/src/core/data_plane/dns_filter_service.rs @@ -9,23 +9,38 @@ use crate::common::log::data_plane::DataPlaneLog; use crate::domain::common::config::AppConfig; use crate::domain::data_plane::error::EbpfError; use crate::interface::data_plane::dns_filter_api::DnsFilterPort; -use crate::interface::data_plane::enforcement::EnforcementRepo; +use crate::interface::data_plane::enforcement::DnsEnforcementPort; pub struct DnsFilterService { - db: Arc, + db: Arc, dns_filter: Arc, config: Arc>, } impl DnsFilterService { pub fn new( - db: Arc, + db: Arc, dns_filter: Arc, config: Arc>, ) -> Self { Self { db, dns_filter, config } } + pub async fn restore(&self) { + let domains = match self.db.load_dns_domains().await { + Ok(d) => d, + Err(_) => return, + }; + for domain in &domains { + if let Err(e) = self.dns_filter.add_domain(domain) { + log!(DataPlaneLog::DnsRestoreFailed(domain.clone(), e.to_string())); + } + } + if !domains.is_empty() { + log!(DataPlaneLog::DnsBlacklistRestored(domains.len())); + } + } + pub fn list_domains(&self) -> Vec { self.dns_filter.list_domains() } @@ -137,9 +152,9 @@ mod tests { } #[async_trait] - impl EnforcementRepo for FailingDnsRepo { - async fn set_rate_limits(&self, _values: &[(String, u64)]) -> Result<(), Error> { - Ok(()) + impl DnsEnforcementPort for FailingDnsRepo { + async fn load_dns_domains(&self) -> Result, Error> { + Ok(self.domains.lock().unwrap().clone()) } async fn insert_dns_domains(&self, domains: &[String]) -> Result<(), Error> { @@ -157,14 +172,6 @@ mod tests { self.domains.lock().unwrap().retain(|d| !domains.contains(d)); Ok(()) } - - async fn insert_geo_countries(&self, _codes: &[String]) -> Result<(), Error> { - Ok(()) - } - - async fn delete_geo_countries(&self, _codes: &[String]) -> Result<(), Error> { - Ok(()) - } } async fn test_config() -> Arc> { diff --git a/net-guardia/src/core/data_plane/rate_limit_service.rs b/net-guardia/src/core/data_plane/rate_limit_service.rs index a092e8f..3968d88 100644 --- a/net-guardia/src/core/data_plane/rate_limit_service.rs +++ b/net-guardia/src/core/data_plane/rate_limit_service.rs @@ -6,7 +6,7 @@ use crate::common::error::Error; use crate::common::log::data_plane::DataPlaneLog; use crate::domain::common::system::rate_limit_settings::RateLimitSettings; use crate::domain::data_plane::error::EbpfError; -use crate::interface::data_plane::enforcement::EnforcementRepo; +use crate::interface::data_plane::enforcement::RateLimitWritePort; use crate::interface::data_plane::rate_limit_api::RateLimitPort; const PACKET_RATE_KEY: &str = "packet_rate"; @@ -22,15 +22,30 @@ struct RateLimitChange { } pub struct RateLimitService { - db: 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 async fn restore(&self) { + let configs = match self.db.load_rate_limit_config().await { + Ok(c) => c, + Err(_) => return, + }; + for (key, value) in &configs { + if let Err(e) = self.apply(key, *value) { + log!(DataPlaneLog::RateLimitRestoreFailed(key.clone(), e.to_string())); + } + } + if !configs.is_empty() { + log!(DataPlaneLog::RateLimitsRestored(configs.len())); + } + } + pub fn current_settings(&self) -> Result { Ok(RateLimitSettings { packet_rate: Some(self.config.get_packet_rate()?), @@ -153,7 +168,11 @@ mod tests { } #[async_trait] - impl EnforcementRepo for FakeRepo { + impl RateLimitWritePort for FakeRepo { + async fn load_rate_limit_config(&self) -> Result, Error> { + Ok(self.values.lock().expect("test lock").clone()) + } + async fn set_rate_limits(&self, values: &[(String, u64)]) -> Result<(), Error> { if self.fail { Err(DatabaseError::QueryFailed("forced db failure"))?; @@ -161,22 +180,6 @@ mod tests { self.values.lock().expect("test lock").extend_from_slice(values); Ok(()) } - - async fn insert_dns_domains(&self, _domains: &[String]) -> Result<(), Error> { - Ok(()) - } - - async fn delete_dns_domains(&self, _domains: &[String]) -> Result<(), Error> { - Ok(()) - } - - async fn insert_geo_countries(&self, _codes: &[String]) -> Result<(), Error> { - Ok(()) - } - - async fn delete_geo_countries(&self, _codes: &[String]) -> Result<(), Error> { - Ok(()) - } } struct FakeRateLimitPort { diff --git a/net-guardia/src/core/detection/beaconing.rs b/net-guardia/src/core/detection/beaconing.rs index f672cde..d865332 100644 --- a/net-guardia/src/core/detection/beaconing.rs +++ b/net-guardia/src/core/detection/beaconing.rs @@ -5,13 +5,13 @@ use arc_swap::ArcSwap; use dashmap::DashMap; use macros::log; use tokio::sync::broadcast::error::RecvError; -use tokio::sync::mpsc::error::TrySendError; use tokio::sync::{broadcast, mpsc}; use tokio::time::interval; use crate::domain::common::config::AppConfig; -use crate::domain::common::event::{DetectionEvent, DetectionSource, FlowObservation}; +use crate::domain::common::event::{DetectionEvent, DetectionSource}; use crate::domain::detection::attack_type::CanonicalAttackType; +use crate::domain::detection::flow_observation::FlowObservation; use crate::domain::detection::log::DetectionLog; pub struct BeaconingDetector { @@ -58,13 +58,7 @@ impl BeaconingDetector { } _ = analysis_interval.tick() => { for event in self.state.analyze() { - if let Err(TrySendError::Full(d)) = self.detection_tx.try_send(event) { - log!(DetectionLog::DetectionChannelDrop( - format!("{:?}", d.source), - d.attack_type, - d.source_ip, - )); - } + super::send_detection_or_log(&self.detection_tx, event); } self.state.cleanup(); } @@ -244,8 +238,9 @@ mod tests { use std::time::{Duration, Instant}; use crate::core::detection::beaconing::{BeaconingState, CachedFlow, compute_cv}; - use crate::domain::common::event::{DetectionSource, FlowObservation}; + use crate::domain::common::event::DetectionSource; use crate::domain::detection::attack_type::CanonicalAttackType; + use crate::domain::detection::flow_observation::FlowObservation; fn test_key(protocol: u8, dst_port: u16) -> super::BeaconingKey { super::BeaconingKey { diff --git a/net-guardia/src/core/detection/mod.rs b/net-guardia/src/core/detection/mod.rs index e0729c4..e84c2c2 100644 --- a/net-guardia/src/core/detection/mod.rs +++ b/net-guardia/src/core/detection/mod.rs @@ -1,3 +1,24 @@ +use macros::log; +use tokio::sync::mpsc; +use tokio::sync::mpsc::error::TrySendError; + +use crate::domain::common::event::DetectionEvent; +use crate::domain::detection::log::DetectionLog; + pub mod beaconing; pub mod metrics; pub mod orchestrator; + +pub fn send_detection_or_log(tx: &mpsc::Sender, event: DetectionEvent) -> bool { + match tx.try_send(event) { + Ok(()) => true, + Err(TrySendError::Full(dropped) | TrySendError::Closed(dropped)) => { + log!(DetectionLog::DetectionChannelDrop( + format!("{:?}", dropped.source), + dropped.attack_type, + dropped.source_ip, + )); + false + } + } +} diff --git a/net-guardia/src/core/detection/orchestrator.rs b/net-guardia/src/core/detection/orchestrator.rs index d874206..c02aaa5 100644 --- a/net-guardia/src/core/detection/orchestrator.rs +++ b/net-guardia/src/core/detection/orchestrator.rs @@ -8,7 +8,6 @@ use macros::log; use tokio::sync::broadcast; use tokio::sync::broadcast::error::RecvError; use tokio::sync::mpsc; -use tokio::sync::mpsc::error::TrySendError; use tokio::time::interval; use crate::common::log::audit::AuditLog; @@ -16,9 +15,10 @@ use crate::core::detection::metrics::FusionMetrics; use crate::domain::common::config::AppConfig; use crate::domain::common::config::constants::{FUSION_AUDIT_ACTION, FUSION_AUDIT_ACTOR}; use crate::domain::common::event::{ - AuditEvent, DetectionDiagnostic, DetectionEvent, DetectionSource, FlowObservation, ThreatDetectedEvent, + AuditEvent, DetectionDiagnostic, DetectionEvent, DetectionSource, ThreatDetectedEvent, }; use crate::domain::detection::attack_type::{CanonicalAttackType, translate}; +use crate::domain::detection::flow_observation::FlowObservation; use crate::domain::detection::fusion_math::{FusionWindowLengths, fused_confidence}; use crate::domain::detection::log::DetectionLog; use crate::domain::detection::ml_detection::AlertMessage; @@ -376,7 +376,7 @@ pub async fn bridge_ml_to_detection(mut rx: broadcast::Receiver, t anomaly_score: alert.anomaly_score, c2_score: alert.c2_score, }; - if !send_detection_event_or_log(&tx, event) && tx.is_closed() { + if !super::send_detection_or_log(&tx, event) && tx.is_closed() { break; } } @@ -419,20 +419,6 @@ pub async fn bridge_ml_to_flow_observation( } } -fn send_detection_event_or_log(tx: &mpsc::Sender, event: DetectionEvent) -> bool { - match tx.try_send(event) { - Ok(()) => true, - Err(TrySendError::Full(dropped) | TrySendError::Closed(dropped)) => { - log!(DetectionLog::DetectionChannelDrop( - format!("{:?}", dropped.source), - dropped.attack_type, - dropped.source_ip, - )); - false - } - } -} - 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() @@ -552,18 +538,18 @@ mod tests { } #[test] - fn send_detection_event_or_log_reports_full_channel_drop() { + fn send_detection_or_log_reports_full_channel_drop() { let (tx, _rx) = mpsc::channel(1); tx.try_send(detection_event()).expect("fill channel"); - assert!(!send_detection_event_or_log(&tx, detection_event())); + assert!(!super::super::send_detection_or_log(&tx, detection_event())); } #[test] - fn send_detection_event_or_log_reports_closed_channel_drop() { + fn send_detection_or_log_reports_closed_channel_drop() { let (tx, rx) = mpsc::channel(1); drop(rx); - assert!(!send_detection_event_or_log(&tx, detection_event())); + assert!(!super::super::send_detection_or_log(&tx, detection_event())); } } diff --git a/net-guardia/src/core/identity/auth_service.rs b/net-guardia/src/core/identity/auth_service.rs index 0754a4b..600c1f0 100644 --- a/net-guardia/src/core/identity/auth_service.rs +++ b/net-guardia/src/core/identity/auth_service.rs @@ -4,10 +4,9 @@ use macros::log; use serde::Serialize; use crate::common::error::Error; -use crate::common::error::codec::CodecError; -use crate::domain::identity::auth::{DEFAULT_ADMIN_USERNAME, GROUP_ADMIN, GROUP_VIEWER, ROLE_ADMIN, ROLE_VIEWER}; -use crate::domain::identity::error::AuthError; -use crate::domain::identity::user::{GroupMemberView, UserGroupView}; +use crate::core::identity::user_service::role_from_group_names; +use crate::domain::identity::auth::{GROUP_ADMIN, GROUP_VIEWER, ROLE_ADMIN, ROLE_VIEWER}; +use crate::domain::identity::error::{AuthError, LoginError, RegisterError}; use crate::domain::identity::validation::{validate_password, validate_username}; use crate::interface::identity::auth_repo::IdentityAuthRepo; use crate::interface::identity::password_hasher::PasswordHasher; @@ -28,98 +27,6 @@ pub struct LoginResult { pub force_password_change: bool, } -#[derive(Serialize)] -pub struct UserProfile { - pub id: i64, - pub username: String, - pub role: String, - pub permissions: Vec, - pub groups: Vec, -} - -#[derive(Serialize)] -pub struct UserListResponse { - pub id: i64, - pub username: String, - pub role: String, - pub force_password_change: bool, - pub created_at: String, - pub groups: Vec, -} - -#[derive(Serialize)] -pub struct UserGroupMembershipResponse { - pub id: i64, - pub name: String, -} - -#[derive(Serialize)] -pub struct GroupMemberResponse { - pub id: i64, - pub username: String, -} - -#[derive(Serialize)] -pub struct GroupListResponse { - pub id: i64, - pub name: String, - pub description: String, - pub permissions: serde_json::Value, - pub created_at: String, - pub members: Vec, -} - -#[derive(Serialize)] -pub struct GroupDetailResponse { - pub id: i64, - pub name: String, - pub description: String, - pub permissions: serde_json::Value, - pub created_at: String, - pub members: Vec, -} - -#[derive(Serialize)] -pub struct GroupMutationResponse { - pub id: i64, - pub name: String, - pub description: String, - pub permissions: serde_json::Value, -} - -#[derive(Debug)] -pub enum LoginError { - Locked { retry_after_secs: u64 }, - InvalidCredentials, - InternalError, -} - -#[derive(Debug)] -pub enum RegisterError { - Validation(&'static str), - InvalidRole, - Forbidden, - HashFailed, - Conflict(Error), - Internal(Error), -} - -#[derive(Debug)] -pub enum IdentityAdminError { - Validation(String), - Unauthorized, - Forbidden(String), - NotFound(String), - HashFailed, - Conflict(Error), - Internal(Error), -} - -pub fn parse_permissions(raw: &str) -> Result { - let parsed = serde_json::from_str(raw).map_err(CodecError::DeserializeFailed)?; - Ok(parsed) -} - impl AuthService { pub fn new(db: Arc, password_hasher: Arc) -> Self { Self { db, password_hasher } @@ -128,9 +35,7 @@ impl AuthService { pub async fn login(&self, username: &str, raw_password: &str) -> Result { match self.db.get_remaining_lock_secs(username).await { Ok(Some(remaining)) => { - return Err(LoginError::Locked { - retry_after_secs: remaining, - }); + return Err(LoginError::Locked(remaining)); } Ok(None) => { if let Err(e) = self.db.clear_expired_login_lock(username).await { @@ -226,9 +131,7 @@ impl AuthService { if let Err(cleanup_err) = self.db.delete_user(new_id).await { log!(AuthError::GroupAssignmentFailed(cleanup_err)); } - return Err(RegisterError::Internal( - AuthError::GroupAssignmentFailed(message).into(), - )); + return Err(RegisterError::Internal(message)); } Ok(new_id) @@ -240,365 +143,13 @@ impl AuthService { .into_iter() .find(|group| group.name == group_name) .map(|group| group.id) - .ok_or_else(|| RegisterError::Internal(AuthError::DefaultGroupMissing(group_name).into())) + .ok_or_else(|| RegisterError::Internal(format!("Default group '{group_name}' is missing"))) } - pub async fn user_profile(&self, user_id: i64, username: &str) -> Result { - let groups_raw = self.db.list_groups_for_user(user_id).await?; - let role = role_from_group_names(groups_raw.iter().map(|group| group.name.as_str())).to_string(); - let groups = groups_raw.into_iter().map(|group| group.name).collect(); - let permissions = self.db.list_user_permissions(user_id).await?; - - Ok(UserProfile { - id: user_id, - username: username.to_string(), - role, - permissions, - groups, - }) - } - - pub async fn derive_role(&self, user_id: i64) -> Result { + async fn derive_role(&self, user_id: i64) -> Result { let groups = self.db.list_groups_for_user(user_id).await?; Ok(role_from_group_names(groups.iter().map(|group| group.name.as_str())).to_string()) } - - pub async fn list_users(&self) -> Result, Error> { - let users = self.db.list_users_with_groups().await?; - Ok(users - .into_iter() - .map(|user| { - let role = role_from_group_names(user.groups.iter().map(|group| group.group_name.as_str())).to_string(); - let groups: Vec = user - .groups - .into_iter() - .map(|group| UserGroupMembershipResponse { - id: group.group_id, - name: group.group_name, - }) - .collect(); - UserListResponse { - id: user.id, - username: user.username, - role, - force_password_change: user.force_password_change, - created_at: user.created_at, - groups, - } - }) - .collect()) - } - - pub async fn change_password( - &self, - user_id: i64, - current_password: &str, - new_password: &str, - ) -> Result<(), IdentityAdminError> { - validate_password(new_password).map_err(|msg| IdentityAdminError::Validation(msg.to_string()))?; - - let user = self - .db - .find_user_by_id(user_id) - .await - .map_err(IdentityAdminError::Internal)? - .ok_or_else(|| IdentityAdminError::NotFound("User not found".to_string()))?; - - match self - .password_hasher - .verify_password(current_password, &user.password_hash) - { - Ok(true) => {} - _ => return Err(IdentityAdminError::Unauthorized), - } - - let new_hash = self - .password_hasher - .hash_password(new_password) - .map_err(|_| IdentityAdminError::HashFailed)?; - self.db - .update_user_password(user_id, &new_hash) - .await - .map_err(IdentityAdminError::Internal) - } - - pub async fn delete_user(&self, caller_user_id: i64, target_user_id: i64) -> Result { - if caller_user_id == target_user_id { - return Err(IdentityAdminError::Validation( - "Cannot delete your own account".to_string(), - )); - } - - let user = self - .db - .find_user_by_id(target_user_id) - .await - .map_err(IdentityAdminError::Internal)?; - if let Some(user) = user.as_ref() - && user.username == DEFAULT_ADMIN_USERNAME - { - return Err(IdentityAdminError::Forbidden( - "Cannot delete the built-in admin account".to_string(), - )); - } - - self.db - .delete_user(target_user_id) - .await - .map_err(IdentityAdminError::Internal) - } - - pub async fn update_role( - &self, - caller_user_id: i64, - target_user_id: i64, - role: &str, - ) -> Result<(), IdentityAdminError> { - if caller_user_id == target_user_id { - return Err(IdentityAdminError::Validation( - "Cannot change your own role".to_string(), - )); - } - if role != ROLE_ADMIN && role != ROLE_VIEWER { - return Err(IdentityAdminError::Validation( - "Role must be 'admin' or 'viewer'".to_string(), - )); - } - - let user = self - .db - .find_user_by_id(target_user_id) - .await - .map_err(IdentityAdminError::Internal)? - .ok_or_else(|| IdentityAdminError::NotFound("User not found".to_string()))?; - - if user.username == DEFAULT_ADMIN_USERNAME { - return Err(IdentityAdminError::Forbidden( - "Cannot change the built-in admin account role".to_string(), - )); - } - - self.db - .update_user_role(target_user_id, role) - .await - .map_err(IdentityAdminError::Internal) - } - - pub async fn reset_password(&self, target_user_id: i64, new_password: &str) -> Result<(), IdentityAdminError> { - validate_password(new_password).map_err(|msg| IdentityAdminError::Validation(msg.to_string()))?; - - self.db - .find_user_by_id(target_user_id) - .await - .map_err(IdentityAdminError::Internal)? - .ok_or_else(|| IdentityAdminError::NotFound("User not found".to_string()))?; - - let hash = self - .password_hasher - .hash_password(new_password) - .map_err(|_| IdentityAdminError::HashFailed)?; - self.db - .reset_user_password(target_user_id, &hash) - .await - .map_err(IdentityAdminError::Internal) - } - - pub async fn list_groups(&self) -> Result, Error> { - let groups = self.db.list_user_groups().await?; - let mut result = Vec::with_capacity(groups.len()); - for group in groups { - let permissions = parse_permissions(&group.permissions)?; - let member_views = self.db.list_group_members(group.id).await?; - let members = member_views.into_iter().map(group_member_response).collect(); - result.push(GroupListResponse { - id: group.id, - name: group.name, - description: group.description, - permissions, - created_at: group.created_at, - members, - }); - } - Ok(result) - } - - pub async fn create_group( - &self, - name: Option<&str>, - description: Option<&str>, - permissions: Option<&serde_json::Value>, - ) -> Result { - let name = match name { - Some(name) if !name.is_empty() => name, - _ => return Err(IdentityAdminError::Validation("Group name is required".to_string())), - }; - let description = description.unwrap_or(""); - let (permissions, permissions_json) = permission_array_json(permissions, "[]")?; - - let id = self - .db - .create_user_group(name, description, &permissions) - .await - .map_err(IdentityAdminError::Conflict)?; - - Ok(GroupMutationResponse { - id, - name: name.to_string(), - description: description.to_string(), - permissions: permissions_json, - }) - } - - pub async fn get_group(&self, group_id: i64) -> Result, Error> { - let Some(group) = self.db.get_user_group(group_id).await? else { - return Ok(None); - }; - let permissions = parse_permissions(&group.permissions)?; - let members = self.db.list_group_member_ids(group_id).await?; - Ok(Some(group_detail_response(group, permissions, members))) - } - - pub async fn update_group( - &self, - group_id: i64, - name: Option<&str>, - description: Option<&str>, - permissions: Option<&serde_json::Value>, - ) -> Result { - let existing = self - .db - .get_user_group(group_id) - .await - .map_err(IdentityAdminError::Internal)? - .ok_or_else(|| IdentityAdminError::NotFound("Group not found".to_string()))?; - - if is_builtin_group(&existing.name) { - return Err(IdentityAdminError::Forbidden( - "Cannot modify built-in groups".to_string(), - )); - } - - let name = name.unwrap_or(&existing.name).to_string(); - let description = description.unwrap_or(&existing.description).to_string(); - let (permissions, permissions_json) = permission_array_json(permissions, &existing.permissions)?; - - self.db - .update_user_group(group_id, &name, &description, &permissions) - .await - .map_err(IdentityAdminError::Internal)?; - - Ok(GroupMutationResponse { - id: group_id, - name, - description, - permissions: permissions_json, - }) - } - - pub async fn delete_group(&self, group_id: i64) -> Result { - let group = self - .db - .get_user_group(group_id) - .await - .map_err(IdentityAdminError::Internal)?; - if let Some(group) = group.as_ref() - && is_builtin_group(&group.name) - { - return Err(IdentityAdminError::Forbidden( - "Cannot delete built-in groups".to_string(), - )); - } - - self.db - .delete_user_group(group_id) - .await - .map_err(IdentityAdminError::Internal) - } - - pub async fn set_user_groups( - &self, - caller_user_id: i64, - target_user_id: i64, - group_ids: &[i64], - ) -> Result<(), IdentityAdminError> { - if caller_user_id == target_user_id { - return Err(IdentityAdminError::Validation( - "Cannot modify your own groups".to_string(), - )); - } - - let user = self - .db - .find_user_by_id(target_user_id) - .await - .map_err(IdentityAdminError::Internal)? - .ok_or_else(|| IdentityAdminError::NotFound("User not found".to_string()))?; - - if user.username == DEFAULT_ADMIN_USERNAME { - return Err(IdentityAdminError::Forbidden( - "Cannot modify groups for the built-in admin account".to_string(), - )); - } - - self.db - .set_user_groups(target_user_id, group_ids) - .await - .map_err(IdentityAdminError::Internal) - } -} - -fn group_member_response(member: GroupMemberView) -> GroupMemberResponse { - GroupMemberResponse { - id: member.id, - username: member.username, - } -} - -fn group_detail_response( - group: UserGroupView, - permissions: serde_json::Value, - members: Vec, -) -> GroupDetailResponse { - GroupDetailResponse { - id: group.id, - name: group.name, - description: group.description, - permissions, - created_at: group.created_at, - members, - } -} - -fn is_builtin_group(name: &str) -> bool { - name == GROUP_ADMIN || name == GROUP_VIEWER -} - -fn permission_array_json( - permissions: Option<&serde_json::Value>, - default: &str, -) -> Result<(String, serde_json::Value), IdentityAdminError> { - match permissions { - Some(value) => { - let permissions = serde_json::from_value::>(value.clone()) - .map_err(|_| IdentityAdminError::Validation("Permissions must be an array of strings".to_string()))?; - let json = serde_json::to_string(&permissions) - .map_err(|err| IdentityAdminError::Internal(CodecError::SerializeFailed(err).into()))?; - let value = serde_json::Value::Array(permissions.into_iter().map(serde_json::Value::String).collect()); - Ok((json, value)) - } - None => Ok(( - default.to_string(), - parse_permissions(default).map_err(IdentityAdminError::Internal)?, - )), - } -} - -fn role_from_group_names<'a>(names: impl IntoIterator) -> &'static str { - if names.into_iter().any(|name| name == GROUP_ADMIN) { - ROLE_ADMIN - } else { - ROLE_VIEWER - } } #[cfg(test)] @@ -610,7 +161,9 @@ mod tests { use super::*; use crate::adapter::identity::password_hasher::Argon2PasswordHasher; use crate::adapter::persistence::Database; + use crate::common::error::Error; use crate::common::error::database::DatabaseError; + use crate::domain::identity::auth::{GROUP_ADMIN, GROUP_VIEWER, ROLE_ADMIN, ROLE_VIEWER}; use crate::domain::identity::user::{GroupMemberView, UserGroupView, UserView, UserWithGroupsView}; use crate::interface::identity::auth_repo::{IdentityAuthRepo, LoginAttemptRepo, UserGroupRepo, UserRepo}; use crate::interface::identity::password_hasher::PasswordHasher; @@ -765,114 +318,6 @@ mod tests { assert!(matches!(result, Err(LoginError::InternalError))); } - #[tokio::test] - async fn change_password_verifies_current_password_and_updates_hash() { - let (db, auth) = auth_fixture().await; - let user_id = create_viewer(&db, "alice", "Correct Horse 123!").await; - - auth.change_password(user_id, "Correct Horse 123!", "New Password 456!") - .await - .expect("change password"); - - let user = db.find_user("alice").await.expect("find user").expect("user exists"); - let hasher = Argon2PasswordHasher; - assert!( - !hasher - .verify_password("Correct Horse 123!", &user.password_hash) - .expect("verify old") - ); - assert!( - hasher - .verify_password("New Password 456!", &user.password_hash) - .expect("verify new") - ); - assert!(!user.force_password_change); - } - - #[tokio::test] - async fn change_password_verifies_password_for_target_user_id() { - let (db, auth) = auth_fixture().await; - let alice_id = create_viewer(&db, "alice", "Correct Horse 123!").await; - create_viewer(&db, "bob", "Bob Password 123!").await; - - let err = auth - .change_password(alice_id, "Bob Password 123!", "New Password 456!") - .await - .expect_err("another user's password must not authorize the change"); - - assert!(matches!(err, IdentityAdminError::Unauthorized)); - - let alice = db.find_user("alice").await.expect("find user").expect("user exists"); - let hasher = Argon2PasswordHasher; - assert!( - hasher - .verify_password("Correct Horse 123!", &alice.password_hash) - .expect("verify alice password") - ); - } - - #[tokio::test] - async fn update_role_rejects_self_change_before_db_update() { - let (db, auth) = auth_fixture().await; - let user_id = create_viewer(&db, "alice", "Correct Horse 123!").await; - - let err = auth - .update_role(user_id, user_id, ROLE_ADMIN) - .await - .expect_err("self role change should be rejected"); - - assert!(matches!(err, IdentityAdminError::Validation(_))); - } - - #[tokio::test] - async fn update_role_rejects_builtin_admin_role_change() { - let (db, auth) = auth_fixture().await; - let hasher = Argon2PasswordHasher; - let hash = hasher.hash_password("Default Admin 123!").expect("hash"); - let admin_id = db - .insert_user(DEFAULT_ADMIN_USERNAME, &hash, ROLE_ADMIN, false) - .await - .expect("insert built-in admin"); - let admin_group = db - .list_user_groups() - .await - .expect("groups") - .into_iter() - .find(|g| g.name == GROUP_ADMIN) - .expect("admin group"); - db.set_user_groups(admin_id, &[admin_group.id]) - .await - .expect("assign admin group"); - - let err = auth - .update_role(999, admin_id, ROLE_VIEWER) - .await - .expect_err("built-in admin role change should be rejected"); - - assert!(matches!(err, IdentityAdminError::Forbidden(_))); - let groups = db.list_groups_for_user(admin_id).await.expect("admin groups"); - assert!(groups.iter().any(|group| group.name == GROUP_ADMIN)); - } - - #[tokio::test] - async fn reset_password_marks_force_password_change() { - let (db, auth) = auth_fixture().await; - let user_id = create_viewer(&db, "alice", "Correct Horse 123!").await; - - auth.reset_password(user_id, "Reset Password 456!") - .await - .expect("reset password"); - - let user = db.find_user("alice").await.expect("find user").expect("user exists"); - let hasher = Argon2PasswordHasher; - assert!( - hasher - .verify_password("Reset Password 456!", &user.password_hash) - .expect("verify reset") - ); - assert!(user.force_password_change); - } - #[tokio::test] async fn force_password_change_login_has_no_admin_permissions() { let (db, auth) = auth_fixture().await; @@ -899,87 +344,6 @@ mod tests { assert!(login.permissions.is_empty()); } - #[tokio::test] - async fn group_workflows_parse_permissions_and_manage_membership() { - let (db, auth) = auth_fixture().await; - let user_id = create_viewer(&db, "alice", "Correct Horse 123!").await; - let permissions = serde_json::json!(["dashboard:view"]); - - let created = auth - .create_group(Some("operators"), Some("Ops"), Some(&permissions)) - .await - .expect("create group"); - assert_eq!(created.name, "operators"); - assert_eq!(created.permissions, permissions); - - auth.set_user_groups(0, user_id, &[created.id]) - .await - .expect("set groups"); - - let detail = auth - .get_group(created.id) - .await - .expect("get group") - .expect("group exists"); - assert_eq!(detail.members, vec![user_id]); - - let updated_permissions = serde_json::json!(["dashboard:view", "audit:read"]); - let updated = auth - .update_group(created.id, Some("operators2"), None, Some(&updated_permissions)) - .await - .expect("update group"); - assert_eq!(updated.name, "operators2"); - assert_eq!(updated.permissions, updated_permissions); - - let groups = auth.list_groups().await.expect("list groups"); - assert!(groups.iter().any(|group| group.id == created.id)); - - assert!(auth.delete_group(created.id).await.expect("delete group")); - assert!(db.get_user_group(created.id).await.expect("get deleted").is_none()); - } - - #[tokio::test] - async fn group_permissions_must_be_string_arrays() { - let (_db, auth) = auth_fixture().await; - - let create_err = match auth - .create_group(Some("operators"), None, Some(&serde_json::json!([1]))) - .await - { - Ok(_) => panic!("numeric permissions must be rejected"), - Err(err) => err, - }; - assert!(matches!(create_err, IdentityAdminError::Validation(_))); - - let created = auth - .create_group(Some("operators"), None, Some(&serde_json::json!(["dashboard:read"]))) - .await - .expect("create group"); - let update_err = match auth - .update_group(created.id, None, None, Some(&serde_json::json!("dashboard:read"))) - .await - { - Ok(_) => panic!("non-array permissions must be rejected"), - Err(err) => err, - }; - - assert!(matches!(update_err, IdentityAdminError::Validation(_))); - } - - #[tokio::test] - async fn set_user_groups_rejects_self_membership_change() { - let (db, auth) = auth_fixture().await; - let user_id = create_viewer(&db, "alice", "Correct Horse 123!").await; - - let err = auth - .set_user_groups(user_id, user_id, &[]) - .await - .expect_err("self group change should be rejected"); - - assert!(matches!(err, IdentityAdminError::Validation(_))); - assert!(!db.list_groups_for_user(user_id).await.expect("groups").is_empty()); - } - #[tokio::test] async fn relogin_permissions_follow_role_promotion_and_demotion() { let (db, auth) = auth_fixture().await; @@ -1026,7 +390,7 @@ mod tests { .await .expect_err("missing default group should fail registration"); - assert!(matches!(err, RegisterError::Internal(_))); + assert!(matches!(err, RegisterError::Internal { .. })); assert!(db.find_user("carol").await.expect("find user").is_none()); } } diff --git a/net-guardia/src/core/identity/group_service.rs b/net-guardia/src/core/identity/group_service.rs new file mode 100644 index 0000000..3217c2e --- /dev/null +++ b/net-guardia/src/core/identity/group_service.rs @@ -0,0 +1,305 @@ +use std::sync::Arc; + +use serde::Serialize; + +use crate::common::error::Error; +use crate::common::error::codec::CodecError; +use crate::core::identity::user_service::parse_permissions; +use crate::domain::identity::auth::{GROUP_ADMIN, GROUP_VIEWER}; +use crate::domain::identity::error::GroupError; +use crate::domain::identity::user::{GroupMemberView, UserGroupView}; +use crate::interface::identity::auth_repo::UserGroupRepo; + +#[derive(Serialize)] +pub struct GroupMemberResponse { + pub id: i64, + pub username: String, +} + +#[derive(Serialize)] +pub struct GroupListResponse { + pub id: i64, + pub name: String, + pub description: String, + pub permissions: serde_json::Value, + pub created_at: String, + pub members: Vec, +} + +#[derive(Serialize)] +pub struct GroupDetailResponse { + pub id: i64, + pub name: String, + pub description: String, + pub permissions: serde_json::Value, + pub created_at: String, + pub members: Vec, +} + +#[derive(Serialize)] +pub struct GroupMutationResponse { + pub id: i64, + pub name: String, + pub description: String, + pub permissions: serde_json::Value, +} + +pub struct GroupService { + db: Arc, +} + +impl GroupService { + pub fn new(db: Arc) -> Self { + Self { db } + } + + pub async fn list_groups(&self) -> Result, Error> { + let groups = self.db.list_user_groups().await?; + let mut result = Vec::with_capacity(groups.len()); + for group in groups { + let permissions = parse_permissions(&group.permissions)?; + let member_views = self.db.list_group_members(group.id).await?; + let members = member_views.into_iter().map(group_member_response).collect(); + result.push(GroupListResponse { + id: group.id, + name: group.name, + description: group.description, + permissions, + created_at: group.created_at, + members, + }); + } + Ok(result) + } + + pub async fn create_group( + &self, + name: Option<&str>, + description: Option<&str>, + permissions: Option<&serde_json::Value>, + ) -> Result { + let name = match name { + Some(name) if !name.is_empty() => name, + _ => return Err(GroupError::Validation("Group name is required".to_string())), + }; + let description = description.unwrap_or(""); + let (permissions, permissions_json) = permission_array_json(permissions, "[]")?; + + let id = self + .db + .create_user_group(name, description, &permissions) + .await + .map_err(GroupError::Conflict)?; + + Ok(GroupMutationResponse { + id, + name: name.to_string(), + description: description.to_string(), + permissions: permissions_json, + }) + } + + pub async fn get_group(&self, group_id: i64) -> Result, Error> { + let Some(group) = self.db.get_user_group(group_id).await? else { + return Ok(None); + }; + let permissions = parse_permissions(&group.permissions)?; + let members = self.db.list_group_member_ids(group_id).await?; + Ok(Some(group_detail_response(group, permissions, members))) + } + + pub async fn update_group( + &self, + group_id: i64, + name: Option<&str>, + description: Option<&str>, + permissions: Option<&serde_json::Value>, + ) -> Result { + let existing = self + .db + .get_user_group(group_id) + .await + .map_err(GroupError::Internal)? + .ok_or_else(|| GroupError::NotFound("Group not found".to_string()))?; + + if is_builtin_group(&existing.name) { + return Err(GroupError::Forbidden("Cannot modify built-in groups".to_string())); + } + + let name = name.unwrap_or(&existing.name).to_string(); + let description = description.unwrap_or(&existing.description).to_string(); + let (permissions, permissions_json) = permission_array_json(permissions, &existing.permissions)?; + + self.db + .update_user_group(group_id, &name, &description, &permissions) + .await + .map_err(GroupError::Internal)?; + + Ok(GroupMutationResponse { + id: group_id, + name, + description, + permissions: permissions_json, + }) + } + + pub async fn delete_group(&self, group_id: i64) -> Result { + let group = self.db.get_user_group(group_id).await.map_err(GroupError::Internal)?; + if let Some(group) = group.as_ref() + && is_builtin_group(&group.name) + { + return Err(GroupError::Forbidden("Cannot delete built-in groups".to_string())); + } + + self.db.delete_user_group(group_id).await.map_err(GroupError::Internal) + } +} + +fn group_member_response(member: GroupMemberView) -> GroupMemberResponse { + GroupMemberResponse { + id: member.id, + username: member.username, + } +} + +fn group_detail_response( + group: UserGroupView, + permissions: serde_json::Value, + members: Vec, +) -> GroupDetailResponse { + GroupDetailResponse { + id: group.id, + name: group.name, + description: group.description, + permissions, + created_at: group.created_at, + members, + } +} + +fn is_builtin_group(name: &str) -> bool { + name == GROUP_ADMIN || name == GROUP_VIEWER +} + +fn permission_array_json( + permissions: Option<&serde_json::Value>, + default: &str, +) -> Result<(String, serde_json::Value), GroupError> { + match permissions { + Some(value) => { + let permissions = serde_json::from_value::>(value.clone()) + .map_err(|_| GroupError::Validation("Permissions must be an array of strings".to_string()))?; + let json = serde_json::to_string(&permissions) + .map_err(|err| GroupError::Internal(CodecError::SerializeFailed(err)))?; + let value = serde_json::Value::Array(permissions.into_iter().map(serde_json::Value::String).collect()); + Ok((json, value)) + } + None => Ok(( + default.to_string(), + parse_permissions(default).map_err(GroupError::Internal)?, + )), + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use crate::adapter::identity::password_hasher::Argon2PasswordHasher; + use crate::adapter::persistence::Database; + use crate::domain::identity::auth::{GROUP_VIEWER, ROLE_VIEWER}; + use crate::interface::identity::auth_repo::UserGroupRepo; + use crate::interface::identity::password_hasher::PasswordHasher; + + async fn group_service_fixture() -> (Arc, GroupService) { + let db = Arc::new(Database::new(":memory:").await.expect("test db")); + let svc = GroupService::new(db.clone() as Arc); + (db, svc) + } + + async fn create_viewer(db: &Database, username: &str, password: &str) -> i64 { + let hasher = Argon2PasswordHasher; + let hash = hasher.hash_password(password).expect("hash"); + let user_id = db + .insert_user(username, &hash, ROLE_VIEWER, false) + .await + .expect("insert user"); + let viewer_group = db + .list_user_groups() + .await + .expect("groups") + .into_iter() + .find(|g| g.name == GROUP_VIEWER) + .expect("viewer group"); + db.set_user_groups(user_id, &[viewer_group.id]) + .await + .expect("assign viewer group"); + user_id + } + + #[tokio::test] + async fn group_workflows_parse_permissions_and_manage_membership() { + let (db, svc) = group_service_fixture().await; + let user_id = create_viewer(&db, "alice", "Correct Horse 123!").await; + let permissions = serde_json::json!(["dashboard:view"]); + + let created = svc + .create_group(Some("operators"), Some("Ops"), Some(&permissions)) + .await + .expect("create group"); + assert_eq!(created.name, "operators"); + assert_eq!(created.permissions, permissions); + + db.set_user_groups(user_id, &[created.id]).await.expect("set groups"); + + let detail = svc + .get_group(created.id) + .await + .expect("get group") + .expect("group exists"); + assert_eq!(detail.members, vec![user_id]); + + let updated_permissions = serde_json::json!(["dashboard:view", "audit:read"]); + let updated = svc + .update_group(created.id, Some("operators2"), None, Some(&updated_permissions)) + .await + .expect("update group"); + assert_eq!(updated.name, "operators2"); + assert_eq!(updated.permissions, updated_permissions); + + let groups = svc.list_groups().await.expect("list groups"); + assert!(groups.iter().any(|group| group.id == created.id)); + + assert!(svc.delete_group(created.id).await.expect("delete group")); + assert!(db.get_user_group(created.id).await.expect("get deleted").is_none()); + } + + #[tokio::test] + async fn group_permissions_must_be_string_arrays() { + let (_db, svc) = group_service_fixture().await; + + let create_err = match svc + .create_group(Some("operators"), None, Some(&serde_json::json!([1]))) + .await + { + Ok(_) => panic!("numeric permissions must be rejected"), + Err(err) => err, + }; + assert!(matches!(create_err, GroupError::Validation { .. })); + + let created = svc + .create_group(Some("operators"), None, Some(&serde_json::json!(["dashboard:read"]))) + .await + .expect("create group"); + let update_err = match svc + .update_group(created.id, None, None, Some(&serde_json::json!("dashboard:read"))) + .await + { + Ok(_) => panic!("non-array permissions must be rejected"), + Err(err) => err, + }; + + assert!(matches!(update_err, GroupError::Validation { .. })); + } +} diff --git a/net-guardia/src/core/identity/mod.rs b/net-guardia/src/core/identity/mod.rs index 8dae443..b801557 100644 --- a/net-guardia/src/core/identity/mod.rs +++ b/net-guardia/src/core/identity/mod.rs @@ -1,2 +1,4 @@ pub mod auth_service; +pub mod group_service; pub mod session_service; +pub mod user_service; diff --git a/net-guardia/src/core/identity/session_service.rs b/net-guardia/src/core/identity/session_service.rs index e71553d..340c4ab 100644 --- a/net-guardia/src/core/identity/session_service.rs +++ b/net-guardia/src/core/identity/session_service.rs @@ -1,4 +1,5 @@ use std::sync::Arc; +use std::sync::atomic::{AtomicU32, Ordering}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use arc_swap::ArcSwap; @@ -33,9 +34,12 @@ pub enum CsrfTokenStatus { MissingSession, } +const GC_INTERVAL_LOGINS: u32 = 64; + pub struct SessionService { config: Arc>, sessions: DashMap, + login_counter: AtomicU32, } impl SessionService { @@ -43,12 +47,19 @@ impl SessionService { Self { config, sessions: DashMap::new(), + login_counter: AtomicU32::new(0), } } pub fn create_session(&self, claims: Claims) -> CreatedSession { let now = now_secs(); - self.remove_expired_sessions(now); + if self + .login_counter + .fetch_add(1, Ordering::Relaxed) + .is_multiple_of(GC_INTERVAL_LOGINS) + { + self.remove_expired_sessions(now); + } let session_id = random_url_token(); let csrf_token = random_url_token(); let max_age_secs = self.session_max_age_secs(); diff --git a/net-guardia/src/core/identity/user_service.rs b/net-guardia/src/core/identity/user_service.rs new file mode 100644 index 0000000..01ab911 --- /dev/null +++ b/net-guardia/src/core/identity/user_service.rs @@ -0,0 +1,403 @@ +use std::sync::Arc; + +use serde::Serialize; + +use crate::common::error::Error; +use crate::common::error::codec::CodecError; +use crate::domain::identity::auth::{DEFAULT_ADMIN_USERNAME, GROUP_ADMIN, ROLE_ADMIN, ROLE_VIEWER}; +use crate::domain::identity::error::UserError; +use crate::domain::identity::validation::validate_password; +use crate::interface::identity::auth_repo::{UserGroupRepo, UserRepo}; +use crate::interface::identity::password_hasher::PasswordHasher; + +#[derive(Serialize)] +pub struct UserProfile { + pub id: i64, + pub username: String, + pub role: String, + pub permissions: Vec, + pub groups: Vec, +} + +#[derive(Serialize)] +pub struct UserListResponse { + pub id: i64, + pub username: String, + pub role: String, + pub force_password_change: bool, + pub created_at: String, + pub groups: Vec, +} + +#[derive(Serialize)] +pub struct UserGroupMembershipResponse { + pub id: i64, + pub name: String, +} + +pub struct UserService { + db: Arc, + group_db: Arc, + password_hasher: Arc, +} + +impl UserService { + pub fn new( + db: Arc, + group_db: Arc, + password_hasher: Arc, + ) -> Self { + Self { + db, + group_db, + password_hasher, + } + } + + pub async fn user_profile(&self, user_id: i64, username: &str) -> Result { + let groups_raw = self.group_db.list_groups_for_user(user_id).await?; + let role = role_from_group_names(groups_raw.iter().map(|group| group.name.as_str())).to_string(); + let groups = groups_raw.into_iter().map(|group| group.name).collect(); + let permissions = self.group_db.list_user_permissions(user_id).await?; + + Ok(UserProfile { + id: user_id, + username: username.to_string(), + role, + permissions, + groups, + }) + } + + pub async fn list_users(&self) -> Result, Error> { + let users = self.db.list_users_with_groups().await?; + Ok(users + .into_iter() + .map(|user| { + let role = role_from_group_names(user.groups.iter().map(|group| group.group_name.as_str())).to_string(); + let groups: Vec = user + .groups + .into_iter() + .map(|group| UserGroupMembershipResponse { + id: group.group_id, + name: group.group_name, + }) + .collect(); + UserListResponse { + id: user.id, + username: user.username, + role, + force_password_change: user.force_password_change, + created_at: user.created_at, + groups, + } + }) + .collect()) + } + + pub async fn change_password( + &self, + user_id: i64, + current_password: &str, + new_password: &str, + ) -> Result<(), UserError> { + validate_password(new_password).map_err(|msg| UserError::Validation(msg.to_string()))?; + + let user = self + .db + .find_user_by_id(user_id) + .await + .map_err(UserError::Internal)? + .ok_or_else(|| UserError::NotFound("User not found".to_string()))?; + + match self + .password_hasher + .verify_password(current_password, &user.password_hash) + { + Ok(true) => {} + _ => return Err(UserError::Unauthorized), + } + + let new_hash = self + .password_hasher + .hash_password(new_password) + .map_err(|_| UserError::HashFailed)?; + self.db + .update_user_password(user_id, &new_hash) + .await + .map_err(UserError::Internal) + } + + pub async fn delete_user(&self, caller_user_id: i64, target_user_id: i64) -> Result { + if caller_user_id == target_user_id { + return Err(UserError::Validation("Cannot delete your own account".to_string())); + } + + let user = self + .db + .find_user_by_id(target_user_id) + .await + .map_err(UserError::Internal)?; + if let Some(user) = user.as_ref() + && user.username == DEFAULT_ADMIN_USERNAME + { + return Err(UserError::Forbidden( + "Cannot delete the built-in admin account".to_string(), + )); + } + + self.db.delete_user(target_user_id).await.map_err(UserError::Internal) + } + + pub async fn update_role(&self, caller_user_id: i64, target_user_id: i64, role: &str) -> Result<(), UserError> { + if caller_user_id == target_user_id { + return Err(UserError::Validation("Cannot change your own role".to_string())); + } + if role != ROLE_ADMIN && role != ROLE_VIEWER { + return Err(UserError::Validation("Role must be 'admin' or 'viewer'".to_string())); + } + + let user = self + .db + .find_user_by_id(target_user_id) + .await + .map_err(UserError::Internal)? + .ok_or_else(|| UserError::NotFound("User not found".to_string()))?; + + if user.username == DEFAULT_ADMIN_USERNAME { + return Err(UserError::Forbidden( + "Cannot change the built-in admin account role".to_string(), + )); + } + + self.db + .update_user_role(target_user_id, role) + .await + .map_err(UserError::Internal) + } + + pub async fn reset_password(&self, target_user_id: i64, new_password: &str) -> Result<(), UserError> { + validate_password(new_password).map_err(|msg| UserError::Validation(msg.to_string()))?; + + self.db + .find_user_by_id(target_user_id) + .await + .map_err(UserError::Internal)? + .ok_or_else(|| UserError::NotFound("User not found".to_string()))?; + + let hash = self + .password_hasher + .hash_password(new_password) + .map_err(|_| UserError::HashFailed)?; + self.db + .reset_user_password(target_user_id, &hash) + .await + .map_err(UserError::Internal) + } + + pub async fn set_user_groups( + &self, + caller_user_id: i64, + target_user_id: i64, + group_ids: &[i64], + ) -> Result<(), UserError> { + if caller_user_id == target_user_id { + return Err(UserError::Validation("Cannot modify your own groups".to_string())); + } + + let user = self + .db + .find_user_by_id(target_user_id) + .await + .map_err(UserError::Internal)? + .ok_or_else(|| UserError::NotFound("User not found".to_string()))?; + + if user.username == DEFAULT_ADMIN_USERNAME { + return Err(UserError::Forbidden( + "Cannot modify groups for the built-in admin account".to_string(), + )); + } + + self.group_db + .set_user_groups(target_user_id, group_ids) + .await + .map_err(UserError::Internal) + } +} + +pub fn parse_permissions(raw: &str) -> Result { + let parsed = serde_json::from_str(raw).map_err(CodecError::DeserializeFailed)?; + Ok(parsed) +} + +pub fn role_from_group_names<'a>(names: impl IntoIterator) -> &'static str { + if names.into_iter().any(|name| name == GROUP_ADMIN) { + ROLE_ADMIN + } else { + ROLE_VIEWER + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use crate::adapter::identity::password_hasher::Argon2PasswordHasher; + use crate::adapter::persistence::Database; + use crate::domain::identity::auth::{GROUP_ADMIN, GROUP_VIEWER, ROLE_ADMIN, ROLE_VIEWER}; + use crate::interface::identity::auth_repo::{UserGroupRepo, UserRepo}; + use crate::interface::identity::password_hasher::PasswordHasher; + + async fn user_service_fixture() -> (Arc, UserService) { + let db = Arc::new(Database::new(":memory:").await.expect("test db")); + let svc = UserService::new( + db.clone() as Arc, + db.clone() as Arc, + Arc::new(Argon2PasswordHasher), + ); + (db, svc) + } + + async fn create_viewer(db: &Database, username: &str, password: &str) -> i64 { + let hasher = Argon2PasswordHasher; + let hash = hasher.hash_password(password).expect("hash"); + let user_id = db + .insert_user(username, &hash, ROLE_VIEWER, false) + .await + .expect("insert user"); + let viewer_group = db + .list_user_groups() + .await + .expect("groups") + .into_iter() + .find(|g| g.name == GROUP_VIEWER) + .expect("viewer group"); + db.set_user_groups(user_id, &[viewer_group.id]) + .await + .expect("assign viewer group"); + user_id + } + + #[tokio::test] + async fn change_password_verifies_current_password_and_updates_hash() { + let (db, svc) = user_service_fixture().await; + let user_id = create_viewer(&db, "alice", "Correct Horse 123!").await; + + svc.change_password(user_id, "Correct Horse 123!", "New Password 456!") + .await + .expect("change password"); + + let user = db.find_user("alice").await.expect("find user").expect("user exists"); + let hasher = Argon2PasswordHasher; + assert!( + !hasher + .verify_password("Correct Horse 123!", &user.password_hash) + .expect("verify old") + ); + assert!( + hasher + .verify_password("New Password 456!", &user.password_hash) + .expect("verify new") + ); + assert!(!user.force_password_change); + } + + #[tokio::test] + async fn change_password_verifies_password_for_target_user_id() { + let (db, svc) = user_service_fixture().await; + let alice_id = create_viewer(&db, "alice", "Correct Horse 123!").await; + create_viewer(&db, "bob", "Bob Password 123!").await; + + let err = svc + .change_password(alice_id, "Bob Password 123!", "New Password 456!") + .await + .expect_err("another user's password must not authorize the change"); + + assert!(matches!(err, UserError::Unauthorized)); + + let alice = db.find_user("alice").await.expect("find user").expect("user exists"); + let hasher = Argon2PasswordHasher; + assert!( + hasher + .verify_password("Correct Horse 123!", &alice.password_hash) + .expect("verify alice password") + ); + } + + #[tokio::test] + async fn update_role_rejects_self_change_before_db_update() { + let (db, svc) = user_service_fixture().await; + let user_id = create_viewer(&db, "alice", "Correct Horse 123!").await; + + let err = svc + .update_role(user_id, user_id, ROLE_ADMIN) + .await + .expect_err("self role change should be rejected"); + + assert!(matches!(err, UserError::Validation { .. })); + } + + #[tokio::test] + async fn update_role_rejects_builtin_admin_role_change() { + let (db, svc) = user_service_fixture().await; + let hasher = Argon2PasswordHasher; + let hash = hasher.hash_password("Default Admin 123!").expect("hash"); + let admin_id = db + .insert_user(DEFAULT_ADMIN_USERNAME, &hash, ROLE_ADMIN, false) + .await + .expect("insert built-in admin"); + let admin_group = db + .list_user_groups() + .await + .expect("groups") + .into_iter() + .find(|g| g.name == GROUP_ADMIN) + .expect("admin group"); + db.set_user_groups(admin_id, &[admin_group.id]) + .await + .expect("assign admin group"); + + let err = svc + .update_role(999, admin_id, ROLE_VIEWER) + .await + .expect_err("built-in admin role change should be rejected"); + + assert!(matches!(err, UserError::Forbidden { .. })); + let groups = db.list_groups_for_user(admin_id).await.expect("admin groups"); + assert!(groups.iter().any(|group| group.name == GROUP_ADMIN)); + } + + #[tokio::test] + async fn reset_password_marks_force_password_change() { + let (db, svc) = user_service_fixture().await; + let user_id = create_viewer(&db, "alice", "Correct Horse 123!").await; + + svc.reset_password(user_id, "Reset Password 456!") + .await + .expect("reset password"); + + let user = db.find_user("alice").await.expect("find user").expect("user exists"); + let hasher = Argon2PasswordHasher; + assert!( + hasher + .verify_password("Reset Password 456!", &user.password_hash) + .expect("verify reset") + ); + assert!(user.force_password_change); + } + + #[tokio::test] + async fn set_user_groups_rejects_self_membership_change() { + let (db, svc) = user_service_fixture().await; + let user_id = create_viewer(&db, "alice", "Correct Horse 123!").await; + + let err = svc + .set_user_groups(user_id, user_id, &[]) + .await + .expect_err("self group change should be rejected"); + + assert!(matches!(err, UserError::Validation { .. })); + assert!(!db.list_groups_for_user(user_id).await.expect("groups").is_empty()); + } +} diff --git a/net-guardia/src/core/inference/engine.rs b/net-guardia/src/core/inference/engine.rs index bb9f94e..0989144 100644 --- a/net-guardia/src/core/inference/engine.rs +++ b/net-guardia/src/core/inference/engine.rs @@ -12,6 +12,7 @@ use super::drift_detector::DriftDetectorHandle; use super::runner::Inference; use crate::core::inference::aggregator::AttackAggregator; use crate::core::inference::flow_tracker::FlowTracker; +use crate::domain::common::config::constants::KNOWN_C2_PORTS; use crate::domain::data_plane::user_packet::UserPacket; use crate::domain::detection::flow_features::FlowFeatures; use crate::domain::detection::flow_tracker::{FlowLimits, FlowSnapshot}; @@ -132,8 +133,8 @@ impl Engine { }, TCP_PROTOCOL => match flow_key.dst_port { DNS_PORT => 2, - 4444 | 8443 | 8080 | 1337 | 31337 => 2, 3333 | 45700 => 2, + port if KNOWN_C2_PORTS.contains(&port) => 2, _ => floored, }, _ => floored, @@ -219,7 +220,7 @@ impl Engine { let feature_names = FlowFeatures::all_feature_names_owned(); for flow in flows { let features = FlowFeatures::extract_from_stats(&flow.feature_stats, feature_names); - sink.log_row(features.to_csv_record()); + sink.log_row(features.to_csv_line()); } } diff --git a/net-guardia/src/core/inference/flow_tracker.rs b/net-guardia/src/core/inference/flow_tracker.rs index 9b48a4b..61b6803 100644 --- a/net-guardia/src/core/inference/flow_tracker.rs +++ b/net-guardia/src/core/inference/flow_tracker.rs @@ -9,7 +9,12 @@ use crate::domain::detection::flow_tracker::{FlowData, FlowLimits, FlowSnapshot} use crate::domain::detection::ml_detection::FlowKey; use netguardia_abi::define::tcp_flags::*; -type FlowEntry = Arc>; +struct TrackedFlow { + data: FlowData, + last_inferred_us: u64, +} + +type FlowEntry = Arc>; pub struct FlowTracker { active: Cache, @@ -30,14 +35,14 @@ impl FlowTracker { if let Some(entry) = self.active.get(&packet_key) { packet.is_forward = true; - entry.lock().add_packet(&packet, &self.limits); + entry.lock().data.add_packet(&packet, &self.limits); return; } let reversed_key = packet_key.reverse(); if let Some(entry) = self.active.get(&reversed_key) { packet.is_forward = false; - entry.lock().add_packet(&packet, &self.limits); + entry.lock().data.add_packet(&packet, &self.limits); return; } @@ -73,13 +78,19 @@ impl FlowTracker { let key_for_init = actual_key; let entry = self.active.get_with(actual_key, || { - Arc::new(Mutex::new(FlowData::new(key_for_init, &packet, initiator_direction))) + Arc::new(Mutex::new(TrackedFlow { + data: FlowData::new(key_for_init, &packet, initiator_direction), + last_inferred_us: 0, + })) }); - entry.lock().add_packet(&packet, &self.limits); + entry.lock().data.add_packet(&packet, &self.limits); } pub fn get_flow_stats(&self, convert: impl Fn(&FlowData) -> T) -> Vec { - self.active.iter().map(|(_, entry)| convert(&entry.lock())).collect() + self.active + .iter() + .map(|(_, entry)| convert(&entry.lock().data)) + .collect() } pub fn get_filtered_flow_stats( @@ -90,26 +101,25 @@ impl FlowTracker { self.active .iter() .filter_map(|(_, entry)| { - let flow = entry.lock(); - filter(&flow).then(|| convert(&flow)) + let tracked = entry.lock(); + filter(&tracked.data).then(|| convert(&tracked.data)) }) .collect() } pub fn get_uninferred_flows(&self, limit: usize) -> Vec { - let mut result = Vec::new(); + let mut clones = Vec::new(); for (_, entry) in self.active.iter() { - if result.len() >= limit { + if clones.len() >= limit { break; } - let mut flow = entry.lock(); - if flow.last_time_us > flow.last_inferred_us { - let snapshot = FlowSnapshot::from_flow(&flow); - flow.last_inferred_us = flow.last_time_us; - result.push(snapshot); + let mut tracked = entry.lock(); + if tracked.data.last_time_us > tracked.last_inferred_us { + clones.push(tracked.data.clone()); + tracked.last_inferred_us = tracked.data.last_time_us; } } - result + clones.iter().map(FlowSnapshot::from_flow_data).collect() } pub fn flow_count(&self) -> usize { @@ -124,8 +134,8 @@ impl FlowTracker { for (_, entry) in self.active.iter() { let flow = entry.lock(); total_flows += 1; - total_bytes += flow.fwd_total_bytes + flow.bwd_total_bytes; - total_packets += flow.packet_count(); + total_bytes += flow.data.fwd_total_bytes + flow.data.bwd_total_bytes; + total_packets += flow.data.packet_count(); } (total_flows, total_bytes, total_packets) @@ -135,8 +145,8 @@ impl FlowTracker { 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 idle = now_us.saturating_sub(flow.data.last_time_us); + let is_terminated = flow.data.fin_count > 0 || flow.data.rst_count > 0; let stale = if is_terminated { idle >= self.limits.terminated_timeout_us } else { diff --git a/net-guardia/src/core/inference/inference_runtime.rs b/net-guardia/src/core/inference/inference_runtime.rs index 1eb2213..6dd24c9 100644 --- a/net-guardia/src/core/inference/inference_runtime.rs +++ b/net-guardia/src/core/inference/inference_runtime.rs @@ -13,7 +13,6 @@ use crate::adapter::model_loading::artifact_resolver::FsModelArtifactResolver; use crate::adapter::model_loading::onnx_runtime::OnnxRuntimeLoader; use crate::common::error::Error; use crate::common::error::io::IOError; -use crate::common::error::system::SystemError; use crate::core::detection::metrics::FusionMetrics; use crate::core::inference::alert::MLAlert; use crate::core::inference::drift_detector::DriftDetectorHandle; @@ -29,11 +28,11 @@ use crate::domain::detection::flow_tracker::FlowLimits; use crate::domain::detection::log::MLLog; use crate::domain::detection::manifest::ModelManifest; use crate::domain::detection::ml_inference_config::MLInferenceConfig; +use crate::domain::detection::model_files::{MANIFEST_FILENAME, MODELS_DIR}; use crate::domain::detection::model_source::ModelInfo; use crate::infrastructure::flow_trace_logger::{RotationPolicy, TrafficLogger}; use crate::interface::detection::flow_trace_sink::FlowTraceSink; use crate::interface::detection::model_artifact_resolver::ModelArtifactResolver; -use crate::interface::detection::model_files::{MANIFEST_FILENAME, MODELS_DIR}; use crate::interface::detection::model_runtime::ModelRuntimeLoader; pub struct InferenceRuntime { @@ -75,15 +74,15 @@ impl InferenceRuntime { Ok(adapter) => { let info = ModelInfo::new( manifest.name.clone(), - manifest.adapter.as_str().to_string(), + manifest.runtime_kind().as_str().to_string(), current_epoch_secs(), - manifest.features.len(), + manifest.runtime_feature_count(), ); log!(MLLog::ModelsLoaded(format!( "{} ({}) — {} features, {} labels", manifest.name, - manifest.adapter.as_str(), - manifest.features.len(), + manifest.runtime_kind().as_str(), + manifest.runtime_feature_count(), manifest.labels.len() ))); ModelSourceState::Active { adapter, info } @@ -195,9 +194,7 @@ impl InferenceRuntime { pub fn terminate(&self) { while let Some(shutdown) = self.shutdowns.pop() { - if shutdown.send(()).is_err() { - log!(SystemError::ShutdownSignalFailed); - } + let _ = shutdown.send(()); } } } diff --git a/net-guardia/src/core/inference/model_loader.rs b/net-guardia/src/core/inference/model_loader.rs index aaa18cd..7903293 100644 --- a/net-guardia/src/core/inference/model_loader.rs +++ b/net-guardia/src/core/inference/model_loader.rs @@ -18,37 +18,37 @@ pub fn build_adapter( runtime_loader: &dyn ModelRuntimeLoader, artifact_resolver: &dyn ModelArtifactResolver, ) -> Result { - match manifest.adapter { + match manifest.runtime_kind() { AdapterKind::AutoencoderOnly => { - let Some(model_name) = manifest.models.model.as_deref() else { + let Some(model) = manifest.primary_model() else { return Err(MLError::ManifestInvalid( manifest_path.unwrap_or_else(|| Path::new("")).to_path_buf(), - "autoencoder_only adapter requires models.model".to_string(), + "pipeline requires a primary model".to_string(), )); }; - let path = artifact_resolver.resolve_model_path(manifest_path, model_name); + let path = artifact_resolver.resolve_model_path(manifest_path, &model.file); let n_features = inference_config.num_ae_features(); - let model = runtime_loader.load(&path, model_name, n_features, batch_size, onnx_load_timeout)?; + let runtime = runtime_loader.load(&path, &model.file, n_features, batch_size, onnx_load_timeout)?; Ok(MLModelAdapter::AutoencoderOnly { - model, + model: runtime, batch_size, n_features, }) } AdapterKind::ClassifierOnly => { - let Some(model_name) = manifest.models.model.as_deref() else { + let Some(model) = manifest.primary_model() else { return Err(MLError::ManifestInvalid( manifest_path.unwrap_or_else(|| Path::new("")).to_path_buf(), - "classifier_only adapter requires models.model".to_string(), + "pipeline requires a primary model".to_string(), )); }; - let path = artifact_resolver.resolve_model_path(manifest_path, model_name); + let path = artifact_resolver.resolve_model_path(manifest_path, &model.file); let n_features = inference_config.num_classifier_features(); - let model = runtime_loader.load(&path, model_name, n_features, batch_size, onnx_load_timeout)?; + let runtime = runtime_loader.load(&path, &model.file, n_features, batch_size, onnx_load_timeout)?; let labels = manifest.labels.clone(); let normal_idx = find_label_index(&labels, "Normal"); Ok(MLModelAdapter::ClassifierOnly { - model, + model: runtime, batch_size, n_features, labels, @@ -56,24 +56,22 @@ pub fn build_adapter( }) } AdapterKind::MultiTask => { - let Some(ae_name) = manifest.models.autoencoder.as_deref() else { + let mut pipeline = manifest.pipeline_models()?; + if pipeline.len() < 2 { return Err(MLError::ManifestInvalid( manifest_path.unwrap_or_else(|| Path::new("")).to_path_buf(), - "multi_task adapter requires models.autoencoder".to_string(), + "multi-stage pipeline requires at least two models".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 ae_model = pipeline.remove(0); + let classifier_model = pipeline.remove(0); let n_ae = inference_config.num_ae_features(); let n_cls = inference_config.num_classifier_features(); - let ae_path = artifact_resolver.resolve_model_path(manifest_path, ae_name); - let cls_path = artifact_resolver.resolve_model_path(manifest_path, cls_name); - let ae = runtime_loader.load(&ae_path, ae_name, n_ae, batch_size, onnx_load_timeout)?; - let classifier = runtime_loader.load(&cls_path, cls_name, n_cls, batch_size, onnx_load_timeout)?; + let ae_path = artifact_resolver.resolve_model_path(manifest_path, &ae_model.file); + let cls_path = artifact_resolver.resolve_model_path(manifest_path, &classifier_model.file); + let ae = runtime_loader.load(&ae_path, &ae_model.file, n_ae, batch_size, onnx_load_timeout)?; + let classifier = + runtime_loader.load(&cls_path, &classifier_model.file, n_cls, batch_size, onnx_load_timeout)?; let labels = manifest.labels.clone(); let normal_idx = find_label_index(&labels, "Normal"); let c2_idx = find_label_index(&labels, "C2 Communication"); @@ -105,7 +103,7 @@ mod tests { use std::sync::Arc; use super::*; - use crate::domain::detection::manifest::ModelPaths; + use crate::domain::detection::manifest::{ModelSpec, OutputHeadSpec, OutputSemantic, PreprocessingStep}; use crate::domain::detection::ml_detection::ClipParams; use crate::interface::detection::model_artifact_resolver::ModelArtifactResolver; use crate::interface::detection::model_runtime::{ModelRuntime, MultiTaskBatchOutput}; @@ -192,16 +190,44 @@ mod tests { }; let manifest = ModelManifest { name: "test".to_string(), - adapter: AdapterKind::MultiTask, - features: config.ae_feature_names.clone(), - models: ModelPaths { - autoencoder: Some("ae.onnx".to_string()), - classifier: Some("classifier.onnx".to_string()), - model: None, - }, - preprocessing: None, - thresholds: Default::default(), + version: 2, + models: vec![ + ModelSpec { + id: "ae".to_string(), + file: "ae.onnx".to_string(), + input_features: config.ae_feature_names.clone(), + preprocessing: vec![PreprocessingStep::StandardScaler { + sidecar: "sidecar.json".to_string(), + }], + outputs: vec![OutputHeadSpec { + name: "reconstruction_error".to_string(), + shape: vec!["1".to_string()], + semantic: OutputSemantic::AnomalyScore, + threshold: Some(0.5), + min_confidence: None, + }], + }, + ModelSpec { + id: "classifier".to_string(), + file: "classifier.onnx".to_string(), + input_features: vec![ + "duration".to_string(), + "bytes".to_string(), + "ae_anomaly_score".to_string(), + ], + preprocessing: vec![], + outputs: vec![OutputHeadSpec { + name: "class_probs".to_string(), + shape: vec!["10".to_string()], + semantic: OutputSemantic::Multiclass, + threshold: None, + min_confidence: Some(0.4), + }], + }, + ], + pipeline: vec!["ae".to_string(), "classifier".to_string()], labels: BTreeMap::new(), + alert_rules: vec![], }; let adapter = build_adapter( diff --git a/net-guardia/src/core/inference/model_promotion.rs b/net-guardia/src/core/inference/model_promotion.rs index c55d098..46c41c0 100644 --- a/net-guardia/src/core/inference/model_promotion.rs +++ b/net-guardia/src/core/inference/model_promotion.rs @@ -10,10 +10,9 @@ use crate::core::inference::model_loader::build_adapter; use crate::core::inference::runner::Inference; use crate::domain::common::config::constants::AUDIT_ACTOR_SECURITY_ADMIN_PREFIX; use crate::domain::detection::log::MLLog; -use crate::domain::detection::manifest::AdapterKind; +use crate::domain::detection::model_files::{MANIFEST_FILENAME, MODELS_DIR}; use crate::interface::detection::model_artifact_resolver::ModelArtifactResolver; use crate::interface::detection::model_config_loader::ModelConfigLoader; -use crate::interface::detection::model_files::{MANIFEST_FILENAME, MODELS_DIR}; use crate::interface::detection::model_promotion_store::ModelPromotionStore; use crate::interface::detection::model_runtime::ModelRuntimeLoader; use crate::interface::system::audit::AuditRepo; @@ -55,8 +54,6 @@ impl Drop for PromoteGuard<'_> { pub struct StagedModelPromotion<'a> { pub staging_dir: &'a Path, - pub uploaded_onnx_filename: &'a str, - pub uploaded_scaler_filename: Option<&'a str>, pub inference: &'a Inference, pub audit_repo: &'a dyn AuditRepo, pub promote_gate: &'a PromoteGate, @@ -77,41 +74,16 @@ pub async fn validate_and_promote(ctx: &StagedModelPromotion<'_>) -> Result) -> Result) -> Result) -> Result Result<(), PromoteErr Ok(()) } -async fn align_uploaded_file_with_manifest( - store: &dyn ModelPromotionStore, - staging_dir: &Path, - uploaded_filename: &str, - declared_filename: &str, - uploaded_field: &str, - operation: &'static str, -) -> Result { - validate_manifest_basename(uploaded_field, uploaded_filename)?; - validate_manifest_basename("manifest artifact filename", declared_filename)?; - let uploaded_path = staging_dir.join(uploaded_filename); - let declared_path = staging_dir.join(declared_filename); - if uploaded_path != declared_path { - store - .rename(&uploaded_path, &declared_path) - .await - .map_err(|e| PromoteError::StagingIo(operation, e))?; - } - Ok(declared_path) -} - pub struct PromoteFileSet { pub staging_manifest: PathBuf, pub staged_onnx: PathBuf, @@ -273,23 +217,15 @@ async fn promote_files_atomically_with_required_audit( audit_actor: &str, audit_detail: &str, ) -> Result<(), PromoteError> { - let backups = promote_files_with_backups(store, files).await?; - let audit_result = audit_repo + audit_repo .insert_audit_log(audit_actor, AUDIT_ACTION_MODEL_SWAP, audit_detail) .await - .map_err(PromoteError::AuditWrite); + .map_err(PromoteError::AuditWrite)?; - match audit_result { - Ok(()) => { - cleanup_backup_dir(store, &files.backup_dir).await; - Ok(()) - } - Err(err) => { - rollback_promote(store, files, backups).await; - cleanup_backup_dir(store, &files.backup_dir).await; - Err(err) - } - } + let backups = promote_files_with_backups(store, files).await?; + cleanup_backup_dir(store, &files.backup_dir).await; + drop(backups); + Ok(()) } async fn promote_files_with_backups( @@ -355,6 +291,7 @@ async fn backup_existing( ) -> Result, PromoteError> { if !store .exists(target) + .await .map_err(|e| PromoteError::PromoteIo(format!("check existing target {}", target.display()), e))? { return Ok(None); @@ -385,7 +322,7 @@ async fn rollback_promote(store: &dyn ModelPromotionStore, files: &PromoteFileSe } async fn remove_if_exists(store: &dyn ModelPromotionStore, path: &Path) { - match store.exists(path) { + match store.exists(path).await { Ok(true) => { if let Err(err) = store.remove_file(path).await { log!(MLLog::ModelPromotionRollbackFailed( @@ -501,53 +438,20 @@ mod tests { dir } - #[tokio::test] - async fn align_uploaded_file_renames_to_manifest_declared_filename() { - let dir = scratch_dir("align-sidecar"); - let uploaded = dir.join("upload.json"); - let declared = dir.join("sidecar.json"); - fs::write(&uploaded, b"sidecar").unwrap(); - let store = FsModelPromotionStore; - - let aligned = align_uploaded_file_with_manifest( - &store, - &dir, - "upload.json", - "sidecar.json", - "uploaded scaler filename", - "rename staged scaler sidecar", - ) - .await - .expect("align"); - - assert_eq!(aligned, declared); - assert!(!uploaded.exists()); - assert_eq!(fs::read(&declared).unwrap(), b"sidecar"); - fs::remove_dir_all(&dir).ok(); + #[test] + fn validate_manifest_basename_accepts_plain_filenames() { + assert!(validate_manifest_basename("field", "sidecar.json").is_ok()); + assert!(validate_manifest_basename("field", "classifier.onnx").is_ok()); } - #[tokio::test] - async fn align_uploaded_file_rejects_uploaded_paths() { - let dir = scratch_dir("reject-uploaded-path"); - let store = FsModelPromotionStore; - - let err = align_uploaded_file_with_manifest( - &store, - &dir, - "../upload.json", - "sidecar.json", - "uploaded scaler filename", - "rename staged scaler sidecar", - ) - .await - .expect_err("path-like uploaded filename should fail"); - + #[test] + fn validate_manifest_basename_rejects_path_values() { + let err = validate_manifest_basename("field", "../upload.json").expect_err("path-like value should fail"); assert!(err.to_string().contains("must be a basename")); - fs::remove_dir_all(&dir).ok(); } #[tokio::test] - async fn required_audit_failure_rolls_back_promoted_files() { + async fn required_audit_failure_prevents_file_promotion() { let tmp = scratch_dir("audit-rollback"); let staging = tmp.join("staging"); let models = tmp.join("models"); @@ -569,8 +473,8 @@ mod tests { let err = promote_files_atomically_with_required_audit( &store, &PromoteFileSet { - staging_manifest, - staged_onnx, + staging_manifest: staging_manifest.clone(), + staged_onnx: staged_onnx.clone(), staged_sidecar: None, target_manifest: target_manifest.clone(), target_onnx: target_onnx.clone(), @@ -587,6 +491,8 @@ mod tests { assert!(matches!(err, PromoteError::AuditWrite { .. })); assert_eq!(fs::read(&target_manifest).unwrap(), b"old manifest"); assert_eq!(fs::read(&target_onnx).unwrap(), b"old onnx"); + assert!(staging_manifest.exists()); + assert!(staged_onnx.exists()); assert!(!backup.exists()); fs::remove_dir_all(&tmp).ok(); } diff --git a/net-guardia/src/core/inference/model_watcher.rs b/net-guardia/src/core/inference/model_watcher.rs index 3b34f55..358a4a1 100644 --- a/net-guardia/src/core/inference/model_watcher.rs +++ b/net-guardia/src/core/inference/model_watcher.rs @@ -14,11 +14,11 @@ use crate::core::inference::model_adapter::ModelSourceState; use crate::domain::common::config::AppConfig; use crate::domain::detection::error::MLError; use crate::domain::detection::log::MLLog; +use crate::domain::detection::model_files::{MANIFEST_FILENAME, MODELS_DIR}; use crate::domain::detection::model_source::ModelInfo; use crate::interface::detection::model_artifact_resolver::ModelArtifactResolver; use crate::interface::detection::model_change_source::ModelChangeSource; use crate::interface::detection::model_config_loader::ModelConfigLoader; -use crate::interface::detection::model_files::{MANIFEST_FILENAME, MODELS_DIR}; use crate::interface::detection::model_runtime::ModelRuntimeLoader; #[derive(Debug)] @@ -141,9 +141,9 @@ pub fn reload_model_from_disk( Ok(adapter) => { let info = ModelInfo::new( manifest.name.clone(), - manifest.adapter.as_str().to_string(), + manifest.runtime_kind().as_str().to_string(), current_epoch_secs(), - manifest.features.len(), + manifest.runtime_feature_count(), ); inference.swap_state(ModelSourceState::Active { adapter, diff --git a/net-guardia/src/core/inference/runner.rs b/net-guardia/src/core/inference/runner.rs index 12b0b09..9b4aa7e 100644 --- a/net-guardia/src/core/inference/runner.rs +++ b/net-guardia/src/core/inference/runner.rs @@ -2,12 +2,11 @@ use std::cmp::Ordering as CmpOrdering; use std::collections::BTreeMap; use std::panic::{self, AssertUnwindSafe}; use std::sync::Arc; -use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::atomic::{AtomicU8, AtomicU32, AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; use arc_swap::ArcSwap; use macros::log; -use parking_lot::Mutex; use crate::core::inference::model_adapter::{MLModelAdapter, ModelSourceState, RunnableModel}; use crate::core::inference::model_runtime_batch::{ @@ -29,37 +28,16 @@ pub struct Inference { pub config: Arc, app_config: Arc>, qps_recent: AtomicU32, - circuit_breaker: Mutex, + cb_phase: AtomicU8, + cb_failure_count: AtomicU32, + cb_window_start_secs: AtomicU64, + cb_open_since_secs: AtomicU64, classifier_ae_feature_indices: Vec>, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum CircuitBreakerPhase { - Closed, - Open { since_secs: u64 }, - HalfOpen, -} - -#[derive(Debug, Clone, Copy)] -struct CircuitBreakerState { - phase: CircuitBreakerPhase, - failure_count: u32, - window_start_secs: u64, -} - -impl CircuitBreakerState { - fn closed() -> Self { - Self { - phase: CircuitBreakerPhase::Closed, - failure_count: 0, - window_start_secs: 0, - } - } - - fn reset(&mut self) { - *self = Self::closed(); - } -} +const CB_CLOSED: u8 = 0; +const CB_OPEN: u8 = 1; +const CB_HALF_OPEN: u8 = 2; impl Inference { pub fn new( @@ -73,7 +51,10 @@ impl Inference { config, app_config, qps_recent: AtomicU32::new(0), - circuit_breaker: Mutex::new(CircuitBreakerState::closed()), + cb_phase: AtomicU8::new(CB_CLOSED), + cb_failure_count: AtomicU32::new(0), + cb_window_start_secs: AtomicU64::new(0), + cb_open_since_secs: AtomicU64::new(0), classifier_ae_feature_indices, } } @@ -297,13 +278,17 @@ impl Inference { let n = flows.len(); let mut scores = Vec::with_capacity(n); let mut chunk_features = Vec::with_capacity(batch_size * n_features); + let mut scratch = FlowFeatures { + features: Vec::with_capacity(n_features), + feature_num: 0, + }; for chunk_start in (0..n).step_by(batch_size) { let chunk_end = (chunk_start + batch_size).min(n); let chunk_rows = chunk_end - chunk_start; chunk_features.clear(); for flow in &flows[chunk_start..chunk_end] { - self.preprocess_ae_features_into(flow, &mut chunk_features); + self.preprocess_ae_features_into(flow, &mut scratch, &mut chunk_features); } match run_autoencoder_flat_batch(model, &chunk_features, chunk_rows, batch_size, n_features) { Ok(s) => scores.extend_from_slice(&s), @@ -353,13 +338,17 @@ impl Inference { let class_min_conf = self.config.class_min_confidence; let mut results = Vec::with_capacity(n); let mut chunk_features = Vec::with_capacity(batch_size * n_features); + let mut scratch = FlowFeatures { + features: Vec::with_capacity(n_features), + feature_num: 0, + }; for chunk_start in (0..n).step_by(batch_size) { let chunk_end = (chunk_start + batch_size).min(n); let chunk_rows = chunk_end - chunk_start; chunk_features.clear(); for flow in &flows[chunk_start..chunk_end] { - self.preprocess_classifier_features_into(flow, &mut chunk_features); + self.preprocess_classifier_features_into(flow, &mut scratch, &mut chunk_features); } let class_probs = match run_classifier_flat_batch(model, &chunk_features, chunk_rows, batch_size, n_features) { @@ -398,24 +387,28 @@ impl Inference { results } - fn preprocess_ae_features_into(&self, flow: &FlowSnapshot, out: &mut Vec) { - let mut features = FlowFeatures::extract_from_stats(&flow.feature_stats, &self.config.ae_feature_names); - features.winsorize(&self.config.ae_clip_params, &self.config.ae_feature_names); - features.normalize(&self.config.ae_scaler_mean, &self.config.ae_scaler_std); - features.clip(self.config.ae_post_clip_min, self.config.ae_post_clip_max); - out.extend(features.features.iter().map(|&x| x as f32)); + fn preprocess_ae_features_into(&self, flow: &FlowSnapshot, scratch: &mut FlowFeatures, out: &mut Vec) { + scratch.reuse_extract_from_stats(&flow.feature_stats, &self.config.ae_feature_names); + scratch.winsorize(&self.config.ae_clip_params, &self.config.ae_feature_names); + scratch.normalize(&self.config.ae_scaler_mean, &self.config.ae_scaler_std); + scratch.clip(self.config.ae_post_clip_min, self.config.ae_post_clip_max); + out.extend(scratch.features.iter().map(|&x| x as f32)); } - fn preprocess_classifier_features_into(&self, flow: &FlowSnapshot, out: &mut Vec) { - let mut features = FlowFeatures::extract_from_stats(&flow.feature_stats, &self.config.classifier_feature_names); - apply_ae_preprocessing_by_feature_index(&mut features, &self.classifier_ae_feature_indices, &self.config); - out.extend(features.features.iter().map(|&x| x as f32)); + fn preprocess_classifier_features_into(&self, flow: &FlowSnapshot, scratch: &mut FlowFeatures, out: &mut Vec) { + scratch.reuse_extract_from_stats(&flow.feature_stats, &self.config.classifier_feature_names); + apply_ae_preprocessing_by_feature_index(scratch, &self.classifier_ae_feature_indices, &self.config); + out.extend(scratch.features.iter().map(|&x| x as f32)); } fn preprocess_ae_feature_batch(&self, flows: &[FlowSnapshot], n_features: usize) -> Vec { let mut rows = Vec::with_capacity(flows.len() * n_features); + let mut scratch = FlowFeatures { + features: Vec::with_capacity(n_features), + feature_num: 0, + }; for flow in flows { - self.preprocess_ae_features_into(flow, &mut rows); + self.preprocess_ae_features_into(flow, &mut scratch, &mut rows); } rows } @@ -433,23 +426,33 @@ impl Inference { } fn is_circuit_open(&self) -> bool { - let mut state = self.circuit_breaker.lock(); - let CircuitBreakerPhase::Open { since_secs } = state.phase else { - return matches!(state.phase, CircuitBreakerPhase::HalfOpen); - }; - - let cooldown = self.app_config.load().ml.circuit_breaker.cooldown_secs; - let elapsed = Self::now_secs().saturating_sub(since_secs); - if elapsed >= cooldown { - state.phase = CircuitBreakerPhase::HalfOpen; - log!(MLLog::CircuitBreakerReset(cooldown)); - return false; + let phase = self.cb_phase.load(Ordering::Acquire); + match phase { + CB_OPEN => { + let cooldown = self.app_config.load().ml.circuit_breaker.cooldown_secs; + let since_secs = self.cb_open_since_secs.load(Ordering::Acquire); + let elapsed = Self::now_secs().saturating_sub(since_secs); + if elapsed >= cooldown { + if self + .cb_phase + .compare_exchange(CB_OPEN, CB_HALF_OPEN, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + log!(MLLog::CircuitBreakerReset(cooldown)); + } + return false; + } + true + } + CB_HALF_OPEN => true, + _ => false, } - true } fn record_success(&self) { - self.circuit_breaker.lock().reset(); + self.cb_phase.store(CB_CLOSED, Ordering::Release); + self.cb_failure_count.store(0, Ordering::Release); + self.cb_window_start_secs.store(0, Ordering::Release); } fn record_failure(&self) { @@ -457,30 +460,43 @@ impl Inference { let cfg = self.app_config.load(); let window_secs = cfg.ml.circuit_breaker.window_secs; let threshold = cfg.ml.circuit_breaker.threshold; - let mut state = self.circuit_breaker.lock(); - match state.phase { - CircuitBreakerPhase::Open { .. } => return, - CircuitBreakerPhase::HalfOpen => { - state.phase = CircuitBreakerPhase::Open { since_secs: now }; - state.failure_count = threshold.max(1); - state.window_start_secs = now; - log!(MLLog::CircuitBreakerOpen(state.failure_count, window_secs)); + let phase = self.cb_phase.load(Ordering::Acquire); + match phase { + CB_OPEN => return, + CB_HALF_OPEN => { + if self + .cb_phase + .compare_exchange(CB_HALF_OPEN, CB_OPEN, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + self.cb_open_since_secs.store(now, Ordering::Release); + self.cb_failure_count.store(threshold.max(1), Ordering::Release); + self.cb_window_start_secs.store(now, Ordering::Release); + log!(MLLog::CircuitBreakerOpen(threshold.max(1), window_secs)); + } return; } - CircuitBreakerPhase::Closed => {} + _ => {} } - if state.window_start_secs == 0 || now.saturating_sub(state.window_start_secs) > window_secs { - state.window_start_secs = now; - state.failure_count = 1; + let window_start = self.cb_window_start_secs.load(Ordering::Acquire); + if window_start == 0 || now.saturating_sub(window_start) > window_secs { + self.cb_window_start_secs.store(now, Ordering::Release); + self.cb_failure_count.store(1, Ordering::Release); } else { - state.failure_count = state.failure_count.saturating_add(1); + self.cb_failure_count.fetch_add(1, Ordering::AcqRel); } - if state.failure_count >= threshold { - state.phase = CircuitBreakerPhase::Open { since_secs: now }; - log!(MLLog::CircuitBreakerOpen(state.failure_count, window_secs)); + let count = self.cb_failure_count.load(Ordering::Acquire); + if count >= threshold + && self + .cb_phase + .compare_exchange(CB_CLOSED, CB_OPEN, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + self.cb_open_since_secs.store(now, Ordering::Release); + log!(MLLog::CircuitBreakerOpen(count, window_secs)); } } } @@ -622,8 +638,12 @@ mod tests { Arc::new(ArcSwap::from_pointee(AppConfig::defaults())), ); let mut out = Vec::new(); + let mut scratch = FlowFeatures { + features: Vec::new(), + feature_num: 0, + }; - inference.preprocess_classifier_features_into(&sample_flow(), &mut out); + inference.preprocess_classifier_features_into(&sample_flow(), &mut scratch, &mut out); assert_eq!(out.len(), 2); assert_eq!( @@ -732,6 +752,6 @@ mod tests { is_forward: true, }; let flow = FlowData::new(FlowKey::from_packet(&packet), &packet, Direction::Ingress); - FlowSnapshot::from_flow(&flow) + FlowSnapshot::from_flow_data(&flow) } } diff --git a/net-guardia/src/core/response/actions.rs b/net-guardia/src/core/response/actions.rs index 5652041..0da4e43 100644 --- a/net-guardia/src/core/response/actions.rs +++ b/net-guardia/src/core/response/actions.rs @@ -223,7 +223,7 @@ impl SoarEngine { "SOAR auto-response triggered (hits: {}{})", event.flow_count, repeat_tag, ), - timestamp: Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(), + timestamp: Utc::now().timestamp(), }; notifier.send_alert(&payload).await?; Ok("Telegram notification sent".to_string()) diff --git a/net-guardia/src/core/response/engine/execution.rs b/net-guardia/src/core/response/engine/execution.rs index ab95828..a7107b5 100644 --- a/net-guardia/src/core/response/engine/execution.rs +++ b/net-guardia/src/core/response/engine/execution.rs @@ -45,6 +45,7 @@ impl SoarEngine { for block in &active_blocks { if let Err(e) = self.access_control.block_ip(&block.source_ip) { log!(SoarLog::RecoveryFailed(block.source_ip.clone(), e.to_string())); + log!(SoarLog::BlockEnforcementGap(block.source_ip.clone())); } } diff --git a/net-guardia/src/core/response/engine/tests.rs b/net-guardia/src/core/response/engine/tests.rs index f93a0b8..87555c2 100644 --- a/net-guardia/src/core/response/engine/tests.rs +++ b/net-guardia/src/core/response/engine/tests.rs @@ -17,7 +17,7 @@ use crate::interface::reporting::email_sender::{ EmailSender as EmailSenderTrait, EmailSenderFactory as EmailSenderFactoryTrait, }; use crate::interface::response::playbook_data::{ActionInput, CreatePlaybookInput}; -use crate::interface::response::soar::SoarRepo; +use crate::interface::response::soar::PlaybookRepo; use crate::interface::response::webhook_sender::WebhookSender as WebhookSenderTrait; use crate::interface::system::secret_store::SecretStorePort; diff --git a/net-guardia/src/core/response/frequency.rs b/net-guardia/src/core/response/frequency.rs index c5b613e..4773b2c 100644 --- a/net-guardia/src/core/response/frequency.rs +++ b/net-guardia/src/core/response/frequency.rs @@ -49,7 +49,10 @@ impl FrequencyTracker { let window = Duration::from_secs(window_secs); let count = { - let mut entry = self.events.entry(key).or_insert_with(|| FrequencyEntry::new(now)); + let mut entry = self + .events + .entry(key.clone()) + .or_insert_with(|| FrequencyEntry::new(now)); let entry = entry.value_mut(); entry.last_seen = now; let deque = &mut entry.events; @@ -72,7 +75,7 @@ impl FrequencyTracker { }; if self.events.len() > self.max_tracked_keys { - self.evict_oldest(self.events.len() - self.max_tracked_keys); + self.evict_oldest_excluding(self.events.len() - self.max_tracked_keys, &key); } count @@ -104,9 +107,18 @@ impl FrequencyTracker { } fn evict_oldest(&self, count: usize) -> u32 { + self.evict_oldest_inner(count, None) + } + + fn evict_oldest_excluding(&self, count: usize, exclude: &FreqKey) -> u32 { + self.evict_oldest_inner(count, Some(exclude)) + } + + fn evict_oldest_inner(&self, count: usize, exclude: Option<&FreqKey>) -> u32 { let mut entries: Vec<(FreqKey, Instant)> = self .events .iter() + .filter(|entry| exclude.is_none_or(|ex| entry.key() != ex)) .map(|entry| (entry.key().clone(), entry.last_seen)) .collect(); if count < entries.len() { diff --git a/net-guardia/src/domain/common/audit.rs b/net-guardia/src/domain/common/audit.rs index 6fa469e..238d95f 100644 --- a/net-guardia/src/domain/common/audit.rs +++ b/net-guardia/src/domain/common/audit.rs @@ -1,4 +1,6 @@ -#[derive(Debug, Clone)] +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] pub struct AuditLogEntry { pub id: i64, pub actor: String, diff --git a/net-guardia/src/domain/common/config/constants.rs b/net-guardia/src/domain/common/config/constants.rs index 37567c2..9686e93 100644 --- a/net-guardia/src/domain/common/config/constants.rs +++ b/net-guardia/src/domain/common/config/constants.rs @@ -3,6 +3,16 @@ pub const FUSION_AUDIT_ACTOR: &str = "FusionEngine"; pub const FUSION_AUDIT_ACTION: &str = "fused_threat_emitted"; pub const AUDIT_ACTOR_SECURITY_ADMIN_PREFIX: &str = "SecurityAdmin"; pub const PERMISSION_SYSTEM_ADMIN: &str = "system:admin"; +pub const PERMISSION_USERS_ADMIN: &str = "users:admin"; +pub const PERMISSION_API_KEYS_ADMIN: &str = "api_keys:admin"; +pub const PERMISSION_ACCESS_CONTROL_WRITE: &str = "access_control:write"; +pub const PERMISSION_DASHBOARD_READ: &str = "dashboard:read"; +pub const PERMISSION_AI_DETECTION_READ: &str = "ai_detection:read"; +pub const PERMISSION_FUSION_READ: &str = "fusion:read"; +pub const PERMISSION_TRAFFIC_MAP_READ: &str = "traffic_map:read"; +pub const PERMISSION_DROPS_READ: &str = "drops:read"; pub const ENFORCE_MODE_MONITOR: &str = "monitor"; pub const ENFORCE_MODE_ML_ONLY: &str = "ml_only"; pub const ENFORCE_MODE_ENFORCE: &str = "enforce"; + +pub const KNOWN_C2_PORTS: &[u16] = &[4444, 8443, 8080, 1337, 31337]; diff --git a/net-guardia/src/domain/common/event.rs b/net-guardia/src/domain/common/event.rs index c43ccaf..4bbde93 100644 --- a/net-guardia/src/domain/common/event.rs +++ b/net-guardia/src/domain/common/event.rs @@ -51,16 +51,6 @@ pub struct DetectionEvent { pub c2_score: f32, } -#[derive(Debug, Clone)] -pub struct FlowObservation { - pub src_ip: String, - pub dst_ip: String, - pub dst_port: u16, - pub protocol: u8, - pub packet_count: u64, - pub flow_duration_us: u64, -} - #[derive(Debug, Clone, Serialize)] pub struct DetectionDiagnostic { pub source: DetectionSource, diff --git a/net-guardia/src/domain/common/notification.rs b/net-guardia/src/domain/common/notification.rs index 50c5018..59badba 100644 --- a/net-guardia/src/domain/common/notification.rs +++ b/net-guardia/src/domain/common/notification.rs @@ -6,5 +6,5 @@ pub struct AlertPayload { pub threat_type: String, pub confidence: f32, pub action_description: String, - pub timestamp: String, + pub timestamp: i64, } diff --git a/net-guardia/src/domain/common/system/mod.rs b/net-guardia/src/domain/common/system/mod.rs index ca0831c..dee6069 100644 --- a/net-guardia/src/domain/common/system/mod.rs +++ b/net-guardia/src/domain/common/system/mod.rs @@ -1,3 +1,2 @@ pub mod health; pub mod rate_limit_settings; -pub mod suricata; diff --git a/net-guardia/src/domain/common/system/rate_limit_settings.rs b/net-guardia/src/domain/common/system/rate_limit_settings.rs index c756e45..a0180f1 100644 --- a/net-guardia/src/domain/common/system/rate_limit_settings.rs +++ b/net-guardia/src/domain/common/system/rate_limit_settings.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -#[derive(Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct RateLimitSettings { pub packet_rate: Option, pub syn_rate: Option, diff --git a/net-guardia/src/domain/detection/attack_type.rs b/net-guardia/src/domain/detection/attack_type.rs index 2964fd2..7da551d 100644 --- a/net-guardia/src/domain/detection/attack_type.rs +++ b/net-guardia/src/domain/detection/attack_type.rs @@ -1,4 +1,5 @@ use std::fmt; +use std::str::FromStr; use serde::{Deserialize, Serialize}; @@ -64,8 +65,16 @@ impl fmt::Display for CanonicalAttackType { } } +impl FromStr for CanonicalAttackType { + type Err = (); + + fn from_str(s: &str) -> Result { + Self::ALL.iter().copied().find(|c| c.as_str() == s).ok_or(()) + } +} + pub fn canonical_from_str(s: &str) -> Option { - CanonicalAttackType::ALL.iter().copied().find(|c| c.as_str() == s) + s.parse().ok() } pub fn translate(source: DetectionSource, raw_label: &str) -> CanonicalAttackType { diff --git a/net-guardia/src/domain/detection/error.rs b/net-guardia/src/domain/detection/error.rs index 75ba7ad..657409c 100644 --- a/net-guardia/src/domain/detection/error.rs +++ b/net-guardia/src/domain/detection/error.rs @@ -53,21 +53,3 @@ traceable! { ModelWatcherFailed => tracing::Level::ERROR, } } - -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, - - #[error("Failed to open eve.json stream at '{path}'")] - EveOpenFailed { path: String } => tracing::Level::ERROR, - } -} diff --git a/net-guardia/src/domain/detection/feature_extractor.rs b/net-guardia/src/domain/detection/feature_extractor.rs index 86ef01c..7b9b77e 100644 --- a/net-guardia/src/domain/detection/feature_extractor.rs +++ b/net-guardia/src/domain/detection/feature_extractor.rs @@ -287,7 +287,7 @@ impl FeatureStats { } } - fn get(&self, feature_name: &str) -> f64 { + pub fn get(&self, feature_name: &str) -> f64 { FEATURE_REGISTRY.get(feature_name).map(|g| g(self)).unwrap_or(0.0) } } diff --git a/net-guardia/src/domain/detection/flow_features.rs b/net-guardia/src/domain/detection/flow_features.rs index ac945e2..5f88fb3 100644 --- a/net-guardia/src/domain/detection/flow_features.rs +++ b/net-guardia/src/domain/detection/flow_features.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use std::sync::LazyLock; +use crate::domain::detection::feature_extractor::FeatureStats; use crate::domain::detection::ml_detection::ClipParams; static ALL_FEATURE_NAMES_OWNED: LazyLock> = LazyLock::new(|| { @@ -105,8 +106,22 @@ pub struct FlowFeatures { } impl FlowFeatures { + pub fn reuse_extract_from_stats(&mut self, precomputed: &FeatureStats, feature_names: &[String]) { + self.feature_num = feature_names.len(); + self.features.clear(); + self.features.reserve(self.feature_num); + for name in feature_names { + self.features.push(precomputed.get(name.trim())); + } + } + pub fn normalize(&mut self, means: &[f64], stds: &[f64]) { - for i in 0..self.feature_num { + let bound = self + .feature_num + .min(means.len()) + .min(stds.len()) + .min(self.features.len()); + for i in 0..bound { if stds[i] > 0.0 { self.features[i] = (self.features[i] - means[i]) / stds[i]; } else { @@ -139,9 +154,16 @@ impl FlowFeatures { &ALL_FEATURE_NAMES_OWNED } - pub fn to_csv_record(&self) -> Vec { - let mut record: Vec = self.features.iter().map(|f| f.to_string()).collect(); - record.push("BENIGN".to_string()); - record + pub fn to_csv_line(&self) -> String { + use std::fmt::Write; + let mut buf = String::with_capacity(self.feature_num * 12); + for (i, f) in self.features.iter().enumerate() { + if i > 0 { + buf.push(','); + } + let _ = write!(buf, "{f}"); + } + buf.push_str(",BENIGN"); + buf } } diff --git a/net-guardia/src/domain/detection/flow_observation.rs b/net-guardia/src/domain/detection/flow_observation.rs new file mode 100644 index 0000000..ad5d702 --- /dev/null +++ b/net-guardia/src/domain/detection/flow_observation.rs @@ -0,0 +1,9 @@ +#[derive(Debug, Clone)] +pub struct FlowObservation { + pub src_ip: String, + pub dst_ip: String, + pub dst_port: u16, + pub protocol: u8, + pub packet_count: u64, + pub flow_duration_us: u64, +} diff --git a/net-guardia/src/domain/detection/flow_tracker.rs b/net-guardia/src/domain/detection/flow_tracker.rs index b42ba9a..50a32b8 100644 --- a/net-guardia/src/domain/detection/flow_tracker.rs +++ b/net-guardia/src/domain/detection/flow_tracker.rs @@ -44,7 +44,6 @@ pub struct FlowData { pub bwd_bulk_state: BulkState, pub act_data_pkt_fwd: u32, pub is_first_packet: bool, - pub last_inferred_us: u64, } #[derive(Debug, Clone)] @@ -61,7 +60,7 @@ pub struct FlowSnapshot { } impl FlowSnapshot { - pub fn from_flow(flow: &FlowData) -> Self { + pub fn from_flow_data(flow: &FlowData) -> Self { Self { flow_key: flow.flow_key, direction: flow.direction, @@ -87,10 +86,10 @@ impl FlowData { direction, start_time_us: first_packet.timestamp_us, last_time_us: first_packet.timestamp_us, - fwd_packets: Vec::new(), + fwd_packets: Vec::with_capacity(32), fwd_total_bytes: 0, fwd_header_bytes: 0, - bwd_packets: Vec::new(), + bwd_packets: Vec::with_capacity(32), bwd_total_bytes: 0, bwd_header_bytes: 0, fin_count: 0, @@ -111,14 +110,13 @@ impl FlowData { } else { 0 }, - active_periods: Vec::new(), - idle_periods: Vec::new(), + active_periods: Vec::with_capacity(16), + idle_periods: Vec::with_capacity(8), last_packet_time: first_packet.timestamp_us, fwd_bulk_state: BulkState::default(), bwd_bulk_state: BulkState::default(), act_data_pkt_fwd: 0, is_first_packet: true, - last_inferred_us: 0, } } diff --git a/net-guardia/src/domain/detection/log.rs b/net-guardia/src/domain/detection/log.rs index 9c8f63b..1751828 100644 --- a/net-guardia/src/domain/detection/log.rs +++ b/net-guardia/src/domain/detection/log.rs @@ -6,6 +6,9 @@ loggable! { #[error("ML models loaded - {info}")] ModelsLoaded { info: String } => tracing::Level::INFO, + #[error("ML model status summary: {summary}")] + ModelStatusSummary { summary: String } => tracing::Level::INFO, + #[error("Inference configuration loaded: {features} features, {attacks} attack types")] ConfigLoaded { features: usize, attacks: usize } => tracing::Level::INFO, @@ -170,49 +173,3 @@ loggable! { DetectionChannelDrop { detector: String, attack_type: String, source_ip: String } => tracing::Level::WARN, } } - -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 subprocess SIGTERM failed: {error}")] - ShutdownSignalFailed { error: String } => tracing::Level::WARN, - - #[error("Suricata subprocess SIGKILL failed after timeout: {error}")] - ShutdownKillFailed { error: String } => tracing::Level::ERROR, - - #[error("Suricata eve.json monitor waiting for file: {path}")] - MonitorWaitingForFile { path: String } => tracing::Level::INFO, - - #[error("Suricata eve.json monitor failed to open {path}: {error}")] - MonitorOpenFailed { path: String, error: String } => tracing::Level::WARN, - - #[error("Suricata eve.json monitor failed to seek to end of {path}: {error}")] - MonitorSeekFailed { path: String, error: String } => tracing::Level::WARN, - - #[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/domain/detection/manifest.rs b/net-guardia/src/domain/detection/manifest.rs index e3b82db..ab562ed 100644 --- a/net-guardia/src/domain/detection/manifest.rs +++ b/net-guardia/src/domain/detection/manifest.rs @@ -1,10 +1,10 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashSet}; use std::path::{Component, Path, PathBuf}; use serde::{Deserialize, Serialize}; use crate::domain::detection::error::MLError; -use crate::domain::detection::feature_extractor::feature_is_known; +use crate::domain::detection::feature_extractor::feature_registry_names; #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] @@ -24,14 +24,77 @@ impl AdapterKind { } } -#[derive(Debug, Clone, Default, Deserialize, Serialize)] -pub struct ModelPaths { +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ModelManifest { + pub name: String, + pub version: u32, + #[serde(default)] + pub models: Vec, + #[serde(default)] + pub pipeline: Vec, + #[serde(default)] + pub labels: BTreeMap, + #[serde(default)] + pub alert_rules: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ModelSpec { + pub id: String, + pub file: String, + #[serde(default)] + pub input_features: Vec, + #[serde(default)] + pub preprocessing: Vec, + #[serde(default)] + pub outputs: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum PreprocessingStep { + StandardScaler { + sidecar: String, + }, + MinMaxScaler { + sidecar: String, + }, + RobustScaler { + sidecar: String, + }, + LogTransform { + #[serde(default = "default_log_offset")] + offset: f32, + }, + Clip { + min: f32, + max: f32, + }, + Quantile { + sidecar: String, + }, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum OutputSemantic { + AnomalyScore, + Binary, + Multiclass, + Multilabel, + Regression, + Passthrough, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct OutputHeadSpec { + pub name: String, + pub shape: Vec, + pub semantic: OutputSemantic, #[serde(default, skip_serializing_if = "Option::is_none")] - pub model: Option, + pub threshold: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub autoencoder: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub classifier: Option, + pub min_confidence: Option, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -43,109 +106,306 @@ pub struct LabelSpec { 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, -} - #[derive(Debug, Clone, Deserialize, Serialize)] -pub struct Preprocessing { - pub scaler_sidecar: String, -} - -#[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, +pub struct AlertRuleSpec { + pub condition: String, + pub source_label: String, } impl ModelManifest { pub fn validate(&self, path: &Path) -> Result<(), MLError> { + if self.version != 2 { + return Err(MLError::ManifestInvalid( + path.to_path_buf(), + format!("version must be 2 (got {})", self.version), + )); + } if self.name.trim().is_empty() { return Err(MLError::ManifestInvalid( path.to_path_buf(), "name is empty".to_string(), )); } - if self.features.is_empty() { + if self.models.is_empty() { return Err(MLError::ManifestInvalid( path.to_path_buf(), - "features is empty".to_string(), + "models is empty".to_string(), )); } - for f in &self.features { - if !feature_is_known(f) { - return Err(MLError::UnknownFeature(f.clone())); - } + if self.pipeline.is_empty() { + return Err(MLError::ManifestInvalid( + path.to_path_buf(), + "pipeline is empty".to_string(), + )); } - 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_artifact_paths(path)?; + + self.validate_models(path)?; + self.validate_pipeline(path)?; + self.validate_pipeline_features(path)?; self.validate_labels(path)?; - self.validate_thresholds(path)?; + self.validate_alert_rules(path)?; Ok(()) } - fn validate_artifact_paths(&self, path: &Path) -> Result<(), MLError> { - let mut seen_artifacts: Vec<(&str, Vec)> = Vec::new(); - for (field, value) in [ - ("models.model", self.models.model.as_deref()), - ("models.autoencoder", self.models.autoencoder.as_deref()), - ("models.classifier", self.models.classifier.as_deref()), - ( - "preprocessing.scaler_sidecar", - self.preprocessing.as_ref().map(|p| p.scaler_sidecar.as_str()), - ), - ] { - if let Some(value) = value { - validate_relative_artifact_path(path, field, value)?; - validate_artifact_not_manifest_path(path, field, value)?; - let components = normalized_artifact_components(value); - if let Some((existing_field, _)) = seen_artifacts + pub fn model_by_id(&self, id: &str) -> Option<&ModelSpec> { + self.models.iter().find(|model| model.id == id) + } + + pub fn pipeline_models(&self) -> Result, MLError> { + let mut ordered = Vec::with_capacity(self.pipeline.len()); + for id in &self.pipeline { + let model = self.model_by_id(id).ok_or_else(|| { + MLError::ManifestInvalid(PathBuf::new(), format!("pipeline references unknown model id '{id}'")) + })?; + ordered.push(model); + } + Ok(ordered) + } + + pub fn primary_model(&self) -> Option<&ModelSpec> { + self.pipeline.first().and_then(|id| self.model_by_id(id)) + } + + pub fn classifier_model(&self) -> Option<&ModelSpec> { + self.pipeline.get(1).and_then(|id| self.model_by_id(id)) + } + + pub fn runtime_kind(&self) -> AdapterKind { + match self.pipeline.len() { + 0 | 1 => { + let outputs = self + .primary_model() + .map(|model| model.outputs.as_slice()) + .unwrap_or(&[]); + if outputs .iter() - .find(|(_, existing_components)| existing_components == &components) + .any(|output| output.semantic == OutputSemantic::AnomalyScore) { - return Err(MLError::ManifestInvalid( - path.to_path_buf(), - format!("{field} must not reuse the same artifact path as {existing_field}: {value:?}"), - )); + AdapterKind::AutoencoderOnly + } else { + AdapterKind::ClassifierOnly } - seen_artifacts.push((field, components)); } + _ => AdapterKind::MultiTask, + } + } + + pub fn runtime_feature_count(&self) -> usize { + self.primary_model() + .map(|model| model.input_features.len()) + .unwrap_or(0) + } + + pub fn primary_scaler_sidecar(&self) -> Option<&str> { + self.primary_model().and_then(|model| { + model.preprocessing.iter().find_map(|step| match step { + PreprocessingStep::StandardScaler { sidecar } + | PreprocessingStep::MinMaxScaler { sidecar } + | PreprocessingStep::RobustScaler { sidecar } + | PreprocessingStep::Quantile { sidecar } => Some(sidecar.as_str()), + _ => None, + }) + }) + } + + fn validate_models(&self, path: &Path) -> Result<(), MLError> { + let mut seen_ids = Vec::with_capacity(self.models.len()); + let mut seen_artifacts: Vec<(String, Vec)> = Vec::new(); + + for model in &self.models { + if model.id.trim().is_empty() { + return Err(MLError::ManifestInvalid( + path.to_path_buf(), + "model id is empty".to_string(), + )); + } + if model.file.trim().is_empty() { + return Err(MLError::ManifestInvalid( + path.to_path_buf(), + format!("model '{}' file is empty", model.id), + )); + } + if seen_ids.iter().any(|id| id == &model.id) { + return Err(MLError::ManifestInvalid( + path.to_path_buf(), + format!("duplicate model id '{}'", model.id), + )); + } + seen_ids.push(model.id.clone()); + + validate_relative_artifact_path(path, &format!("models[{}].file", model.id), &model.file)?; + validate_artifact_not_manifest_path(path, &format!("models[{}].file", model.id), &model.file)?; + let file_components = normalized_artifact_components(&model.file); + if let Some((existing_field, _)) = seen_artifacts + .iter() + .find(|(_, existing_components)| existing_components == &file_components) + { + return Err(MLError::ManifestInvalid( + path.to_path_buf(), + format!( + "model file for '{}' must not reuse the same artifact path as {}: {:?}", + model.id, existing_field, model.file + ), + )); + } + seen_artifacts.push((model.id.clone(), file_components)); + + if model.outputs.is_empty() { + return Err(MLError::ManifestInvalid( + path.to_path_buf(), + format!("model '{}' outputs is empty", model.id), + )); + } + self.validate_preprocessing(path, &model.id, &model.preprocessing)?; + self.validate_outputs(path, &model.id, &model.outputs)?; + } + + Ok(()) + } + + fn validate_preprocessing(&self, path: &Path, model_id: &str, steps: &[PreprocessingStep]) -> Result<(), MLError> { + for (idx, step) in steps.iter().enumerate() { + match step { + PreprocessingStep::StandardScaler { sidecar } + | PreprocessingStep::MinMaxScaler { sidecar } + | PreprocessingStep::RobustScaler { sidecar } + | PreprocessingStep::Quantile { sidecar } => { + validate_relative_artifact_path( + path, + &format!("models[{model_id}].preprocessing[{idx}]"), + sidecar, + )?; + validate_artifact_not_manifest_path( + path, + &format!("models[{model_id}].preprocessing[{idx}]"), + sidecar, + )?; + } + PreprocessingStep::LogTransform { offset } => { + if !offset.is_finite() || *offset < 0.0 { + return Err(MLError::ManifestInvalid( + path.to_path_buf(), + format!( + "models[{model_id}].preprocessing[{idx}].offset must be finite and >= 0 (got {offset})" + ), + )); + } + } + PreprocessingStep::Clip { min, max } => { + if !min.is_finite() || !max.is_finite() || min > max { + return Err(MLError::ManifestInvalid( + path.to_path_buf(), + format!( + "models[{model_id}].preprocessing[{idx}] clip range must be finite and ordered lower <= upper (got {min}..{max})" + ), + )); + } + } + } + } + Ok(()) + } + + fn validate_pipeline_features(&self, path: &Path) -> Result<(), MLError> { + let mut available: HashSet = feature_registry_names() + .iter() + .map(|name| (*name).to_string()) + .collect(); + + for model in self.pipeline_models()? { + for feature in &model.input_features { + if !available.contains(feature) { + return Err(MLError::UnknownFeature(feature.clone())); + } + } + for output in &model.outputs { + available.insert(output.name.clone()); + } + } + + if available.is_empty() { + return Err(MLError::ManifestInvalid( + path.to_path_buf(), + "no features or pipeline outputs are available".to_string(), + )); + } + + Ok(()) + } + + fn validate_outputs(&self, path: &Path, model_id: &str, outputs: &[OutputHeadSpec]) -> Result<(), MLError> { + let mut seen_names = Vec::with_capacity(outputs.len()); + for output in outputs { + if output.name.trim().is_empty() { + return Err(MLError::ManifestInvalid( + path.to_path_buf(), + format!("model '{model_id}' has an output with empty name"), + )); + } + if output.shape.is_empty() { + return Err(MLError::ManifestInvalid( + path.to_path_buf(), + format!("model '{model_id}' output '{}' shape is empty", output.name), + )); + } + for dim in &output.shape { + validate_output_shape_dim(path, model_id, &output.name, dim)?; + } + if seen_names.iter().any(|name| name == &output.name) { + return Err(MLError::ManifestInvalid( + path.to_path_buf(), + format!("model '{model_id}' has duplicate output name '{}'", output.name), + )); + } + seen_names.push(output.name.clone()); + if let Some(value) = output.threshold + && !(value.is_finite() && value >= 0.0) + { + return Err(MLError::ManifestInvalid( + path.to_path_buf(), + format!( + "model '{model_id}' output '{}' threshold must be finite and >= 0 (got {value})", + output.name + ), + )); + } + if let Some(value) = output.min_confidence + && !(value.is_finite() && (0.0..=1.0).contains(&value)) + { + return Err(MLError::ManifestInvalid( + path.to_path_buf(), + format!( + "model '{model_id}' output '{}' min_confidence must be finite and in [0, 1] (got {value})", + output.name + ), + )); + } + } + Ok(()) + } + + fn validate_pipeline(&self, path: &Path) -> Result<(), MLError> { + let mut seen = Vec::with_capacity(self.pipeline.len()); + for id in &self.pipeline { + if id.trim().is_empty() { + return Err(MLError::ManifestInvalid( + path.to_path_buf(), + "pipeline contains an empty model id".to_string(), + )); + } + if seen.iter().any(|seen_id| seen_id == id) { + return Err(MLError::ManifestInvalid( + path.to_path_buf(), + format!("pipeline contains duplicate model id '{id}'"), + )); + } + if self.model_by_id(id).is_none() { + return Err(MLError::ManifestInvalid( + path.to_path_buf(), + format!("pipeline references unknown model id '{id}'"), + )); + } + seen.push(id.clone()); } Ok(()) } @@ -164,7 +424,7 @@ impl ModelManifest { { return Err(MLError::ManifestInvalid( path.to_path_buf(), - format!("label '{}' has confirmations: 0 (must be ≥ 1)", spec.name), + format!("label '{}' has confirmations: 0 (must be >= 1)", spec.name), )); } let lower = spec.name.to_ascii_lowercase(); @@ -179,36 +439,20 @@ impl ModelManifest { Ok(()) } - fn validate_thresholds(&self, path: &Path) -> Result<(), MLError> { - for (field, value) in [ - ("thresholds.anomaly", self.thresholds.anomaly), - ("thresholds.c2", self.thresholds.c2), - ("thresholds.ae", self.thresholds.ae), - ] { - if let Some(v) = value - && !(v.is_finite() && v >= 0.0) - { + fn validate_alert_rules(&self, path: &Path) -> Result<(), MLError> { + for (idx, rule) in self.alert_rules.iter().enumerate() { + if rule.condition.trim().is_empty() { return Err(MLError::ManifestInvalid( path.to_path_buf(), - format!("{field} must be finite and >= 0 (got {v})"), + format!("alert_rules[{idx}].condition is empty"), + )); + } + if rule.source_label.trim().is_empty() { + return Err(MLError::ManifestInvalid( + path.to_path_buf(), + format!("alert_rules[{idx}].source_label is empty"), )); } - } - if let Some(v) = self.thresholds.class_min_confidence - && !(v.is_finite() && (0.0..=1.0).contains(&v)) - { - return Err(MLError::ManifestInvalid( - path.to_path_buf(), - format!("thresholds.class_min_confidence must be finite and in [0, 1] (got {v})"), - )); - } - 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(()) } @@ -218,6 +462,33 @@ impl ModelManifest { } } +fn default_log_offset() -> f32 { + 1.0 +} + +fn validate_output_shape_dim(path: &Path, model_id: &str, output_name: &str, dim: &str) -> Result<(), MLError> { + let trimmed = dim.trim(); + if trimmed.is_empty() { + return Err(MLError::ManifestInvalid( + path.to_path_buf(), + format!("model '{model_id}' output '{output_name}' has an empty shape dimension"), + )); + } + if trimmed.parse::().is_ok() { + return Ok(()); + } + if trimmed + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-') + { + return Ok(()); + } + Err(MLError::ManifestInvalid( + path.to_path_buf(), + format!("model '{model_id}' output '{output_name}' has invalid shape dimension '{dim}'"), + )) +} + fn validate_relative_artifact_path(manifest_path: &Path, field: &str, value: &str) -> Result<(), MLError> { if value.is_empty() { return Err(MLError::ManifestInvalid( @@ -264,7 +535,7 @@ fn normalized_artifact_components(value: &str) -> Vec { Path::new(value) .components() .filter_map(|component| match component { - Component::Normal(value) => value.to_str().map(str::to_string), + Component::Normal(s) => s.to_str().map(|s| s.to_string()), _ => None, }) .collect() @@ -274,252 +545,78 @@ fn normalized_artifact_components(value: &str) -> Vec { mod tests { use super::*; - const V10_MANIFEST: &str = r#" -name: netguardia-v10 -adapter: multi_task + const V2_MANIFEST: &str = r#" +name: netguardia-v2 +version: 2 models: - autoencoder: deep_autoencoder.onnx - classifier: classifier.onnx -features: - - flow_duration - - fwd_packets - - bwd_packets + - id: anomaly_detector + file: deep_autoencoder.onnx + input_features: + - flow_duration + - fwd_packets + preprocessing: + - type: standard_scaler + sidecar: inference_config.json + - type: clip + min: -5.0 + max: 5.0 + outputs: + - name: ae_anomaly_score + shape: [1] + semantic: anomaly_score + threshold: 0.23 + - id: classifier + file: classifier.onnx + input_features: + - flow_duration + - fwd_packets + - ae_anomaly_score + outputs: + - name: anomaly + shape: [1] + semantic: binary + threshold: 0.91 + - name: class_probs + shape: [10] + semantic: multiclass + min_confidence: 0.4 +pipeline: + - anomaly_detector + - classifier labels: - "0": { name: Bot } + "0": { name: Bot, confirmations: 1 } "7": { name: Normal } -thresholds: - anomaly: 0.9 - c2: 0.85 -preprocessing: - scaler_sidecar: inference_config.json +alert_rules: + - condition: "anomaly > threshold" + source_label: anomaly "#; #[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")); + fn parse_v2_manifest() { + let parsed: ModelManifest = serde_yaml_ng::from_str(V2_MANIFEST).expect("parse"); + assert_eq!(parsed.version, 2); + assert_eq!(parsed.runtime_kind(), AdapterKind::MultiTask); + assert_eq!(parsed.runtime_feature_count(), 2); + assert_eq!(parsed.primary_scaler_sidecar(), Some("inference_config.json")); } #[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_numeric_label_key() { - let yaml = r#" -name: bad -adapter: classifier_only -models: - model: m.onnx -features: - - flow_duration -labels: - normal: { name: Normal } -"#; - 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 non-numeric label key"); - assert!(matches!(err, MLError::ManifestInvalid { .. }), "got {err:?}"); - } - - #[test] - fn rejects_absolute_or_parent_artifact_paths() { - let path = Path::new("/tmp/test-manifest.yaml"); - for model_path in ["/tmp/model.onnx", "../model.onnx", "./model.onnx"] { - let yaml = format!( - r#" -name: bad -adapter: classifier_only -models: - model: {model_path} -features: - - flow_duration -"# - ); - let parsed: ModelManifest = serde_yaml_ng::from_str(&yaml).unwrap(); - let err = parsed.validate(path).expect_err("should reject unsafe artifact path"); - assert!(matches!(err, MLError::ManifestInvalid { .. }), "got {err:?}"); - } - } - - #[test] - fn rejects_unsafe_scaler_sidecar_path() { - let yaml = r#" -name: bad -adapter: classifier_only -models: - model: m.onnx -features: - - flow_duration -preprocessing: - scaler_sidecar: ../inference_config.json -"#; - 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 unsafe sidecar path"); - assert!(matches!(err, MLError::ManifestInvalid { .. }), "got {err:?}"); - } - - #[test] - fn rejects_artifact_path_pointing_at_manifest_file() { - let yaml = r#" -name: bad -adapter: classifier_only -models: - model: manifest.yaml -features: - - flow_duration -"#; - let path = Path::new("/tmp/manifest.yaml"); - let parsed: ModelManifest = serde_yaml_ng::from_str(yaml).unwrap(); + fn reject_invalid_version() { + let yaml = V2_MANIFEST.replace("version: 2", "version: 1"); + let parsed: ModelManifest = serde_yaml_ng::from_str(&yaml).expect("parse"); let err = parsed - .validate(path) - .expect_err("should reject manifest self-reference"); - assert!(err.to_string().contains("manifest file itself"), "got {err:?}"); + .validate(Path::new("/tmp/manifest.yaml")) + .expect_err("should reject version"); + assert!(err.to_string().contains("version must be 2")); } #[test] - fn rejects_duplicate_artifact_paths() { - let yaml = r#" -name: bad -adapter: multi_task -models: - autoencoder: model.onnx - classifier: model.onnx -features: - - flow_duration -"#; - let path = Path::new("/tmp/manifest.yaml"); - let parsed: ModelManifest = serde_yaml_ng::from_str(yaml).unwrap(); - let err = parsed.validate(path).expect_err("should reject duplicate artifacts"); - assert!(err.to_string().contains("same artifact path"), "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:?}"); - } - - #[test] - fn rejects_invalid_threshold_overrides() { - for thresholds in ["anomaly: -0.1", "c2: .nan", "ae: -.inf", "class_min_confidence: 1.1"] { - let yaml = format!( - r#" -name: bad -adapter: classifier_only -models: - model: m.onnx -features: - - flow_duration -thresholds: - {thresholds} -"# - ); - 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 invalid threshold"); - - assert!(matches!(err, MLError::ManifestInvalid { .. }), "got {err:?}"); - } + fn reject_unknown_feature() { + let yaml = V2_MANIFEST.replace("flow_duration", "not_a_real_feature"); + let parsed: ModelManifest = serde_yaml_ng::from_str(&yaml).expect("parse"); + let err = parsed + .validate(Path::new("/tmp/manifest.yaml")) + .expect_err("should reject unknown feature"); + assert!(err.to_string().contains("Unknown feature")); } } diff --git a/net-guardia/src/domain/detection/mod.rs b/net-guardia/src/domain/detection/mod.rs index 8c34479..f889ca7 100644 --- a/net-guardia/src/domain/detection/mod.rs +++ b/net-guardia/src/domain/detection/mod.rs @@ -3,10 +3,13 @@ pub mod drift; pub mod error; pub mod feature_extractor; pub mod flow_features; +pub mod flow_observation; pub mod flow_tracker; pub mod fusion_math; pub mod log; pub mod manifest; pub mod ml_detection; pub mod ml_inference_config; +pub mod model_files; pub mod model_source; +pub mod suricata_health; diff --git a/net-guardia/src/interface/detection/model_files.rs b/net-guardia/src/domain/detection/model_files.rs similarity index 100% rename from net-guardia/src/interface/detection/model_files.rs rename to net-guardia/src/domain/detection/model_files.rs diff --git a/net-guardia/src/domain/detection/model_source.rs b/net-guardia/src/domain/detection/model_source.rs index ed944d8..3607bff 100644 --- a/net-guardia/src/domain/detection/model_source.rs +++ b/net-guardia/src/domain/detection/model_source.rs @@ -1,24 +1,44 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ModelInfo { +pub struct ModelIdentity { pub name: String, pub adapter_kind: String, - pub loaded_at_secs: u64, pub features_count: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModelInfo { + #[serde(flatten)] + pub identity: ModelIdentity, + pub loaded_at_secs: u64, pub qps_recent: f32, } impl ModelInfo { pub fn new(name: String, adapter_kind: String, loaded_at_secs: u64, features_count: usize) -> Self { Self { - name, - adapter_kind, + identity: ModelIdentity { + name, + adapter_kind, + features_count, + }, loaded_at_secs, - features_count, qps_recent: 0.0, } } + + pub fn name(&self) -> &str { + &self.identity.name + } + + pub fn adapter_kind(&self) -> &str { + &self.identity.adapter_kind + } + + pub fn features_count(&self) -> usize { + self.identity.features_count + } } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/net-guardia/src/domain/common/system/suricata.rs b/net-guardia/src/domain/detection/suricata_health.rs similarity index 100% rename from net-guardia/src/domain/common/system/suricata.rs rename to net-guardia/src/domain/detection/suricata_health.rs diff --git a/net-guardia/src/domain/identity/error.rs b/net-guardia/src/domain/identity/error.rs index 6783fa0..b69635c 100644 --- a/net-guardia/src/domain/identity/error.rs +++ b/net-guardia/src/domain/identity/error.rs @@ -1,4 +1,98 @@ -use macros::traceable; +use macros::{fallible, traceable}; + +fallible! { + LoginError { + #[no_source] + #[error("Account locked, retry after {retry_after_secs}s")] + Locked { retry_after_secs: u64 }, + + #[no_source] + #[error("Invalid credentials")] + InvalidCredentials, + + #[no_source] + #[error("Internal authentication error")] + InternalError, + } +} + +fallible! { + RegisterError { + #[no_source] + #[error("Validation failed: {reason}")] + Validation { reason: String }, + + #[no_source] + #[error("Invalid role")] + InvalidRole, + + #[no_source] + #[error("Insufficient permissions")] + Forbidden, + + #[no_source] + #[error("Password hashing failed")] + HashFailed, + + #[error("User already exists: {err}")] + Conflict, + + #[error("Internal error: {err}")] + Internal, + } +} + +fallible! { + UserError { + #[no_source] + #[error("Validation failed: {reason}")] + Validation { reason: String }, + + #[no_source] + #[error("Current password is incorrect")] + Unauthorized, + + #[no_source] + #[error("Forbidden: {reason}")] + Forbidden { reason: String }, + + #[no_source] + #[error("Not found: {entity}")] + NotFound { entity: String }, + + #[no_source] + #[error("Password hashing failed")] + HashFailed, + + #[error("Conflict: {err}")] + Conflict, + + #[error("Internal error: {err}")] + Internal, + } +} + +fallible! { + GroupError { + #[no_source] + #[error("Validation failed: {reason}")] + Validation { reason: String }, + + #[no_source] + #[error("Forbidden: {reason}")] + Forbidden { reason: String }, + + #[no_source] + #[error("Not found: {entity}")] + NotFound { entity: String }, + + #[error("Conflict: {err}")] + Conflict, + + #[error("Internal error: {err}")] + Internal, + } +} traceable! { AuthError { diff --git a/net-guardia/src/domain/response/error.rs b/net-guardia/src/domain/response/error.rs index 11ab8d3..ef45f45 100644 --- a/net-guardia/src/domain/response/error.rs +++ b/net-guardia/src/domain/response/error.rs @@ -75,6 +75,10 @@ traceable! { #[error("Invalid operator '{operator}' for SOAR condition type '{condition_type}'")] InvalidConditionOperator { condition_type: String, operator: String } => tracing::Level::WARN, + #[no_source] + #[error("{reason}")] + ValidationFailed { reason: String } => tracing::Level::WARN, + #[no_source] #[error("Rate-limit owner task is unavailable (channel closed)")] RateLimitOwnerUnavailable => tracing::Level::ERROR, diff --git a/net-guardia/src/domain/response/log.rs b/net-guardia/src/domain/response/log.rs index 802b9f8..1b5b2b3 100644 --- a/net-guardia/src/domain/response/log.rs +++ b/net-guardia/src/domain/response/log.rs @@ -54,6 +54,9 @@ loggable! { #[error("Failed to recover block for IP {ip} during startup: {error}")] RecoveryFailed { ip: String, error: String } => tracing::Level::WARN, + #[error("Block enforcement gap: IP {ip} is persisted in DB but eBPF enforcement failed — block is not active until next successful recovery")] + BlockEnforcementGap { ip: String } => tracing::Level::ERROR, + #[error("Successfully unblocked orphan IP {ip} on retry #{attempt}")] PendingUnblockRecovered { ip: String, attempt: i64 } => tracing::Level::INFO, diff --git a/net-guardia/src/domain/response/mod.rs b/net-guardia/src/domain/response/mod.rs index eddcff6..bfce765 100644 --- a/net-guardia/src/domain/response/mod.rs +++ b/net-guardia/src/domain/response/mod.rs @@ -4,3 +4,4 @@ pub mod error; pub mod log; pub mod outcome; pub mod playbook; +pub mod playbook_validator; diff --git a/net-guardia/src/domain/response/playbook_validator.rs b/net-guardia/src/domain/response/playbook_validator.rs new file mode 100644 index 0000000..882c3ac --- /dev/null +++ b/net-guardia/src/domain/response/playbook_validator.rs @@ -0,0 +1,377 @@ +use std::str::FromStr; + +use crate::domain::common::event::DetectionSource; +use crate::domain::response::condition::{ConditionType, is_valid_ip_pattern, is_valid_operator}; +use crate::domain::response::error::SoarError; +use crate::domain::response::playbook::ActionType; +use crate::interface::response::playbook_data::CreateConditionInput; + +const RATE_LIMIT_FACTOR_MIN: f64 = 0.01; +const RATE_LIMIT_FACTOR_MAX: f64 = 1.0; + +pub fn validate_optional_positive_i64(field: &str, value: Option) -> Result<(), SoarError> { + if value.is_some_and(|value| value <= 0) { + return Err(SoarError::ValidationFailed(format!("{field} must be greater than 0"))); + } + Ok(()) +} + +pub fn validate_cooldown_secs(cooldown_secs: i64) -> Result<(), SoarError> { + if cooldown_secs < 0 { + return Err(SoarError::ValidationFailed( + "cooldown_secs must be greater than or equal to 0".to_string(), + )); + } + Ok(()) +} + +pub fn validate_condition_input(condition: &CreateConditionInput) -> Result<(), SoarError> { + let condition_type = condition + .condition_type + .parse::() + .map_err(|_| SoarError::ValidationFailed(format!("unknown condition_type: {}", condition.condition_type)))?; + if !is_valid_operator(&condition_type, &condition.operator) { + return Err(SoarError::ValidationFailed(format!( + "invalid operator '{}' for condition_type '{}'", + condition.operator, condition.condition_type + ))); + } + validate_condition_value(&condition_type, condition) +} + +fn validate_condition_value(condition_type: &ConditionType, condition: &CreateConditionInput) -> Result<(), SoarError> { + match condition_type { + ConditionType::Threshold | ConditionType::FusedConfidenceAbove => { + parse_finite_f64(&condition.value, &condition.condition_type)?; + } + ConditionType::Frequency | ConditionType::MultiSourceMin => { + parse_positive_usize(&condition.value, &condition.condition_type)?; + if let Some(value2) = &condition.value2 { + parse_positive_u64(value2, "value2")?; + } + } + ConditionType::SourceCountry => { + if condition.value.split(',').all(|part| part.trim().is_empty()) { + return Err(SoarError::ValidationFailed( + "source_country value must contain at least one country code".to_string(), + )); + } + } + ConditionType::IpPattern => { + if !is_valid_ip_pattern(&condition.value) { + return Err(SoarError::ValidationFailed(format!( + "ip_pattern value must be a valid CIDR: {}", + condition.value + ))); + } + } + ConditionType::RepeatOffender => { + parse_bool_literal(&condition.value, &condition.condition_type)?; + } + ConditionType::SingleSourceHigh => { + DetectionSource::from_str(&condition.value).map_err(|_| { + SoarError::ValidationFailed(format!( + "single_source_high value must be a valid DetectionSource: {}", + condition.value + )) + })?; + if let Some(value2) = &condition.value2 { + parse_finite_f64(value2, "value2")?; + } + } + } + Ok(()) +} + +pub fn validate_action( + action_type: &str, + params: Option<&serde_json::Value>, + max_ttl_secs: u64, +) -> Result<(), SoarError> { + if let Some(params) = params + && !params.is_object() + { + return Err(SoarError::ValidationFailed(format!( + "action '{}' params must be a JSON object", + action_type + ))); + } + + match action_type.parse::() { + Ok(ActionType::BlockIp) => { + let ttl_secs = params.and_then(|p| p.get("ttl_secs")); + validate_optional_positive_u64("ttl_secs", ttl_secs)?; + validate_optional_max_u64("ttl_secs", ttl_secs, max_ttl_secs) + } + Ok(ActionType::AdjustRateLimit) => { + let ttl_secs = params.and_then(|p| p.get("ttl_secs")); + validate_optional_positive_u64("ttl_secs", ttl_secs)?; + validate_optional_max_u64("ttl_secs", ttl_secs, max_ttl_secs)?; + validate_optional_rate_limit_factor(params.and_then(|p| p.get("factor"))) + } + Ok(ActionType::SendTelegram | ActionType::SendEmail) => Ok(()), + Ok(ActionType::Webhook) => { + validate_required_non_empty_string("url", params.and_then(|p| p.get("url")))?; + validate_optional_positive_u64("timeout_secs", params.and_then(|p| p.get("timeout_secs"))) + } + Ok(ActionType::Log) => validate_optional_string("level", params.and_then(|p| p.get("level"))), + Err(_) => Err(SoarError::ValidationFailed(format!( + "unknown action_type: {}", + action_type + ))), + } +} + +fn parse_finite_f64(value: &str, field: &str) -> Result { + match value.parse::() { + Ok(parsed) if parsed.is_finite() => Ok(parsed), + _ => Err(SoarError::ValidationFailed(format!( + "{field} value must be a finite number" + ))), + } +} + +fn parse_positive_usize(value: &str, field: &str) -> Result { + match value.parse::() { + Ok(parsed) if parsed > 0 => Ok(parsed), + _ => Err(SoarError::ValidationFailed(format!( + "{field} value must be a positive integer" + ))), + } +} + +fn parse_positive_u64(value: &str, field: &str) -> Result { + match value.parse::() { + Ok(parsed) if parsed > 0 => Ok(parsed), + _ => Err(SoarError::ValidationFailed(format!( + "{field} must be a positive integer" + ))), + } +} + +fn parse_bool_literal(value: &str, field: &str) -> Result { + match value.to_ascii_lowercase().as_str() { + "true" => Ok(true), + "false" => Ok(false), + _ => Err(SoarError::ValidationFailed(format!( + "{field} value must be 'true' or 'false'" + ))), + } +} + +fn validate_optional_positive_u64(field: &str, value: Option<&serde_json::Value>) -> Result<(), SoarError> { + let Some(value) = value else { + return Ok(()); + }; + match value.as_u64() { + Some(value) if value > 0 => Ok(()), + Some(_) => Err(SoarError::ValidationFailed(format!("{field} must be greater than 0"))), + None => Err(SoarError::ValidationFailed(format!( + "{field} must be a positive integer" + ))), + } +} + +fn validate_optional_max_u64(field: &str, value: Option<&serde_json::Value>, max: u64) -> Result<(), SoarError> { + let Some(value) = value.and_then(|value| value.as_u64()) else { + return Ok(()); + }; + if value > max { + return Err(SoarError::ValidationFailed(format!( + "{field} must be less than or equal to {max}" + ))); + } + Ok(()) +} + +fn validate_optional_rate_limit_factor(value: Option<&serde_json::Value>) -> Result<(), SoarError> { + let Some(value) = value else { + return Ok(()); + }; + match value.as_f64() { + Some(value) if (RATE_LIMIT_FACTOR_MIN..=RATE_LIMIT_FACTOR_MAX).contains(&value) => Ok(()), + Some(_) => Err(SoarError::ValidationFailed(format!( + "factor must be between {RATE_LIMIT_FACTOR_MIN} and {RATE_LIMIT_FACTOR_MAX}" + ))), + None => Err(SoarError::ValidationFailed("factor must be a number".to_string())), + } +} + +fn validate_required_non_empty_string(field: &str, value: Option<&serde_json::Value>) -> Result<(), SoarError> { + match value.and_then(|value| value.as_str()) { + Some(value) if !value.trim().is_empty() => Ok(()), + Some(_) => Err(SoarError::ValidationFailed(format!("{field} must not be empty"))), + None => Err(SoarError::ValidationFailed(format!("{field} is required"))), + } +} + +fn validate_optional_string(field: &str, value: Option<&serde_json::Value>) -> Result<(), SoarError> { + let Some(value) = value else { + return Ok(()); + }; + if value.is_string() { + Ok(()) + } else { + Err(SoarError::ValidationFailed(format!("{field} must be a string"))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_negative_cooldown() { + let err = validate_cooldown_secs(-1).unwrap_err(); + assert_eq!(err.to_string(), "cooldown_secs must be greater than or equal to 0"); + } + + #[test] + fn accepts_zero_cooldown() { + validate_cooldown_secs(0).unwrap(); + } + + #[test] + fn rejects_non_positive_condition_count() { + let err = validate_optional_positive_i64("condition_count", Some(0)).unwrap_err(); + assert_eq!(err.to_string(), "condition_count must be greater than 0"); + } + + #[test] + fn accepts_none_condition_count() { + validate_optional_positive_i64("condition_count", None).unwrap(); + } + + #[test] + fn rejects_unknown_action_type() { + let err = validate_action("typo", None, 3_600).unwrap_err(); + assert_eq!(err.to_string(), "unknown action_type: typo"); + } + + #[test] + fn rejects_block_ip_zero_ttl() { + let params = serde_json::json!({"ttl_secs": 0}); + let err = validate_action("block_ip", Some(¶ms), 3_600).unwrap_err(); + assert_eq!(err.to_string(), "ttl_secs must be greater than 0"); + } + + #[test] + fn rejects_block_ip_exceeding_max_ttl() { + let params = serde_json::json!({"ttl_secs": 3_601}); + let err = validate_action("block_ip", Some(¶ms), 3_600).unwrap_err(); + assert_eq!(err.to_string(), "ttl_secs must be less than or equal to 3600"); + } + + #[test] + fn rejects_webhook_missing_url() { + let params = serde_json::json!({"timeout_secs": 5}); + let err = validate_action("webhook", Some(¶ms), 3_600).unwrap_err(); + assert_eq!(err.to_string(), "url is required"); + } + + #[test] + fn rejects_webhook_zero_timeout() { + let params = serde_json::json!({"url": "https://example.test/hook", "timeout_secs": 0}); + let err = validate_action("webhook", Some(¶ms), 3_600).unwrap_err(); + assert_eq!(err.to_string(), "timeout_secs must be greater than 0"); + } + + #[test] + fn rejects_unknown_condition_type() { + let input = CreateConditionInput { + condition_type: "typo".to_string(), + operator: ">=".to_string(), + value: "0.9".to_string(), + value2: None, + }; + let err = validate_condition_input(&input).unwrap_err(); + assert!(err.to_string().contains("typo")); + } + + #[test] + fn rejects_invalid_condition_operator() { + let input = CreateConditionInput { + condition_type: "threshold".to_string(), + operator: "in".to_string(), + value: "0.9".to_string(), + value2: None, + }; + let err = validate_condition_input(&input).unwrap_err(); + assert!(err.to_string().contains("in")); + assert!(err.to_string().contains("threshold")); + } + + #[test] + fn rejects_invalid_threshold_value() { + let input = CreateConditionInput { + condition_type: "threshold".to_string(), + operator: ">=".to_string(), + value: "not-a-number".to_string(), + value2: None, + }; + let err = validate_condition_input(&input).unwrap_err(); + assert_eq!(err.to_string(), "threshold value must be a finite number"); + } + + #[test] + fn rejects_invalid_frequency_value() { + let input = CreateConditionInput { + condition_type: "frequency".to_string(), + operator: ">=".to_string(), + value: "0".to_string(), + value2: None, + }; + let err = validate_condition_input(&input).unwrap_err(); + assert_eq!(err.to_string(), "frequency value must be a positive integer"); + } + + #[test] + fn rejects_invalid_ip_pattern() { + let input = CreateConditionInput { + condition_type: "ip_pattern".to_string(), + operator: "in".to_string(), + value: "not-cidr".to_string(), + value2: None, + }; + let err = validate_condition_input(&input).unwrap_err(); + assert_eq!(err.to_string(), "ip_pattern value must be a valid CIDR: not-cidr"); + } + + #[test] + fn rejects_invalid_repeat_offender_value() { + let input = CreateConditionInput { + condition_type: "repeat_offender".to_string(), + operator: "==".to_string(), + value: "maybe".to_string(), + value2: None, + }; + let err = validate_condition_input(&input).unwrap_err(); + assert_eq!(err.to_string(), "repeat_offender value must be 'true' or 'false'"); + } + + #[test] + fn accepts_false_repeat_offender() { + let input = CreateConditionInput { + condition_type: "repeat_offender".to_string(), + operator: "==".to_string(), + value: "false".to_string(), + value2: None, + }; + validate_condition_input(&input).unwrap(); + } + + #[test] + fn rejects_invalid_single_source_high_value() { + let input = CreateConditionInput { + condition_type: "single_source_high".to_string(), + operator: ">=".to_string(), + value: "UnknownSource".to_string(), + value2: None, + }; + let err = validate_condition_input(&input).unwrap_err(); + assert_eq!( + err.to_string(), + "single_source_high value must be a valid DetectionSource: UnknownSource" + ); + } +} diff --git a/net-guardia/src/infrastructure/audit_logger.rs b/net-guardia/src/infrastructure/audit_logger.rs index d842330..5d000bd 100644 --- a/net-guardia/src/infrastructure/audit_logger.rs +++ b/net-guardia/src/infrastructure/audit_logger.rs @@ -36,7 +36,7 @@ impl AuditLogger { this.handle_audit_event(event).await; } Err(RecvError::Lagged(n)) => { - log!(AuditLog::AuditLagged(n)); + log!(AuditLog::AuditLagged("audit".to_string(), n)); } Err(RecvError::Closed) => { log!(AuditLog::AuditChannelClosed); @@ -60,7 +60,7 @@ impl AuditLogger { this.handle_drift_event(event).await; } Err(RecvError::Lagged(n)) => { - log!(AuditLog::AuditLagged(n)); + log!(AuditLog::AuditLagged("drift".to_string(), n)); } Err(RecvError::Closed) => { log!(AuditLog::AuditChannelClosed); diff --git a/net-guardia/src/infrastructure/flow_trace_logger.rs b/net-guardia/src/infrastructure/flow_trace_logger.rs index 8c536ff..d541460 100644 --- a/net-guardia/src/infrastructure/flow_trace_logger.rs +++ b/net-guardia/src/infrastructure/flow_trace_logger.rs @@ -39,7 +39,7 @@ impl Default for RotationPolicy { } pub struct TrafficLogger { - sender: Sender>, + sender: Sender, directory: Arc, } @@ -58,7 +58,7 @@ impl TrafficLogger { .unwrap_or_else(|| PathBuf::from(".")); store.ensure_directory(&directory)?; - let (sender, receiver) = bounded::>(channel_capacity.max(1)); + let (sender, receiver) = bounded::(channel_capacity.max(1)); let writer_dir = directory.clone(); let writer_header = header; @@ -85,8 +85,8 @@ impl TrafficLogger { } impl FlowTraceSink for TrafficLogger { - fn log_row(&self, record: Vec) { - match self.sender.try_send(record) { + fn log_row(&self, csv_line: String) { + match self.sender.try_send(csv_line) { Ok(()) => {} Err(TrySendError::Full(_)) => { log!(MLLog::TrafficLogChannelBackpressure); @@ -103,7 +103,7 @@ impl FlowTraceSink for TrafficLogger { } fn writer_loop( - receiver: Receiver>, + receiver: Receiver, directory: PathBuf, header: Vec, policy: RotationPolicy, @@ -120,7 +120,7 @@ fn writer_loop( } }; - while let Ok(record) = receiver.recv() { + while let Ok(csv_line) = receiver.recv() { if active.bytes_written >= policy.max_file_bytes || active.opened_at.elapsed() >= policy.max_file_age { if let Err(e) = active.writer.flush() { log!(MLLog::TrafficLogWriteError(e.to_string())); @@ -143,12 +143,15 @@ fn writer_loop( }; } - let line = format!("{}\n", record.join(",")); - if let Err(e) = active.writer.write_all(line.as_bytes()) { + if let Err(e) = active.writer.write_all(csv_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.write_all(b"\n") { + log!(MLLog::TrafficLogWriteError(e.to_string())); + continue; + } + active.bytes_written = active.bytes_written.saturating_add(csv_line.len() as u64 + 1); } if let Err(e) = active.writer.flush() { diff --git a/net-guardia/src/infrastructure/health.rs b/net-guardia/src/infrastructure/health.rs index 8edc37c..3af4bf1 100644 --- a/net-guardia/src/infrastructure/health.rs +++ b/net-guardia/src/infrastructure/health.rs @@ -21,10 +21,11 @@ use crate::interface::system::health_query::HealthQuery; pub struct SystemHealth { config: Arc>, metrics: Arc>, - broadcast_tx: broadcast::Sender, + broadcast_tx: broadcast::Sender>, ingress_interface: String, egress_interface: String, ebpf_health: Arc>, + cached_disk: Arc>>, } impl SystemHealth { @@ -45,6 +46,8 @@ impl SystemHealth { (**ebpf_health.load()).clone(), ); + let cached_disk = Arc::new(ArcSwap::from_pointee(Self::probe_disk_usage())); + Ok(SystemHealth { config, metrics: Arc::new(ArcSwap::from_pointee(initial)), @@ -52,6 +55,7 @@ impl SystemHealth { ingress_interface, egress_interface, ebpf_health, + cached_disk, }) } @@ -62,12 +66,15 @@ impl SystemHealth { let ingress_interface = self.ingress_interface.clone(); let egress_interface = self.egress_interface.clone(); let ebpf_health = self.ebpf_health.clone(); + let cached_disk = self.cached_disk.clone(); let handle = tokio::spawn(async move { 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); + let mut disk_refresh_counter: u32 = 0; + const DISK_REFRESH_EVERY: u32 = 6; loop { tokio::select! { @@ -78,6 +85,12 @@ impl SystemHealth { networks.refresh(true); components.refresh(true); + disk_refresh_counter += 1; + if disk_refresh_counter >= DISK_REFRESH_EVERY { + disk_refresh_counter = 0; + cached_disk.store(Arc::new(Self::probe_disk_usage())); + } + let snapshot = Self::collect_metrics( &system, &networks, @@ -87,7 +100,8 @@ impl SystemHealth { (**ebpf_health.load()).clone(), ); - metrics.store(Arc::new(snapshot.clone())); + let snapshot = Arc::new(snapshot); + metrics.store(Arc::clone(&snapshot)); if broadcast_tx.receiver_count() > 0 && let Err(e) = broadcast_tx.send(snapshot) @@ -253,7 +267,7 @@ impl SystemHealth { (**self.metrics.load()).clone() } - pub fn subscribe_to_metrics(&self) -> broadcast::Receiver { + pub fn subscribe_to_metrics(&self) -> broadcast::Receiver> { self.broadcast_tx.subscribe() } @@ -308,7 +322,7 @@ impl SystemHealth { status.issues.push("Egress interface not available".to_string()); } - let disk_usage = Self::check_disk_usage(); + let disk_usage = **self.cached_disk.load(); if let Some((usage_percent, available_gb)) = disk_usage { if usage_percent > h.disk_issue_percent { status.overall_healthy = false; @@ -327,7 +341,7 @@ impl SystemHealth { status } - fn check_disk_usage() -> Option<(f32, f64)> { + fn probe_disk_usage() -> Option<(f32, f64)> { let disks = Disks::new_with_refreshed_list(); for disk in disks.list() { let mount = disk.mount_point().to_string_lossy(); @@ -366,7 +380,7 @@ impl HealthQuery for SystemHealth { (**self.ebpf_health.load()).clone() } - fn subscribe_to_metrics(&self) -> broadcast::Receiver { + fn subscribe_to_metrics(&self) -> broadcast::Receiver> { SystemHealth::subscribe_to_metrics(self) } } diff --git a/net-guardia/src/infrastructure/http_server/mod.rs b/net-guardia/src/infrastructure/http_server/mod.rs index c4f9ecf..b04e741 100644 --- a/net-guardia/src/infrastructure/http_server/mod.rs +++ b/net-guardia/src/infrastructure/http_server/mod.rs @@ -13,11 +13,6 @@ use crate::adapter::http::middleware::setup_guard::SetupGuard; use crate::adapter::http::ready; use crate::adapter::http::session::SessionCookieService; use crate::adapter::http::setup::SetupToken; -use crate::adapter::identity::password_hasher::Argon2PasswordHasher; -use crate::adapter::model_loading::artifact_resolver::FsModelArtifactResolver; -use crate::adapter::model_loading::config_loader::FsModelConfigLoader; -use crate::adapter::model_loading::onnx_runtime::OnnxRuntimeLoader; -use crate::adapter::model_promotion_store::FsModelPromotionStore; use crate::adapter::persistence::Database; use crate::adapter::secret_store::SecretStore; use crate::adapter::websocket::routes; @@ -27,30 +22,23 @@ use crate::common::log::http::HttpLog; use crate::core::common::setup_service::SetupService; use crate::core::identity::auth_service::AuthService; use crate::core::identity::session_service::SessionService; -use crate::core::inference::model_promotion::PromoteGate; use crate::domain::common::config::constants::HTTP_FALLBACK_PORT; -use crate::infrastructure::boot_time::SysinfoBootTimeQuery; use crate::infrastructure::http_runtime::{ForceHttpsFlag, ReadyFlag, SetupCompleteFlag}; use crate::infrastructure::log_buffer::LogBuffer; use crate::infrastructure::logger::Logger; -use crate::infrastructure::model_promotion_deps::ModelPromotionDeps; use crate::infrastructure::startup::SystemRuntime; use crate::infrastructure::system::ShutdownHandle; -use crate::interface::app_repo::AppRepo; use crate::interface::data_plane::drop_stats::DropStatsPort; -use crate::interface::data_plane::protocol_filter::ProtocolFilterPort; -use crate::interface::detection::model_artifact_resolver::ModelArtifactResolver; -use crate::interface::detection::model_config_loader::ModelConfigLoader; -use crate::interface::detection::model_promotion_store::ModelPromotionStore; -use crate::interface::detection::model_runtime::ModelRuntimeLoader; +use crate::interface::data_plane::protocol_filter::HttpFilterPort; +use crate::interface::data_plane::protocol_filter::SshFilterPort; use crate::interface::identity::api_key::ApiKeyRepo; +use crate::interface::identity::api_key_hasher::ApiKeyHasher; +use crate::interface::identity::auth_repo::LoginAttemptRepo; use crate::interface::system::audit::AuditRepo; use crate::interface::system::health_query::{HealthQuery, SuricataHealthQuery}; use crate::interface::system::http_runtime::ReadinessQuery; use crate::interface::system::live_logs::LiveLogQuery; -use crate::interface::system::secret_store::SecretStorePort; -use crate::interface::system::setup::SetupRepo; -use crate::interface::system::system_control::{BootTimeQuery, LogLevelControl, SystemCommandPort, XdpModeQuery}; +use crate::interface::system::system_control::{LogLevelControl, SystemCommandPort, XdpModeQuery}; mod api_routes; @@ -119,9 +107,11 @@ fn is_private_origin(origin: &str) -> bool { pub struct SetupServerParams { pub database: Arc, pub secret_store: Arc, + pub setup_service: Arc, pub session_service: Arc, pub session_cookie_service: Arc, pub auth_service: Arc, + pub api_key_hasher: Arc, pub setup_complete: SetupCompleteFlag, pub setup_token: SetupToken, pub port: u16, @@ -130,25 +120,22 @@ pub struct SetupServerParams { pub fn start_setup_server(params: SetupServerParams) -> Result { let database = params.database; let secret_store = params.secret_store; + let setup_service = params.setup_service; let session_service = params.session_service; let session_cookie_service = params.session_cookie_service; let auth_service = params.auth_service; + let api_key_hasher = params.api_key_hasher; let setup_complete = params.setup_complete; let setup_token = params.setup_token; let port = params.port; - let setup_service = Arc::new(SetupService::new( - database.clone() as Arc, - secret_store.clone() as Arc, - Arc::new(Argon2PasswordHasher), - )); let app = move || { App::new() .wrap(cors(vec![])) - .app_data(web::Data::from(database.clone() as Arc)) + .app_data(web::Data::from(database.clone() as Arc)) .app_data(web::Data::from(database.clone() as Arc)) .app_data(web::Data::from(database.clone() as Arc)) - .app_data(web::Data::from(database.clone())) + .app_data(web::Data::from(api_key_hasher.clone())) .app_data(web::Data::from(secret_store.clone())) .app_data(web::Data::from(setup_service.clone())) .app_data(web::Data::from(session_service.clone())) @@ -201,7 +188,8 @@ pub fn bind(params: HttpServerParams<'_>) -> Result { let log_buffer = params.log_buffer; let access_control = runtime.data_plane.ebpf_services.access_control.clone(); - let protocol_filter: Arc = runtime.data_plane.ebpf_services.protocol_filter.clone(); + let http_filter: Arc = runtime.data_plane.ebpf_services.protocol_filter.clone(); + let ssh_filter: Arc = runtime.data_plane.ebpf_services.protocol_filter.clone(); let geo_block = runtime.data_plane.ebpf_services.geo_block.clone(); let rate_limit = runtime.data_plane.ebpf_services.rate_limit.clone(); let drop_monitor = runtime.data_plane.ebpf_services.drop_monitor.clone(); @@ -216,7 +204,10 @@ pub fn bind(params: HttpServerParams<'_>) -> Result { let session_service = runtime.identity.session_service.clone(); let session_cookie_service = runtime.identity.session_cookie_service.clone(); let auth_service = runtime.identity.auth_service.clone(); + let user_service = runtime.identity.user_service.clone(); + let group_service = runtime.identity.group_service.clone(); let enforce_handler = runtime.identity.enforce_handler.clone(); + let api_key_hasher = runtime.identity.api_key_hasher.clone(); let acl_service = runtime.response.acl_service.clone(); let config_service = runtime.response.config_service.clone(); @@ -238,21 +229,12 @@ pub fn bind(params: HttpServerParams<'_>) -> Result { let force_https = params.force_https; let shutdown_handle = params.shutdown_handle; - let port = app_config.load().http_server.port; + let setup_service = runtime.foundation.setup_service.clone(); + let boot_time_query = runtime.foundation.boot_time_query.clone(); + let promote_gate = runtime.detection.promote_gate.clone(); + let model_promotion_deps = runtime.detection.model_promotion_deps.clone(); - let promote_lock: Arc = Arc::new(PromoteGate::new()); - let model_promotion_deps = Arc::new(ModelPromotionDeps { - model_runtime_loader: Arc::new(OnnxRuntimeLoader) as Arc, - model_artifact_resolver: Arc::new(FsModelArtifactResolver) as Arc, - model_config_loader: Arc::new(FsModelConfigLoader) as Arc, - promotion_store: Arc::new(FsModelPromotionStore) as Arc, - }); - let setup_service = Arc::new(SetupService::new( - database.clone() as Arc, - secret_store.clone() as Arc, - Arc::new(Argon2PasswordHasher), - )); - let boot_time_query = Arc::new(SysinfoBootTimeQuery) as Arc; + let port = app_config.load().http_server.port; let server = HttpServer::new(move || { let app = App::new() @@ -261,17 +243,14 @@ pub fn bind(params: HttpServerParams<'_>) -> Result { .app_data(web::Data::from(app_config.clone())) .app_data(web::Data::from(runtime_state.clone() as Arc)) .app_data(web::Data::from(inference_config.clone())) - .app_data(web::Data::from(database.clone() as Arc)) + .app_data(web::Data::from(database.clone() as Arc)) .app_data(web::Data::from(database.clone() as Arc)) .app_data(web::Data::from(database.clone() as Arc)) - .app_data(web::Data::from(database.clone())) + .app_data(web::Data::from(api_key_hasher.clone())) .app_data(web::Data::from(secret_store.clone())) - .app_data(web::Data::from(setup_service.clone())) - .app_data(web::Data::from(boot_time_query.clone())) - .app_data(web::Data::from(logger.clone() as Arc)) - .app_data(web::Data::from(log_buffer.clone() as Arc)) .app_data(web::Data::from(access_control.clone())) - .app_data(web::Data::from(protocol_filter.clone())) + .app_data(web::Data::from(http_filter.clone())) + .app_data(web::Data::from(ssh_filter.clone())) .app_data(web::Data::from(geo_block.clone())) .app_data(web::Data::from(rate_limit.clone())) .app_data(web::Data::from(drop_monitor.clone())) @@ -280,11 +259,14 @@ pub fn bind(params: HttpServerParams<'_>) -> Result { .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(health.clone() as Arc)) .app_data(web::Data::from(flow_statistics.clone())) + .app_data(web::Data::from(promote_gate.clone())) + .app_data(web::Data::from(model_promotion_deps.clone())) .app_data(web::Data::from(session_service.clone())) .app_data(web::Data::from(session_cookie_service.clone())) .app_data(web::Data::from(auth_service.clone())) + .app_data(web::Data::from(user_service.clone())) + .app_data(web::Data::from(group_service.clone())) .app_data(web::Data::from(enforce_handler.clone())) .app_data(web::Data::from(acl_service.clone())) .app_data(web::Data::from(config_service.clone())) @@ -295,16 +277,19 @@ pub fn bind(params: HttpServerParams<'_>) -> Result { .app_data(web::Data::from(soar_engine.clone())) .app_data(web::Data::from(report_generation_service.clone())) .app_data(web::Data::from(report_delivery_service.clone())) + .app_data(web::Data::from(health.clone() as Arc)) .app_data(web::Data::from(suricata_manager.clone() as Arc)) - .app_data(web::Data::new(threat_tx.clone())) - .app_data(web::Data::new(audit_tx.clone())) + .app_data(web::Data::from(logger.clone() as Arc)) + .app_data(web::Data::from(log_buffer.clone() as Arc)) + .app_data(web::Data::from(setup_service.clone())) + .app_data(web::Data::from(boot_time_query.clone())) .app_data(web::Data::new(setup_complete.clone())) .app_data(web::Data::new(ready.clone())) .app_data(web::Data::from(readiness_state.clone() as Arc)) .app_data(web::Data::new(force_https.clone())) .app_data(web::Data::from(shutdown_handle.clone() as Arc)) - .app_data(web::Data::from(promote_lock.clone())) - .app_data(web::Data::from(model_promotion_deps.clone())); + .app_data(web::Data::new(threat_tx.clone())) + .app_data(web::Data::new(audit_tx.clone())); app.wrap(SetupGuard) .configure(api_routes::configure_authenticated_api) .service(routes::initialize()) diff --git a/net-guardia/src/infrastructure/log_buffer.rs b/net-guardia/src/infrastructure/log_buffer.rs index f5efbd5..980f235 100644 --- a/net-guardia/src/infrastructure/log_buffer.rs +++ b/net-guardia/src/infrastructure/log_buffer.rs @@ -1,53 +1,75 @@ -use std::collections::VecDeque; use std::fmt::{Arguments, Debug, Write as _}; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; +use crossbeam::queue::ArrayQueue; use parking_lot::Mutex; use tracing::field::{Field, Visit}; use tracing::{Event, Level, Subscriber}; use tracing_subscriber::Layer; use tracing_subscriber::layer::Context; -use crate::interface::system::live_logs::{LiveLogQuery, LogEntry, LogSnapshot, level_severity}; +use crate::common::utils::log_level::level_severity; +use crate::interface::system::live_logs::{LiveLogQuery, LogEntry, LogSnapshot}; struct LogRingBuffer { - entries: Mutex>, - capacity: usize, + queue: ArrayQueue, + snapshot_lock: Mutex<()>, max_message_bytes: usize, next_id: AtomicU64, + latest_id: AtomicU64, } impl LogRingBuffer { fn new(capacity: usize, max_message_bytes: usize) -> Self { let cap = capacity.max(1); Self { - entries: Mutex::new(VecDeque::with_capacity(cap)), - capacity: cap, + queue: ArrayQueue::new(cap), + snapshot_lock: Mutex::new(()), max_message_bytes: max_message_bytes.max(64), next_id: AtomicU64::new(1), + latest_id: AtomicU64::new(0), } } fn push(&self, entry: LogEntry) { - let mut guard = self.entries.lock(); - if guard.len() >= self.capacity { - guard.pop_front(); + self.latest_id.store(entry.id, Ordering::Release); + match self.queue.push(entry) { + Ok(()) => {} + Err(entry) => { + let _ = self.queue.pop(); + let _ = self.queue.push(entry); + } } - guard.push_back(entry); } fn snapshot(&self, since_id: u64, min_severity: u8, limit: usize) -> LogSnapshot { - 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(); + let _guard = self.snapshot_lock.lock(); + let cap = self.queue.capacity(); + let mut entries = Vec::with_capacity(cap.min(limit)); + let mut drained = Vec::with_capacity(cap); + + while let Some(entry) = self.queue.pop() { + drained.push(entry); + } + + let total = drained.len(); + let latest_id = drained.last().map(|e| e.id).unwrap_or(0); + + for entry in &drained { + if entries.len() >= limit { + break; + } + if entry.id > since_id && level_severity(entry.level) <= min_severity { + entries.push(entry.clone()); + } + } + + for entry in drained { + let _ = self.queue.push(entry); + } + LogSnapshot { entries, latest_id, diff --git a/net-guardia/src/infrastructure/logger.rs b/net-guardia/src/infrastructure/logger.rs index 6a66770..b26f35c 100644 --- a/net-guardia/src/infrastructure/logger.rs +++ b/net-guardia/src/infrastructure/logger.rs @@ -173,7 +173,7 @@ fn extract_main_level(raw: &str) -> String { raw.split(',') .map(str::trim) .find(|d| !d.is_empty() && !d.contains('=')) - .unwrap_or(raw) + .unwrap_or("info") .to_lowercase() } @@ -189,7 +189,7 @@ mod tests { } #[test] - fn falls_back_when_no_bare_level() { - assert_eq!(extract_main_level("maxminddb=warn"), "maxminddb=warn"); + fn falls_back_to_info_when_no_bare_level() { + assert_eq!(extract_main_level("maxminddb=warn"), "info"); } } diff --git a/net-guardia/src/infrastructure/startup/data_plane.rs b/net-guardia/src/infrastructure/startup/data_plane.rs index 860e43a..623eac1 100644 --- a/net-guardia/src/infrastructure/startup/data_plane.rs +++ b/net-guardia/src/infrastructure/startup/data_plane.rs @@ -1,4 +1,3 @@ -use std::net::{Ipv4Addr, Ipv6Addr, SocketAddrV4, SocketAddrV6}; use std::sync::Arc; use arc_swap::ArcSwap; @@ -9,15 +8,12 @@ use aya_log::EbpfLogger; use macros::log; use crate::adapter::ebpf::EbpfServices; -use crate::adapter::persistence::Database; use crate::common::error::Error; -use crate::common::log::data_plane::DataPlaneLog; use crate::common::log::system::SystemLog; use crate::core::data_plane::dns_filter::DnsFilter; use crate::domain::common::config::AppConfig; use crate::domain::common::system::health::EbpfFailStage; use crate::domain::data_plane::error::EbpfError; -use crate::domain::data_plane::ip_version::IpVersion; use crate::domain::data_plane::log::EbpfLog; use crate::infrastructure::ebpf_preflight; use crate::infrastructure::startup::{DataPlaneRuntime, FoundationRuntime}; @@ -32,17 +28,11 @@ struct EbpfBuild { services: EbpfServices, } -pub async fn build_data_plane(foundation: &FoundationRuntime) -> (DataPlaneRuntime, Arc) { +pub fn build_data_plane(foundation: &FoundationRuntime) -> (DataPlaneRuntime, Arc) { let dns_filter = Arc::new(DnsFilter::new()); let dns_query_filter: Arc = dns_filter.clone(); let dns_filter_port: Arc = dns_filter; let data_plane = build_ebpf_runtime(foundation, dns_query_filter); - restore_persisted_state( - foundation.database.as_ref(), - dns_filter_port.as_ref(), - &data_plane.ebpf_services, - ) - .await; (data_plane, dns_filter_port) } @@ -232,102 +222,3 @@ pub fn aya_log_init(ingress_ebpf: &mut Ebpf, egress_ebpf: &mut Ebpf) -> Result<( EbpfLogger::init(egress_ebpf).map_err(EbpfError::LoggerInitFailed)?; Ok(()) } - -async fn restore_persisted_state(db: &Database, dns_filter_port: &dyn DnsFilterPort, ebpf_services: &EbpfServices) { - restore_dns_blacklist(db, dns_filter_port).await; - restore_geo_countries(db, ebpf_services).await; - restore_rate_limits(db, ebpf_services).await; - restore_acl_rules(db, ebpf_services).await; -} - -async fn restore_dns_blacklist(db: &Database, dns_filter_port: &dyn DnsFilterPort) { - if let Ok(domains) = db.load_dns_domains().await { - for domain in &domains { - if let Err(e) = dns_filter_port.add_domain(domain) { - log!(DataPlaneLog::DnsRestoreFailed(domain.clone(), e.to_string())); - } - } - if !domains.is_empty() { - log!(DataPlaneLog::DnsBlacklistRestored(domains.len())); - } - } -} - -async fn restore_geo_countries(db: &Database, ebpf_services: &EbpfServices) { - if let Ok(countries) = db.load_geo_countries().await - && !countries.is_empty() - { - if let Err(e) = ebpf_services.geo_block.block_countries(&countries) { - log!(DataPlaneLog::GeoRestoreFailed(e.to_string())); - } else { - log!(DataPlaneLog::GeoCountriesRestored(countries.len())); - } - } -} - -async fn restore_rate_limits(db: &Database, ebpf_services: &EbpfServices) { - if let Ok(configs) = db.load_rate_limit_config().await { - for (key, value) in &configs { - let result = match key.as_str() { - "packet_rate" => ebpf_services.rate_limit.set_packet_rate(*value), - "syn_rate" => ebpf_services.rate_limit.set_syn_rate(*value), - "udp_rate" => ebpf_services.rate_limit.set_udp_rate(*value), - "dns_rate" => ebpf_services.rate_limit.set_dns_rate(*value), - "window_ns" => ebpf_services.rate_limit.set_window_ns(*value), - _ => Ok(()), - }; - if let Err(e) = result { - log!(DataPlaneLog::RateLimitRestoreFailed(key.clone(), e.to_string())); - } - } - if !configs.is_empty() { - log!(DataPlaneLog::RateLimitsRestored(configs.len())); - } - } -} - -async fn restore_acl_rules(db: &Database, ebpf_services: &EbpfServices) { - if let Ok(rules) = db.list_acl_rules().await { - let mut restored = 0u32; - for rule in &rules { - let result = match rule.ip_version { - IpVersion::V4 => match rule.ip_address.parse::() { - Ok(addr) => ebpf_services.access_control.add_ipv4_list( - rule.direction, - rule.list_type, - SocketAddrV4::new(addr, rule.port), - ), - Err(e) => { - log!(DataPlaneLog::AclIpv4ParseFailed(rule.ip_address.clone(), e.to_string())); - continue; - } - }, - IpVersion::V6 => match rule.ip_address.parse::() { - Ok(addr) => ebpf_services.access_control.add_ipv6_list( - rule.direction, - rule.list_type, - SocketAddrV6::new(addr, rule.port, 0, 0), - ), - Err(e) => { - log!(DataPlaneLog::AclIpv6ParseFailed(rule.ip_address.clone(), e.to_string())); - continue; - } - }, - }; - if let Err(e) = result { - log!(DataPlaneLog::AclRuleRestoreFailed( - rule.direction.as_str().to_string(), - rule.list_type.as_str().to_string(), - rule.ip_address.clone(), - rule.port, - e.to_string() - )); - } else { - restored += 1; - } - } - if restored > 0 { - log!(DataPlaneLog::AclRulesRestored(restored as usize)); - } - } -} diff --git a/net-guardia/src/infrastructure/startup/detection.rs b/net-guardia/src/infrastructure/startup/detection.rs index afc433a..28371bc 100644 --- a/net-guardia/src/infrastructure/startup/detection.rs +++ b/net-guardia/src/infrastructure/startup/detection.rs @@ -5,18 +5,27 @@ use std::time::Duration; use arc_swap::ArcSwap; use macros::log; +use crate::adapter::model_loading::artifact_resolver::FsModelArtifactResolver; use crate::adapter::model_loading::config_loader::FsModelConfigLoader; +use crate::adapter::model_loading::onnx_runtime::OnnxRuntimeLoader; +use crate::adapter::model_promotion_store::FsModelPromotionStore; use crate::common::error::Error; use crate::core::common::statistics::FlowStatistics; use crate::core::inference::drift_detector::{DriftDetectorHandle, DriftDetectorRunner}; use crate::core::inference::inference_runtime::InferenceRuntime; +use crate::core::inference::model_promotion::PromoteGate; use crate::domain::common::config::AppConfig; use crate::domain::data_plane::flow_stats::FlowStatsLimits; use crate::domain::detection::drift::FeatureBaselines; use crate::domain::detection::log::MLLog; use crate::domain::detection::manifest::ModelManifest; use crate::domain::detection::ml_inference_config::MLInferenceConfig; +use crate::infrastructure::model_promotion_deps::ModelPromotionDeps; use crate::infrastructure::startup::{DetectionRuntime, FoundationRuntime}; +use crate::interface::detection::model_artifact_resolver::ModelArtifactResolver; +use crate::interface::detection::model_config_loader::ModelConfigLoader; +use crate::interface::detection::model_promotion_store::ModelPromotionStore; +use crate::interface::detection::model_runtime::ModelRuntimeLoader; pub fn build_detection(foundation: &FoundationRuntime) -> Result { let (inference_config, ml_manifest) = load_inference_config(&foundation.app_config)?; @@ -40,12 +49,22 @@ pub fn build_detection(foundation: &FoundationRuntime) -> Result, + model_artifact_resolver: Arc::new(FsModelArtifactResolver) as Arc, + model_config_loader: Arc::new(FsModelConfigLoader) as Arc, + promotion_store: Arc::new(FsModelPromotionStore) as Arc, + }); + Ok(DetectionRuntime { inference_config, inference_runtime, flow_statistics, drift_detector, drift_detector_runner: Some(drift_detector_runner), + promote_gate, + model_promotion_deps, }) } @@ -58,8 +77,8 @@ fn load_inference_config( let (cfg, manifest) = model_config_loader.load_manifest_with_sidecar(&manifest_path)?; log!(MLLog::ManifestLoaded( manifest.name.clone(), - manifest.adapter.as_str().to_string(), - manifest.features.len(), + manifest.runtime_kind().as_str().to_string(), + manifest.runtime_feature_count(), manifest.labels.len(), )); Ok((Arc::new(cfg), Some(manifest))) diff --git a/net-guardia/src/infrastructure/startup/foundation.rs b/net-guardia/src/infrastructure/startup/foundation.rs index c2c070c..dcd6d30 100644 --- a/net-guardia/src/infrastructure/startup/foundation.rs +++ b/net-guardia/src/infrastructure/startup/foundation.rs @@ -4,18 +4,23 @@ use std::sync::atomic::Ordering; use arc_swap::ArcSwap; use tokio::sync::broadcast; +use crate::adapter::identity::password_hasher::Argon2PasswordHasher; use crate::adapter::persistence::Database; use crate::adapter::secret_store::SecretStore; use crate::common::error::Error; +use crate::core::common::setup_service::SetupService; use crate::domain::common::config::AppConfig; use crate::domain::common::event::{AuditEvent, DriftDetectedEvent, ThreatDetectedEvent}; use crate::domain::common::system::health::EbpfHealth; +use crate::infrastructure::boot_time::SysinfoBootTimeQuery; use crate::infrastructure::log_buffer::LogBuffer; use crate::infrastructure::logger::Logger; use crate::infrastructure::readiness::ReadinessState; use crate::infrastructure::runtime_state::RuntimeState; use crate::infrastructure::startup::{EVENT_CHANNEL_CAPACITY, EventChannels, FoundationRuntime}; use crate::interface::system::secret_store::SecretStorePort; +use crate::interface::system::setup::SetupRepo; +use crate::interface::system::system_control::BootTimeQuery; pub async fn build_foundation( db: Arc, @@ -32,6 +37,13 @@ pub async fn build_foundation( let readiness = Arc::new(ReadinessState::new()); readiness.db_connected.store(true, Ordering::SeqCst); + let setup_service = Arc::new(SetupService::new( + db.clone() as Arc, + secret_store.clone() as Arc, + Arc::new(Argon2PasswordHasher), + )); + let boot_time_query = Arc::new(SysinfoBootTimeQuery) as Arc; + Ok(FoundationRuntime { app_config, runtime_state, @@ -43,6 +55,8 @@ pub async fn build_foundation( ebpf_health, channels, readiness, + setup_service, + boot_time_query, }) } diff --git a/net-guardia/src/infrastructure/startup/identity.rs b/net-guardia/src/infrastructure/startup/identity.rs index 795e039..b472efb 100644 --- a/net-guardia/src/infrastructure/startup/identity.rs +++ b/net-guardia/src/infrastructure/startup/identity.rs @@ -2,23 +2,36 @@ use std::sync::Arc; use std::sync::atomic::AtomicU8; use crate::adapter::http::session::SessionCookieService; +use crate::adapter::identity::api_key_hasher::HmacApiKeyHasher; use crate::adapter::identity::password_hasher::Argon2PasswordHasher; use crate::common::error::Error; use crate::core::common::enforce_mode_handler::{EnforceModeHandler, enforce_mode_to_u8}; use crate::core::identity::auth_service::AuthService; +use crate::core::identity::group_service::GroupService; use crate::core::identity::session_service::SessionService; +use crate::core::identity::user_service::UserService; use crate::infrastructure::startup::{FoundationRuntime, IdentityRuntime}; -use crate::interface::identity::auth_repo::IdentityAuthRepo; +use crate::interface::identity::api_key_hasher::ApiKeyHasher; +use crate::interface::identity::auth_repo::{IdentityAuthRepo, UserGroupRepo, UserRepo}; +use crate::interface::identity::password_hasher::PasswordHasher; use crate::interface::system::audit::AuditRepo; use crate::interface::system::config_repo::ConfigRepo; -pub fn build_identity(foundation: &FoundationRuntime) -> Result { +pub fn build_identity(foundation: &FoundationRuntime, api_key_hmac: [u8; 32]) -> Result { let session_service = Arc::new(SessionService::new(foundation.app_config.clone())); let session_cookie_service = Arc::new(SessionCookieService::new(foundation.app_config.clone())); + let password_hasher: Arc = Arc::new(Argon2PasswordHasher); + let api_key_hasher: Arc = Arc::new(HmacApiKeyHasher::new(api_key_hmac)); let auth_service = Arc::new(AuthService::new( foundation.database.clone() as Arc, - Arc::new(Argon2PasswordHasher), + password_hasher.clone(), )); + let user_service = Arc::new(UserService::new( + foundation.database.clone() as Arc, + foundation.database.clone() as Arc, + password_hasher, + )); + let group_service = Arc::new(GroupService::new(foundation.database.clone() as Arc)); let enforce_level_cache = Arc::new(AtomicU8::new({ let mode = foundation.app_config.load().system.enforce_mode; enforce_mode_to_u8(mode) @@ -34,7 +47,10 @@ pub fn build_identity(foundation: &FoundationRuntime) -> Result>, pub channels: EventChannels, pub readiness: Arc, + pub setup_service: Arc, + pub boot_time_query: Arc, } pub struct EventChannels { @@ -100,8 +109,11 @@ pub struct IdentityRuntime { pub session_service: Arc, pub session_cookie_service: Arc, pub auth_service: Arc, + pub user_service: Arc, + pub group_service: Arc, pub enforce_handler: Arc, pub enforce_level_cache: Arc, + pub api_key_hasher: Arc, } pub struct DetectionRuntime { @@ -110,6 +122,8 @@ pub struct DetectionRuntime { pub flow_statistics: Arc, pub drift_detector: DriftDetectorHandle, pub drift_detector_runner: Option, + pub promote_gate: Arc, + pub model_promotion_deps: Arc, } pub struct ResponseRuntime { @@ -136,15 +150,16 @@ pub struct ObservabilityRuntime { pub suricata_manager: Arc, } -pub async fn crate_runtime( +pub async fn create_runtime( db: Arc, app_config: AppConfig, logger: Arc, log_buffer: Arc, + api_key_hmac: [u8; 32], ) -> Result { let foundation = build_foundation(db, app_config, logger, log_buffer).await?; - let (data_plane, dns_filter_port) = build_data_plane(&foundation).await; - let identity = build_identity(&foundation)?; + let (data_plane, dns_filter_port) = build_data_plane(&foundation); + let identity = build_identity(&foundation, api_key_hmac)?; let detection = build_detection(&foundation)?; let response = build_response(&foundation, &data_plane, dns_filter_port, &identity).await?; let reporting = build_reporting(&foundation).await; diff --git a/net-guardia/src/infrastructure/startup/response.rs b/net-guardia/src/infrastructure/startup/response.rs index bd09a74..c13b097 100644 --- a/net-guardia/src/infrastructure/startup/response.rs +++ b/net-guardia/src/infrastructure/startup/response.rs @@ -26,7 +26,9 @@ use crate::interface::data_plane::access_control::AccessControlPort; use crate::interface::data_plane::access_control_admin::AccessControlAdminPort; use crate::interface::data_plane::acl::AclRepo; use crate::interface::data_plane::dns_filter_api::DnsFilterPort; -use crate::interface::data_plane::enforcement::EnforcementRepo; +use crate::interface::data_plane::enforcement::DnsEnforcementPort; +use crate::interface::data_plane::enforcement::GeoEnforcementPort; +use crate::interface::data_plane::enforcement::RateLimitWritePort; use crate::interface::data_plane::geo_block_api::GeoBlockPort; use crate::interface::data_plane::rate_limit_api::RateLimitPort; use crate::interface::detection::geo_lookup::GeoLookup; @@ -76,17 +78,17 @@ pub async fn build_response( let geo_block_port: Arc = data_plane.ebpf_services.geo_block.clone(); let acl_service = Arc::new(AclService::new( foundation.database.clone() as Arc, - foundation.database.clone() as Arc, + foundation.database.clone() as Arc, access_control_admin, geo_block_port, )); let dns_filter_service = Arc::new(DnsFilterService::new( - foundation.database.clone() as Arc, + foundation.database.clone() as Arc, dns_filter_port, foundation.app_config.clone(), )); let rate_limit_service = Arc::new(RateLimitService::new( - foundation.database.clone() as Arc, + foundation.database.clone() as Arc, rate_limit_port, )); let playbook_service = Arc::new(PlaybookService::new( @@ -114,6 +116,10 @@ pub async fn build_response( email_sender_factory, )); + dns_filter_service.restore().await; + acl_service.restore().await; + rate_limit_service.restore().await; + Ok(ResponseRuntime { acl_service, config_service, diff --git a/net-guardia/src/infrastructure/suricata_manager.rs b/net-guardia/src/infrastructure/suricata_manager.rs index 96d68b5..19b3867 100644 --- a/net-guardia/src/infrastructure/suricata_manager.rs +++ b/net-guardia/src/infrastructure/suricata_manager.rs @@ -11,10 +11,10 @@ use tokio::task::JoinHandle; use tokio::time::{sleep, timeout}; use crate::common::error::Error; +use crate::common::error::suricata::SuricataError; +use crate::common::log::suricata::SuricataLog; use crate::domain::common::config::AppConfig; -use crate::domain::common::system::suricata::SuricataHealth; -use crate::domain::detection::error::SuricataError; -use crate::domain::detection::log::SuricataLog; +use crate::domain::detection::suricata_health::SuricataHealth; use crate::interface::system::health_query::SuricataHealthQuery; pub struct SuricataManager { diff --git a/net-guardia/src/infrastructure/system/mod.rs b/net-guardia/src/infrastructure/system/mod.rs index 4fd6a25..1539ccf 100644 --- a/net-guardia/src/infrastructure/system/mod.rs +++ b/net-guardia/src/infrastructure/system/mod.rs @@ -93,18 +93,29 @@ impl System { } let database = Arc::new(Database::new(&self.db_path).await?); + let api_key_hmac = Database::derive_api_key_hmac(&self.db_path)?; seed_config_defaults(database.as_ref()).await?; let app_config = load_app_config(database.as_ref()).await?; let (logger, log_buffer) = Logger::initialize(&app_config)?; let setup_complete = is_setup_complete(&database).await?; if !setup_complete { - run_setup_wizard(&database).await?; + run_setup_wizard(&database, api_key_hmac).await?; seed_initial_data(&database).await?; } - self.runtime = - Some(startup::crate_runtime(database, app_config, Arc::new(logger), Arc::new(log_buffer)).await?); + let app_config = load_app_config(database.as_ref()).await?; + + self.runtime = Some( + startup::create_runtime( + database, + app_config, + Arc::new(logger), + Arc::new(log_buffer), + api_key_hmac, + ) + .await?, + ); self.phase = SystemPhase::Ready; Ok(()) } diff --git a/net-guardia/src/infrastructure/system/runtime_start.rs b/net-guardia/src/infrastructure/system/runtime_start.rs index 5f72b6f..434cc28 100644 --- a/net-guardia/src/infrastructure/system/runtime_start.rs +++ b/net-guardia/src/infrastructure/system/runtime_start.rs @@ -25,9 +25,11 @@ use crate::core::detection::orchestrator::{bridge_ml_to_detection, bridge_ml_to_ use crate::core::inference::drift_detector::run_drift_monitor; use crate::core::inference::model_watcher::ModelWatcher; use crate::core::reporting::stats_aggregator::StatsAggregator; -use crate::domain::common::event::{DetectionEvent, FlowObservation}; +use crate::domain::common::event::DetectionEvent; use crate::domain::common::system::health::EbpfFailStage; +use crate::domain::detection::flow_observation::FlowObservation; use crate::domain::detection::log::MLLog; +use crate::domain::detection::model_files::{MODELS_DIR, STAGING_SUBDIR}; use crate::domain::response::log::SoarLog; use crate::infrastructure::audit_logger::AuditLogger; use crate::infrastructure::ebpf_preflight; @@ -37,7 +39,6 @@ use crate::infrastructure::staging_cleanup; use crate::infrastructure::startup::{self, SystemRuntime}; use crate::infrastructure::system::{ShutdownHandle, ShutdownMode, System}; use crate::interface::data_plane::packet_sink::PacketSinkFactory; -use crate::interface::detection::model_files::{MODELS_DIR, STAGING_SUBDIR}; use crate::interface::reporting::report_snapshot::ReportSnapshotRepo; use crate::interface::reporting::stats::StatsRepo; use crate::interface::system::audit::AuditRepo; @@ -222,8 +223,8 @@ pub fn start_detection_graph(system: &mut System) -> Result log!(MLLog::ModelsLoaded(s)), - Err(e) => log!(MLLog::ModelsLoaded(format!(""))), + Ok(s) => log!(MLLog::ModelStatusSummary(s)), + Err(e) => log!(MLLog::ModelStatusSummary(format!(""))), } log!(MLLog::ConfigLoaded(inference_features, attack_types,)); diff --git a/net-guardia/src/infrastructure/system/setup.rs b/net-guardia/src/infrastructure/system/setup.rs index 28beee6..1a3988f 100644 --- a/net-guardia/src/infrastructure/system/setup.rs +++ b/net-guardia/src/infrastructure/system/setup.rs @@ -9,23 +9,27 @@ use tokio::signal; use crate::adapter::http::session::SessionCookieService; use crate::adapter::http::setup::SetupToken; +use crate::adapter::identity::api_key_hasher::HmacApiKeyHasher; use crate::adapter::identity::password_hasher::Argon2PasswordHasher; use crate::adapter::persistence::Database; use crate::adapter::secret_store::SecretStore; use crate::common::error::Error; use crate::common::error::system::SystemError; use crate::common::log::system::SystemLog; +use crate::core::common::setup_service::SetupService; use crate::core::identity::auth_service::AuthService; use crate::core::identity::session_service::SessionService; use crate::domain::common::config::AppConfig; use crate::infrastructure::http_runtime::SetupCompleteFlag; use crate::infrastructure::http_server::{self, SetupServerParams}; +use crate::interface::identity::api_key_hasher::ApiKeyHasher; use crate::interface::identity::auth_repo::IdentityAuthRepo; -use crate::interface::response::soar::SoarRepo; +use crate::interface::system::secret_store::SecretStorePort; +use crate::interface::system::setup::SetupRepo; use crate::interface::system::system_state::SystemStateRepo; pub async fn seed_initial_data(database: &Arc) -> Result<(), Error> { - (database.as_ref() as &dyn SoarRepo).seed_default_playbooks().await?; + database.seed_default_playbooks().await?; Ok(()) } @@ -38,7 +42,7 @@ pub async fn is_setup_complete(database: &Arc) -> Result .unwrap_or(false)) } -pub async fn run_setup_wizard(database: &Arc) -> Result<(), Error> { +pub async fn run_setup_wizard(database: &Arc, api_key_hmac: [u8; 32]) -> Result<(), Error> { log!(SystemLog::SetupMode); let setup_complete_flag = SetupCompleteFlag::new(false); @@ -46,6 +50,11 @@ pub async fn run_setup_wizard(database: &Arc) -> Result<(), Error> { log!(SystemLog::SetupTokenGenerated(setup_token_raw.clone())); let database = database.clone(); let secret_store = Arc::new(SecretStore::new(database.clone())); + let setup_service = Arc::new(SetupService::new( + database.clone() as Arc, + secret_store.clone() as Arc, + Arc::new(Argon2PasswordHasher), + )); let app_config = Arc::new(ArcSwap::from_pointee(AppConfig::defaults())); let session_service = Arc::new(SessionService::new(app_config.clone())); let session_cookie_service = Arc::new(SessionCookieService::new(app_config)); @@ -54,12 +63,15 @@ pub async fn run_setup_wizard(database: &Arc) -> Result<(), Error> { database.clone() as Arc, password_hasher, )); + let api_key_hasher: Arc = Arc::new(HmacApiKeyHasher::new(api_key_hmac)); let params = SetupServerParams { database, secret_store, + setup_service, session_service, session_cookie_service, auth_service, + api_key_hasher, setup_complete: setup_complete_flag.clone(), setup_token: SetupToken::new(setup_token_raw), port: 8080, diff --git a/net-guardia/src/interface/app_repo.rs b/net-guardia/src/interface/app_repo.rs deleted file mode 100644 index db10ed8..0000000 --- a/net-guardia/src/interface/app_repo.rs +++ /dev/null @@ -1,50 +0,0 @@ -use crate::interface::data_plane::acl::AclRepo; -use crate::interface::data_plane::enforcement::EnforcementRepo; -use crate::interface::identity::api_key::ApiKeyRepo; -use crate::interface::identity::auth_repo::{LoginAttemptRepo, UserGroupRepo, UserRepo}; -use crate::interface::reporting::report_snapshot::ReportSnapshotRepo; -use crate::interface::reporting::stats::StatsRepo; -use crate::interface::response::soar::SoarRepo; -use crate::interface::system::audit::AuditRepo; -use crate::interface::system::config_repo::ConfigRepo; -use crate::interface::system::db_admin::DbAdminRepo; -use crate::interface::system::system_state::SystemStateRepo; - -pub trait AppRepo: - AclRepo - + ApiKeyRepo - + AuditRepo - + DbAdminRepo - + EnforcementRepo - + UserRepo - + UserGroupRepo - + LoginAttemptRepo - + ConfigRepo - + SystemStateRepo - + SoarRepo - + StatsRepo - + ReportSnapshotRepo - + Send - + Sync -{ -} - -impl AppRepo for T where - T: AclRepo - + ApiKeyRepo - + AuditRepo - + DbAdminRepo - + EnforcementRepo - + UserRepo - + UserGroupRepo - + LoginAttemptRepo - + ConfigRepo - + SystemStateRepo - + SoarRepo - + StatsRepo - + ReportSnapshotRepo - + Send - + Sync - + ?Sized -{ -} diff --git a/net-guardia/src/interface/data_plane/acl.rs b/net-guardia/src/interface/data_plane/acl.rs index 09ad6cf..ff739b6 100644 --- a/net-guardia/src/interface/data_plane/acl.rs +++ b/net-guardia/src/interface/data_plane/acl.rs @@ -1,12 +1,14 @@ use async_trait::async_trait; use crate::common::error::Error; +use crate::domain::data_plane::acl_rule::AclRuleView; use crate::domain::data_plane::direction::FlowDirection; use crate::domain::data_plane::ip_version::IpVersion; use crate::domain::data_plane::list_type::ListType; #[async_trait] pub trait AclRepo: Send + Sync { + async fn list_acl_rules(&self) -> Result, Error>; async fn has_manual_acl_rule(&self, ip_address: &str) -> Result; async fn list_admin_whitelist(&self) -> Result, Error>; diff --git a/net-guardia/src/interface/data_plane/enforcement.rs b/net-guardia/src/interface/data_plane/enforcement.rs index 2a26163..74d62aa 100644 --- a/net-guardia/src/interface/data_plane/enforcement.rs +++ b/net-guardia/src/interface/data_plane/enforcement.rs @@ -3,12 +3,21 @@ use async_trait::async_trait; use crate::common::error::Error; #[async_trait] -pub trait EnforcementRepo: Send + Sync { +pub trait RateLimitWritePort: Send + Sync { + async fn load_rate_limit_config(&self) -> Result, Error>; async fn set_rate_limits(&self, values: &[(String, u64)]) -> Result<(), Error>; +} +#[async_trait] +pub trait DnsEnforcementPort: Send + Sync { + async fn load_dns_domains(&self) -> Result, Error>; async fn insert_dns_domains(&self, domains: &[String]) -> Result<(), Error>; async fn delete_dns_domains(&self, domains: &[String]) -> Result<(), Error>; +} +#[async_trait] +pub trait GeoEnforcementPort: Send + Sync { + async fn load_geo_countries(&self) -> Result, Error>; async fn insert_geo_countries(&self, codes: &[String]) -> Result<(), Error>; async fn delete_geo_countries(&self, codes: &[String]) -> Result<(), Error>; } diff --git a/net-guardia/src/interface/data_plane/protocol_filter.rs b/net-guardia/src/interface/data_plane/protocol_filter.rs index 6a95d19..9cb150f 100644 --- a/net-guardia/src/interface/data_plane/protocol_filter.rs +++ b/net-guardia/src/interface/data_plane/protocol_filter.rs @@ -5,7 +5,7 @@ use crate::common::error::Error; use crate::domain::data_plane::ip_version::IpVersion; use netguardia_abi::model::http_method::HttpMethod; -pub trait ProtocolFilterPort: Send + Sync { +pub trait HttpFilterPort: Send + Sync { fn get_http_service(&self, version: IpVersion) -> HashMap>; fn add_http_service(&self, version: IpVersion, address: SocketAddr, methods: Vec) -> Result<(), Error>; fn remove_http_service( @@ -14,7 +14,9 @@ pub trait ProtocolFilterPort: Send + Sync { address: SocketAddr, methods: Vec, ) -> Result<(), Error>; +} +pub trait SshFilterPort: Send + Sync { fn is_ssh_white_list_enable(&self) -> bool; fn enable_ssh_white_list(&self) -> Result<(), Error>; fn disable_ssh_white_list(&self) -> Result<(), Error>; diff --git a/net-guardia/src/interface/detection/flow_trace_sink.rs b/net-guardia/src/interface/detection/flow_trace_sink.rs index 3259fe0..af03519 100644 --- a/net-guardia/src/interface/detection/flow_trace_sink.rs +++ b/net-guardia/src/interface/detection/flow_trace_sink.rs @@ -1,6 +1,6 @@ use std::path::Path; pub trait FlowTraceSink: Send + Sync { - fn log_row(&self, record: Vec); + fn log_row(&self, csv_line: String); fn directory(&self) -> &Path; } diff --git a/net-guardia/src/interface/detection/mod.rs b/net-guardia/src/interface/detection/mod.rs index 1a0f0b2..db41fd8 100644 --- a/net-guardia/src/interface/detection/mod.rs +++ b/net-guardia/src/interface/detection/mod.rs @@ -4,6 +4,5 @@ pub mod geo_lookup; pub mod model_artifact_resolver; pub mod model_change_source; pub mod model_config_loader; -pub mod model_files; pub mod model_promotion_store; pub mod model_runtime; diff --git a/net-guardia/src/interface/detection/model_promotion_store.rs b/net-guardia/src/interface/detection/model_promotion_store.rs index b147a25..59f4f22 100644 --- a/net-guardia/src/interface/detection/model_promotion_store.rs +++ b/net-guardia/src/interface/detection/model_promotion_store.rs @@ -3,7 +3,7 @@ use std::path::Path; #[async_trait::async_trait] pub trait ModelPromotionStore: Send + Sync { - fn exists(&self, path: &Path) -> io::Result; + async fn exists(&self, path: &Path) -> io::Result; async fn create_dir_all(&self, path: &Path) -> io::Result<()>; async fn rename(&self, src: &Path, dst: &Path) -> io::Result<()>; async fn remove_dir_all(&self, path: &Path) -> io::Result<()>; diff --git a/net-guardia/src/interface/identity/api_key.rs b/net-guardia/src/interface/identity/api_key.rs index b2239e2..d026818 100644 --- a/net-guardia/src/interface/identity/api_key.rs +++ b/net-guardia/src/interface/identity/api_key.rs @@ -6,8 +6,7 @@ use crate::domain::identity::user::ApiKeyView; #[async_trait] pub trait ApiKeyRepo: Send + Sync { - async fn validate_api_key(&self, api_key: &str) -> Result, Error>; - fn hmac_api_key(&self, raw_key: &str) -> String; + async fn validate_api_key(&self, key_hash: &str) -> Result, Error>; async fn list_api_keys(&self) -> Result, Error>; async fn insert_api_key(&self, key_hash: &str, name: &str, permission_level: &str) -> Result; async fn delete_api_key(&self, id: i64) -> Result; diff --git a/net-guardia/src/interface/identity/api_key_hasher.rs b/net-guardia/src/interface/identity/api_key_hasher.rs new file mode 100644 index 0000000..ef5a0ca --- /dev/null +++ b/net-guardia/src/interface/identity/api_key_hasher.rs @@ -0,0 +1,3 @@ +pub trait ApiKeyHasher: Send + Sync { + fn hash_api_key(&self, raw_key: &str) -> String; +} diff --git a/net-guardia/src/interface/identity/mod.rs b/net-guardia/src/interface/identity/mod.rs index 80cb888..3740692 100644 --- a/net-guardia/src/interface/identity/mod.rs +++ b/net-guardia/src/interface/identity/mod.rs @@ -1,3 +1,4 @@ pub mod api_key; +pub mod api_key_hasher; pub mod auth_repo; pub mod password_hasher; diff --git a/net-guardia/src/interface/mod.rs b/net-guardia/src/interface/mod.rs index ba47f9e..e5c915f 100644 --- a/net-guardia/src/interface/mod.rs +++ b/net-guardia/src/interface/mod.rs @@ -1,4 +1,3 @@ -pub mod app_repo; pub mod data_plane; pub mod detection; pub mod identity; diff --git a/net-guardia/src/interface/response/soar.rs b/net-guardia/src/interface/response/soar.rs index 0d55b6c..5471d8e 100644 --- a/net-guardia/src/interface/response/soar.rs +++ b/net-guardia/src/interface/response/soar.rs @@ -1,31 +1,16 @@ use async_trait::async_trait; use crate::common::error::Error; +use crate::domain::data_plane::ip_version::IpVersion; use crate::interface::data_plane::acl::AclRepo; use crate::interface::response::playbook_data::{ ActionInput, ActiveBlockView, CreateConditionInput, CreatePlaybookInput, ExecutionView, PendingUnblock, PlaybookView, UpdatePlaybookInput, }; -use crate::interface::system::db_admin::DbAdminRepo; #[async_trait] -pub trait SoarRepo: Send + Sync { +pub trait PlaybookRepo: Send + Sync { async fn list_playbooks(&self) -> Result, Error>; - async fn count_active_soar_blocks(&self) -> Result; - async fn list_active_soar_blocks(&self) -> Result, Error>; - async fn find_soar_block_by_id(&self, id: i64) -> Result, Error>; - async fn list_expired_soar_blocks(&self) -> Result, Error>; - async fn list_pending_unblocks(&self) -> Result, Error>; - async fn list_soar_executions(&self, limit: i64) -> Result, Error>; - async fn seed_default_playbooks(&self) -> Result<(), Error>; - async fn insert_pending_unblock(&self, source_ip: &str) -> Result; - async fn insert_soar_execution( - &self, - playbook_id: i64, - source_ip: Option<&str>, - trigger_event: &str, - actions_json: &str, - ) -> Result; async fn insert_playbook_atomic( &self, input: &CreatePlaybookInput, @@ -40,13 +25,44 @@ pub trait SoarRepo: Send + Sync { actions: &[ActionInput], conditions: &[CreateConditionInput], ) -> Result; + async fn delete_playbook(&self, id: i64) -> Result; +} + +#[async_trait] +pub trait SoarBlockRepo: Send + Sync { + async fn count_active_soar_blocks(&self) -> Result; + async fn list_active_soar_blocks(&self) -> Result, Error>; + async fn find_soar_block_by_id(&self, id: i64) -> Result, Error>; + async fn list_expired_soar_blocks(&self) -> Result, Error>; + async fn list_pending_unblocks(&self) -> Result, Error>; + async fn list_soar_executions(&self, limit: i64) -> Result, Error>; + async fn insert_pending_unblock(&self, source_ip: &str) -> Result; + async fn insert_soar_execution( + &self, + playbook_id: i64, + source_ip: Option<&str>, + trigger_event: &str, + actions_json: &str, + ) -> Result; async fn mark_soar_block_unblocked(&self, id: i64) -> Result<(), Error>; async fn increment_pending_unblock_retry(&self, id: i64) -> Result<(), Error>; async fn mark_pending_unblock_exhausted(&self, id: i64, last_error: &str) -> Result<(), Error>; - async fn delete_playbook(&self, id: i64) -> Result; async fn delete_pending_unblock(&self, id: i64) -> Result<(), Error>; + async fn commit_soar_block_to_db( + &self, + source_ip: &str, + ip_version: IpVersion, + playbook_id: i64, + expires_at: &str, + ) -> Result; + async fn commit_soar_unblock_to_db( + &self, + soar_block_id: i64, + ip_version: IpVersion, + source_ip: &str, + ) -> Result<(), Error>; } -pub trait SoarControlRepo: SoarRepo + AclRepo + DbAdminRepo + Send + Sync {} +pub trait SoarControlRepo: PlaybookRepo + SoarBlockRepo + AclRepo + Send + Sync {} -impl SoarControlRepo for T where T: SoarRepo + AclRepo + DbAdminRepo + Send + Sync + ?Sized {} +impl SoarControlRepo for T where T: PlaybookRepo + SoarBlockRepo + AclRepo + Send + Sync + ?Sized {} diff --git a/net-guardia/src/interface/system/db_admin.rs b/net-guardia/src/interface/system/db_admin.rs deleted file mode 100644 index 781e470..0000000 --- a/net-guardia/src/interface/system/db_admin.rs +++ /dev/null @@ -1,21 +0,0 @@ -use async_trait::async_trait; - -use crate::common::error::Error; -use crate::domain::data_plane::ip_version::IpVersion; - -#[async_trait] -pub trait DbAdminRepo: Send + Sync { - async fn commit_soar_block_to_db( - &self, - source_ip: &str, - ip_version: IpVersion, - playbook_id: i64, - expires_at: &str, - ) -> Result; - async fn commit_soar_unblock_to_db( - &self, - soar_block_id: i64, - ip_version: IpVersion, - source_ip: &str, - ) -> Result<(), Error>; -} diff --git a/net-guardia/src/interface/system/health_query.rs b/net-guardia/src/interface/system/health_query.rs index 986d616..91d842f 100644 --- a/net-guardia/src/interface/system/health_query.rs +++ b/net-guardia/src/interface/system/health_query.rs @@ -1,13 +1,15 @@ +use std::sync::Arc; + use tokio::sync::broadcast; use crate::domain::common::system::health::{EbpfHealth, SystemHealthMetrics, SystemHealthStatus}; -use crate::domain::common::system::suricata::SuricataHealth; +use crate::domain::detection::suricata_health::SuricataHealth; pub trait HealthQuery: Send + Sync { fn get_current_metrics(&self) -> SystemHealthMetrics; fn get_health_status(&self) -> SystemHealthStatus; fn get_ebpf_health(&self) -> EbpfHealth; - fn subscribe_to_metrics(&self) -> broadcast::Receiver; + fn subscribe_to_metrics(&self) -> broadcast::Receiver>; } pub trait SuricataHealthQuery: Send + Sync { diff --git a/net-guardia/src/interface/system/live_logs.rs b/net-guardia/src/interface/system/live_logs.rs index bdc0deb..3a5e3db 100644 --- a/net-guardia/src/interface/system/live_logs.rs +++ b/net-guardia/src/interface/system/live_logs.rs @@ -18,13 +18,3 @@ pub struct LogSnapshot { pub trait LiveLogQuery: Send + Sync { fn snapshot(&self, since_id: u64, min_severity: u8, limit: usize) -> LogSnapshot; } - -pub fn level_severity(level: &str) -> u8 { - match level { - "ERROR" => 1, - "WARN" => 2, - "INFO" => 3, - "DEBUG" => 4, - _ => 5, - } -} diff --git a/net-guardia/src/interface/system/mod.rs b/net-guardia/src/interface/system/mod.rs index 9e7f1a9..605f959 100644 --- a/net-guardia/src/interface/system/mod.rs +++ b/net-guardia/src/interface/system/mod.rs @@ -1,6 +1,5 @@ pub mod audit; pub mod config_repo; -pub mod db_admin; pub mod health_query; pub mod http_runtime; pub mod live_logs;