refactor: simplify trainer code

This commit is contained in:
DaLaw2 2026-05-10 01:17:18 +08:00
parent bac8b11516
commit 3012959163
132 changed files with 4004 additions and 3243 deletions

536
Cargo.lock generated
View File

@ -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"

View File

@ -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"] }

View File

@ -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

187
macros/src/fallible.rs Normal file
View File

@ -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<Attribute>,
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<FallibleVariant>,
}
impl Parse for FallibleInput {
fn parse(input: ParseStream) -> Result<Self> {
let enum_name = input.parse::<Ident>()?;
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::<LitStr>(list.tokens.clone())?,
_ => {
return Err(Error::new(error_attr.span(), "Invalid error attribute format"));
}
};
let name = content.parse::<Ident>()?;
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::<Ident>()?;
fields_content.parse::<Token![:]>()?;
let field_type = fields_content.parse::<Type>()?;
fields.push((field_name, field_type));
if !fields_content.is_empty() {
fields_content.parse::<Token![,]>()?;
}
}
}
if !content.is_empty() {
content.parse::<Token![,]>()?;
}
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)
}

View File

@ -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)

View File

@ -275,7 +275,7 @@
},
"anomaly_threshold": 0.9179317355155945,
"c2_threshold": 0.9085615873336792,
"model_type": "MultiTaskModel",
"model_type": "multi_task",
"output_names": [
"anomaly",
"class_probs",

View File

@ -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

@ -1 +1 @@
Subproject commit 9d32885d4cf3781b861e8f3e582dd46fe3bb187c
Subproject commit bb44252915e0a8e0ae56508b2695c8dbf748c404

@ -1 +1 @@
Subproject commit 283a44ba4357b9a509400f62a59361da9025b8d9
Subproject commit 2fc51b61fbbe463d88bc72ee0f792452a0ef2c3f

View File

@ -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 }

View File

@ -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([

View File

@ -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);
}

View File

@ -171,20 +171,20 @@ impl GeoBlock {
}
}
let mut v4_entries: Vec<(Key<u32>, u8)> = Vec::new();
let mut v6_entries: Vec<(Key<u128>, 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<MapData, u32, u8>) -> Result<(), Error> {
let keys: Vec<Key<u32>> = trie
fn remove_stale_v4(trie: &mut LpmTrie<MapData, u32, u8>, desired: &HashSet<(u32, u32)>) {
let stale: Vec<Key<u32>> = trie
.iter()
.map(|entry| entry.map(|(key, _)| key).map_err(EbpfError::MapOperationError))
.collect::<Result<Vec<_>, _>>()?;
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<MapData, u128, u8>) -> Result<(), Error> {
let keys: Vec<Key<u128>> = trie
fn remove_stale_v6(trie: &mut LpmTrie<MapData, u128, u8>, desired: &HashSet<(u128, u32)>) {
let stale: Vec<Key<u128>> = trie
.iter()
.map(|entry| entry.map(|(key, _)| key).map_err(EbpfError::MapOperationError))
.collect::<Result<Vec<_>, _>>()?;
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(())
}
}

View File

@ -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<Self>) {
while let Some(shutdown) = self.shutdowns.pop() {
if shutdown.send(()).is_err() {
log!(SystemError::ShutdownSignalFailed);
}
let _ = shutdown.send(());
}
}
}

View File

@ -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<Ipv6Addr, Error> {
}
}
impl ProtocolFilterPort for ProtocolFilter {
impl HttpFilterPort for ProtocolFilter {
fn get_http_service(&self, version: IpVersion) -> HashMap<SocketAddr, Vec<HttpMethod>> {
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()
}

View File

@ -14,21 +14,7 @@ pub fn initialize() -> Scope {
async fn list_audit_logs(_auth: AuthClaims, audit: web::Data<dyn AuditRepo>) -> HttpResponse {
match audit.list_audit_logs().await {
Ok(entries) => {
let json: Vec<serde_json::Value> = 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),
}
}

View File

@ -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<String>, service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
async fn get_http_service(path: web::Path<String>, service: web::Data<dyn HttpFilterPort>) -> 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<String>, service: web::Data<dyn Protoc
async fn add_http_service(
path: web::Path<String>,
payload: web::Json<(SocketAddr, Vec<HttpMethod>)>,
service: web::Data<dyn ProtocolFilterPort>,
service: web::Data<dyn HttpFilterPort>,
) -> 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<String>,
payload: web::Json<(SocketAddr, Vec<HttpMethod>)>,
service: web::Data<dyn ProtocolFilterPort>,
service: web::Data<dyn HttpFilterPort>,
) -> 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<String>, service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
async fn get_ssh_service(path: web::Path<String>, service: web::Data<dyn SshFilterPort>) -> 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<String>, service: web::Data<dyn Protoco
async fn add_ssh_service(
path: web::Path<String>,
payload: web::Json<SocketAddr>,
service: web::Data<dyn ProtocolFilterPort>,
service: web::Data<dyn SshFilterPort>,
) -> 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<String>,
payload: web::Json<SocketAddr>,
service: web::Data<dyn ProtocolFilterPort>,
service: web::Data<dyn SshFilterPort>,
) -> 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<dyn ProtocolFilterPort>) -> impl Responder {
async fn is_ssh_white_list_enable(service: web::Data<dyn SshFilterPort>) -> impl Responder {
HttpResponse::Ok().json(serde_json::json!({
"enabled": service.is_ssh_white_list_enable(),
}))
}
async fn enable_ssh_white_list(service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
async fn enable_ssh_white_list(service: web::Data<dyn SshFilterPort>) -> impl Responder {
protocol_filter_result(service.enable_ssh_white_list())
}
async fn disable_ssh_white_list(service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
async fn disable_ssh_white_list(service: web::Data<dyn SshFilterPort>) -> impl Responder {
protocol_filter_result(service.disable_ssh_white_list())
}
async fn get_ssh_white_list(path: web::Path<String>, service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
async fn get_ssh_white_list(path: web::Path<String>, service: web::Data<dyn SshFilterPort>) -> 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<String>, service: web::Data<dyn Prot
async fn add_ssh_white_list(
path: web::Path<String>,
payload: web::Json<IpAddr>,
service: web::Data<dyn ProtocolFilterPort>,
service: web::Data<dyn SshFilterPort>,
) -> 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<String>,
payload: web::Json<IpAddr>,
service: web::Data<dyn ProtocolFilterPort>,
service: web::Data<dyn SshFilterPort>,
) -> 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<String>, service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
async fn get_ssh_black_list(path: web::Path<String>, service: web::Data<dyn SshFilterPort>) -> 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<String>, service: web::Data<dyn Prot
async fn add_ssh_black_list(
path: web::Path<String>,
payload: web::Json<IpAddr>,
service: web::Data<dyn ProtocolFilterPort>,
service: web::Data<dyn SshFilterPort>,
) -> 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<String>,
payload: web::Json<IpAddr>,
service: web::Data<dyn ProtocolFilterPort>,
service: web::Data<dyn SshFilterPort>,
) -> impl Responder {
let Some(version) = parse_ip_version(&path) else {
return bad_request("invalid IP version");

View File

@ -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<Inference> {
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<ModelManifest, MLError> {
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()))
}
}

View File

@ -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<String>,
scaler_bytes: Option<usize>,
}
#[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<usize> = 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<usize> = 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<usize, UploadError> {
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<String>, filename: &str) -> Result<(),
Ok(())
}
#[derive(Debug)]
struct BundleExtractionSummary {
manifest_bytes: usize,
onnx_bytes: usize,
scaler_bytes: Option<usize>,
}
fn extract_bundle_zip(
bundle_path: &Path,
staging_dir: &Path,
caps: UploadCaps,
) -> Result<BundleExtractionSummary, UploadError> {
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<String> = 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<str>) -> 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<str>) -> 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");

View File

@ -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<dyn ApiKeyRepo>,
hasher: web::Data<dyn ApiKeyHasher>,
body: web::Json<GenerateKeyRequest>,
) -> 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 {

View File

@ -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<String>,
password: Option<String>,
}
#[derive(Deserialize)]
struct CreateGroupRequest {
name: Option<String>,
description: Option<String>,
permissions: Option<serde_json::Value>,
}
#[derive(Deserialize)]
struct UpdateGroupRequest {
name: Option<String>,
description: Option<String>,
permissions: Option<serde_json::Value>,
}
#[derive(Deserialize)]
struct SetUserGroupsRequest {
group_ids: Vec<i64>,
}
#[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<i64> {
match auth_svc.get_group(group_id).await {
async fn group_member_ids(group_svc: &GroupService, group_id: i64) -> Vec<i64> {
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<AuthService>,
user_svc: web::Data<UserService>,
session_service: web::Data<SessionService>,
cookie_service: web::Data<SessionCookieService>,
) -> 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<ChangePasswordRequest>,
auth_svc: web::Data<AuthService>,
user_svc: web::Data<UserService>,
session_service: web::Data<SessionService>,
cookie_service: web::Data<SessionCookieService>,
) -> 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<AuthService>) -> impl Responder {
match auth_svc.list_users().await {
async fn list_users(_auth: AuthClaims, user_svc: web::Data<UserService>) -> 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<AuthService>) -> impl
async fn delete_user(
auth: AuthClaims,
path: web::Path<i64>,
auth_svc: web::Data<AuthService>,
user_svc: web::Data<UserService>,
session_service: web::Data<SessionService>,
) -> 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<i64>,
body: web::Json<serde_json::Value>,
auth_svc: web::Data<AuthService>,
body: web::Json<UpdateRoleRequest>,
user_svc: web::Data<UserService>,
session_service: web::Data<SessionService>,
) -> 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<i64>,
body: web::Json<serde_json::Value>,
auth_svc: web::Data<AuthService>,
body: web::Json<ResetPasswordRequest>,
user_svc: web::Data<UserService>,
session_service: web::Data<SessionService>,
cookie_service: web::Data<SessionCookieService>,
) -> 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<AuthService>) -> 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<GroupService>) -> 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<AuthService>) -> imp
async fn create_group(
_auth: AuthClaims,
body: web::Json<serde_json::Value>,
auth_svc: web::Data<AuthService>,
body: web::Json<CreateGroupRequest>,
group_svc: web::Data<GroupService>,
) -> 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<i64>, auth_svc: web::Data<AuthService>) -> impl Responder {
async fn get_group(_auth: AuthClaims, path: web::Path<i64>, group_svc: web::Data<GroupService>) -> 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<i64>, auth_svc: web::Data<
async fn update_group(
_auth: AuthClaims,
path: web::Path<i64>,
body: web::Json<serde_json::Value>,
auth_svc: web::Data<AuthService>,
body: web::Json<UpdateGroupRequest>,
group_svc: web::Data<GroupService>,
session_service: web::Data<SessionService>,
) -> 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<i64>,
auth_svc: web::Data<AuthService>,
group_svc: web::Data<GroupService>,
session_service: web::Data<SessionService>,
) -> 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<i64>,
body: web::Json<serde_json::Value>,
auth_svc: web::Data<AuthService>,
body: web::Json<SetUserGroupsRequest>,
user_svc: web::Data<UserService>,
session_service: web::Data<SessionService>,
) -> 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<Vec<i64>, &'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<SetUserGroupsRequest, _> = 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]);
}
}

View File

@ -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<String>, app_config: web::Data<ArcSwap<App
Ok(_) => {}
}
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)]

View File

@ -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<String> {
{
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<String> {
} 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::<web::Data<dyn AppRepo>>() {
let api_key_hasher = match req.app_data::<web::Data<dyn ApiKeyHasher>>() {
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::<web::Data<dyn LoginAttemptRepo>>() {
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");

View File

@ -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<ReportGenerationService>) -> 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(),

View File

@ -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<CreatePlaybookInput, String> {
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::<Result<Vec<_>, 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::<ConditionType>()
.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<f64, String> {
match value.parse::<f64>() {
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<usize, String> {
match value.parse::<usize>() {
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<u64, String> {
match value.parse::<u64>() {
Ok(parsed) if parsed > 0 => Ok(parsed),
_ => Err(format!("{field} must be a positive integer")),
}
}
fn parse_bool_literal(value: &str, field: &str) -> Result<bool, String> {
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<i64>) -> 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::<ActionType>() {
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))

View File

@ -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<Sha256>;
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);
}
}

View File

@ -1 +1,2 @@
pub mod api_key_hasher;
pub mod password_hasher;

View File

@ -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,

View File

@ -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;

View File

@ -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<MLInferenceConfig, MLError> {
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<String, LabelSpec>) -> HashMap<String, String> {
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());
}
}

View File

@ -15,8 +15,11 @@ pub struct FsModelPromotionStore;
#[async_trait::async_trait]
impl ModelPromotionStore for FsModelPromotionStore {
fn exists(&self, path: &Path) -> io::Result<bool> {
path.try_exists()
async fn exists(&self, path: &Path) -> io::Result<bool> {
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<()> {

View File

@ -199,6 +199,10 @@ fn decode_acl_list_type(raw_list_type: String) -> Result<ListType, RusqliteError
#[async_trait]
impl AclRepo for Database {
async fn list_acl_rules(&self) -> Result<Vec<AclRuleView>, Error> {
self.list_acl_rules().await
}
async fn has_manual_acl_rule(&self, ip_address: &str) -> Result<bool, Error> {
self.has_manual_acl_rule(ip_address).await
}

View File

@ -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<Sha256>;
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<Option<Claims>, Error> {
let digest = self.hmac_api_key(api_key);
pub async fn validate_api_key(&self, key_hash: &str) -> Result<Option<Claims>, 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<Option<Claims>, 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<Option<Claims>, Error> {
self.validate_api_key(key_hash).await
}
async fn list_api_keys(&self) -> Result<Vec<ApiKeyView>, 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());
}
}

View File

@ -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())],

View File

@ -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<u64, RusqliteError> {
}
#[async_trait]
impl EnforcementRepo for Database {
impl RateLimitWritePort for Database {
async fn load_rate_limit_config(&self) -> Result<Vec<(String, u64)>, 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<Vec<String>, 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<Vec<String>, Error> {
self.load_geo_countries().await
}
async fn insert_geo_countries(&self, codes: &[String]) -> Result<(), Error> {
self.insert_geo_countries(codes).await

View File

@ -52,7 +52,6 @@ fn db_encryption_key() -> Option<String> {
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::<Sha256>::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() {

View File

@ -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<Vec<PlaybookView>, Error> {
let rows = self.list_playbooks_with_actions().await?;
let mut result: Vec<PlaybookView> = Vec::new();
@ -250,49 +251,6 @@ impl SoarRepo for Database {
Ok(result)
}
async fn count_active_soar_blocks(&self) -> Result<u32, Error> {
self.count_active_soar_blocks().await
}
async fn list_active_soar_blocks(&self) -> Result<Vec<ActiveBlockView>, Error> {
self.list_active_soar_blocks().await
}
async fn find_soar_block_by_id(&self, id: i64) -> Result<Option<ActiveBlockView>, Error> {
self.find_soar_block_by_id(id).await
}
async fn list_expired_soar_blocks(&self) -> Result<Vec<ActiveBlockView>, Error> {
self.list_expired_soar_blocks().await
}
async fn list_pending_unblocks(&self) -> Result<Vec<PendingUnblock>, Error> {
self.list_pending_unblocks().await
}
async fn list_soar_executions(&self, limit: i64) -> Result<Vec<ExecutionView>, 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<i64, Error> {
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<i64, Error> {
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<bool, Error> {
self.delete_playbook(id).await
}
}
#[async_trait]
impl SoarBlockRepo for Database {
async fn count_active_soar_blocks(&self) -> Result<u32, Error> {
self.count_active_soar_blocks().await
}
async fn list_active_soar_blocks(&self) -> Result<Vec<ActiveBlockView>, Error> {
self.list_active_soar_blocks().await
}
async fn find_soar_block_by_id(&self, id: i64) -> Result<Option<ActiveBlockView>, Error> {
self.find_soar_block_by_id(id).await
}
async fn list_expired_soar_blocks(&self) -> Result<Vec<ActiveBlockView>, Error> {
self.list_expired_soar_blocks().await
}
async fn list_pending_unblocks(&self) -> Result<Vec<PendingUnblock>, Error> {
self.list_pending_unblocks().await
}
async fn list_soar_executions(&self, limit: i64) -> Result<Vec<ExecutionView>, Error> {
self.list_soar_executions(limit).await
}
async fn insert_pending_unblock(&self, source_ip: &str) -> Result<i64, Error> {
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<i64, Error> {
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<bool, Error> {
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<i64, Error> {
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)]

View File

@ -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<ActiveBlockView> {
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<i64, Error> {
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::*;

View File

@ -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<DetectionEvent>, 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;

View File

@ -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!(
"🛡 <b>[NetGuardia] {action}</b>\n\
Source: <code>{src}</code> ({country})\n\
@ -315,7 +321,7 @@ mod tests {
threat_type: "\"><b>owned</b>".to_string(),
confidence: 0.9,
action_description: "blocked <now> & notified".to_string(),
timestamp: "2026-05-07T00:00:00Z".to_string(),
timestamp: 1778284800,
};
let message = TelegramAdapter::format_alert_message(&payload);

View File

@ -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<HttpResponse> {
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<SystemHealthMetrics>| ws_bridge::serialize_json(event.as_ref()),
));
Ok(response)
}

View File

@ -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))

View File

@ -29,7 +29,7 @@ async fn handle_client_message(
}
}
fn serialize_json<T: Serialize>(value: &T) -> Option<String> {
pub fn serialize_json<T: Serialize>(value: &T) -> Option<String> {
match serde_json::to_string(value) {
Ok(json) => Some(json),
Err(err) => {

View File

@ -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;

View File

@ -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,
}
}

View File

@ -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,

View File

@ -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,

View File

@ -6,4 +6,5 @@ pub mod health;
pub mod http;
pub mod notification;
pub mod reporting;
pub mod suricata;
pub mod system;

View File

@ -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,
}
}

View File

@ -0,0 +1,9 @@
pub fn level_severity(level: &str) -> u8 {
match level {
"ERROR" => 1,
"WARN" => 2,
"INFO" => 3,
"DEBUG" => 4,
_ => 5,
}
}

View File

@ -1,3 +1,4 @@
pub mod ip_address;
pub mod log_level;
pub mod packet_parser;
pub mod security;

View File

@ -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 {

View File

@ -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<DetectionEvent>, 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()));
}
}

View File

@ -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 {

View File

@ -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 {

View File

@ -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<dyn AclRepo>,
enforcement_repo: Arc<dyn EnforcementRepo>,
enforcement_repo: Arc<dyn GeoEnforcementPort>,
access_control: Arc<dyn AccessControlAdminPort>,
geo_block: Arc<dyn GeoBlockPort>,
}
@ -24,7 +25,7 @@ pub struct AclService {
impl AclService {
pub fn new(
acl_repo: Arc<dyn AclRepo>,
enforcement_repo: Arc<dyn EnforcementRepo>,
enforcement_repo: Arc<dyn GeoEnforcementPort>,
access_control: Arc<dyn AccessControlAdminPort>,
geo_block: Arc<dyn GeoBlockPort>,
) -> 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::<Ipv4Addr>() {
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::<Ipv6Addr>() {
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,

View File

@ -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<dyn EnforcementRepo>,
db: Arc<dyn DnsEnforcementPort>,
dns_filter: Arc<dyn DnsFilterPort>,
config: Arc<ArcSwap<AppConfig>>,
}
impl DnsFilterService {
pub fn new(
db: Arc<dyn EnforcementRepo>,
db: Arc<dyn DnsEnforcementPort>,
dns_filter: Arc<dyn DnsFilterPort>,
config: Arc<ArcSwap<AppConfig>>,
) -> 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<String> {
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<Vec<String>, 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<ArcSwap<AppConfig>> {

View File

@ -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<dyn EnforcementRepo>,
db: Arc<dyn RateLimitWritePort>,
config: Arc<dyn RateLimitPort>,
}
impl RateLimitService {
pub fn new(db: Arc<dyn EnforcementRepo>, config: Arc<dyn RateLimitPort>) -> Self {
pub fn new(db: Arc<dyn RateLimitWritePort>, config: Arc<dyn RateLimitPort>) -> 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<RateLimitSettings, Error> {
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<Vec<(String, u64)>, 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 {

View File

@ -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 {

View File

@ -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<DetectionEvent>, 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
}
}
}

View File

@ -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<AlertMessage>, 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<DetectionEvent>, 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<serde_json::Value> = 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()));
}
}

View File

@ -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<String>,
pub groups: Vec<String>,
}
#[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<UserGroupMembershipResponse>,
}
#[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<GroupMemberResponse>,
}
#[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<i64>,
}
#[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<serde_json::Value, Error> {
let parsed = serde_json::from_str(raw).map_err(CodecError::DeserializeFailed)?;
Ok(parsed)
}
impl AuthService {
pub fn new(db: Arc<dyn IdentityAuthRepo>, password_hasher: Arc<dyn PasswordHasher>) -> Self {
Self { db, password_hasher }
@ -128,9 +35,7 @@ impl AuthService {
pub async fn login(&self, username: &str, raw_password: &str) -> Result<LoginResult, LoginError> {
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<UserProfile, Error> {
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<String, Error> {
async fn derive_role(&self, user_id: i64) -> Result<String, Error> {
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<Vec<UserListResponse>, 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<UserGroupMembershipResponse> = 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<bool, IdentityAdminError> {
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<Vec<GroupListResponse>, 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<GroupMutationResponse, IdentityAdminError> {
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<Option<GroupDetailResponse>, 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<GroupMutationResponse, IdentityAdminError> {
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<bool, IdentityAdminError> {
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<i64>,
) -> 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::<Vec<String>>(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<Item = &'a str>) -> &'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());
}
}

View File

@ -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<GroupMemberResponse>,
}
#[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<i64>,
}
#[derive(Serialize)]
pub struct GroupMutationResponse {
pub id: i64,
pub name: String,
pub description: String,
pub permissions: serde_json::Value,
}
pub struct GroupService {
db: Arc<dyn UserGroupRepo>,
}
impl GroupService {
pub fn new(db: Arc<dyn UserGroupRepo>) -> Self {
Self { db }
}
pub async fn list_groups(&self) -> Result<Vec<GroupListResponse>, 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<GroupMutationResponse, GroupError> {
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<Option<GroupDetailResponse>, 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<GroupMutationResponse, GroupError> {
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<bool, GroupError> {
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<i64>,
) -> 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::<Vec<String>>(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<Database>, GroupService) {
let db = Arc::new(Database::new(":memory:").await.expect("test db"));
let svc = GroupService::new(db.clone() as Arc<dyn UserGroupRepo>);
(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 { .. }));
}
}

View File

@ -1,2 +1,4 @@
pub mod auth_service;
pub mod group_service;
pub mod session_service;
pub mod user_service;

View File

@ -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<ArcSwap<AppConfig>>,
sessions: DashMap<String, SessionRecord>,
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();

View File

@ -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<String>,
pub groups: Vec<String>,
}
#[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<UserGroupMembershipResponse>,
}
#[derive(Serialize)]
pub struct UserGroupMembershipResponse {
pub id: i64,
pub name: String,
}
pub struct UserService {
db: Arc<dyn UserRepo>,
group_db: Arc<dyn UserGroupRepo>,
password_hasher: Arc<dyn PasswordHasher>,
}
impl UserService {
pub fn new(
db: Arc<dyn UserRepo>,
group_db: Arc<dyn UserGroupRepo>,
password_hasher: Arc<dyn PasswordHasher>,
) -> Self {
Self {
db,
group_db,
password_hasher,
}
}
pub async fn user_profile(&self, user_id: i64, username: &str) -> Result<UserProfile, Error> {
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<Vec<UserListResponse>, 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<UserGroupMembershipResponse> = 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<bool, UserError> {
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<serde_json::Value, Error> {
let parsed = serde_json::from_str(raw).map_err(CodecError::DeserializeFailed)?;
Ok(parsed)
}
pub fn role_from_group_names<'a>(names: impl IntoIterator<Item = &'a str>) -> &'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<Database>, UserService) {
let db = Arc::new(Database::new(":memory:").await.expect("test db"));
let svc = UserService::new(
db.clone() as Arc<dyn UserRepo>,
db.clone() as Arc<dyn UserGroupRepo>,
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());
}
}

View File

@ -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());
}
}

View File

@ -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<Mutex<FlowData>>;
struct TrackedFlow {
data: FlowData,
last_inferred_us: u64,
}
type FlowEntry = Arc<Mutex<TrackedFlow>>;
pub struct FlowTracker {
active: Cache<FlowKey, FlowEntry>,
@ -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<T>(&self, convert: impl Fn(&FlowData) -> T) -> Vec<T> {
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<T>(
@ -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<FlowSnapshot> {
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 {

View File

@ -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(());
}
}
}

View File

@ -18,37 +18,37 @@ pub fn build_adapter(
runtime_loader: &dyn ModelRuntimeLoader,
artifact_resolver: &dyn ModelArtifactResolver,
) -> Result<MLModelAdapter, MLError> {
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(

View File

@ -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<Prom
.load_manifest(&staging_manifest)
.map_err(PromoteError::ManifestInvalid)?;
if matches!(manifest_preview.adapter, AdapterKind::MultiTask) {
return Err(PromoteError::UnsupportedAdapter);
}
let declared_onnx = manifest_preview
.models
.model
.clone()
.ok_or_else(|| PromoteError::ManifestInvalid("single-onnx adapters require models.model".to_string()))?;
validate_manifest_basename("models.model", &declared_onnx)?;
if let Some(ref pp) = manifest_preview.preprocessing {
validate_manifest_basename("preprocessing.scaler_sidecar", &pp.scaler_sidecar)?;
}
let staged_onnx = align_uploaded_file_with_manifest(
ctx.promotion_store,
ctx.staging_dir,
ctx.uploaded_onnx_filename,
&declared_onnx,
"uploaded onnx filename",
"rename staged onnx",
)
.await?;
if let (Some(pp), Some(uploaded_scaler_filename)) =
(manifest_preview.preprocessing.as_ref(), ctx.uploaded_scaler_filename)
{
align_uploaded_file_with_manifest(
ctx.promotion_store,
ctx.staging_dir,
uploaded_scaler_filename,
&pp.scaler_sidecar,
"uploaded scaler filename",
"rename staged scaler sidecar",
)
.await?;
let Some(primary_model) = manifest_preview.primary_model() else {
return Err(PromoteError::ManifestInvalid(
"manifest requires a primary model".to_string(),
));
};
let declared_onnx = primary_model.file.clone();
validate_manifest_basename("models[primary].file", &declared_onnx)?;
let primary_scaler_sidecar = manifest_preview.primary_scaler_sidecar();
if let Some(sidecar) = primary_scaler_sidecar {
validate_manifest_basename("preprocessing.scaler_sidecar", sidecar)?;
}
let guard = ctx.promote_gate.try_acquire().ok_or(PromoteError::ConcurrentPromote)?;
@ -131,6 +103,14 @@ pub async fn validate_and_promote(ctx: &StagedModelPromotion<'_>) -> Result<Prom
)
.map_err(PromoteError::ValidationFailed)?;
let before_status = ctx.inference.model_source_status();
let models_dir = PathBuf::from(MODELS_DIR);
let target_onnx = models_dir.join(&declared_onnx);
let target_manifest = models_dir.join(MANIFEST_FILENAME);
let staged_onnx = ctx.staging_dir.join(&declared_onnx);
let staged_sidecar = primary_scaler_sidecar.map(|sidecar| ctx.staging_dir.join(sidecar));
let target_sidecar = primary_scaler_sidecar.map(|sidecar| models_dir.join(sidecar));
let manifest_sha256 = ctx
.promotion_store
.sha256_file(&staging_manifest)
@ -142,26 +122,11 @@ pub async fn validate_and_promote(ctx: &StagedModelPromotion<'_>) -> Result<Prom
.await
.map_err(|e| PromoteError::StagingIo("sha256 onnx", e))?;
let before_status = ctx.inference.model_source_status();
let models_dir = PathBuf::from(MODELS_DIR);
let target_onnx = models_dir.join(&declared_onnx);
let target_manifest = models_dir.join(MANIFEST_FILENAME);
let staged_sidecar = if let Some(ref pp) = manifest.preprocessing {
validate_manifest_basename("preprocessing.scaler_sidecar", &pp.scaler_sidecar)?;
Some(ctx.staging_dir.join(&pp.scaler_sidecar))
} else {
None
};
let target_sidecar = manifest
.preprocessing
.as_ref()
.map(|pp| models_dir.join(&pp.scaler_sidecar));
let backup_dir = models_dir.join(format!(".promote-backup-{}", Uuid::new_v4()));
let before_json = serde_json::to_value(&before_status).map_err(PromoteError::AuditDetailSerialize)?;
let audit_detail = serde_json::json!({
"manifest_name": manifest.name,
"adapter_kind": manifest.adapter.as_str(),
"adapter_kind": manifest.runtime_kind().as_str(),
"manifest_sha256": manifest_sha256,
"onnx_sha256": onnx_sha256,
"before": before_json,
@ -189,8 +154,8 @@ pub async fn validate_and_promote(ctx: &StagedModelPromotion<'_>) -> Result<Prom
drop(guard);
Ok(PromoteReport {
manifest_name: manifest.name,
adapter_kind: manifest.adapter.as_str().to_string(),
manifest_name: manifest.name.clone(),
adapter_kind: manifest.runtime_kind().as_str().to_string(),
manifest_sha256,
onnx_sha256,
})
@ -225,27 +190,6 @@ fn validate_manifest_basename(field: &str, value: &str) -> 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<PathBuf, PromoteError> {
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<Option<PathBuf>, 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();
}

View File

@ -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,

View File

@ -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<MLInferenceConfig>,
app_config: Arc<ArcSwap<AppConfig>>,
qps_recent: AtomicU32,
circuit_breaker: Mutex<CircuitBreakerState>,
cb_phase: AtomicU8,
cb_failure_count: AtomicU32,
cb_window_start_secs: AtomicU64,
cb_open_since_secs: AtomicU64,
classifier_ae_feature_indices: Vec<Option<usize>>,
}
#[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<f32>) {
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<f32>) {
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<f32>) {
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<f32>) {
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<f32> {
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)
}
}

View File

@ -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())

View File

@ -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()));
}
}

View File

@ -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;

View File

@ -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() {

View File

@ -1,4 +1,6 @@
#[derive(Debug, Clone)]
use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
pub struct AuditLogEntry {
pub id: i64,
pub actor: String,

View File

@ -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];

View File

@ -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,

View File

@ -6,5 +6,5 @@ pub struct AlertPayload {
pub threat_type: String,
pub confidence: f32,
pub action_description: String,
pub timestamp: String,
pub timestamp: i64,
}

View File

@ -1,3 +1,2 @@
pub mod health;
pub mod rate_limit_settings;
pub mod suricata;

View File

@ -1,6 +1,6 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RateLimitSettings {
pub packet_rate: Option<u64>,
pub syn_rate: Option<u64>,

View File

@ -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, Self::Err> {
Self::ALL.iter().copied().find(|c| c.as_str() == s).ok_or(())
}
}
pub fn canonical_from_str(s: &str) -> Option<CanonicalAttackType> {
CanonicalAttackType::ALL.iter().copied().find(|c| c.as_str() == s)
s.parse().ok()
}
pub fn translate(source: DetectionSource, raw_label: &str) -> CanonicalAttackType {

View File

@ -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,
}
}

View File

@ -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)
}
}

View File

@ -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<Vec<String>> = 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<String> {
let mut record: Vec<String> = 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
}
}

View File

@ -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,
}

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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<ModelSpec>,
#[serde(default)]
pub pipeline: Vec<String>,
#[serde(default)]
pub labels: BTreeMap<String, LabelSpec>,
#[serde(default)]
pub alert_rules: Vec<AlertRuleSpec>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ModelSpec {
pub id: String,
pub file: String,
#[serde(default)]
pub input_features: Vec<String>,
#[serde(default)]
pub preprocessing: Vec<PreprocessingStep>,
#[serde(default)]
pub outputs: Vec<OutputHeadSpec>,
}
#[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<String>,
pub semantic: OutputSemantic,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
pub threshold: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub autoencoder: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub classifier: Option<String>,
pub min_confidence: Option<f32>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
@ -43,109 +106,306 @@ pub struct LabelSpec {
pub playbook: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct Thresholds {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub anomaly: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub c2: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub class_min_confidence: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ae: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub alert_multiplier: Option<f32>,
}
#[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<String>,
#[serde(default)]
pub labels: BTreeMap<String, LabelSpec>,
#[serde(default)]
pub thresholds: Thresholds,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preprocessing: Option<Preprocessing>,
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<String>)> = 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<Vec<&ModelSpec>, 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<String>)> = 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<String> = 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::<usize>().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<String> {
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<String> {
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"));
}
}

View File

@ -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;

View File

@ -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)]

View File

@ -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 {

View File

@ -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,

View File

@ -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,

View File

@ -4,3 +4,4 @@ pub mod error;
pub mod log;
pub mod outcome;
pub mod playbook;
pub mod playbook_validator;

View File

@ -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<i64>) -> 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::<ConditionType>()
.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::<ActionType>() {
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<f64, SoarError> {
match value.parse::<f64>() {
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<usize, SoarError> {
match value.parse::<usize>() {
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<u64, SoarError> {
match value.parse::<u64>() {
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<bool, SoarError> {
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(&params), 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(&params), 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(&params), 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(&params), 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"
);
}
}

Some files were not shown because too many files have changed in this diff Show More