mirror of
https://github.com/DaLaw2/NetGuardia.git
synced 2026-08-24 14:10:28 +09:00
Refactor backend architecture and code organization (#20)
This commit is contained in:
parent
67dcae2e40
commit
84a1c29e1e
25
.github/workflows/ci.yml
vendored
25
.github/workflows/ci.yml
vendored
@ -37,7 +37,7 @@ jobs:
|
||||
cache-dependency-path: net-guardia-frontend/package-lock.json
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: npm install
|
||||
run: npm ci
|
||||
working-directory: net-guardia-frontend
|
||||
|
||||
- name: Install Rust stable toolchain
|
||||
@ -72,14 +72,25 @@ jobs:
|
||||
ls -la "$HOME/.cargo/bin/bpf-linker"
|
||||
timeout-minutes: 45
|
||||
|
||||
- name: cargo check
|
||||
run: cargo check --package net-guardia
|
||||
- name: cargo check default workspace members
|
||||
run: cargo check
|
||||
|
||||
- name: cargo test
|
||||
run: cargo test --package net-guardia
|
||||
- name: cargo test default workspace members
|
||||
run: cargo test
|
||||
|
||||
- name: cargo clippy
|
||||
run: cargo clippy --package net-guardia -- -D warnings
|
||||
- name: cargo clippy default workspace members
|
||||
run: cargo clippy -- -D warnings
|
||||
|
||||
- name: Frontend build
|
||||
run: npm run build
|
||||
working-directory: net-guardia-frontend
|
||||
|
||||
- name: Frontend tests
|
||||
run: npm test
|
||||
working-directory: net-guardia-frontend
|
||||
|
||||
- name: Trainer Python compile check
|
||||
run: python3 -m compileall -q net-guardia-trainer/src
|
||||
|
||||
integration-test:
|
||||
name: Integration Test (placeholder)
|
||||
|
||||
17
.gitignore
vendored
17
.gitignore
vendored
@ -30,20 +30,13 @@ net-guardia/static/web
|
||||
*.profraw
|
||||
*.profdata
|
||||
|
||||
# License keys
|
||||
license-generator/target/
|
||||
*.hex
|
||||
license.key
|
||||
license_priv.key
|
||||
license_pub.key
|
||||
.gstack/
|
||||
interfaces.txt
|
||||
traffic_log.csv
|
||||
|
||||
# Project docs (local only)
|
||||
# CLAUDE.md — tracked on dev branches; MUST be untracked before PR to master
|
||||
# (see CLAUDE.md "Branch discipline" section)
|
||||
# CLAUDE.md
|
||||
CLAUDE.md
|
||||
AGENT.md
|
||||
DESIGN.md
|
||||
TODOS.md
|
||||
VERSION
|
||||
@ -51,11 +44,7 @@ CHANGELOG.md
|
||||
|
||||
# Benchmark data/results (local only)
|
||||
benchmark/
|
||||
|
||||
# Generated docs
|
||||
# docs/ — tracked on dev branches; MUST be untracked before PR to master
|
||||
# (see CLAUDE.md "Branch discipline" section)
|
||||
# docs/
|
||||
docs/
|
||||
|
||||
# SQLite database files
|
||||
*.db
|
||||
|
||||
163
Cargo.lock
generated
163
Cargo.lock
generated
@ -337,7 +337,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures 0.2.17",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -490,7 +490,7 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures 0.2.17",
|
||||
"cpufeatures",
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
@ -500,6 +500,18 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9"
|
||||
|
||||
[[package]]
|
||||
name = "async-sqlite"
|
||||
version = "0.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1025c093eea82d9b8a37bbf3065c83814654c5b57f156bd48d49c2a38f99ac18"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"rusqlite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
@ -841,17 +853,6 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
"rand_core 0.10.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
@ -990,15 +991,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
@ -1249,7 +1241,7 @@ checksum = "15401da73a9ed8c80e3b2d4dc05fe10e7b72d7243b9f614e516a44fa99986e88"
|
||||
|
||||
[[package]]
|
||||
name = "egress-ebpf"
|
||||
version = "0.1.0"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"aya-ebpf",
|
||||
"aya-log-ebpf",
|
||||
@ -1302,7 +1294,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1492,7 +1484,6 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core 0.10.0",
|
||||
"wasip2",
|
||||
"wasip3",
|
||||
]
|
||||
@ -1583,11 +1574,11 @@ checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
|
||||
checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230"
|
||||
dependencies = [
|
||||
"hashbrown 0.15.5",
|
||||
"hashbrown 0.16.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1741,7 +1732,7 @@ dependencies = [
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2 0.5.10",
|
||||
"socket2 0.6.3",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@ -1906,7 +1897,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ingress-ebpf"
|
||||
version = "0.1.0"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"aya-ebpf",
|
||||
"aya-log-ebpf",
|
||||
@ -2183,9 +2174,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.32.0"
|
||||
version = "0.37.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fbb8270bb4060bd76c6e96f20c52d80620f1d82a3470885694e41e0f81ef6fe7"
|
||||
checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
@ -2318,7 +2309,7 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "macros"
|
||||
version = "0.1.0"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@ -2365,7 +2356,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mcp-server"
|
||||
version = "0.1.0"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"reqwest",
|
||||
@ -2478,7 +2469,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "net-guardia"
|
||||
version = "0.1.0"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"actix",
|
||||
"actix-cors",
|
||||
@ -2489,12 +2480,14 @@ dependencies = [
|
||||
"aes-gcm",
|
||||
"arc-swap",
|
||||
"argon2",
|
||||
"async-sqlite",
|
||||
"async-trait",
|
||||
"aya",
|
||||
"aya-log",
|
||||
"base64",
|
||||
"cargo_metadata",
|
||||
"chrono",
|
||||
"clap",
|
||||
"common",
|
||||
"crossbeam",
|
||||
"dashmap",
|
||||
@ -2515,8 +2508,6 @@ dependencies = [
|
||||
"network-types",
|
||||
"notify",
|
||||
"parking_lot",
|
||||
"r2d2",
|
||||
"r2d2_sqlite",
|
||||
"rand 0.9.2",
|
||||
"reqwest",
|
||||
"rusqlite",
|
||||
@ -2552,7 +2543,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ng-cli"
|
||||
version = "0.1.0"
|
||||
version = "1.0.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"libc",
|
||||
@ -2892,7 +2883,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"cpufeatures",
|
||||
"opaque-debug",
|
||||
"universal-hash",
|
||||
]
|
||||
@ -3012,7 +3003,7 @@ dependencies = [
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2 0.5.10",
|
||||
"socket2 0.6.3",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@ -3049,7 +3040,7 @@ dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2 0.5.10",
|
||||
"socket2 0.6.3",
|
||||
"tracing",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
@ -3081,28 +3072,6 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "r2d2"
|
||||
version = "0.8.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93"
|
||||
dependencies = [
|
||||
"log",
|
||||
"parking_lot",
|
||||
"scheduled-thread-pool",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r2d2_sqlite"
|
||||
version = "0.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "180da684f0a188977d3968f139eb44260192ef8d9a5b7b7cbd01d881e0353179"
|
||||
dependencies = [
|
||||
"r2d2",
|
||||
"rusqlite",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.5"
|
||||
@ -3124,17 +3093,6 @@ dependencies = [
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom 0.4.2",
|
||||
"rand_core 0.10.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
@ -3173,12 +3131,6 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba"
|
||||
|
||||
[[package]]
|
||||
name = "rand_distr"
|
||||
version = "0.4.3"
|
||||
@ -3301,10 +3253,20 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.34.0"
|
||||
name = "rsqlite-vfs"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37e34486da88d8e051c7c0e23c3f15fd806ea8546260aa2fec247e97242ec143"
|
||||
checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d"
|
||||
dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.39.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"fallible-iterator",
|
||||
@ -3312,6 +3274,7 @@ dependencies = [
|
||||
"hashlink",
|
||||
"libsqlite3-sys",
|
||||
"smallvec",
|
||||
"sqlite-wasm-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -3387,7 +3350,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -3466,15 +3429,6 @@ dependencies = [
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scheduled-thread-pool"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19"
|
||||
dependencies = [
|
||||
"parking_lot",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
@ -3593,7 +3547,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
@ -3604,7 +3558,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
@ -3683,6 +3637,18 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlite-wasm-rs"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b2c760607300407ddeaee518acf28c795661b7108c75421303dbefb237d3a36"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"js-sys",
|
||||
"rsqlite-vfs",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
@ -3804,10 +3770,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -4418,7 +4384,6 @@ checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37"
|
||||
dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"js-sys",
|
||||
"rand 0.10.0",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
@ -4638,7 +4603,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
29
Cargo.toml
29
Cargo.toml
@ -4,6 +4,10 @@ members = ["net-guardia", "common", "macros", "ingress-ebpf", "egress-ebpf", "mc
|
||||
default-members = ["net-guardia", "common", "mcp-server", "cli"]
|
||||
|
||||
[workspace.dependencies]
|
||||
# Local crates
|
||||
common = { path = "common" }
|
||||
macros = { path = "macros" }
|
||||
|
||||
# eBPF - kernel side (pinned: aya-ebpf 0.1.2 was yanked, see aya-rs/aya#1400)
|
||||
aya-ebpf = { version = "=0.1.1", default-features = false }
|
||||
aya-log-ebpf = { version = "=0.1.0", default-features = false }
|
||||
@ -30,6 +34,9 @@ actix = "0.13.5"
|
||||
actix-web = "4.13.0"
|
||||
actix-cors = "0.7.1"
|
||||
actix-ws = "0.4.0"
|
||||
actix-multipart = "0.7"
|
||||
actix-files = "0.6"
|
||||
tokio-tungstenite = "0.28.0"
|
||||
|
||||
# Logging / tracing
|
||||
tracing = "0.1.44"
|
||||
@ -51,17 +58,37 @@ sysinfo = "0.38.4"
|
||||
maxminddb = "0.27.3"
|
||||
ipnetwork = "0.21.1"
|
||||
lru = "0.16.3"
|
||||
rusqlite = { version = "0.34", features = ["bundled"] }
|
||||
rusqlite = { version = "0.39", features = ["bundled-sqlcipher"] }
|
||||
async-sqlite = { version = "0.5.7", default-features = false, features = ["bundled-sqlcipher"] }
|
||||
jsonwebtoken = "9"
|
||||
argon2 = "0.5"
|
||||
rand = "0.9"
|
||||
ed25519-dalek = { version = "2", features = ["std", "rand_core"] }
|
||||
base64 = "0.22"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
rust-embed = "8.11.0"
|
||||
mime_guess = "2.0.5"
|
||||
url = "2.5.8"
|
||||
toml = "1.0.7"
|
||||
lettre = { version = "0.11", default-features = false, features = ["builder", "hostname", "smtp-transport", "tokio1-rustls-tls"] }
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
async-trait = "0.1"
|
||||
dashmap = "6"
|
||||
arc-swap = "1"
|
||||
moka = { version = "0.12", features = ["sync"] }
|
||||
notify = "7"
|
||||
sha2 = "0.10"
|
||||
hmac = "0.12"
|
||||
aes-gcm = "0.10"
|
||||
hkdf = "0.12"
|
||||
sd-notify = "0.4"
|
||||
|
||||
# Build dependencies
|
||||
cargo_metadata = { version = "0.23.1", default-features = false }
|
||||
which = "8.0.2"
|
||||
dotenvy = "0.15.7"
|
||||
|
||||
# Proc macro
|
||||
proc-macro2 = "1.0.106"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# NetGuardia
|
||||
|
||||
Inline network security platform built on eBPF/XDP. Runs four independent detectors (per-packet ML, temporal beaconing, graph correlation, Suricata) over the same data plane, fuses their verdicts, drives SOAR playbooks, and writes every decision into a WORM audit chain.
|
||||
Inline network security platform built on eBPF/XDP. Combines ONNX-based ML, temporal beaconing, correlation heuristics, and Suricata `eve.json` alerts in one fusion path, drives SOAR playbooks, and writes decisions into a WORM audit chain.
|
||||
|
||||
## Stack
|
||||
|
||||
@ -8,7 +8,7 @@ Inline network security platform built on eBPF/XDP. Runs four independent detect
|
||||
- **Detection** — Rust + tract-onnx for ML, custom temporal / graph engines, Suricata `eve.json` ingest
|
||||
- **Control plane** — actix-web REST + WebSocket, SQLite + SQLCipher, argon2 / JWT / CSRF, per-playbook SOAR
|
||||
- **Frontend** — Vue 3 + Pinia + Vue-i18n (en / zh-TW / zh-CN / ja)
|
||||
- **Architecture** — hexagonal: `adapter/` · `core/` · `infrastructure/` · `interface/` · `model/`
|
||||
- **Architecture** — hexagonal-ish Rust workspace: `domain/` · `interface/` · `core/` · `adapter/` · `infrastructure/`
|
||||
|
||||
## Screens
|
||||
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
[package]
|
||||
name = "ng-cli"
|
||||
version = "0.1.0"
|
||||
version = "1.0.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
reqwest = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
259
cli/src/main.rs
259
cli/src/main.rs
@ -1,3 +1,7 @@
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::io::AsRawFd;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
@ -5,6 +9,8 @@ use clap::{Parser, Subcommand};
|
||||
use reqwest::Client;
|
||||
use serde_json::Value;
|
||||
|
||||
const CSRF_HEADER: &str = "X-CSRF-Token";
|
||||
|
||||
/// NetGuardia CLI management tool.
|
||||
#[derive(Parser)]
|
||||
#[command(name = "ng", about = "NetGuardia CLI", version)]
|
||||
@ -24,9 +30,7 @@ enum Commands {
|
||||
/// ML engine status
|
||||
Ml,
|
||||
/// Add IP to source blacklist
|
||||
Block {
|
||||
ip: String,
|
||||
},
|
||||
Block { ip: String },
|
||||
/// Remove IP from source blacklist
|
||||
Unblock { ip: String },
|
||||
/// List ACL rules (source blacklist by default)
|
||||
@ -88,18 +92,22 @@ impl ApiClient {
|
||||
|
||||
let token_path = dirs_next().join("token");
|
||||
|
||||
Self { client, base_url, token_path }
|
||||
Self {
|
||||
client,
|
||||
base_url,
|
||||
token_path,
|
||||
}
|
||||
}
|
||||
|
||||
fn load_token(&self) -> Option<String> {
|
||||
std::fs::read_to_string(&self.token_path).ok()
|
||||
fs::read_to_string(&self.token_path).ok()
|
||||
}
|
||||
|
||||
fn save_token(&self, token: &str) {
|
||||
fn save_token(&self, token: &str) -> Result<(), String> {
|
||||
if let Some(parent) = self.token_path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
fs::create_dir_all(parent).map_err(|e| format!("Failed to create token directory: {}", e))?;
|
||||
}
|
||||
let _ = std::fs::write(&self.token_path, token);
|
||||
fs::write(&self.token_path, token).map_err(|e| format!("Failed to save token: {}", e))
|
||||
}
|
||||
|
||||
async fn get(&self, path: &str) -> Result<Value, String> {
|
||||
@ -114,15 +122,25 @@ impl ApiClient {
|
||||
return Err("Session expired. Run `ng login` to re-authenticate.".into());
|
||||
}
|
||||
let text = resp.text().await.map_err(|e| format!("Read error: {}", e))?;
|
||||
serde_json::from_str(&text).map_err(|_| format!("Unexpected response (HTTP {}): {}", status, &text[..text.len().min(200)]))
|
||||
serde_json::from_str(&text).map_err(|_| {
|
||||
format!(
|
||||
"Unexpected response (HTTP {}): {}",
|
||||
status,
|
||||
&text[..text.len().min(200)]
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async fn request(&self, method: reqwest::Method, path: &str, body: Option<Value>) -> Result<Value, String> {
|
||||
let url = format!("{}{}", self.base_url, path);
|
||||
let include_csrf = should_send_csrf(&method);
|
||||
let mut req = self.client.request(method, &url);
|
||||
if let Some(token) = self.load_token() {
|
||||
req = req.header("Authorization", format!("Bearer {}", token.trim()));
|
||||
}
|
||||
if include_csrf {
|
||||
req = req.header(CSRF_HEADER, "ng-cli");
|
||||
}
|
||||
if let Some(b) = body {
|
||||
req = req.json(&b);
|
||||
}
|
||||
@ -138,17 +156,35 @@ impl ApiClient {
|
||||
}
|
||||
return Err(format!("Empty response (HTTP {})", status));
|
||||
}
|
||||
serde_json::from_str(&text).map_err(|_| format!("Unexpected response (HTTP {}): {}", status, &text[..text.len().min(200)]))
|
||||
serde_json::from_str(&text).map_err(|_| {
|
||||
format!(
|
||||
"Unexpected response (HTTP {}): {}",
|
||||
status,
|
||||
&text[..text.len().min(200)]
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async fn login(&self, username: &str, password: &str) -> Result<String, String> {
|
||||
let url = format!("{}/api/auth/login", self.base_url);
|
||||
let body = serde_json::json!({"username": username, "password": password});
|
||||
let resp = self.client.post(&url).json(&body).send().await
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Connection error: {}", e))?;
|
||||
let data: Value = resp.json().await.map_err(|e| format!("Parse error: {}", e))?;
|
||||
data.get("token").and_then(|t| t.as_str()).map(|s| s.to_string())
|
||||
.ok_or_else(|| data.get("error").and_then(|e| e.as_str()).unwrap_or("Login failed").to_string())
|
||||
data.get("token")
|
||||
.and_then(|t| t.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| {
|
||||
data.get("error")
|
||||
.and_then(|e| e.as_str())
|
||||
.unwrap_or("Login failed")
|
||||
.to_string()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -161,11 +197,17 @@ fn print_json(data: &Value) {
|
||||
println!("{}", serde_json::to_string_pretty(data).unwrap_or_default());
|
||||
}
|
||||
|
||||
fn should_send_csrf(method: &reqwest::Method) -> bool {
|
||||
!matches!(
|
||||
*method,
|
||||
reqwest::Method::GET | reqwest::Method::HEAD | reqwest::Method::OPTIONS
|
||||
)
|
||||
}
|
||||
|
||||
fn read_password() -> String {
|
||||
// Disable echo for password input
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::io::AsRawFd;
|
||||
let fd = std::io::stdin().as_raw_fd();
|
||||
let mut termios = unsafe { std::mem::zeroed::<libc::termios>() };
|
||||
unsafe { libc::tcgetattr(fd, &mut termios) };
|
||||
@ -194,25 +236,39 @@ async fn main() {
|
||||
let api = ApiClient::new(cli.url);
|
||||
|
||||
let result = match cli.command {
|
||||
Commands::Status => {
|
||||
api.get("/api/health/status").await.map(|d| print_json(&d))
|
||||
}
|
||||
Commands::Ml => {
|
||||
api.get("/api/ml/status").await.map(|d| print_json(&d))
|
||||
}
|
||||
Commands::Status => api.get("/api/health/status").await.map(|d| print_json(&d)),
|
||||
Commands::Ml => api.get("/api/ml/status").await.map(|d| print_json(&d)),
|
||||
Commands::Block { ip } => {
|
||||
let is_v6 = ip.contains(':');
|
||||
let ip_ver = if is_v6 { "ipv6" } else { "ipv4" };
|
||||
let addr = if is_v6 { format!("[{}]:0", ip) } else { format!("{}:0", ip) };
|
||||
api.request(reqwest::Method::PUT, &format!("/api/acl/{}/source/blacklist", ip_ver), Some(Value::String(addr)))
|
||||
.await.map(|_| println!("Blocked: {}", ip))
|
||||
let addr = if is_v6 {
|
||||
format!("[{}]:0", ip)
|
||||
} else {
|
||||
format!("{}:0", ip)
|
||||
};
|
||||
api.request(
|
||||
reqwest::Method::PUT,
|
||||
&format!("/api/acl/{}/source/blacklist", ip_ver),
|
||||
Some(Value::String(addr)),
|
||||
)
|
||||
.await
|
||||
.map(|_| println!("Blocked: {}", ip))
|
||||
}
|
||||
Commands::Unblock { ip } => {
|
||||
let is_v6 = ip.contains(':');
|
||||
let ip_ver = if is_v6 { "ipv6" } else { "ipv4" };
|
||||
let addr = if is_v6 { format!("[{}]:0", ip) } else { format!("{}:0", ip) };
|
||||
api.request(reqwest::Method::DELETE, &format!("/api/acl/{}/source/blacklist", ip_ver), Some(Value::String(addr)))
|
||||
.await.map(|_| println!("Unblocked: {}", ip))
|
||||
let addr = if is_v6 {
|
||||
format!("[{}]:0", ip)
|
||||
} else {
|
||||
format!("{}:0", ip)
|
||||
};
|
||||
api.request(
|
||||
reqwest::Method::DELETE,
|
||||
&format!("/api/acl/{}/source/blacklist", ip_ver),
|
||||
Some(Value::String(addr)),
|
||||
)
|
||||
.await
|
||||
.map(|_| println!("Unblocked: {}", ip))
|
||||
}
|
||||
Commands::Rules { direction, list_type } => {
|
||||
// Try both IPv4 and IPv6
|
||||
@ -234,97 +290,89 @@ async fn main() {
|
||||
// Use /api/report/data for JSON output
|
||||
api.get("/api/report/data").await.map(|d| print_json(&d))
|
||||
}
|
||||
Commands::Mode { mode } => {
|
||||
match mode {
|
||||
Some(m) => {
|
||||
let body = serde_json::json!({"mode": m});
|
||||
api.request(reqwest::Method::PUT, "/api/system/enforce-mode", Some(body))
|
||||
.await.map(|d| print_json(&d))
|
||||
}
|
||||
None => {
|
||||
api.get("/api/system/enforce-mode").await.map(|d| print_json(&d))
|
||||
}
|
||||
Commands::Mode { mode } => match mode {
|
||||
Some(m) => {
|
||||
let body = serde_json::json!({"mode": m});
|
||||
api.request(reqwest::Method::PUT, "/api/system/enforce-mode", Some(body))
|
||||
.await
|
||||
.map(|d| print_json(&d))
|
||||
}
|
||||
}
|
||||
None => api.get("/api/system/enforce-mode").await.map(|d| print_json(&d)),
|
||||
},
|
||||
Commands::Login => {
|
||||
print!("Username: ");
|
||||
std::io::Write::flush(&mut std::io::stdout()).unwrap();
|
||||
let mut stdout = std::io::stdout();
|
||||
stdout.flush().unwrap();
|
||||
let mut username = String::new();
|
||||
std::io::stdin().read_line(&mut username).unwrap();
|
||||
let username = username.trim();
|
||||
|
||||
print!("Password: ");
|
||||
std::io::Write::flush(&mut std::io::stdout()).unwrap();
|
||||
stdout.flush().unwrap();
|
||||
let password = read_password();
|
||||
|
||||
match api.login(username, &password).await {
|
||||
Ok(token) => {
|
||||
api.save_token(&token);
|
||||
println!("Login successful. Token saved to ~/.ng/token");
|
||||
Ok(())
|
||||
}
|
||||
Ok(token) => match api.save_token(&token) {
|
||||
Ok(()) => {
|
||||
println!("Login successful. Token saved to ~/.ng/token");
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
},
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
Commands::Blocks => {
|
||||
api.get("/api/soar/blocks").await.map(|d| print_json(&d))
|
||||
}
|
||||
Commands::Playbooks => {
|
||||
api.get("/api/soar/playbooks").await.map(|d| print_json(&d))
|
||||
}
|
||||
Commands::Executions => {
|
||||
api.get("/api/soar/executions").await.map(|d| print_json(&d))
|
||||
}
|
||||
Commands::ApiKey { action } => {
|
||||
match action {
|
||||
ApiKeyAction::Generate { name, level } => {
|
||||
let body = serde_json::json!({"name": name, "level": level});
|
||||
api.request(reqwest::Method::POST, "/api/api-keys/generate", Some(body))
|
||||
.await.map(|data| {
|
||||
if let Some(key) = data.get("key").and_then(|k| k.as_str()) {
|
||||
println!("Generated API key: {}", key);
|
||||
println!("Name: {}, Level: {}", name, level);
|
||||
println!("Set NETGUARDIA_API_KEY={} in your client config", key);
|
||||
} else {
|
||||
print_json(&data);
|
||||
}
|
||||
})
|
||||
}
|
||||
ApiKeyAction::List => {
|
||||
api.get("/api/api-keys").await.map(|data| {
|
||||
if let Some(keys) = data.as_array() {
|
||||
if keys.is_empty() {
|
||||
println!("No API keys found.");
|
||||
} else {
|
||||
println!("{:<6} {:<20} {:<15} {:<22} Last Used", "ID", "Name", "Level", "Created");
|
||||
println!("{}", "-".repeat(80));
|
||||
for key in keys {
|
||||
println!("{:<6} {:<20} {:<15} {:<22} {}",
|
||||
key.get("id").and_then(|v| v.as_i64()).unwrap_or(0),
|
||||
key.get("name").and_then(|v| v.as_str()).unwrap_or("-"),
|
||||
key.get("permission_level").and_then(|v| v.as_str()).unwrap_or("-"),
|
||||
key.get("created_at").and_then(|v| v.as_str()).unwrap_or("-"),
|
||||
key.get("last_used_at").and_then(|v| v.as_str()).unwrap_or("never"),
|
||||
);
|
||||
}
|
||||
}
|
||||
Commands::Blocks => api.get("/api/soar/blocks").await.map(|d| print_json(&d)),
|
||||
Commands::Playbooks => api.get("/api/soar/playbooks").await.map(|d| print_json(&d)),
|
||||
Commands::Executions => api.get("/api/soar/executions").await.map(|d| print_json(&d)),
|
||||
Commands::ApiKey { action } => match action {
|
||||
ApiKeyAction::Generate { name, level } => {
|
||||
let body = serde_json::json!({"name": name, "level": level});
|
||||
api.request(reqwest::Method::POST, "/api/api-keys/generate", Some(body))
|
||||
.await
|
||||
.map(|data| {
|
||||
if let Some(key) = data.get("key").and_then(|k| k.as_str()) {
|
||||
println!("Generated API key: {}", key);
|
||||
println!("Name: {}, Level: {}", name, level);
|
||||
println!("Set NETGUARDIA_API_KEY={} in your client config", key);
|
||||
} else {
|
||||
print_json(&data);
|
||||
}
|
||||
})
|
||||
}
|
||||
ApiKeyAction::Revoke { id } => {
|
||||
api.request(reqwest::Method::DELETE, &format!("/api/api-keys/{}", id), None)
|
||||
.await.map(|data| {
|
||||
if data.get("deleted").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||
println!("Key #{} revoked successfully.", id);
|
||||
} else {
|
||||
print_json(&data);
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
ApiKeyAction::List => api.get("/api/api-keys").await.map(|data| {
|
||||
if let Some(keys) = data.as_array() {
|
||||
if keys.is_empty() {
|
||||
println!("No API keys found.");
|
||||
} else {
|
||||
println!("{:<6} {:<20} {:<15} {:<22} Last Used", "ID", "Name", "Level", "Created");
|
||||
println!("{}", "-".repeat(80));
|
||||
for key in keys {
|
||||
println!(
|
||||
"{:<6} {:<20} {:<15} {:<22} {}",
|
||||
key.get("id").and_then(|v| v.as_i64()).unwrap_or(0),
|
||||
key.get("name").and_then(|v| v.as_str()).unwrap_or("-"),
|
||||
key.get("permission_level").and_then(|v| v.as_str()).unwrap_or("-"),
|
||||
key.get("created_at").and_then(|v| v.as_str()).unwrap_or("-"),
|
||||
key.get("last_used_at").and_then(|v| v.as_str()).unwrap_or("never"),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
print_json(&data);
|
||||
}
|
||||
}),
|
||||
ApiKeyAction::Revoke { id } => api
|
||||
.request(reqwest::Method::DELETE, &format!("/api/api-keys/{}", id), None)
|
||||
.await
|
||||
.map(|data| {
|
||||
if data.get("deleted").and_then(|v| v.as_bool()).unwrap_or(false) {
|
||||
println!("Key #{} revoked successfully.", id);
|
||||
} else {
|
||||
print_json(&data);
|
||||
}
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
if let Err(e) = result {
|
||||
@ -332,3 +380,18 @@ async fn main() {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn csrf_header_is_only_needed_for_state_changing_methods() {
|
||||
assert!(!should_send_csrf(&reqwest::Method::GET));
|
||||
assert!(!should_send_csrf(&reqwest::Method::HEAD));
|
||||
assert!(!should_send_csrf(&reqwest::Method::OPTIONS));
|
||||
assert!(should_send_csrf(&reqwest::Method::POST));
|
||||
assert!(should_send_csrf(&reqwest::Method::PUT));
|
||||
assert!(should_send_csrf(&reqwest::Method::DELETE));
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
use core::mem::size_of;
|
||||
|
||||
use aya_ebpf::helpers::bpf_ktime_get_ns;
|
||||
use network_types::eth::{EthHdr, EtherType};
|
||||
use network_types::ip::{IpProto, Ipv4Hdr, Ipv6Hdr};
|
||||
@ -7,6 +9,7 @@ use network_types::udp::UdpHdr;
|
||||
use crate::define::offset::*;
|
||||
use crate::model::parsed_packet::ParsedPacket;
|
||||
|
||||
#[allow(clippy::result_unit_err, clippy::not_unsafe_ptr_arg_deref)]
|
||||
pub fn parse_packet(start: usize, end: usize, target: *mut ParsedPacket) -> Result<(), ()> {
|
||||
unsafe {
|
||||
if start + ETHER_HEADER_END > end {
|
||||
@ -30,6 +33,8 @@ unsafe fn parse_ipv4_packet(start: usize, end: usize, target: *mut ParsedPacket)
|
||||
|
||||
unsafe {
|
||||
let ipv4 = &*((start + IPV4_HEADER_START) as *const Ipv4Hdr);
|
||||
let ipv4_header_len = parse_ipv4_header_len(start, end)?;
|
||||
let l4_start = IPV4_HEADER_START + ipv4_header_len;
|
||||
let packet_length = (end - start) as u32;
|
||||
|
||||
let t = &mut *target;
|
||||
@ -41,12 +46,12 @@ unsafe fn parse_ipv4_packet(start: usize, end: usize, target: *mut ParsedPacket)
|
||||
t.protocol = ipv4.proto;
|
||||
|
||||
let (src_port, dst_port, tcp_flags, l4_header_len) = match ipv4.proto {
|
||||
IpProto::Tcp => parse_tcp(start, end, IPV4_TCP_HEADER_START, IPV4_TCP_HEADER_END)?,
|
||||
IpProto::Udp => parse_udp(start, end, IPV4_UDP_HEADER_START, IPV4_UDP_HEADER_END)?,
|
||||
IpProto::Tcp => parse_tcp(start, end, l4_start)?,
|
||||
IpProto::Udp => parse_udp(start, end, l4_start)?,
|
||||
_ => (0, 0, 0, 0),
|
||||
};
|
||||
|
||||
t.payload_length = packet_length.saturating_sub((IPV4_HEADER_END + l4_header_len) as u32);
|
||||
t.payload_length = packet_length.saturating_sub((l4_start + l4_header_len) as u32);
|
||||
t.src_port = src_port;
|
||||
t.dst_port = dst_port;
|
||||
t.tcp_flags = tcp_flags;
|
||||
@ -74,8 +79,8 @@ unsafe fn parse_ipv6_packet(start: usize, end: usize, target: *mut ParsedPacket)
|
||||
t.protocol = ipv6.next_hdr;
|
||||
|
||||
let (src_port, dst_port, tcp_flags, l4_header_len) = match ipv6.next_hdr {
|
||||
IpProto::Tcp => parse_tcp(start, end, IPV6_TCP_HEADER_START, IPV6_TCP_HEADER_END)?,
|
||||
IpProto::Udp => parse_udp(start, end, IPV6_UDP_HEADER_START, IPV6_UDP_HEADER_END)?,
|
||||
IpProto::Tcp => parse_tcp(start, end, IPV6_TCP_HEADER_START)?,
|
||||
IpProto::Udp => parse_udp(start, end, IPV6_UDP_HEADER_START)?,
|
||||
_ => (0, 0, 0, 0),
|
||||
};
|
||||
|
||||
@ -89,8 +94,31 @@ unsafe fn parse_ipv6_packet(start: usize, end: usize, target: *mut ParsedPacket)
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
unsafe fn parse_tcp(start: usize, end: usize, tcp_start: usize, tcp_end: usize) -> Result<(u16, u16, u8, usize), ()> {
|
||||
if start + tcp_end > end {
|
||||
#[allow(clippy::manual_range_contains)]
|
||||
unsafe fn parse_ipv4_header_len(start: usize, end: usize) -> Result<usize, ()> {
|
||||
if start + IPV4_HEADER_START + 1 > end {
|
||||
return Err(());
|
||||
}
|
||||
|
||||
let version_ihl = unsafe { *((start + IPV4_HEADER_START) as *const u8) };
|
||||
let version = version_ihl >> 4;
|
||||
let ihl = (version_ihl & 0x0f) as usize;
|
||||
if version != 4 || ihl < 5 || ihl > 15 {
|
||||
return Err(());
|
||||
}
|
||||
|
||||
let header_len = ihl * 4;
|
||||
if start + IPV4_HEADER_START + header_len > end {
|
||||
return Err(());
|
||||
}
|
||||
|
||||
Ok(header_len)
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
#[allow(clippy::manual_range_contains)]
|
||||
unsafe fn parse_tcp(start: usize, end: usize, tcp_start: usize) -> Result<(u16, u16, u8, usize), ()> {
|
||||
if start + tcp_start + size_of::<TcpHdr>() > end {
|
||||
return Err(());
|
||||
}
|
||||
|
||||
@ -115,8 +143,8 @@ unsafe fn parse_tcp(start: usize, end: usize, tcp_start: usize, tcp_end: usize)
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
unsafe fn parse_udp(start: usize, end: usize, udp_start: usize, udp_end: usize) -> Result<(u16, u16, u8, usize), ()> {
|
||||
if start + udp_end > end {
|
||||
unsafe fn parse_udp(start: usize, end: usize, udp_start: usize) -> Result<(u16, u16, u8, usize), ()> {
|
||||
if start + udp_start + size_of::<UdpHdr>() > end {
|
||||
return Err(());
|
||||
}
|
||||
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
#[cfg(feature = "user")]
|
||||
use aya::Pod;
|
||||
#[cfg(feature = "user")]
|
||||
use std::vec::Vec;
|
||||
|
||||
#[cfg(feature = "user")]
|
||||
use aya::Pod;
|
||||
|
||||
use crate::define::setting::MAX_RULES_PORT;
|
||||
use crate::model::ip_address::Port;
|
||||
|
||||
|
||||
@ -17,7 +17,6 @@ RUN dnf install -y epel-release && \
|
||||
git \
|
||||
gh \
|
||||
vim \
|
||||
openssh-server \
|
||||
ethtool \
|
||||
nodejs24 \
|
||||
nodejs24-npm \
|
||||
@ -39,9 +38,5 @@ RUN ln -s /usr/bin/node-24 /usr/local/bin/node && \
|
||||
ln -s /usr/bin/npm-24 /usr/local/bin/npm && \
|
||||
ln -s /usr/bin/npx-24 /usr/local/bin/npx
|
||||
|
||||
RUN echo 'root:REDACTED' | chpasswd && \
|
||||
sed -i 's/^#PermitRootLogin.*/PermitRootLogin yes/' /etc/ssh/sshd_config && \
|
||||
sed -i 's/^#PasswordAuthentication.*/PasswordAuthentication yes/' /etc/ssh/sshd_config
|
||||
|
||||
WORKDIR /root/NetGuardia
|
||||
CMD sh -c "ssh-keygen -A && /usr/sbin/sshd && sleep infinity"
|
||||
CMD ["sleep", "infinity"]
|
||||
|
||||
@ -22,7 +22,6 @@ services:
|
||||
mgmt-net:
|
||||
ipv4_address: 10.10.3.10
|
||||
ports:
|
||||
- "2222:22"
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- /home/dalaw2/NetGuardia:/root/NetGuardia:z
|
||||
|
||||
@ -1,146 +1,465 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
#!/usr/bin/env bash
|
||||
# Build the NetGuardia development containers and inline veth topology.
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
set -Eeuo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DEPLOY_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
COMPOSE_FILE="$DEPLOY_DIR/compose/podman-compose.yml"
|
||||
ROOT_DIR="$(cd "$DEPLOY_DIR/.." && pwd)"
|
||||
BASE_COMPOSE_FILE="$DEPLOY_DIR/compose/podman-compose.yml"
|
||||
COMPOSE_FILE="/tmp/netguardia-compose-$$.yml"
|
||||
COMPOSE_PROJECT="compose"
|
||||
LOG_FILE="/tmp/netguardia-dev-$(date +%Y%m%d-%H%M%S).log"
|
||||
|
||||
if command -v podman-compose &>/dev/null; then
|
||||
COMPOSE="podman-compose -f $COMPOSE_FILE"
|
||||
RT="podman"
|
||||
elif command -v docker &>/dev/null && docker compose version &>/dev/null 2>&1; then
|
||||
COMPOSE="docker compose -f $COMPOSE_FILE"
|
||||
RT="docker"
|
||||
else
|
||||
echo "ERROR: No container runtime found"
|
||||
exit 1
|
||||
fi
|
||||
VERBOSE=0
|
||||
CLEANUP_FIRST=1
|
||||
RT=""
|
||||
declare -a RT_CMD=()
|
||||
declare -a COMPOSE_CMD=()
|
||||
|
||||
echo "=== Runtime: $RT ==="
|
||||
echo "=== Kernel: $(uname -r) ==="
|
||||
echo ""
|
||||
|
||||
echo "=== Building containers ==="
|
||||
$COMPOSE build
|
||||
|
||||
echo "=== Starting containers ==="
|
||||
$COMPOSE up -d
|
||||
|
||||
echo ""
|
||||
echo "=== Containers running ==="
|
||||
$RT ps --format "table {{.Names}}\t{{.Status}}" 2>/dev/null || $RT ps
|
||||
|
||||
get_pid() {
|
||||
$RT inspect --format '{{.State.Pid}}' "$1"
|
||||
info() {
|
||||
printf '[INFO] %s\n' "$*"
|
||||
}
|
||||
|
||||
mkdir -p /var/run/netns
|
||||
warn() {
|
||||
printf '[WARN] %s\n' "$*" >&2
|
||||
}
|
||||
|
||||
EXT_PID=$(get_pid external)
|
||||
INT_PID=$(get_pid internal)
|
||||
RTR_PID=$(get_pid router)
|
||||
NG_PID=$(get_pid netguardia)
|
||||
ln -sf /proc/$EXT_PID/ns/net /var/run/netns/external
|
||||
ln -sf /proc/$INT_PID/ns/net /var/run/netns/internal
|
||||
ln -sf /proc/$RTR_PID/ns/net /var/run/netns/router
|
||||
ln -sf /proc/$NG_PID/ns/net /var/run/netns/netguardia
|
||||
fatal() {
|
||||
printf '[ERROR] %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# Segment 1: external <-> router (10.10.1.0/24)
|
||||
# Direct connection, no inspection needed
|
||||
# ============================================================
|
||||
echo ""
|
||||
echo "=== Segment 1: external <-> router (10.10.1.0/24) ==="
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: sudo bash deploy/scripts/dev.sh [--verbose] [--no-cleanup]
|
||||
|
||||
ip link add ext-eth0 type veth peer name rtr-ext
|
||||
ip link set ext-eth0 netns external
|
||||
ip link set rtr-ext netns router
|
||||
Options:
|
||||
--verbose Print compose build/up output in addition to writing the log.
|
||||
--no-cleanup Skip the default preflight cleanup of old containers/veth links.
|
||||
-h, --help Show this help.
|
||||
EOF
|
||||
}
|
||||
|
||||
ip netns exec external ip link set lo up
|
||||
ip netns exec external ip link set ext-eth0 up
|
||||
ip netns exec external ip addr add 10.10.1.2/24 dev ext-eth0
|
||||
for i in 3 4 5 6 7; do
|
||||
ip netns exec external ip addr add 10.10.1.${i}/24 dev ext-eth0
|
||||
done
|
||||
ip netns exec external ip route add default via 10.10.1.1
|
||||
parse_args() {
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--verbose)
|
||||
VERBOSE=1
|
||||
shift
|
||||
;;
|
||||
--no-cleanup)
|
||||
CLEANUP_FIRST=0
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
fatal "Unknown argument: $1"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
ip netns exec router ip link set lo up
|
||||
ip netns exec router ip link set rtr-ext up
|
||||
ip netns exec router ip addr add 10.10.1.1/24 dev rtr-ext
|
||||
cleanup_netns_links() {
|
||||
rm -f \
|
||||
/var/run/netns/external \
|
||||
/var/run/netns/internal \
|
||||
/var/run/netns/router \
|
||||
/var/run/netns/netguardia
|
||||
}
|
||||
|
||||
echo " external: ext-eth0 10.10.1.{2-7}/24, gw 10.10.1.1"
|
||||
echo " router: rtr-ext 10.10.1.1/24"
|
||||
cleanup_temp_files() {
|
||||
cleanup_netns_links
|
||||
rm -f "$COMPOSE_FILE"
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# Segment 2: router <-> netguardia <-> internal (10.10.2.0/24)
|
||||
# NetGuardia inline: XDP on ng-ext (router side) and ng-int (internal side)
|
||||
# No bridges, no inline veth pair — direct XSK forwarding
|
||||
# ============================================================
|
||||
echo ""
|
||||
echo "=== Segment 2: router <-> [NetGuardia] <-> internal (10.10.2.0/24) ==="
|
||||
trap cleanup_temp_files EXIT
|
||||
|
||||
# router <-> netguardia: ng-ext is the netguardia side
|
||||
ip link add rtr-int type veth peer name ng-ext
|
||||
ip link set rtr-int netns router
|
||||
ip link set ng-ext netns netguardia
|
||||
require_root() {
|
||||
[[ "$(id -u)" -eq 0 ]] || fatal "dev.sh must run as root. Use: sudo bash deploy/scripts/dev.sh"
|
||||
}
|
||||
|
||||
# netguardia <-> internal: ng-int is the netguardia side
|
||||
ip link add int-eth0 type veth peer name ng-int
|
||||
ip link set int-eth0 netns internal
|
||||
ip link set ng-int netns netguardia
|
||||
require_linux_host() {
|
||||
[[ "$(uname -s)" == "Linux" ]] || fatal "dev.sh supports Linux hosts only."
|
||||
if grep -qiE 'microsoft|wsl' /proc/version 2>/dev/null; then
|
||||
fatal "WSL2 is not supported for this XDP/AF_XDP development topology."
|
||||
fi
|
||||
}
|
||||
|
||||
# Router internal side
|
||||
ip netns exec router ip link set rtr-int up
|
||||
ip netns exec router ip addr add 10.10.2.1/24 dev rtr-int
|
||||
ip netns exec router sh -c 'echo 1 > /proc/sys/net/ipv4/ip_forward'
|
||||
package_manager() {
|
||||
if command -v dnf >/dev/null 2>&1; then
|
||||
printf 'dnf'
|
||||
elif command -v apt-get >/dev/null 2>&1; then
|
||||
printf 'apt-get'
|
||||
elif command -v zypper >/dev/null 2>&1; then
|
||||
printf 'zypper'
|
||||
elif command -v pacman >/dev/null 2>&1; then
|
||||
printf 'pacman'
|
||||
fi
|
||||
}
|
||||
|
||||
# Internal container
|
||||
ip netns exec internal ip link set lo up
|
||||
ip netns exec internal ip link set int-eth0 up
|
||||
ip netns exec internal ip addr add 10.10.2.2/24 dev int-eth0
|
||||
for i in 3 4 5 6; do
|
||||
ip netns exec internal ip addr add 10.10.2.${i}/24 dev int-eth0
|
||||
done
|
||||
ip netns exec internal ip route add default via 10.10.2.1
|
||||
package_for_command() {
|
||||
local manager="$1"
|
||||
local command_name="$2"
|
||||
|
||||
# NetGuardia interfaces (no IP, transparent)
|
||||
ip netns exec netguardia ip link set ng-ext up
|
||||
ip netns exec netguardia ip link set ng-int up
|
||||
case "$manager:$command_name" in
|
||||
dnf:ip) printf 'iproute' ;;
|
||||
dnf:ping) printf 'iputils' ;;
|
||||
dnf:ethtool) printf 'ethtool' ;;
|
||||
dnf:curl) printf 'curl' ;;
|
||||
dnf:ln|dnf:mkdir|dnf:rm|dnf:uname) printf 'coreutils' ;;
|
||||
apt-get:ip) printf 'iproute2' ;;
|
||||
apt-get:ping) printf 'iputils-ping' ;;
|
||||
apt-get:ethtool) printf 'ethtool' ;;
|
||||
apt-get:curl) printf 'curl' ;;
|
||||
apt-get:ln|apt-get:mkdir|apt-get:rm|apt-get:uname) printf 'coreutils' ;;
|
||||
zypper:ip) printf 'iproute2' ;;
|
||||
zypper:ping) printf 'iputils' ;;
|
||||
zypper:ethtool) printf 'ethtool' ;;
|
||||
zypper:curl) printf 'curl' ;;
|
||||
zypper:ln|zypper:mkdir|zypper:rm|zypper:uname) printf 'coreutils' ;;
|
||||
pacman:ip) printf 'iproute2' ;;
|
||||
pacman:ping) printf 'iputils' ;;
|
||||
pacman:ethtool) printf 'ethtool' ;;
|
||||
pacman:curl) printf 'curl' ;;
|
||||
pacman:ln|pacman:mkdir|pacman:rm|pacman:uname) printf 'coreutils' ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Disable checksum offload on ALL veth endpoints.
|
||||
# AF_XDP TX bypasses the kernel stack, so checksums are not computed.
|
||||
# Without this, TCP packets forwarded through XSK have bad checksums and get dropped.
|
||||
ip netns exec router ethtool -K rtr-int tx off rx off 2>/dev/null || true
|
||||
ip netns exec router ethtool -K rtr-ext tx off rx off 2>/dev/null || true
|
||||
ip netns exec internal ethtool -K int-eth0 tx off rx off 2>/dev/null || true
|
||||
ip netns exec external ethtool -K ext-eth0 tx off rx off 2>/dev/null || true
|
||||
ip netns exec netguardia ethtool -K ng-ext tx off rx off 2>/dev/null || true
|
||||
ip netns exec netguardia ethtool -K ng-int tx off rx off 2>/dev/null || true
|
||||
append_unique() {
|
||||
local value="$1"
|
||||
shift
|
||||
local existing
|
||||
|
||||
echo " router: rtr-int (10.10.2.1) <-> ng-ext (XDP ingress)"
|
||||
echo " netguardia: ng-ext <-> [XSK forwarding] <-> ng-int"
|
||||
echo " internal: int-eth0 (10.10.2.{2-6}) <-> ng-int (XDP egress)"
|
||||
echo " checksum offload disabled on all veth endpoints"
|
||||
for existing in "$@"; do
|
||||
[[ "$existing" == "$value" ]] && return 1
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# Verify
|
||||
# ============================================================
|
||||
echo ""
|
||||
echo "=== Interfaces inside netguardia ==="
|
||||
ip netns exec netguardia ip -br link show
|
||||
install_packages() {
|
||||
local manager="$1"
|
||||
shift
|
||||
|
||||
echo ""
|
||||
echo "=== Testing connectivity ==="
|
||||
case "$manager" in
|
||||
dnf)
|
||||
dnf install -y "$@"
|
||||
;;
|
||||
apt-get)
|
||||
DEBIAN_FRONTEND=noninteractive apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y "$@"
|
||||
;;
|
||||
zypper)
|
||||
zypper --non-interactive install "$@"
|
||||
;;
|
||||
pacman)
|
||||
pacman -Sy --noconfirm "$@"
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
echo -n " external -> router: "
|
||||
ip netns exec external ping -c 1 -W 2 10.10.1.1 >/dev/null 2>&1 && echo "OK" || echo "FAIL"
|
||||
manual_install_command() {
|
||||
local manager="$1"
|
||||
shift
|
||||
|
||||
# Without net-guardia, traffic between router and internal won't pass
|
||||
# because ng-ext/ng-int are just veth endpoints with no forwarding
|
||||
echo -n " router -> internal: "
|
||||
ip netns exec router ping -c 1 -W 2 10.10.2.2 >/dev/null 2>&1 && echo "OK" || echo "FAIL (expected - needs net-guardia)"
|
||||
case "$manager" in
|
||||
dnf) printf 'dnf install -y %s\n' "$*" ;;
|
||||
apt-get) printf 'apt-get update && apt-get install -y %s\n' "$*" ;;
|
||||
zypper) printf 'zypper --non-interactive install %s\n' "$*" ;;
|
||||
pacman) printf 'pacman -Sy --noconfirm %s\n' "$*" ;;
|
||||
*) printf 'Install packages manually: %s\n' "$*" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
cat > /tmp/netguardia_interfaces.txt << IEOF
|
||||
check_host_tools() {
|
||||
local required_commands=(ip ping ethtool curl ln mkdir rm uname)
|
||||
local missing_commands=()
|
||||
local packages=()
|
||||
local command_name manager package answer
|
||||
|
||||
for command_name in "${required_commands[@]}"; do
|
||||
if ! command -v "$command_name" >/dev/null 2>&1; then
|
||||
missing_commands+=("$command_name")
|
||||
fi
|
||||
done
|
||||
|
||||
if ((${#missing_commands[@]} == 0)); then
|
||||
info "Host tools OK"
|
||||
return 0
|
||||
fi
|
||||
|
||||
manager="$(package_manager || true)"
|
||||
[[ -n "$manager" ]] || fatal "Missing host commands: ${missing_commands[*]}. No supported package manager found."
|
||||
|
||||
for command_name in "${missing_commands[@]}"; do
|
||||
package="$(package_for_command "$manager" "$command_name")"
|
||||
[[ -n "$package" ]] || fatal "No package mapping for missing command '$command_name' on $manager."
|
||||
if append_unique "$package" "${packages[@]}"; then
|
||||
packages+=("$package")
|
||||
fi
|
||||
done
|
||||
|
||||
warn "Missing host commands: ${missing_commands[*]}"
|
||||
warn "Package manager: $manager"
|
||||
warn "Packages to install: ${packages[*]}"
|
||||
|
||||
if [[ ! -t 0 ]]; then
|
||||
manual_install_command "$manager" "${packages[@]}" >&2
|
||||
fatal "Non-interactive shell; refusing to install packages without consent."
|
||||
fi
|
||||
|
||||
read -r -p "Install missing packages? [y/N] " answer
|
||||
case "$answer" in
|
||||
y|Y|yes|YES)
|
||||
install_packages "$manager" "${packages[@]}"
|
||||
;;
|
||||
*)
|
||||
manual_install_command "$manager" "${packages[@]}" >&2
|
||||
fatal "Required host packages were not installed."
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
runtime_install_hint() {
|
||||
cat >&2 <<'EOF'
|
||||
Install a supported container runtime first.
|
||||
|
||||
Examples:
|
||||
dnf install -y podman podman-compose
|
||||
apt-get install -y podman podman-compose
|
||||
apt-get install -y docker.io docker-compose-plugin
|
||||
EOF
|
||||
}
|
||||
|
||||
check_docker_supported() {
|
||||
local context security_options operating_system
|
||||
|
||||
context="$(docker context show 2>/dev/null || true)"
|
||||
if [[ "$context" == "desktop-linux" ]]; then
|
||||
fatal "Docker Desktop is not supported for this XDP/netns topology."
|
||||
fi
|
||||
|
||||
operating_system="$(docker info --format '{{.OperatingSystem}}' 2>/dev/null || true)"
|
||||
if [[ "$operating_system" == *"Docker Desktop"* ]]; then
|
||||
fatal "Docker Desktop is not supported for this XDP/netns topology."
|
||||
fi
|
||||
|
||||
security_options="$(docker info --format '{{json .SecurityOptions}}' 2>/dev/null || true)"
|
||||
if grep -qi rootless <<<"$security_options"; then
|
||||
fatal "Rootless Docker is not supported for this privileged XDP/netns topology."
|
||||
fi
|
||||
}
|
||||
|
||||
detect_runtime() {
|
||||
if command -v podman-compose >/dev/null 2>&1 && command -v podman >/dev/null 2>&1; then
|
||||
RT="podman"
|
||||
RT_CMD=(podman)
|
||||
COMPOSE_CMD=(podman-compose -p "$COMPOSE_PROJECT" -f "$COMPOSE_FILE")
|
||||
elif command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
|
||||
check_docker_supported
|
||||
RT="docker"
|
||||
RT_CMD=(docker)
|
||||
COMPOSE_CMD=(docker compose -p "$COMPOSE_PROJECT" -f "$COMPOSE_FILE")
|
||||
else
|
||||
runtime_install_hint
|
||||
fatal "No supported runtime found. Need podman + podman-compose or docker + docker compose."
|
||||
fi
|
||||
|
||||
info "Runtime: $RT"
|
||||
}
|
||||
|
||||
generate_compose_file() {
|
||||
local yaml_deploy
|
||||
local yaml_root
|
||||
|
||||
[[ -f "$BASE_COMPOSE_FILE" ]] || fatal "Compose file not found: $BASE_COMPOSE_FILE"
|
||||
yaml_deploy="${DEPLOY_DIR//\'/\'\'}"
|
||||
yaml_root="${ROOT_DIR//\'/\'\'}"
|
||||
|
||||
: >"$COMPOSE_FILE"
|
||||
while IFS= read -r line; do
|
||||
case "$line" in
|
||||
" context: ..")
|
||||
printf " context: '%s'\n" "$yaml_deploy" >>"$COMPOSE_FILE"
|
||||
;;
|
||||
" - /home/dalaw2/NetGuardia:/root/NetGuardia:z")
|
||||
printf " - '%s:/root/NetGuardia:z'\n" "$yaml_root" >>"$COMPOSE_FILE"
|
||||
;;
|
||||
*)
|
||||
printf '%s\n' "$line" >>"$COMPOSE_FILE"
|
||||
;;
|
||||
esac
|
||||
done <"$BASE_COMPOSE_FILE"
|
||||
}
|
||||
|
||||
run_logged() {
|
||||
local label="$1"
|
||||
shift
|
||||
|
||||
info "$label"
|
||||
if ((VERBOSE)); then
|
||||
"$@" 2>&1 | tee -a "$LOG_FILE"
|
||||
elif [[ -t 1 ]]; then
|
||||
run_with_spinner "$label" "$@"
|
||||
elif ! "$@" >>"$LOG_FILE" 2>&1; then
|
||||
warn "$label failed. Last log lines:"
|
||||
tail -n 80 "$LOG_FILE" >&2 || true
|
||||
fatal "Full log: $LOG_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
run_with_spinner() {
|
||||
local label="$1"
|
||||
shift
|
||||
local pid status
|
||||
|
||||
"$@" >>"$LOG_FILE" 2>&1 &
|
||||
pid=$!
|
||||
spinner "$pid" "$label"
|
||||
set +e
|
||||
wait "$pid"
|
||||
status=$?
|
||||
set -e
|
||||
clear_spinner_line
|
||||
if ((status != 0)); then
|
||||
warn "$label failed. Last log lines:"
|
||||
tail -n 80 "$LOG_FILE" >&2 || true
|
||||
fatal "Full log: $LOG_FILE"
|
||||
fi
|
||||
}
|
||||
|
||||
spinner() {
|
||||
local pid="$1"
|
||||
local label="$2"
|
||||
local frames='⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
|
||||
local i=0
|
||||
local frame
|
||||
local started_at=$SECONDS
|
||||
|
||||
while kill -0 "$pid" 2>/dev/null; do
|
||||
frame="${frames:i++%${#frames}:1}"
|
||||
printf '\r%s %s... %02ds' "$frame" "$label" "$((SECONDS - started_at))"
|
||||
sleep 0.12
|
||||
done
|
||||
}
|
||||
|
||||
clear_spinner_line() {
|
||||
printf '\r\033[K'
|
||||
}
|
||||
|
||||
runtime_rm_containers() {
|
||||
"${RT_CMD[@]}" rm -f netguardia router external internal >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
delete_host_link() {
|
||||
local link_name="$1"
|
||||
ip link del "$link_name" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
preflight_cleanup() {
|
||||
((CLEANUP_FIRST)) || return 0
|
||||
|
||||
info "Cleaning old development topology"
|
||||
runtime_rm_containers
|
||||
cleanup_netns_links
|
||||
delete_host_link ext-eth0
|
||||
delete_host_link rtr-ext
|
||||
delete_host_link rtr-int
|
||||
delete_host_link ng-ext
|
||||
delete_host_link int-eth0
|
||||
delete_host_link ng-int
|
||||
}
|
||||
|
||||
container_pid() {
|
||||
"${RT_CMD[@]}" inspect --format '{{.State.Pid}}' "$1"
|
||||
}
|
||||
|
||||
link_netns() {
|
||||
local container="$1"
|
||||
local pid
|
||||
|
||||
pid="$(container_pid "$container")"
|
||||
[[ -n "$pid" && "$pid" != "0" ]] || fatal "Container '$container' is not running."
|
||||
ln -sf "/proc/$pid/ns/net" "/var/run/netns/$container"
|
||||
}
|
||||
|
||||
link_container_namespaces() {
|
||||
mkdir -p /var/run/netns
|
||||
link_netns external
|
||||
link_netns internal
|
||||
link_netns router
|
||||
link_netns netguardia
|
||||
}
|
||||
|
||||
netns() {
|
||||
ip netns exec "$@"
|
||||
}
|
||||
|
||||
disable_offload() {
|
||||
local namespace="$1"
|
||||
local interface="$2"
|
||||
|
||||
netns "$namespace" ethtool -K "$interface" tx off rx off >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
create_topology() {
|
||||
info "Creating inline veth topology"
|
||||
|
||||
ip link add ext-eth0 type veth peer name rtr-ext
|
||||
ip link set ext-eth0 netns external
|
||||
ip link set rtr-ext netns router
|
||||
|
||||
netns external ip link set lo up
|
||||
netns external ip link set ext-eth0 up
|
||||
netns external ip addr add 10.10.1.2/24 dev ext-eth0
|
||||
for i in 3 4 5 6 7; do
|
||||
netns external ip addr add "10.10.1.$i/24" dev ext-eth0
|
||||
done
|
||||
netns external ip route replace default via 10.10.1.1
|
||||
|
||||
netns router ip link set lo up
|
||||
netns router ip link set rtr-ext up
|
||||
netns router ip addr add 10.10.1.1/24 dev rtr-ext
|
||||
|
||||
ip link add rtr-int type veth peer name ng-ext
|
||||
ip link set rtr-int netns router
|
||||
ip link set ng-ext netns netguardia
|
||||
|
||||
ip link add int-eth0 type veth peer name ng-int
|
||||
ip link set int-eth0 netns internal
|
||||
ip link set ng-int netns netguardia
|
||||
|
||||
netns router ip link set rtr-int up
|
||||
netns router ip addr add 10.10.2.1/24 dev rtr-int
|
||||
netns router sh -c 'echo 1 > /proc/sys/net/ipv4/ip_forward'
|
||||
|
||||
netns internal ip link set lo up
|
||||
netns internal ip link set int-eth0 up
|
||||
netns internal ip addr add 10.10.2.2/24 dev int-eth0
|
||||
for i in 3 4 5 6; do
|
||||
netns internal ip addr add "10.10.2.$i/24" dev int-eth0
|
||||
done
|
||||
netns internal ip route replace default via 10.10.2.1
|
||||
|
||||
netns netguardia ip link set ng-ext up
|
||||
netns netguardia ip link set ng-int up
|
||||
|
||||
disable_offload router rtr-int
|
||||
disable_offload router rtr-ext
|
||||
disable_offload internal int-eth0
|
||||
disable_offload external ext-eth0
|
||||
disable_offload netguardia ng-ext
|
||||
disable_offload netguardia ng-int
|
||||
}
|
||||
|
||||
write_interface_mapping() {
|
||||
cat >/tmp/netguardia_interfaces.txt <<'IEOF'
|
||||
# NetGuardia interface mapping - realistic inline deployment
|
||||
# Router handles L3 (10.10.1.0/24 <-> 10.10.2.0/24)
|
||||
# NetGuardia inline on 10.10.2.0/24 (no IP, no bridge)
|
||||
@ -149,30 +468,54 @@ cat > /tmp/netguardia_interfaces.txt << IEOF
|
||||
# XSK forwards packets: ng-ext RX -> ng-int TX and ng-int RX -> ng-ext TX
|
||||
# Management: eth0 (10.10.3.10)
|
||||
IEOF
|
||||
$RT cp /tmp/netguardia_interfaces.txt netguardia:/root/NetGuardia/interfaces.txt 2>/dev/null || true
|
||||
"${RT_CMD[@]}" cp /tmp/netguardia_interfaces.txt netguardia:/root/NetGuardia/interfaces.txt >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
rm -f /var/run/netns/external /var/run/netns/internal /var/run/netns/router /var/run/netns/netguardia
|
||||
connectivity_check() {
|
||||
local external_router="FAIL"
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo " NetGuardia realistic inline deployment!"
|
||||
echo ""
|
||||
echo " external (10.10.1.{2-7})"
|
||||
echo " |"
|
||||
echo " [router] 10.10.1.1 <-> 10.10.2.1"
|
||||
echo " | rtr-int"
|
||||
echo " |"
|
||||
echo " ng-ext (no IP) <- XDP ingress"
|
||||
echo " |"
|
||||
echo " [net-guardia XSK]"
|
||||
echo " |"
|
||||
echo " ng-int (no IP) <- XDP egress"
|
||||
echo " |"
|
||||
echo " | int-eth0"
|
||||
echo " internal (10.10.2.{2-6})"
|
||||
echo ""
|
||||
echo " All 10.10.2.0/24 traffic requires net-guardia!"
|
||||
echo " Mgmt: 10.10.3.10"
|
||||
echo " SSH: ssh -p 2222 root@<host-ip>"
|
||||
echo " Web: http://<host-ip>:8080"
|
||||
echo "=========================================="
|
||||
if netns external ping -c 1 -W 2 10.10.1.1 >/dev/null 2>&1; then
|
||||
external_router="OK"
|
||||
fi
|
||||
|
||||
info "Connectivity: external -> router: $external_router"
|
||||
}
|
||||
|
||||
print_summary() {
|
||||
cat <<EOF
|
||||
|
||||
NetGuardia development topology is ready.
|
||||
|
||||
Mgmt: http://<host-ip>:8080
|
||||
|
||||
external (10.10.1.{2-7}) -> router -> ng-ext
|
||||
ng-ext <-> net-guardia XSK <-> ng-int
|
||||
ng-int -> internal (10.10.2.{2-6})
|
||||
EOF
|
||||
}
|
||||
|
||||
main() {
|
||||
parse_args "$@"
|
||||
require_root
|
||||
require_linux_host
|
||||
check_host_tools
|
||||
generate_compose_file
|
||||
detect_runtime
|
||||
preflight_cleanup
|
||||
|
||||
: >"$LOG_FILE"
|
||||
info "Compose log: $LOG_FILE"
|
||||
run_logged "Building containers" "${COMPOSE_CMD[@]}" build
|
||||
run_logged "Starting containers" "${COMPOSE_CMD[@]}" up -d
|
||||
|
||||
info "Containers running"
|
||||
"${RT_CMD[@]}" ps --format "table {{.Names}}\t{{.Status}}" 2>/dev/null || "${RT_CMD[@]}" ps
|
||||
|
||||
link_container_namespaces
|
||||
create_topology
|
||||
write_interface_mapping
|
||||
connectivity_check
|
||||
print_summary
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
[package]
|
||||
name = "egress-ebpf"
|
||||
version = "0.1.0"
|
||||
version = "1.0.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
common = { path = "../common", features = ["kernel"] }
|
||||
common = { workspace = true, features = ["kernel"] }
|
||||
aya-ebpf = { workspace = true }
|
||||
aya-log-ebpf = { workspace = true }
|
||||
network-types = { workspace = true }
|
||||
|
||||
@ -19,9 +19,7 @@ static EGRESS_XSKS_MAP: XskMap = XskMap::pinned(64, 0);
|
||||
|
||||
#[xdp]
|
||||
pub fn net_guardia(ctx: XdpContext) -> u32 {
|
||||
let queue_id = unsafe {
|
||||
compute_symmetric_queue_id(&ctx).unwrap_or((*ctx.ctx).rx_queue_index)
|
||||
};
|
||||
let queue_id = unsafe { compute_symmetric_queue_id(&ctx).unwrap_or((*ctx.ctx).rx_queue_index) };
|
||||
match EGRESS_XSKS_MAP.redirect(queue_id, 0) {
|
||||
Ok(action) => action,
|
||||
Err(_) => xdp_action::XDP_PASS,
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
[package]
|
||||
name = "ingress-ebpf"
|
||||
version = "0.1.0"
|
||||
version = "1.0.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
common = { path = "../common", features = ["kernel"] }
|
||||
common = { workspace = true, features = ["kernel"] }
|
||||
aya-ebpf = { workspace = true }
|
||||
aya-log-ebpf = { workspace = true }
|
||||
network-types = { workspace = true }
|
||||
|
||||
@ -2,9 +2,9 @@ use aya_ebpf::macros::map;
|
||||
use aya_ebpf::maps::HashMap;
|
||||
use aya_ebpf::maps::LpmTrie;
|
||||
use aya_ebpf::maps::lpm_trie::Key;
|
||||
use common::define::setting::{MAX_RULES, MAX_GEO_ENTRIES};
|
||||
use common::model::parsed_packet::ParsedPacket;
|
||||
use common::define::setting::{MAX_GEO_ENTRIES, MAX_RULES};
|
||||
use common::model::ip_address::{IPv4, IPv6};
|
||||
use common::model::parsed_packet::ParsedPacket;
|
||||
use common::model::port_rule::PortRule;
|
||||
|
||||
#[map]
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
pub mod access_control;
|
||||
pub mod rate_limit;
|
||||
pub mod protocol_filter;
|
||||
pub mod rate_limit;
|
||||
|
||||
@ -50,19 +50,11 @@ fn get_config(index: u32, default: u64) -> u64 {
|
||||
|
||||
#[inline(always)]
|
||||
fn is_syn_only(pkt: &ParsedPacket) -> bool {
|
||||
matches!(pkt.protocol, IpProto::Tcp)
|
||||
&& (pkt.tcp_flags & TCP_SYN != 0)
|
||||
&& (pkt.tcp_flags & TCP_ACK == 0)
|
||||
matches!(pkt.protocol, IpProto::Tcp) && (pkt.tcp_flags & TCP_SYN != 0) && (pkt.tcp_flags & TCP_ACK == 0)
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn check_rate<K>(
|
||||
map: &LruHashMap<K, RateState>,
|
||||
key: &K,
|
||||
now: u64,
|
||||
window: u64,
|
||||
limit: u64,
|
||||
) -> bool {
|
||||
fn check_rate<K>(map: &LruHashMap<K, RateState>, key: &K, now: u64, window: u64, limit: u64) -> bool {
|
||||
unsafe {
|
||||
if let Some(state) = map.get_ptr_mut(key) {
|
||||
if now - (*state).window_start >= window {
|
||||
@ -91,24 +83,48 @@ fn ipv4_should_drop(pkt: &ParsedPacket) -> Option<u8> {
|
||||
let window = get_config(CFG_WINDOW_NS, DEFAULT_WINDOW_NS);
|
||||
let src_ip = pkt.src_ip_v4();
|
||||
|
||||
if check_rate(&IPV4_PACKET_RATE_MAP, &src_ip, now, window, get_config(CFG_PACKET_RATE, DEFAULT_PACKET_RATE)) {
|
||||
if check_rate(
|
||||
&IPV4_PACKET_RATE_MAP,
|
||||
&src_ip,
|
||||
now,
|
||||
window,
|
||||
get_config(CFG_PACKET_RATE, DEFAULT_PACKET_RATE),
|
||||
) {
|
||||
return Some(DROP_REASON_RATE_LIMIT_PKT);
|
||||
}
|
||||
|
||||
if is_syn_only(pkt) {
|
||||
if check_rate(&IPV4_SYN_RATE_MAP, &src_ip, now, window, get_config(CFG_SYN_RATE, DEFAULT_SYN_RATE)) {
|
||||
if check_rate(
|
||||
&IPV4_SYN_RATE_MAP,
|
||||
&src_ip,
|
||||
now,
|
||||
window,
|
||||
get_config(CFG_SYN_RATE, DEFAULT_SYN_RATE),
|
||||
) {
|
||||
return Some(DROP_REASON_RATE_LIMIT_SYN);
|
||||
}
|
||||
}
|
||||
|
||||
if matches!(pkt.protocol, IpProto::Udp) {
|
||||
if check_rate(&IPV4_UDP_RATE_MAP, &src_ip, now, window, get_config(CFG_UDP_RATE, DEFAULT_UDP_RATE)) {
|
||||
if check_rate(
|
||||
&IPV4_UDP_RATE_MAP,
|
||||
&src_ip,
|
||||
now,
|
||||
window,
|
||||
get_config(CFG_UDP_RATE, DEFAULT_UDP_RATE),
|
||||
) {
|
||||
return Some(DROP_REASON_RATE_LIMIT_UDP);
|
||||
}
|
||||
}
|
||||
|
||||
if matches!(pkt.protocol, IpProto::Udp) && pkt.dst_port == 53 {
|
||||
if check_rate(&IPV4_DNS_RATE_MAP, &src_ip, now, window, get_config(CFG_DNS_RATE, DEFAULT_DNS_RATE)) {
|
||||
if check_rate(
|
||||
&IPV4_DNS_RATE_MAP,
|
||||
&src_ip,
|
||||
now,
|
||||
window,
|
||||
get_config(CFG_DNS_RATE, DEFAULT_DNS_RATE),
|
||||
) {
|
||||
return Some(DROP_REASON_RATE_LIMIT_DNS);
|
||||
}
|
||||
}
|
||||
@ -122,24 +138,48 @@ fn ipv6_should_drop(pkt: &ParsedPacket) -> Option<u8> {
|
||||
let window = get_config(CFG_WINDOW_NS, DEFAULT_WINDOW_NS);
|
||||
let src_ip = pkt.src_ip_v6();
|
||||
|
||||
if check_rate(&IPV6_PACKET_RATE_MAP, &src_ip, now, window, get_config(CFG_PACKET_RATE, DEFAULT_PACKET_RATE)) {
|
||||
if check_rate(
|
||||
&IPV6_PACKET_RATE_MAP,
|
||||
&src_ip,
|
||||
now,
|
||||
window,
|
||||
get_config(CFG_PACKET_RATE, DEFAULT_PACKET_RATE),
|
||||
) {
|
||||
return Some(DROP_REASON_RATE_LIMIT_PKT);
|
||||
}
|
||||
|
||||
if is_syn_only(pkt) {
|
||||
if check_rate(&IPV6_SYN_RATE_MAP, &src_ip, now, window, get_config(CFG_SYN_RATE, DEFAULT_SYN_RATE)) {
|
||||
if check_rate(
|
||||
&IPV6_SYN_RATE_MAP,
|
||||
&src_ip,
|
||||
now,
|
||||
window,
|
||||
get_config(CFG_SYN_RATE, DEFAULT_SYN_RATE),
|
||||
) {
|
||||
return Some(DROP_REASON_RATE_LIMIT_SYN);
|
||||
}
|
||||
}
|
||||
|
||||
if matches!(pkt.protocol, IpProto::Udp) {
|
||||
if check_rate(&IPV6_UDP_RATE_MAP, &src_ip, now, window, get_config(CFG_UDP_RATE, DEFAULT_UDP_RATE)) {
|
||||
if check_rate(
|
||||
&IPV6_UDP_RATE_MAP,
|
||||
&src_ip,
|
||||
now,
|
||||
window,
|
||||
get_config(CFG_UDP_RATE, DEFAULT_UDP_RATE),
|
||||
) {
|
||||
return Some(DROP_REASON_RATE_LIMIT_UDP);
|
||||
}
|
||||
}
|
||||
|
||||
if matches!(pkt.protocol, IpProto::Udp) && pkt.dst_port == 53 {
|
||||
if check_rate(&IPV6_DNS_RATE_MAP, &src_ip, now, window, get_config(CFG_DNS_RATE, DEFAULT_DNS_RATE)) {
|
||||
if check_rate(
|
||||
&IPV6_DNS_RATE_MAP,
|
||||
&src_ip,
|
||||
now,
|
||||
window,
|
||||
get_config(CFG_DNS_RATE, DEFAULT_DNS_RATE),
|
||||
) {
|
||||
return Some(DROP_REASON_RATE_LIMIT_DNS);
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,17 +5,17 @@ mod action;
|
||||
use aya_ebpf::bindings::xdp_action;
|
||||
use aya_ebpf::macros::{map, xdp};
|
||||
use aya_ebpf::maps::{Array, PerCpuArray, ProgramArray, RingBuf, XskMap};
|
||||
use common::ebpf::symmetric_hash::symmetric_queue_id;
|
||||
use aya_ebpf::programs::XdpContext;
|
||||
#[allow(unused_imports)]
|
||||
use aya_log_ebpf::info;
|
||||
use common::ebpf::parsing;
|
||||
use common::define::pipeline::*;
|
||||
use common::define::drop_reason::*;
|
||||
use common::define::pipeline::*;
|
||||
use common::ebpf::parsing;
|
||||
use common::ebpf::symmetric_hash::symmetric_queue_id;
|
||||
use common::model::drop_event::DropEvent;
|
||||
use common::model::parsed_packet::ParsedPacket;
|
||||
|
||||
use crate::action::{access_control, rate_limit, protocol_filter};
|
||||
use crate::action::{access_control, protocol_filter, rate_limit};
|
||||
|
||||
#[map]
|
||||
static PROGRAM_ARRAY: ProgramArray = ProgramArray::with_max_entries(MAX_STAGES, 0);
|
||||
@ -70,7 +70,9 @@ unsafe fn emit_drop_event(pkt: &ParsedPacket, reason: u8) {
|
||||
|
||||
#[inline(always)]
|
||||
unsafe fn packet_intake(ctx: &XdpContext) {
|
||||
let Some(ptr) = PARSED_PACKET.get_ptr_mut(0) else { return };
|
||||
let Some(ptr) = PARSED_PACKET.get_ptr_mut(0) else {
|
||||
return;
|
||||
};
|
||||
if parsing::parse_packet(ctx.data(), ctx.data_end(), ptr).is_ok() {
|
||||
chain_next(ctx, STAGE_ENTRY);
|
||||
}
|
||||
@ -209,9 +211,7 @@ unsafe fn compute_symmetric_queue_id() -> Option<u32> {
|
||||
|
||||
#[xdp]
|
||||
pub fn transmission(ctx: XdpContext) -> u32 {
|
||||
let queue_id = unsafe {
|
||||
compute_symmetric_queue_id().unwrap_or((*ctx.ctx).rx_queue_index)
|
||||
};
|
||||
let queue_id = unsafe { compute_symmetric_queue_id().unwrap_or((*ctx.ctx).rx_queue_index) };
|
||||
match INGRESS_XSKS_MAP.redirect(queue_id, 0) {
|
||||
Ok(action) => action,
|
||||
Err(_) => xdp_action::XDP_PASS,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "macros"
|
||||
version = "0.1.0"
|
||||
version = "1.0.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
|
||||
569
macros/src/config.rs
Normal file
569
macros/src/config.rs
Normal file
@ -0,0 +1,569 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use proc_macro::TokenStream;
|
||||
use proc_macro2::TokenStream as TokenStream2;
|
||||
use quote::{format_ident, quote};
|
||||
use syn::parse::{Parse, ParseStream};
|
||||
use syn::{Fields, Ident, ItemStruct, LitBool, LitStr, Result, Token, Type};
|
||||
|
||||
// ── Attribute parsing ──────────────────────────────────────────────
|
||||
|
||||
struct StructAttr {
|
||||
default_section: Option<String>,
|
||||
}
|
||||
|
||||
impl Parse for StructAttr {
|
||||
fn parse(input: ParseStream) -> Result<Self> {
|
||||
let mut section = None;
|
||||
while !input.is_empty() {
|
||||
let key: Ident = input.parse()?;
|
||||
input.parse::<Token![=]>()?;
|
||||
let val: LitStr = input.parse()?;
|
||||
if key == "section" {
|
||||
section = Some(val.value());
|
||||
}
|
||||
if !input.is_empty() {
|
||||
input.parse::<Token![,]>()?;
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
default_section: section,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Field model ────────────────────────────────────────────────────
|
||||
|
||||
enum ConfigField {
|
||||
Setting(SettingField),
|
||||
Flatten(FlattenField),
|
||||
MappedParent(MappedParent),
|
||||
}
|
||||
|
||||
struct SettingField {
|
||||
ident: Ident,
|
||||
ty: Type,
|
||||
key: String,
|
||||
default: String,
|
||||
default_debug: Option<String>,
|
||||
section: Option<String>,
|
||||
api: bool,
|
||||
}
|
||||
|
||||
struct FlattenField {
|
||||
ident: Ident,
|
||||
ty: Type,
|
||||
}
|
||||
|
||||
struct MappedSetting {
|
||||
key: String,
|
||||
default: String,
|
||||
default_debug: Option<String>,
|
||||
parent: String,
|
||||
sub_field: String,
|
||||
section: Option<String>,
|
||||
api: bool,
|
||||
}
|
||||
|
||||
struct MappedParent {
|
||||
ident: Ident,
|
||||
ty: Type,
|
||||
settings: Vec<MappedSetting>,
|
||||
}
|
||||
|
||||
// ── Parsing ────────────────────────────────────────────────────────
|
||||
|
||||
fn parse_struct_mapped_settings(input: &mut ItemStruct, default_section: &Option<String>) -> Vec<MappedSetting> {
|
||||
let mut mapped = Vec::new();
|
||||
input.attrs.retain(|attr| {
|
||||
if !attr.path().is_ident("setting") {
|
||||
return true;
|
||||
}
|
||||
let mut key = None;
|
||||
let mut default = None;
|
||||
let mut default_debug = None;
|
||||
let mut path = None;
|
||||
let mut section = None;
|
||||
let mut api = true;
|
||||
|
||||
let _ = attr.parse_nested_meta(|meta| {
|
||||
if meta.path.is_ident("key") {
|
||||
let val: LitStr = meta.value()?.parse()?;
|
||||
key = Some(val.value());
|
||||
} else if meta.path.is_ident("default") {
|
||||
let val: LitStr = meta.value()?.parse()?;
|
||||
default = Some(val.value());
|
||||
} else if meta.path.is_ident("default_debug") {
|
||||
let val: LitStr = meta.value()?.parse()?;
|
||||
default_debug = Some(val.value());
|
||||
} else if meta.path.is_ident("path") {
|
||||
let val: LitStr = meta.value()?.parse()?;
|
||||
path = Some(val.value());
|
||||
} else if meta.path.is_ident("section") {
|
||||
let val: LitStr = meta.value()?.parse()?;
|
||||
section = Some(val.value());
|
||||
} else if meta.path.is_ident("api") {
|
||||
let val: LitBool = meta.value()?.parse()?;
|
||||
api = val.value();
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
|
||||
if let (Some(key), Some(default), Some(path)) = (key, default, path) {
|
||||
let (parent, sub_field) = path
|
||||
.split_once('.')
|
||||
.expect("#[setting] `path` must be `parent.sub_field`");
|
||||
mapped.push(MappedSetting {
|
||||
key,
|
||||
default,
|
||||
default_debug,
|
||||
parent: parent.to_string(),
|
||||
sub_field: sub_field.to_string(),
|
||||
section: section.or_else(|| default_section.clone()),
|
||||
api,
|
||||
});
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
mapped
|
||||
}
|
||||
|
||||
fn parse_field(field: &mut syn::Field, default_section: &Option<String>) -> Option<ConfigField> {
|
||||
let idx = field.attrs.iter().position(|a| a.path().is_ident("setting"))?;
|
||||
let attr = field.attrs.remove(idx);
|
||||
|
||||
let mut is_flatten = false;
|
||||
let mut key = None;
|
||||
let mut default = None;
|
||||
let mut default_debug = None;
|
||||
let mut section = None;
|
||||
let mut api = true;
|
||||
|
||||
attr.parse_nested_meta(|meta| {
|
||||
if meta.path.is_ident("flatten") {
|
||||
is_flatten = true;
|
||||
} else if meta.path.is_ident("key") {
|
||||
let val: LitStr = meta.value()?.parse()?;
|
||||
key = Some(val.value());
|
||||
} else if meta.path.is_ident("default") {
|
||||
let val: LitStr = meta.value()?.parse()?;
|
||||
default = Some(val.value());
|
||||
} else if meta.path.is_ident("default_debug") {
|
||||
let val: LitStr = meta.value()?.parse()?;
|
||||
default_debug = Some(val.value());
|
||||
} else if meta.path.is_ident("section") {
|
||||
let val: LitStr = meta.value()?.parse()?;
|
||||
section = Some(val.value());
|
||||
} else if meta.path.is_ident("api") {
|
||||
let val: LitBool = meta.value()?.parse()?;
|
||||
api = val.value();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap_or_else(|e| panic!("invalid #[setting]: {e}"));
|
||||
|
||||
let ident = field.ident.clone().expect("named field");
|
||||
let ty = field.ty.clone();
|
||||
|
||||
if is_flatten {
|
||||
return Some(ConfigField::Flatten(FlattenField { ident, ty }));
|
||||
}
|
||||
|
||||
Some(ConfigField::Setting(SettingField {
|
||||
ident,
|
||||
ty,
|
||||
key: key.expect("#[setting] requires `key`"),
|
||||
default: default.expect("#[setting] requires `default`"),
|
||||
default_debug,
|
||||
section: section.or_else(|| default_section.clone()),
|
||||
api,
|
||||
}))
|
||||
}
|
||||
|
||||
// ── Type detection ─────────────────────────────────────────────────
|
||||
|
||||
fn is_type(ty: &Type, name: &str) -> bool {
|
||||
matches!(ty, Type::Path(tp) if tp.path.is_ident(name))
|
||||
}
|
||||
|
||||
fn is_vec_string(ty: &Type) -> bool {
|
||||
if let Type::Path(tp) = ty
|
||||
&& let Some(seg) = tp.path.segments.last()
|
||||
{
|
||||
return seg.ident == "Vec";
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
// ── Code generation: defaults() ────────────────────────────────────
|
||||
|
||||
fn make_default_val(ty: &Type, default: &str) -> TokenStream2 {
|
||||
if is_type(ty, "String") {
|
||||
quote! { #default.to_string() }
|
||||
} else if is_type(ty, "bool") {
|
||||
let val = default == "true" || default == "1";
|
||||
quote! { #val }
|
||||
} else if is_vec_string(ty) {
|
||||
if default.is_empty() {
|
||||
quote! { Vec::new() }
|
||||
} else {
|
||||
let items: Vec<&str> = default.split(',').map(|v| v.trim()).collect();
|
||||
quote! { vec![#(#items.to_string()),*] }
|
||||
}
|
||||
} else {
|
||||
// SAFETY: literal default, validated by tests
|
||||
quote! { #default.parse().unwrap() }
|
||||
}
|
||||
}
|
||||
|
||||
fn gen_default(f: &SettingField) -> TokenStream2 {
|
||||
let ident = &f.ident;
|
||||
let ty = &f.ty;
|
||||
|
||||
match &f.default_debug {
|
||||
Some(dbg) => {
|
||||
let release_val = make_default_val(ty, &f.default);
|
||||
let debug_val = make_default_val(ty, dbg);
|
||||
quote! { #ident: if cfg!(debug_assertions) { #debug_val } else { #release_val } }
|
||||
}
|
||||
None => {
|
||||
let val = make_default_val(ty, &f.default);
|
||||
quote! { #ident: #val }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn gen_flatten_default(f: &FlattenField) -> TokenStream2 {
|
||||
let ident = &f.ident;
|
||||
let ty = &f.ty;
|
||||
quote! { #ident: #ty::defaults() }
|
||||
}
|
||||
|
||||
fn gen_mapped_default(mp: &MappedParent) -> TokenStream2 {
|
||||
let ident = &mp.ident;
|
||||
let ty = &mp.ty;
|
||||
let sub_fields: Vec<_> = mp
|
||||
.settings
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let sub = format_ident!("{}", s.sub_field);
|
||||
let val: TokenStream2 = match &s.default_debug {
|
||||
Some(dbg) => {
|
||||
let release = &s.default;
|
||||
// SAFETY: literal default, validated by tests
|
||||
quote! { if cfg!(debug_assertions) { #dbg.parse().unwrap() } else { #release.parse().unwrap() } }
|
||||
}
|
||||
None => {
|
||||
let default = &s.default;
|
||||
// SAFETY: literal default, validated by tests
|
||||
quote! { #default.parse().unwrap() }
|
||||
}
|
||||
};
|
||||
quote! { #sub: #val }
|
||||
})
|
||||
.collect();
|
||||
quote! { #ident: #ty { #(#sub_fields,)* } }
|
||||
}
|
||||
|
||||
// ── Code generation: from_config_repo() ───────────────────────────────
|
||||
|
||||
fn gen_override(f: &SettingField) -> TokenStream2 {
|
||||
let ident = &f.ident;
|
||||
let key = &f.key;
|
||||
let ty = &f.ty;
|
||||
|
||||
if is_type(ty, "String") {
|
||||
quote! {
|
||||
crate::domain::common::config::helpers::override_string_nonempty(
|
||||
&mut cfg.#ident, repo, #key,
|
||||
).await?;
|
||||
}
|
||||
} else if is_type(ty, "bool") {
|
||||
quote! {
|
||||
crate::domain::common::config::helpers::override_bool(
|
||||
&mut cfg.#ident, repo, #key,
|
||||
).await?;
|
||||
}
|
||||
} else if is_vec_string(ty) {
|
||||
quote! {
|
||||
crate::domain::common::config::helpers::override_csv(
|
||||
&mut cfg.#ident, repo, #key,
|
||||
).await?;
|
||||
}
|
||||
} else {
|
||||
quote! {
|
||||
crate::domain::common::config::helpers::override_parsed(
|
||||
&mut cfg.#ident, repo, #key,
|
||||
).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn gen_flatten_override(f: &FlattenField) -> TokenStream2 {
|
||||
let ident = &f.ident;
|
||||
let ty = &f.ty;
|
||||
quote! { cfg.#ident = #ty::from_config_repo(repo).await?; }
|
||||
}
|
||||
|
||||
fn gen_mapped_overrides(mp: &MappedParent) -> TokenStream2 {
|
||||
let parent = &mp.ident;
|
||||
let calls: Vec<_> = mp
|
||||
.settings
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let sub = format_ident!("{}", s.sub_field);
|
||||
let key = &s.key;
|
||||
quote! {
|
||||
crate::domain::common::config::helpers::override_parsed(
|
||||
&mut cfg.#parent.#sub, repo, #key,
|
||||
).await?;
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
quote! { #(#calls)* }
|
||||
}
|
||||
|
||||
// ── Code generation: seed_config_defaults() ───────────────────────────────
|
||||
|
||||
fn gen_seed(f: &SettingField) -> TokenStream2 {
|
||||
let key = &f.key;
|
||||
let default = &f.default;
|
||||
|
||||
match &f.default_debug {
|
||||
Some(dbg) => quote! {
|
||||
crate::domain::common::config::helpers::seed_key(
|
||||
repo, #key,
|
||||
if cfg!(debug_assertions) { #dbg } else { #default },
|
||||
).await?;
|
||||
},
|
||||
None => quote! {
|
||||
crate::domain::common::config::helpers::seed_key(repo, #key, #default).await?;
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn gen_flatten_seed(f: &FlattenField) -> TokenStream2 {
|
||||
let ty = &f.ty;
|
||||
quote! { #ty::seed_config_defaults(repo).await?; }
|
||||
}
|
||||
|
||||
fn gen_mapped_seeds(mp: &MappedParent) -> TokenStream2 {
|
||||
let calls: Vec<_> = mp
|
||||
.settings
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let key = &s.key;
|
||||
let default = &s.default;
|
||||
match &s.default_debug {
|
||||
Some(dbg) => quote! {
|
||||
crate::domain::common::config::helpers::seed_key(
|
||||
repo, #key,
|
||||
if cfg!(debug_assertions) { #dbg } else { #default },
|
||||
).await?;
|
||||
},
|
||||
None => quote! {
|
||||
crate::domain::common::config::helpers::seed_key(repo, #key, #default).await?;
|
||||
},
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
quote! { #(#calls)* }
|
||||
}
|
||||
|
||||
// ── Code generation: API_KEYS ──────────────────────────────────────
|
||||
|
||||
fn collect_api_keys(fields: &[ConfigField]) -> Vec<(&str, &str)> {
|
||||
let mut keys = Vec::new();
|
||||
for f in fields {
|
||||
match f {
|
||||
ConfigField::Setting(s) if s.api => {
|
||||
let sec = s.section.as_deref().unwrap_or("default");
|
||||
keys.push((sec, s.key.as_str()));
|
||||
}
|
||||
ConfigField::MappedParent(mp) => {
|
||||
for s in &mp.settings {
|
||||
if s.api {
|
||||
let sec = s.section.as_deref().unwrap_or("default");
|
||||
keys.push((sec, s.key.as_str()));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
keys
|
||||
}
|
||||
|
||||
fn gen_keys_consts(fields: &[ConfigField]) -> TokenStream2 {
|
||||
let api_keys = collect_api_keys(fields);
|
||||
if api_keys.is_empty() {
|
||||
return quote! {};
|
||||
}
|
||||
|
||||
let mut sections: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
|
||||
for (sec, key) in &api_keys {
|
||||
sections.entry(sec).or_default().push(key);
|
||||
}
|
||||
|
||||
let single = sections.len() == 1;
|
||||
sections
|
||||
.iter()
|
||||
.map(|(section, keys)| {
|
||||
let name = if single {
|
||||
format_ident!("API_KEYS")
|
||||
} else {
|
||||
format_ident!("{}_KEYS", section.to_uppercase())
|
||||
};
|
||||
quote! { pub const #name: &[&str] = &[#(#keys),*]; }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ── Code generation: api_values() ─────────────────────────────────
|
||||
|
||||
fn value_to_string_expr(ty: &Type, expr: TokenStream2) -> TokenStream2 {
|
||||
if is_type(ty, "String") {
|
||||
quote! { #expr.clone() }
|
||||
} else if is_vec_string(ty) {
|
||||
quote! { #expr.join(",") }
|
||||
} else {
|
||||
quote! { #expr.to_string() }
|
||||
}
|
||||
}
|
||||
|
||||
fn gen_api_values(fields: &[ConfigField]) -> TokenStream2 {
|
||||
let entries: Vec<_> = fields
|
||||
.iter()
|
||||
.flat_map(|f| match f {
|
||||
ConfigField::Setting(s) if s.api => {
|
||||
let key = &s.key;
|
||||
let ident = &s.ident;
|
||||
let value = value_to_string_expr(&s.ty, quote! { self.#ident });
|
||||
vec![quote! { values.push((#key, #value)); }]
|
||||
}
|
||||
ConfigField::Flatten(f) => {
|
||||
let ident = &f.ident;
|
||||
vec![quote! { values.extend(self.#ident.api_values()); }]
|
||||
}
|
||||
ConfigField::MappedParent(mp) => mp
|
||||
.settings
|
||||
.iter()
|
||||
.filter(|s| s.api)
|
||||
.map(|s| {
|
||||
let key = &s.key;
|
||||
let parent = &mp.ident;
|
||||
let sub = format_ident!("{}", s.sub_field);
|
||||
quote! { values.push((#key, self.#parent.#sub.to_string())); }
|
||||
})
|
||||
.collect(),
|
||||
_ => Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
quote! {
|
||||
pub fn api_values(&self) -> Vec<(&'static str, String)> {
|
||||
let mut values = Vec::new();
|
||||
#(#entries)*
|
||||
values
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Entry point ────────────────────────────────────────────────────
|
||||
|
||||
pub fn config_settings_impl(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||
let struct_attr = syn::parse_macro_input!(attr as StructAttr);
|
||||
let mut input = syn::parse_macro_input!(item as ItemStruct);
|
||||
|
||||
let mapped_settings = parse_struct_mapped_settings(&mut input, &struct_attr.default_section);
|
||||
|
||||
let mut mapped_groups: BTreeMap<String, Vec<MappedSetting>> = BTreeMap::new();
|
||||
for ms in mapped_settings {
|
||||
mapped_groups.entry(ms.parent.clone()).or_default().push(ms);
|
||||
}
|
||||
|
||||
let fields = match &mut input.fields {
|
||||
Fields::Named(f) => f,
|
||||
_ => panic!("config_settings only supports named fields"),
|
||||
};
|
||||
|
||||
let mut config_fields = Vec::new();
|
||||
for field in &mut fields.named {
|
||||
let field_name = field.ident.as_ref().expect("named field").to_string();
|
||||
|
||||
if let Some(settings) = mapped_groups.remove(&field_name) {
|
||||
config_fields.push(ConfigField::MappedParent(MappedParent {
|
||||
ident: field.ident.clone().unwrap(),
|
||||
ty: field.ty.clone(),
|
||||
settings,
|
||||
}));
|
||||
} else if let Some(cf) = parse_field(field, &struct_attr.default_section) {
|
||||
config_fields.push(cf);
|
||||
}
|
||||
}
|
||||
|
||||
let struct_name = &input.ident;
|
||||
let keys_consts = gen_keys_consts(&config_fields);
|
||||
let api_values = gen_api_values(&config_fields);
|
||||
|
||||
let default_fields: Vec<_> = config_fields
|
||||
.iter()
|
||||
.map(|f| match f {
|
||||
ConfigField::Setting(s) => gen_default(s),
|
||||
ConfigField::Flatten(s) => gen_flatten_default(s),
|
||||
ConfigField::MappedParent(mp) => gen_mapped_default(mp),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let override_calls: Vec<_> = config_fields
|
||||
.iter()
|
||||
.map(|f| match f {
|
||||
ConfigField::Setting(s) => gen_override(s),
|
||||
ConfigField::Flatten(s) => gen_flatten_override(s),
|
||||
ConfigField::MappedParent(mp) => gen_mapped_overrides(mp),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let seed_calls: Vec<_> = config_fields
|
||||
.iter()
|
||||
.map(|f| match f {
|
||||
ConfigField::Setting(s) => gen_seed(s),
|
||||
ConfigField::Flatten(s) => gen_flatten_seed(s),
|
||||
ConfigField::MappedParent(mp) => gen_mapped_seeds(mp),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let expanded = quote! {
|
||||
#input
|
||||
|
||||
impl #struct_name {
|
||||
#keys_consts
|
||||
#api_values
|
||||
|
||||
pub fn defaults() -> Self {
|
||||
Self {
|
||||
#(#default_fields,)*
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn from_config_repo(
|
||||
repo: &dyn crate::interface::config_repo::ConfigRepo,
|
||||
) -> Result<Self, crate::domain::common::error::Error> {
|
||||
let mut cfg = Self::defaults();
|
||||
#(#override_calls)*
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
pub async fn seed_config_defaults(
|
||||
repo: &dyn crate::interface::config_repo::ConfigRepo,
|
||||
) -> Result<(), crate::domain::common::error::Error> {
|
||||
#(#seed_calls)*
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
TokenStream::from(expanded)
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
mod config;
|
||||
mod error_enum;
|
||||
mod log;
|
||||
mod loggable;
|
||||
@ -5,6 +6,11 @@ mod traceable;
|
||||
|
||||
use proc_macro::TokenStream;
|
||||
|
||||
#[proc_macro_attribute]
|
||||
pub fn config_settings(attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||
config::config_settings_impl(attr, item)
|
||||
}
|
||||
|
||||
#[proc_macro]
|
||||
pub fn log(input: TokenStream) -> TokenStream {
|
||||
log::log_impl(input)
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
use proc_macro::TokenStream;
|
||||
use quote::quote;
|
||||
use syn::parse::{Parse, ParseStream};
|
||||
use syn::{parse_macro_input, Expr, Token};
|
||||
use syn::{Expr, Token, parse_macro_input};
|
||||
|
||||
struct LogInput {
|
||||
error: Expr,
|
||||
@ -62,5 +62,5 @@ pub fn log_impl(input: TokenStream) -> TokenStream {
|
||||
}
|
||||
}
|
||||
}
|
||||
.into()
|
||||
.into()
|
||||
}
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
[package]
|
||||
name = "mcp-server"
|
||||
version = "0.1.0"
|
||||
version = "1.0.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
reqwest = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
|
||||
@ -57,7 +57,11 @@ impl McpServer {
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.expect("Failed to create HTTP client");
|
||||
Self { client, api_url, api_key }
|
||||
Self {
|
||||
client,
|
||||
api_url,
|
||||
api_key,
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_request(&self, req: JsonRpcRequest) -> JsonRpcResponse {
|
||||
@ -69,7 +73,10 @@ impl McpServer {
|
||||
jsonrpc: "2.0".into(),
|
||||
id: req.id,
|
||||
result: None,
|
||||
error: Some(JsonRpcError { code: -32601, message: "Method not found".into() }),
|
||||
error: Some(JsonRpcError {
|
||||
code: -32601,
|
||||
message: "Method not found".into(),
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -120,7 +127,10 @@ impl McpServer {
|
||||
|
||||
async fn handle_tool_call(&self, id: Option<Value>, params: Value) -> JsonRpcResponse {
|
||||
let tool_name = params.get("name").and_then(|n| n.as_str()).unwrap_or("");
|
||||
let arguments = params.get("arguments").cloned().unwrap_or(Value::Object(Default::default()));
|
||||
let arguments = params
|
||||
.get("arguments")
|
||||
.cloned()
|
||||
.unwrap_or(Value::Object(Default::default()));
|
||||
|
||||
let (method, path, body): (&str, String, Option<Value>) = match tool_name {
|
||||
"get_health" => ("GET", "/api/health/status".into(), None),
|
||||
@ -136,34 +146,65 @@ impl McpServer {
|
||||
let ip = arguments.get("ip").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let is_v6 = ip.contains(':');
|
||||
let ip_ver = if is_v6 { "ipv6" } else { "ipv4" };
|
||||
let addr = if is_v6 { format!("[{}]:0", ip) } else { format!("{}:0", ip) };
|
||||
("PUT", format!("/api/acl/{}/source/blacklist", ip_ver), Some(Value::String(addr)))
|
||||
let addr = if is_v6 {
|
||||
format!("[{}]:0", ip)
|
||||
} else {
|
||||
format!("{}:0", ip)
|
||||
};
|
||||
(
|
||||
"PUT",
|
||||
format!("/api/acl/{}/source/blacklist", ip_ver),
|
||||
Some(Value::String(addr)),
|
||||
)
|
||||
}
|
||||
"unblock_ip" => {
|
||||
let ip = arguments.get("ip").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let is_v6 = ip.contains(':');
|
||||
let ip_ver = if is_v6 { "ipv6" } else { "ipv4" };
|
||||
let addr = if is_v6 { format!("[{}]:0", ip) } else { format!("{}:0", ip) };
|
||||
("DELETE", format!("/api/acl/{}/source/blacklist", ip_ver), Some(Value::String(addr)))
|
||||
let addr = if is_v6 {
|
||||
format!("[{}]:0", ip)
|
||||
} else {
|
||||
format!("{}:0", ip)
|
||||
};
|
||||
(
|
||||
"DELETE",
|
||||
format!("/api/acl/{}/source/blacklist", ip_ver),
|
||||
Some(Value::String(addr)),
|
||||
)
|
||||
}
|
||||
"set_enforce_mode" => {
|
||||
let mode = arguments.get("mode").and_then(|v| v.as_str()).unwrap_or("monitor");
|
||||
("PUT", "/api/system/enforce-mode".into(), Some(serde_json::json!({"mode": mode})))
|
||||
(
|
||||
"PUT",
|
||||
"/api/system/enforce-mode".into(),
|
||||
Some(serde_json::json!({"mode": mode})),
|
||||
)
|
||||
}
|
||||
"add_dns_filter" => {
|
||||
let domain = arguments.get("domain").and_then(|v| v.as_str()).unwrap_or("");
|
||||
("PUT", "/api/filter/dns/blacklist".into(), Some(serde_json::json!({"domains": [domain]})))
|
||||
(
|
||||
"PUT",
|
||||
"/api/filter/dns/blacklist".into(),
|
||||
Some(serde_json::json!({"domains": [domain]})),
|
||||
)
|
||||
}
|
||||
"add_geo_block" => {
|
||||
let code = arguments.get("country_code").and_then(|v| v.as_str()).unwrap_or("");
|
||||
("PUT", "/api/acl/geo/block".into(), Some(serde_json::json!({"country_codes": [code]})))
|
||||
(
|
||||
"PUT",
|
||||
"/api/acl/geo/block".into(),
|
||||
Some(serde_json::json!({"country_codes": [code]})),
|
||||
)
|
||||
}
|
||||
_ => {
|
||||
return JsonRpcResponse {
|
||||
jsonrpc: "2.0".into(),
|
||||
id,
|
||||
result: None,
|
||||
error: Some(JsonRpcError { code: -32602, message: format!("Unknown tool: {}", tool_name) }),
|
||||
error: Some(JsonRpcError {
|
||||
code: -32602,
|
||||
message: format!("Unknown tool: {}", tool_name),
|
||||
}),
|
||||
};
|
||||
}
|
||||
};
|
||||
@ -208,17 +249,15 @@ impl McpServer {
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
JsonRpcResponse {
|
||||
jsonrpc: "2.0".into(),
|
||||
id,
|
||||
result: Some(serde_json::json!({
|
||||
"content": [{ "type": "text", "text": format!("Connection error: {}", e) }],
|
||||
"isError": true
|
||||
})),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
Err(e) => JsonRpcResponse {
|
||||
jsonrpc: "2.0".into(),
|
||||
id,
|
||||
result: Some(serde_json::json!({
|
||||
"content": [{ "type": "text", "text": format!("Connection error: {}", e) }],
|
||||
"isError": true
|
||||
})),
|
||||
error: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -227,7 +266,8 @@ impl McpServer {
|
||||
async fn main() {
|
||||
let args = Args::parse();
|
||||
|
||||
let api_key = args.api_key
|
||||
let api_key = args
|
||||
.api_key
|
||||
.or_else(|| std::env::var("NETGUARDIA_API_KEY").ok())
|
||||
.unwrap_or_else(|| {
|
||||
eprintln!("Error: No API key provided. Set NETGUARDIA_API_KEY env var or use --api-key flag.");
|
||||
@ -256,7 +296,10 @@ async fn main() {
|
||||
jsonrpc: "2.0".into(),
|
||||
id: None,
|
||||
result: None,
|
||||
error: Some(JsonRpcError { code: -32700, message: format!("Parse error: {}", e) }),
|
||||
error: Some(JsonRpcError {
|
||||
code: -32700,
|
||||
message: format!("Parse error: {}", e),
|
||||
}),
|
||||
};
|
||||
let _ = writeln!(stdout, "{}", serde_json::to_string(&err_resp).unwrap());
|
||||
let _ = stdout.flush();
|
||||
|
||||
@ -1 +1 @@
|
||||
Subproject commit 00d347c5eae3ed32f595b0a3553601f16bedfa7e
|
||||
Subproject commit 4fd1b9027860ce86b29cd54eb3f5eea523a7e692
|
||||
@ -1,11 +1,11 @@
|
||||
[package]
|
||||
name = "net-guardia"
|
||||
version = "0.1.0"
|
||||
version = "1.0.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
common = { path = "../common", features = ["user"] }
|
||||
macros = { path = "../macros" }
|
||||
common = { workspace = true, features = ["user"] }
|
||||
macros = { workspace = true }
|
||||
|
||||
# eBPF userspace
|
||||
aya = { workspace = true }
|
||||
@ -20,19 +20,19 @@ actix = { workspace = true }
|
||||
actix-web = { workspace = true }
|
||||
actix-cors = { workspace = true }
|
||||
actix-ws = { workspace = true }
|
||||
actix-multipart = "0.7"
|
||||
actix-files = "0.6"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
rust-embed = "8.11.0"
|
||||
mime_guess = "2.0.5"
|
||||
url = "2.5.8"
|
||||
tokio-tungstenite = "0.28.0"
|
||||
actix-multipart = { workspace = true }
|
||||
actix-files = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
rust-embed = { workspace = true }
|
||||
mime_guess = { workspace = true }
|
||||
url = { workspace = true }
|
||||
tokio-tungstenite = { workspace = true }
|
||||
|
||||
# Serialization
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
serde_yaml_ng = { workspace = true }
|
||||
toml = "1.0.7"
|
||||
toml = { workspace = true }
|
||||
|
||||
# Async
|
||||
tokio = { workspace = true }
|
||||
@ -48,18 +48,21 @@ tracing-subscriber = { workspace = true }
|
||||
tract-onnx = { workspace = true }
|
||||
|
||||
# Email
|
||||
lettre = { version = "0.11", default-features = false, features = ["builder", "hostname", "smtp-transport", "tokio1-rustls-tls"] }
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
|
||||
lettre = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
# HTTP client (Telegram, MCP proxy)
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
reqwest = { workspace = true }
|
||||
|
||||
# CLI
|
||||
clap = { workspace = true, features = ["env"] }
|
||||
|
||||
# Architecture
|
||||
async-trait = "0.1"
|
||||
dashmap = "6"
|
||||
arc-swap = "1"
|
||||
moka = { version = "0.12", features = ["sync"] }
|
||||
notify = "7"
|
||||
async-trait = { workspace = true }
|
||||
dashmap = { workspace = true }
|
||||
arc-swap = { workspace = true }
|
||||
moka = { workspace = true }
|
||||
notify = { workspace = true }
|
||||
|
||||
# Utilities
|
||||
parking_lot = { workspace = true }
|
||||
@ -68,17 +71,16 @@ sysinfo = { workspace = true }
|
||||
maxminddb = { workspace = true }
|
||||
ipnetwork = { workspace = true }
|
||||
lru = { workspace = true }
|
||||
rusqlite = { version = "0.34", features = ["bundled-sqlcipher"] }
|
||||
r2d2 = "0.8"
|
||||
r2d2_sqlite = "0.27"
|
||||
rusqlite = { workspace = true }
|
||||
async-sqlite = { workspace = true }
|
||||
jsonwebtoken = { workspace = true }
|
||||
argon2 = { workspace = true }
|
||||
sha2 = "0.10"
|
||||
hmac = "0.12"
|
||||
aes-gcm = "0.10"
|
||||
hkdf = "0.12"
|
||||
sha2 = { workspace = true }
|
||||
hmac = { workspace = true }
|
||||
aes-gcm = { workspace = true }
|
||||
hkdf = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
sd-notify = "0.4"
|
||||
sd-notify = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
|
||||
[features]
|
||||
@ -87,7 +89,7 @@ default = []
|
||||
[build-dependencies]
|
||||
cargo_metadata = { workspace = true }
|
||||
which = { workspace = true }
|
||||
dotenvy = "0.15.7"
|
||||
dotenvy = { workspace = true }
|
||||
|
||||
[[bin]]
|
||||
name = "net-guardia"
|
||||
|
||||
@ -2,24 +2,24 @@ use std::net::{IpAddr, SocketAddrV4, SocketAddrV6};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::adapter::ebpf::access_control::AccessControl;
|
||||
use crate::interface::port::access_control::AccessControlPort;
|
||||
use crate::model::access_control::list_type::ListType;
|
||||
use crate::model::error::Error;
|
||||
use crate::model::error::ebpf::EbpfError;
|
||||
use crate::model::monitoring::direction::FlowDirection;
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::domain::data_plane::direction::FlowDirection;
|
||||
use crate::domain::data_plane::error::EbpfError;
|
||||
use crate::domain::data_plane::list_type::ListType;
|
||||
use crate::interface::access_control::AccessControlPort;
|
||||
|
||||
/// Adapter that implements AccessControlPort by delegating to the eBPF AccessControl.
|
||||
pub struct EbpfAccessControlAdapter {
|
||||
pub struct AccessControlAdapter {
|
||||
access_control: Arc<AccessControl>,
|
||||
}
|
||||
|
||||
impl EbpfAccessControlAdapter {
|
||||
impl AccessControlAdapter {
|
||||
pub fn new(access_control: Arc<AccessControl>) -> Self {
|
||||
Self { access_control }
|
||||
}
|
||||
}
|
||||
|
||||
impl AccessControlPort for EbpfAccessControlAdapter {
|
||||
impl AccessControlPort for AccessControlAdapter {
|
||||
fn block_ip(&self, ip: &str) -> Result<(), Error> {
|
||||
let addr: IpAddr = ip
|
||||
.parse()
|
||||
@ -7,12 +7,12 @@ use common::model::ip_address::{IPv4, IPv6, Port};
|
||||
use common::model::port_rule::PortRule;
|
||||
use parking_lot::RwLock;
|
||||
|
||||
use crate::interface::port::access_control_admin::AccessControlAdminPort;
|
||||
use crate::model::access_control::ip_address::NativeConvert;
|
||||
use crate::model::access_control::list_type::ListType;
|
||||
use crate::model::error::Error;
|
||||
use crate::model::error::ebpf::EbpfError;
|
||||
use crate::model::monitoring::direction::FlowDirection;
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::domain::data_plane::direction::FlowDirection;
|
||||
use crate::domain::data_plane::error::EbpfError;
|
||||
use crate::domain::data_plane::ip_address::NativeConvert;
|
||||
use crate::domain::data_plane::list_type::ListType;
|
||||
use crate::interface::access_control_admin::AccessControlAdminPort;
|
||||
|
||||
pub struct AccessControl {
|
||||
ipv4_src_whitelist: RwLock<MapWrapper<IPv4>>,
|
||||
@ -40,9 +40,6 @@ impl AccessControl {
|
||||
Ok(access_control)
|
||||
}
|
||||
|
||||
/// Construct an AccessControl backed by no eBPF maps. Used when eBPF
|
||||
/// failed to load at startup; every mutating call returns `EbpfError::NotLoaded`,
|
||||
/// and list queries return empty maps.
|
||||
pub fn unavailable() -> Self {
|
||||
Self {
|
||||
ipv4_src_whitelist: RwLock::new(MapWrapper::unavailable()),
|
||||
@ -146,12 +143,22 @@ impl AccessControl {
|
||||
}
|
||||
|
||||
impl AccessControlAdminPort for AccessControl {
|
||||
fn get_ipv4_list(&self, direction: FlowDirection, list_type: ListType) -> HashMap<Ipv4Addr, Vec<Port>> {
|
||||
self.get_ipv4_list(direction, list_type)
|
||||
}
|
||||
|
||||
fn get_ipv6_list(&self, direction: FlowDirection, list_type: ListType) -> HashMap<Ipv6Addr, Vec<Port>> {
|
||||
self.get_ipv6_list(direction, list_type)
|
||||
}
|
||||
|
||||
fn add_ipv4_list(&self, direction: FlowDirection, list_type: ListType, address: SocketAddrV4) -> Result<(), Error> {
|
||||
self.add_ipv4_list(direction, list_type, address)
|
||||
}
|
||||
|
||||
fn add_ipv6_list(&self, direction: FlowDirection, list_type: ListType, address: SocketAddrV6) -> Result<(), Error> {
|
||||
self.add_ipv6_list(direction, list_type, address)
|
||||
}
|
||||
|
||||
fn remove_ipv4_list(
|
||||
&self,
|
||||
direction: FlowDirection,
|
||||
@ -160,6 +167,7 @@ impl AccessControlAdminPort for AccessControl {
|
||||
) -> Result<(), Error> {
|
||||
self.remove_ipv4_list(direction, list_type, address)
|
||||
}
|
||||
|
||||
fn remove_ipv6_list(
|
||||
&self,
|
||||
direction: FlowDirection,
|
||||
@ -168,12 +176,6 @@ impl AccessControlAdminPort for AccessControl {
|
||||
) -> Result<(), Error> {
|
||||
self.remove_ipv6_list(direction, list_type, address)
|
||||
}
|
||||
fn get_ipv4_list(&self, direction: FlowDirection, list_type: ListType) -> HashMap<Ipv4Addr, Vec<Port>> {
|
||||
self.get_ipv4_list(direction, list_type)
|
||||
}
|
||||
fn get_ipv6_list(&self, direction: FlowDirection, list_type: ListType) -> HashMap<Ipv6Addr, Vec<Port>> {
|
||||
self.get_ipv6_list(direction, list_type)
|
||||
}
|
||||
}
|
||||
|
||||
struct MapWrapper<T> {
|
||||
|
||||
@ -1,18 +1,45 @@
|
||||
use std::mem;
|
||||
use std::net::Ipv6Addr;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use aya::maps::{MapData, RingBuf};
|
||||
use common::define::drop_reason::*;
|
||||
use common::model::drop_event::DropEvent as RawDropEvent;
|
||||
use tokio::sync::{broadcast, oneshot};
|
||||
use tokio::time::interval;
|
||||
|
||||
use common::define::drop_reason::*;
|
||||
use common::model::drop_event::DropEvent as RawDropEvent;
|
||||
use crate::domain::data_plane::drop_event::{DropCounters, DropEventMessage};
|
||||
use crate::interface::drop_stats::DropStatsPort;
|
||||
|
||||
use crate::model::config::constants::DROP_CHANNEL_CAPACITY;
|
||||
use crate::model::monitoring::drop_event::{DropCounters, DropCountersAtomic, DropEventMessage};
|
||||
#[derive(Default)]
|
||||
pub struct DropCountersAtomic {
|
||||
acl_blacklist: AtomicU64,
|
||||
rate_limit_pkt: AtomicU64,
|
||||
rate_limit_syn: AtomicU64,
|
||||
rate_limit_udp: AtomicU64,
|
||||
rate_limit_dns: AtomicU64,
|
||||
protocol_filter: AtomicU64,
|
||||
dns_blacklist: AtomicU64,
|
||||
geo_block: AtomicU64,
|
||||
total: AtomicU64,
|
||||
}
|
||||
|
||||
impl DropCountersAtomic {
|
||||
pub fn snapshot(&self) -> DropCounters {
|
||||
DropCounters {
|
||||
acl_blacklist: self.acl_blacklist.load(Ordering::Relaxed),
|
||||
rate_limit_pkt: self.rate_limit_pkt.load(Ordering::Relaxed),
|
||||
rate_limit_syn: self.rate_limit_syn.load(Ordering::Relaxed),
|
||||
rate_limit_udp: self.rate_limit_udp.load(Ordering::Relaxed),
|
||||
rate_limit_dns: self.rate_limit_dns.load(Ordering::Relaxed),
|
||||
protocol_filter: self.protocol_filter.load(Ordering::Relaxed),
|
||||
dns_blacklist: self.dns_blacklist.load(Ordering::Relaxed),
|
||||
geo_block: self.geo_block.load(Ordering::Relaxed),
|
||||
total: self.total.load(Ordering::Relaxed),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DropMonitor {
|
||||
broadcast_tx: broadcast::Sender<DropEventMessage>,
|
||||
@ -20,8 +47,8 @@ pub struct DropMonitor {
|
||||
}
|
||||
|
||||
impl DropMonitor {
|
||||
pub fn new() -> Self {
|
||||
let (tx, _) = broadcast::channel(DROP_CHANNEL_CAPACITY);
|
||||
pub fn new(channel_capacity: usize) -> Self {
|
||||
let (tx, _) = broadcast::channel(channel_capacity.max(1));
|
||||
Self {
|
||||
broadcast_tx: tx,
|
||||
counters: DropCountersAtomic::default(),
|
||||
@ -32,18 +59,8 @@ impl DropMonitor {
|
||||
self.broadcast_tx.subscribe()
|
||||
}
|
||||
|
||||
pub fn get_counters(&self) -> DropCounters {
|
||||
self.counters.snapshot()
|
||||
}
|
||||
|
||||
/// Record a userspace drop decision (XSK worker's DNS filter) by the
|
||||
/// per-reason counter. Callers at this layer haven't parsed src/dst yet,
|
||||
/// so no broadcast event is emitted — `/api/stats/drops` stays correct,
|
||||
/// `/ws/drops` simply does not surface the individual packet. Parse the
|
||||
/// packet upstream if you need a structured event.
|
||||
pub fn record_userspace_drop_count_only(&self, reason: u8) {
|
||||
self.counters.total.fetch_add(1, Ordering::Relaxed);
|
||||
let bucket = match reason {
|
||||
fn bucket_for(&self, reason: u8) -> Option<&AtomicU64> {
|
||||
match reason {
|
||||
DROP_REASON_ACL_BLACKLIST => Some(&self.counters.acl_blacklist),
|
||||
DROP_REASON_RATE_LIMIT_PKT => Some(&self.counters.rate_limit_pkt),
|
||||
DROP_REASON_RATE_LIMIT_SYN => Some(&self.counters.rate_limit_syn),
|
||||
@ -53,32 +70,25 @@ impl DropMonitor {
|
||||
DROP_REASON_DNS_BLACKLIST => Some(&self.counters.dns_blacklist),
|
||||
DROP_REASON_GEO_BLOCK => Some(&self.counters.geo_block),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(counter) = bucket {
|
||||
}
|
||||
}
|
||||
|
||||
fn record_drop(&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);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_userspace_drop_count_only(&self, reason: u8) {
|
||||
self.record_drop(reason);
|
||||
}
|
||||
|
||||
fn process_event(&self, raw: &RawDropEvent) {
|
||||
self.counters.total.fetch_add(1, Ordering::Relaxed);
|
||||
let bucket = match raw.reason {
|
||||
DROP_REASON_ACL_BLACKLIST => Some(&self.counters.acl_blacklist),
|
||||
DROP_REASON_RATE_LIMIT_PKT => Some(&self.counters.rate_limit_pkt),
|
||||
DROP_REASON_RATE_LIMIT_SYN => Some(&self.counters.rate_limit_syn),
|
||||
DROP_REASON_RATE_LIMIT_UDP => Some(&self.counters.rate_limit_udp),
|
||||
DROP_REASON_RATE_LIMIT_DNS => Some(&self.counters.rate_limit_dns),
|
||||
DROP_REASON_PROTOCOL_FILTER => Some(&self.counters.protocol_filter),
|
||||
DROP_REASON_DNS_BLACKLIST => Some(&self.counters.dns_blacklist),
|
||||
DROP_REASON_GEO_BLOCK => Some(&self.counters.geo_block),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(counter) = bucket {
|
||||
counter.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
self.record_drop(raw.reason);
|
||||
|
||||
let reason_str = reason_to_str(raw.reason);
|
||||
|
||||
// Format IPs based on version
|
||||
let (src_ip, dst_ip) = format_ips(raw);
|
||||
|
||||
let msg = DropEventMessage {
|
||||
@ -96,9 +106,9 @@ impl DropMonitor {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for DropMonitor {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
impl DropStatsPort for DropMonitor {
|
||||
fn get_counters(&self) -> DropCounters {
|
||||
self.counters.snapshot()
|
||||
}
|
||||
}
|
||||
|
||||
@ -116,7 +126,6 @@ fn format_ips(raw: &RawDropEvent) -> (String, String) {
|
||||
(src, dst)
|
||||
}
|
||||
_ => {
|
||||
// IPv6 - format as hex
|
||||
let src = format_ipv6(&raw.src_ip);
|
||||
let dst = format_ipv6(&raw.dst_ip);
|
||||
(src, dst)
|
||||
@ -142,12 +151,12 @@ fn reason_to_str(reason: u8) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the ring buffer consumer as a tokio task. Returns a shutdown sender.
|
||||
pub async fn start_consumer(ring_buf: RingBuf<MapData>, monitor: Arc<DropMonitor>) -> oneshot::Sender<()> {
|
||||
let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut ring_buf = ring_buf;
|
||||
// todo add interval value to config
|
||||
let mut interval = interval(Duration::from_millis(100));
|
||||
|
||||
loop {
|
||||
@ -157,7 +166,7 @@ pub async fn start_consumer(ring_buf: RingBuf<MapData>, monitor: Arc<DropMonitor
|
||||
}
|
||||
|
||||
while let Some(item) = ring_buf.next() {
|
||||
if item.len() >= mem::size_of::<RawDropEvent>() {
|
||||
if item.len() >= size_of::<RawDropEvent>() {
|
||||
let event = unsafe { &*(item.as_ptr() as *const RawDropEvent) };
|
||||
monitor.process_event(event);
|
||||
}
|
||||
|
||||
@ -9,15 +9,14 @@ use ipnetwork::IpNetwork;
|
||||
use maxminddb::{Reader, geoip2};
|
||||
use parking_lot::RwLock;
|
||||
|
||||
use crate::infrastructure::app_config::AppConfig;
|
||||
use crate::interface::port::geo_block_api::GeoBlockPort;
|
||||
use crate::model::error::Error;
|
||||
use crate::model::error::ebpf::EbpfError;
|
||||
use crate::model::error::misc::MiscError;
|
||||
use crate::domain::common::config::AppConfig;
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::domain::common::error::misc::MiscError;
|
||||
use crate::domain::data_plane::error::EbpfError;
|
||||
use crate::interface::geo_block_api::GeoBlockPort;
|
||||
|
||||
/// Pre-indexed GeoIP prefix table, built once at startup.
|
||||
struct GeoIndex {
|
||||
v4: StdHashMap<String, Vec<(u32, u32)>>, // country -> [(ip_be, prefix_len)]
|
||||
v4: StdHashMap<String, Vec<(u32, u32)>>,
|
||||
v6: StdHashMap<String, Vec<(u128, u32)>>,
|
||||
}
|
||||
|
||||
@ -29,15 +28,16 @@ pub struct GeoBlock {
|
||||
}
|
||||
|
||||
impl GeoBlock {
|
||||
pub fn new(ebpf: &mut Ebpf, app_config: &AppConfig) -> Result<Self, Error> {
|
||||
pub fn new(ebpf: &mut Ebpf, app_config: Arc<ArcSwap<AppConfig>>) -> Result<Self, Error> {
|
||||
let v4_map = ebpf.take_map("GEO_BLOCK_V4").ok_or(EbpfError::MapNotFound)?;
|
||||
let v4_trie = LpmTrie::try_from(v4_map).map_err(EbpfError::MapOperationError)?;
|
||||
|
||||
let v6_map = ebpf.take_map("GEO_BLOCK_V6").ok_or(EbpfError::MapNotFound)?;
|
||||
let v6_trie = LpmTrie::try_from(v6_map).map_err(EbpfError::MapOperationError)?;
|
||||
|
||||
let db_path = &app_config.misc.geoip_db_name;
|
||||
let reader = Reader::open_readfile(db_path).map_err(|e| MiscError::GeoIPDatabaseError(db_path.clone(), e))?;
|
||||
// todo read config from AppConfig, not db
|
||||
let db_path = app_config.load().acl.geoip_db_path.clone();
|
||||
let reader = Reader::open_readfile(&db_path).map_err(|e| MiscError::GeoIPDatabaseError(db_path.clone(), e))?;
|
||||
|
||||
let index = Self::build_index(&reader)?;
|
||||
|
||||
@ -49,12 +49,8 @@ impl GeoBlock {
|
||||
})
|
||||
}
|
||||
|
||||
/// Construct a GeoBlock with no eBPF trie backing. Attempts to still load
|
||||
/// the GeoIP index so the frontend can list what *would* be enforced;
|
||||
/// mutating calls (`block_countries`, `unblock_countries`) return
|
||||
/// `EbpfError::NotLoaded`.
|
||||
pub fn unavailable(app_config: &AppConfig) -> Self {
|
||||
let index = Reader::open_readfile(&app_config.misc.geoip_db_name)
|
||||
pub fn unavailable(app_config: Arc<ArcSwap<AppConfig>>) -> Self {
|
||||
let index = Reader::open_readfile(&app_config.load().acl.geoip_db_path)
|
||||
.ok()
|
||||
.and_then(|reader| Self::build_index(&reader).ok())
|
||||
.unwrap_or(GeoIndex {
|
||||
@ -69,7 +65,6 @@ impl GeoBlock {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build index from MaxMind DB at startup. One-time cost.
|
||||
fn build_index(reader: &Reader<Vec<u8>>) -> Result<GeoIndex, Error> {
|
||||
let mut v4: StdHashMap<String, Vec<(u32, u32)>> = StdHashMap::new();
|
||||
let mut v6: StdHashMap<String, Vec<(u128, u32)>> = StdHashMap::new();
|
||||
@ -115,7 +110,10 @@ impl GeoBlock {
|
||||
Ok(GeoIndex { v4, v6 })
|
||||
}
|
||||
|
||||
/// Block multiple countries at once, rebuilding tries only once.
|
||||
pub fn get_blocked_countries(&self) -> Vec<String> {
|
||||
self.blocked_countries.load().iter().cloned().collect()
|
||||
}
|
||||
|
||||
pub fn block_countries(&self, country_codes: &[String]) -> Result<u64, Error> {
|
||||
self.blocked_countries.rcu(|cur| {
|
||||
let mut next: HashSet<String> = (**cur).clone();
|
||||
@ -130,7 +128,6 @@ impl GeoBlock {
|
||||
self.rebuild_tries()
|
||||
}
|
||||
|
||||
/// Unblock multiple countries at once, rebuilding tries only once.
|
||||
pub fn unblock_countries(&self, country_codes: &[String]) -> Result<u64, Error> {
|
||||
self.blocked_countries.rcu(|cur| {
|
||||
let mut next: HashSet<String> = (**cur).clone();
|
||||
@ -142,15 +139,9 @@ impl GeoBlock {
|
||||
self.rebuild_tries()
|
||||
}
|
||||
|
||||
pub fn get_blocked_countries(&self) -> Vec<String> {
|
||||
self.blocked_countries.load().iter().cloned().collect()
|
||||
}
|
||||
|
||||
/// Rebuild LPM tries from pre-indexed data. Fast — no DB scan.
|
||||
fn rebuild_tries(&self) -> Result<u64, Error> {
|
||||
let countries = self.blocked_countries.load_full();
|
||||
|
||||
// Collect entries from index (no DB scan)
|
||||
let mut v4_entries: Vec<(Key<u32>, u8)> = Vec::new();
|
||||
let mut v6_entries: Vec<(Key<u128>, u8)> = Vec::new();
|
||||
|
||||
@ -167,7 +158,6 @@ impl GeoBlock {
|
||||
}
|
||||
}
|
||||
|
||||
// Lock, clear, insert
|
||||
let mut v4_guard = self.geo_block_v4.write();
|
||||
let mut v6_guard = self.geo_block_v6.write();
|
||||
let (v4_trie, v6_trie) = match (v4_guard.as_mut(), v6_guard.as_mut()) {
|
||||
@ -208,13 +198,15 @@ impl GeoBlock {
|
||||
}
|
||||
|
||||
impl GeoBlockPort for GeoBlock {
|
||||
fn block_countries(&self, codes: &[String]) -> Result<u64, Error> {
|
||||
self.block_countries(codes)
|
||||
}
|
||||
fn unblock_countries(&self, codes: &[String]) -> Result<u64, Error> {
|
||||
self.unblock_countries(codes)
|
||||
}
|
||||
fn list_blocked(&self) -> Vec<String> {
|
||||
self.get_blocked_countries()
|
||||
}
|
||||
|
||||
fn block_countries(&self, codes: &[String]) -> Result<u64, Error> {
|
||||
self.block_countries(codes)
|
||||
}
|
||||
|
||||
fn unblock_countries(&self, codes: &[String]) -> Result<u64, Error> {
|
||||
self.unblock_countries(codes)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
pub mod access_control;
|
||||
pub mod dns_filter;
|
||||
pub mod drop_monitor;
|
||||
pub mod geo_block;
|
||||
pub mod protocol_filter;
|
||||
@ -8,6 +7,7 @@ pub mod xsk_manager;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
use aya::Ebpf;
|
||||
use aya::maps::{MapData, RingBuf};
|
||||
use crossbeam::queue::SegQueue;
|
||||
@ -16,24 +16,23 @@ use parking_lot::Mutex;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use crate::adapter::ebpf::access_control::AccessControl;
|
||||
use crate::adapter::ebpf::dns_filter::DnsFilter;
|
||||
use crate::adapter::ebpf::drop_monitor::DropMonitor;
|
||||
use crate::adapter::ebpf::geo_block::GeoBlock;
|
||||
use crate::adapter::ebpf::protocol_filter::ProtocolFilter;
|
||||
use crate::adapter::ebpf::rate_limit::RateLimitConfig;
|
||||
use crate::adapter::ebpf::xsk_manager::XskManager;
|
||||
use crate::infrastructure::app_config::AppConfig;
|
||||
use crate::interface::port::dns_query_filter::DnsQueryFilter;
|
||||
use crate::interface::port::packet_sink::PacketSinkFactory;
|
||||
use crate::model::error::Error;
|
||||
use crate::model::error::ebpf::EbpfError;
|
||||
use crate::model::error::system::SystemError;
|
||||
use crate::domain::common::config::AppConfig;
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::domain::common::error::system::SystemError;
|
||||
use crate::domain::data_plane::error::EbpfError;
|
||||
use crate::interface::dns_query_filter::DnsQueryFilter;
|
||||
use crate::interface::packet_sink::PacketSinkFactory;
|
||||
|
||||
pub struct EbpfServices {
|
||||
pub xsk_manager: Arc<XskManager>,
|
||||
pub access_control: Arc<AccessControl>,
|
||||
pub protocol_filter: Arc<ProtocolFilter>,
|
||||
pub dns_filter: Arc<DnsFilter>,
|
||||
pub dns_query_filter: Arc<dyn DnsQueryFilter>,
|
||||
pub geo_block: Arc<GeoBlock>,
|
||||
pub rate_limit: Arc<RateLimitConfig>,
|
||||
pub drop_monitor: Arc<DropMonitor>,
|
||||
@ -42,14 +41,18 @@ pub struct EbpfServices {
|
||||
}
|
||||
|
||||
impl EbpfServices {
|
||||
pub fn new(app_config: Arc<AppConfig>, ingress_ebpf: &mut Ebpf, egress_ebpf: &mut Ebpf) -> Result<Self, Error> {
|
||||
pub fn new(
|
||||
app_config: Arc<ArcSwap<AppConfig>>,
|
||||
ingress_ebpf: &mut Ebpf,
|
||||
egress_ebpf: &mut Ebpf,
|
||||
dns_query_filter: Arc<dyn DnsQueryFilter>,
|
||||
) -> Result<Self, Error> {
|
||||
let xsk_manager = XskManager::new(app_config.clone(), ingress_ebpf, egress_ebpf)?;
|
||||
let access_control = AccessControl::new(ingress_ebpf)?;
|
||||
let protocol_filter = ProtocolFilter::new(ingress_ebpf)?;
|
||||
let dns_filter = DnsFilter::new();
|
||||
let geo_block = GeoBlock::new(ingress_ebpf, &app_config)?;
|
||||
let geo_block = GeoBlock::new(ingress_ebpf, app_config.clone())?;
|
||||
let rate_limit = RateLimitConfig::new(ingress_ebpf)?;
|
||||
let drop_monitor = Arc::new(DropMonitor::new());
|
||||
let drop_monitor = Arc::new(DropMonitor::new(app_config.load().observability.drop_channel_capacity));
|
||||
let drop_ring_buf = {
|
||||
let map = ingress_ebpf.take_map("DROP_EVENTS").ok_or(EbpfError::MapNotFound)?;
|
||||
RingBuf::try_from(map).map_err(EbpfError::MapOperationError)?
|
||||
@ -58,7 +61,7 @@ impl EbpfServices {
|
||||
xsk_manager: Arc::new(xsk_manager),
|
||||
access_control: Arc::new(access_control),
|
||||
protocol_filter: Arc::new(protocol_filter),
|
||||
dns_filter: Arc::new(dns_filter),
|
||||
dns_query_filter,
|
||||
geo_block: Arc::new(geo_block),
|
||||
rate_limit: Arc::new(rate_limit),
|
||||
drop_monitor,
|
||||
@ -67,18 +70,15 @@ impl EbpfServices {
|
||||
})
|
||||
}
|
||||
|
||||
/// Build an EbpfServices with every eBPF-backed subservice in the
|
||||
/// "unavailable" state. Used when eBPF failed to load at startup.
|
||||
/// Queries return empty results; mutating calls return `EbpfError::NotLoaded`.
|
||||
pub fn unavailable(app_config: Arc<AppConfig>) -> Self {
|
||||
pub fn unavailable(app_config: Arc<ArcSwap<AppConfig>>, dns_query_filter: Arc<dyn DnsQueryFilter>) -> Self {
|
||||
Self {
|
||||
xsk_manager: Arc::new(XskManager::unavailable(app_config.clone())),
|
||||
access_control: Arc::new(AccessControl::unavailable()),
|
||||
protocol_filter: Arc::new(ProtocolFilter::unavailable()),
|
||||
dns_filter: Arc::new(DnsFilter::new()),
|
||||
geo_block: Arc::new(GeoBlock::unavailable(&app_config)),
|
||||
dns_query_filter,
|
||||
geo_block: Arc::new(GeoBlock::unavailable(app_config.clone())),
|
||||
rate_limit: Arc::new(RateLimitConfig::unavailable()),
|
||||
drop_monitor: Arc::new(DropMonitor::new()),
|
||||
drop_monitor: Arc::new(DropMonitor::new(app_config.load().observability.drop_channel_capacity)),
|
||||
drop_ring_buf: Mutex::new(None),
|
||||
shutdowns: SegQueue::new(),
|
||||
}
|
||||
@ -86,7 +86,7 @@ impl EbpfServices {
|
||||
|
||||
pub async fn run(self: Arc<Self>, sink_factory: Arc<dyn PacketSinkFactory>) -> Result<(), Error> {
|
||||
let xsk_manager = self.xsk_manager.clone();
|
||||
let dns: Arc<dyn DnsQueryFilter> = self.dns_filter.clone();
|
||||
let dns = self.dns_query_filter.clone();
|
||||
xsk_manager.run(
|
||||
Some(sink_factory),
|
||||
Some(dns),
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
use std::collections::HashMap;
|
||||
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddrV4, SocketAddrV6};
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV6};
|
||||
|
||||
use aya::maps::{Array as AyaArray, HashMap as AyaHashMap, MapData};
|
||||
use aya::{Ebpf, Pod};
|
||||
@ -8,9 +8,10 @@ use common::model::ip_address::{AddrPortV4, AddrPortV6, IPv4, IPv6};
|
||||
use common::model::placeholder::PlaceHolder;
|
||||
use parking_lot::RwLock;
|
||||
|
||||
use crate::model::access_control::ip_address::NativeConvert;
|
||||
use crate::model::error::Error;
|
||||
use crate::model::error::ebpf::EbpfError;
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::domain::data_plane::error::EbpfError;
|
||||
use crate::domain::data_plane::ip_address::NativeConvert;
|
||||
use crate::interface::protocol_filter::{IpVersion, ProtocolFilterPort};
|
||||
|
||||
pub struct ProtocolFilter {
|
||||
ipv4_http_service: RwLock<HttpServiceWrapper<AddrPortV4>>,
|
||||
@ -40,7 +41,6 @@ impl ProtocolFilter {
|
||||
Ok(service)
|
||||
}
|
||||
|
||||
/// Construct a ProtocolFilter backed by no eBPF maps.
|
||||
pub fn unavailable() -> Self {
|
||||
Self {
|
||||
ipv4_http_service: RwLock::new(HttpServiceWrapper::unavailable()),
|
||||
@ -54,125 +54,196 @@ impl ProtocolFilter {
|
||||
ipv6_ssh_black_list: RwLock::new(EntryMap::unavailable()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_ipv4_http_service(&self) -> HashMap<SocketAddrV4, Vec<HttpMethod>> {
|
||||
self.ipv4_http_service.read().get_http_method()
|
||||
fn require_v4_socket(addr: SocketAddr) -> Result<SocketAddrV4, Error> {
|
||||
match addr {
|
||||
SocketAddr::V4(a) => Ok(a),
|
||||
SocketAddr::V6(_) => Err(EbpfError::IpVersionMismatch("IPv4".to_string()))?,
|
||||
}
|
||||
}
|
||||
|
||||
fn require_v6_socket(addr: SocketAddr) -> Result<SocketAddrV6, Error> {
|
||||
match addr {
|
||||
SocketAddr::V6(a) => Ok(a),
|
||||
SocketAddr::V4(_) => Err(EbpfError::IpVersionMismatch("IPv6".to_string()))?,
|
||||
}
|
||||
}
|
||||
|
||||
fn require_v4_ip(ip: IpAddr) -> Result<Ipv4Addr, Error> {
|
||||
match ip {
|
||||
IpAddr::V4(a) => Ok(a),
|
||||
IpAddr::V6(_) => Err(EbpfError::IpVersionMismatch("IPv4".to_string()))?,
|
||||
}
|
||||
}
|
||||
|
||||
fn require_v6_ip(ip: IpAddr) -> Result<Ipv6Addr, Error> {
|
||||
match ip {
|
||||
IpAddr::V6(a) => Ok(a),
|
||||
IpAddr::V4(_) => Err(EbpfError::IpVersionMismatch("IPv6".to_string()))?,
|
||||
}
|
||||
}
|
||||
|
||||
impl ProtocolFilterPort for ProtocolFilter {
|
||||
fn get_http_service(&self, version: IpVersion) -> HashMap<SocketAddr, Vec<HttpMethod>> {
|
||||
match version {
|
||||
IpVersion::V4 => self
|
||||
.ipv4_http_service
|
||||
.read()
|
||||
.get_http_method()
|
||||
.into_iter()
|
||||
.map(|(k, v)| (SocketAddr::V4(k), v))
|
||||
.collect(),
|
||||
IpVersion::V6 => self
|
||||
.ipv6_http_service
|
||||
.read()
|
||||
.get_http_method()
|
||||
.into_iter()
|
||||
.map(|(k, v)| (SocketAddr::V6(k), v))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_ipv6_http_service(&self) -> HashMap<SocketAddrV6, Vec<HttpMethod>> {
|
||||
self.ipv6_http_service.read().get_http_method()
|
||||
fn add_http_service(&self, version: IpVersion, address: SocketAddr, methods: Vec<HttpMethod>) -> Result<(), Error> {
|
||||
match version {
|
||||
IpVersion::V4 => self
|
||||
.ipv4_http_service
|
||||
.write()
|
||||
.add_http_service(require_v4_socket(address)?, methods),
|
||||
IpVersion::V6 => self
|
||||
.ipv6_http_service
|
||||
.write()
|
||||
.add_http_service(require_v6_socket(address)?, methods),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_ipv4_http_service(&self, address: SocketAddrV4, http_method: Vec<HttpMethod>) -> Result<(), Error> {
|
||||
self.ipv4_http_service.write().add_http_service(address, http_method)
|
||||
}
|
||||
|
||||
pub fn add_ipv6_http_service(&self, address: SocketAddrV6, http_method: Vec<HttpMethod>) -> Result<(), Error> {
|
||||
self.ipv6_http_service.write().add_http_service(address, http_method)
|
||||
}
|
||||
|
||||
pub fn remove_ipv4_http_service(
|
||||
fn remove_http_service(
|
||||
&self,
|
||||
address: SocketAddrV4,
|
||||
removed_http_method: Vec<HttpMethod>,
|
||||
version: IpVersion,
|
||||
address: SocketAddr,
|
||||
methods: Vec<HttpMethod>,
|
||||
) -> Result<(), Error> {
|
||||
self.ipv4_http_service
|
||||
.write()
|
||||
.remove_http_service(address, removed_http_method)
|
||||
match version {
|
||||
IpVersion::V4 => self
|
||||
.ipv4_http_service
|
||||
.write()
|
||||
.remove_http_service(require_v4_socket(address)?, methods),
|
||||
IpVersion::V6 => self
|
||||
.ipv6_http_service
|
||||
.write()
|
||||
.remove_http_service(require_v6_socket(address)?, methods),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_ipv6_http_service(
|
||||
&self,
|
||||
address: SocketAddrV6,
|
||||
removed_http_method: Vec<HttpMethod>,
|
||||
) -> Result<(), Error> {
|
||||
self.ipv6_http_service
|
||||
.write()
|
||||
.remove_http_service(address, removed_http_method)
|
||||
}
|
||||
|
||||
pub fn is_ssh_white_list_enable(&self) -> bool {
|
||||
fn is_ssh_white_list_enable(&self) -> bool {
|
||||
self.ssh_white_list_enable.read().is_white_list_enable()
|
||||
}
|
||||
|
||||
pub fn enable_ssh_white_list(&self) -> Result<(), Error> {
|
||||
fn enable_ssh_white_list(&self) -> Result<(), Error> {
|
||||
self.ssh_white_list_enable.write().enable_white_list()
|
||||
}
|
||||
|
||||
pub fn disable_ssh_white_list(&self) -> Result<(), Error> {
|
||||
fn disable_ssh_white_list(&self) -> Result<(), Error> {
|
||||
self.ssh_white_list_enable.write().disable_white_list()
|
||||
}
|
||||
|
||||
pub fn get_ipv4_ssh_service(&self) -> Vec<SocketAddrV4> {
|
||||
self.ipv4_ssh_service.read().get_all()
|
||||
fn get_ssh_service(&self, version: IpVersion) -> Vec<SocketAddr> {
|
||||
match version {
|
||||
IpVersion::V4 => self
|
||||
.ipv4_ssh_service
|
||||
.read()
|
||||
.get_all()
|
||||
.into_iter()
|
||||
.map(SocketAddr::V4)
|
||||
.collect(),
|
||||
IpVersion::V6 => self
|
||||
.ipv6_ssh_service
|
||||
.read()
|
||||
.get_all()
|
||||
.into_iter()
|
||||
.map(SocketAddr::V6)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_ipv6_ssh_service(&self) -> Vec<SocketAddrV6> {
|
||||
self.ipv6_ssh_service.read().get_all()
|
||||
fn add_ssh_service(&self, version: IpVersion, address: SocketAddr) -> Result<(), Error> {
|
||||
match version {
|
||||
IpVersion::V4 => self.ipv4_ssh_service.write().add(require_v4_socket(address)?),
|
||||
IpVersion::V6 => self.ipv6_ssh_service.write().add(require_v6_socket(address)?),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_ipv4_ssh_service(&self, address: SocketAddrV4) -> Result<(), Error> {
|
||||
self.ipv4_ssh_service.write().add(address)
|
||||
fn remove_ssh_service(&self, version: IpVersion, address: SocketAddr) -> Result<(), Error> {
|
||||
match version {
|
||||
IpVersion::V4 => self.ipv4_ssh_service.write().remove(require_v4_socket(address)?),
|
||||
IpVersion::V6 => self.ipv6_ssh_service.write().remove(require_v6_socket(address)?),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_ipv6_ssh_service(&self, address: SocketAddrV6) -> Result<(), Error> {
|
||||
self.ipv6_ssh_service.write().add(address)
|
||||
fn get_ssh_white_list(&self, version: IpVersion) -> Vec<IpAddr> {
|
||||
match version {
|
||||
IpVersion::V4 => self
|
||||
.ipv4_ssh_white_list
|
||||
.read()
|
||||
.get_all()
|
||||
.into_iter()
|
||||
.map(IpAddr::V4)
|
||||
.collect(),
|
||||
IpVersion::V6 => self
|
||||
.ipv6_ssh_white_list
|
||||
.read()
|
||||
.get_all()
|
||||
.into_iter()
|
||||
.map(IpAddr::V6)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_ipv4_ssh_service(&self, address: SocketAddrV4) -> Result<(), Error> {
|
||||
self.ipv4_ssh_service.write().remove(address)
|
||||
fn add_ssh_white_list(&self, version: IpVersion, ip: IpAddr) -> Result<(), Error> {
|
||||
match version {
|
||||
IpVersion::V4 => self.ipv4_ssh_white_list.write().add(require_v4_ip(ip)?),
|
||||
IpVersion::V6 => self.ipv6_ssh_white_list.write().add(require_v6_ip(ip)?),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_ipv6_ssh_service(&self, address: SocketAddrV6) -> Result<(), Error> {
|
||||
self.ipv6_ssh_service.write().remove(address)
|
||||
fn remove_ssh_white_list(&self, version: IpVersion, ip: IpAddr) -> Result<(), Error> {
|
||||
match version {
|
||||
IpVersion::V4 => self.ipv4_ssh_white_list.write().remove(require_v4_ip(ip)?),
|
||||
IpVersion::V6 => self.ipv6_ssh_white_list.write().remove(require_v6_ip(ip)?),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_ipv4_ssh_white_list(&self) -> Vec<Ipv4Addr> {
|
||||
self.ipv4_ssh_white_list.read().get_all()
|
||||
fn get_ssh_black_list(&self, version: IpVersion) -> Vec<IpAddr> {
|
||||
match version {
|
||||
IpVersion::V4 => self
|
||||
.ipv4_ssh_black_list
|
||||
.read()
|
||||
.get_all()
|
||||
.into_iter()
|
||||
.map(IpAddr::V4)
|
||||
.collect(),
|
||||
IpVersion::V6 => self
|
||||
.ipv6_ssh_black_list
|
||||
.read()
|
||||
.get_all()
|
||||
.into_iter()
|
||||
.map(IpAddr::V6)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_ipv6_ssh_white_list(&self) -> Vec<Ipv6Addr> {
|
||||
self.ipv6_ssh_white_list.read().get_all()
|
||||
fn add_ssh_black_list(&self, version: IpVersion, ip: IpAddr) -> Result<(), Error> {
|
||||
match version {
|
||||
IpVersion::V4 => self.ipv4_ssh_black_list.write().add(require_v4_ip(ip)?),
|
||||
IpVersion::V6 => self.ipv6_ssh_black_list.write().add(require_v6_ip(ip)?),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_ipv4_ssh_white_list(&self, ip: Ipv4Addr) -> Result<(), Error> {
|
||||
self.ipv4_ssh_white_list.write().add(ip)
|
||||
}
|
||||
|
||||
pub fn add_ipv6_ssh_white_list(&self, ip: Ipv6Addr) -> Result<(), Error> {
|
||||
self.ipv6_ssh_white_list.write().add(ip)
|
||||
}
|
||||
|
||||
pub fn remove_ipv4_ssh_white_list(&self, ip: Ipv4Addr) -> Result<(), Error> {
|
||||
self.ipv4_ssh_white_list.write().remove(ip)
|
||||
}
|
||||
|
||||
pub fn remove_ipv6_ssh_white_list(&self, ip: Ipv6Addr) -> Result<(), Error> {
|
||||
self.ipv6_ssh_white_list.write().remove(ip)
|
||||
}
|
||||
|
||||
pub fn get_ipv4_ssh_black_list(&self) -> Vec<Ipv4Addr> {
|
||||
self.ipv4_ssh_black_list.read().get_all()
|
||||
}
|
||||
|
||||
pub fn get_ipv6_ssh_black_list(&self) -> Vec<Ipv6Addr> {
|
||||
self.ipv6_ssh_black_list.read().get_all()
|
||||
}
|
||||
|
||||
pub fn add_ipv4_ssh_black_list(&self, ip: Ipv4Addr) -> Result<(), Error> {
|
||||
self.ipv4_ssh_black_list.write().add(ip)
|
||||
}
|
||||
|
||||
pub fn add_ipv6_ssh_black_list(&self, ip: Ipv6Addr) -> Result<(), Error> {
|
||||
self.ipv6_ssh_black_list.write().add(ip)
|
||||
}
|
||||
|
||||
pub fn remove_ipv4_ssh_black_list(&self, ip: Ipv4Addr) -> Result<(), Error> {
|
||||
self.ipv4_ssh_black_list.write().remove(ip)
|
||||
}
|
||||
|
||||
pub fn remove_ipv6_ssh_black_list(&self, ip: Ipv6Addr) -> Result<(), Error> {
|
||||
self.ipv6_ssh_black_list.write().remove(ip)
|
||||
fn remove_ssh_black_list(&self, version: IpVersion, ip: IpAddr) -> Result<(), Error> {
|
||||
match version {
|
||||
IpVersion::V4 => self.ipv4_ssh_black_list.write().remove(require_v4_ip(ip)?),
|
||||
IpVersion::V6 => self.ipv6_ssh_black_list.write().remove(require_v6_ip(ip)?),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -2,9 +2,9 @@ use aya::Ebpf;
|
||||
use aya::maps::{Array, MapData};
|
||||
use parking_lot::Mutex;
|
||||
|
||||
use crate::interface::port::rate_limit_api::RateLimitPort;
|
||||
use crate::model::error::Error;
|
||||
use crate::model::error::ebpf::EbpfError;
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::domain::data_plane::error::EbpfError;
|
||||
use crate::interface::rate_limit_api::RateLimitPort;
|
||||
|
||||
pub struct RateLimitConfig {
|
||||
config_map: Mutex<Option<Array<MapData, u64>>>,
|
||||
@ -83,30 +83,39 @@ impl RateLimitPort for RateLimitConfig {
|
||||
fn set_packet_rate(&self, rate: u64) -> Result<(), Error> {
|
||||
self.set_packet_rate(rate)
|
||||
}
|
||||
|
||||
fn set_syn_rate(&self, rate: u64) -> Result<(), Error> {
|
||||
self.set_syn_rate(rate)
|
||||
}
|
||||
|
||||
fn set_udp_rate(&self, rate: u64) -> Result<(), Error> {
|
||||
self.set_udp_rate(rate)
|
||||
}
|
||||
|
||||
fn set_dns_rate(&self, rate: u64) -> Result<(), Error> {
|
||||
self.set_dns_rate(rate)
|
||||
}
|
||||
|
||||
fn set_window_ns(&self, ns: u64) -> Result<(), Error> {
|
||||
self.set_window_ns(ns)
|
||||
}
|
||||
|
||||
fn get_packet_rate(&self) -> Result<u64, Error> {
|
||||
self.get_packet_rate()
|
||||
}
|
||||
|
||||
fn get_syn_rate(&self) -> Result<u64, Error> {
|
||||
self.get_syn_rate()
|
||||
}
|
||||
|
||||
fn get_udp_rate(&self) -> Result<u64, Error> {
|
||||
self.get_udp_rate()
|
||||
}
|
||||
|
||||
fn get_dns_rate(&self) -> Result<u64, Error> {
|
||||
self.get_dns_rate()
|
||||
}
|
||||
|
||||
fn get_window_ns(&self) -> Result<u64, Error> {
|
||||
self.get_window_ns()
|
||||
}
|
||||
|
||||
@ -6,8 +6,10 @@ use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
use aya::Ebpf;
|
||||
use aya::maps::{MapData, XskMap};
|
||||
use common::define::drop_reason::DROP_REASON_DNS_BLACKLIST;
|
||||
use crossbeam::channel::{Receiver, Sender, TrySendError, bounded};
|
||||
use crossbeam::queue::SegQueue;
|
||||
use macros::log;
|
||||
@ -16,21 +18,18 @@ use tokio::sync::oneshot::{self, error::TryRecvError};
|
||||
use xsk_rs::config::{BindFlags, FrameSize, Interface, LibxdpFlags, QueueSize, SocketConfig, UmemConfig};
|
||||
use xsk_rs::{CompQueue, FillQueue, FrameDesc, RxQueue, Socket, TxQueue, Umem};
|
||||
|
||||
use common::define::drop_reason::DROP_REASON_DNS_BLACKLIST;
|
||||
|
||||
use crate::adapter::ebpf::drop_monitor::DropMonitor;
|
||||
use crate::infrastructure::app_config::AppConfig;
|
||||
use crate::interface::port::dns_query_filter::DnsQueryFilter;
|
||||
use crate::interface::port::packet_sink::{PacketSink, PacketSinkFactory};
|
||||
use crate::model::error::Error;
|
||||
use crate::model::error::ebpf::EbpfError;
|
||||
use crate::model::error::system::SystemError;
|
||||
use crate::model::log::ebpf::EbpfLog;
|
||||
use crate::model::monitoring::direction::Direction;
|
||||
use crate::model::system::config::NetworkConfig;
|
||||
use crate::domain::common::config::AppConfig;
|
||||
use crate::domain::common::config::ebpf::EbpfConfig;
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::domain::common::error::system::SystemError;
|
||||
use crate::domain::data_plane::direction::Direction;
|
||||
use crate::domain::data_plane::error::EbpfError;
|
||||
use crate::domain::data_plane::log::EbpfLog;
|
||||
use crate::interface::dns_query_filter::DnsQueryFilter;
|
||||
use crate::interface::packet_sink::{PacketSink, PacketSinkFactory};
|
||||
use crate::utils::packet_parser::parse_packet;
|
||||
|
||||
/// Pre-allocated buffer pool to avoid per-packet malloc.
|
||||
struct BufferPool {
|
||||
buffers: Vec<Vec<u8>>,
|
||||
buffer_size: usize,
|
||||
@ -62,32 +61,36 @@ impl BufferPool {
|
||||
}
|
||||
|
||||
pub struct XskManager {
|
||||
app_config: Arc<AppConfig>,
|
||||
xsk_map: Mutex<Option<XskMap<MapData>>>,
|
||||
app_config: Arc<ArcSwap<AppConfig>>,
|
||||
ingress_xsk_map: Mutex<Option<XskMap<MapData>>>,
|
||||
egress_xsk_map: Mutex<Option<XskMap<MapData>>>,
|
||||
}
|
||||
|
||||
impl XskManager {
|
||||
pub fn new(app_config: Arc<AppConfig>, ingress_ebpf: &mut Ebpf, egress_ebpf: &mut Ebpf) -> Result<Self, Error> {
|
||||
let map = ingress_ebpf
|
||||
pub fn new(
|
||||
app_config: Arc<ArcSwap<AppConfig>>,
|
||||
ingress_ebpf: &mut Ebpf,
|
||||
egress_ebpf: &mut Ebpf,
|
||||
) -> Result<Self, Error> {
|
||||
let ingress_map = ingress_ebpf
|
||||
.take_map("INGRESS_XSKS_MAP")
|
||||
.ok_or(EbpfError::MapNotFound)?;
|
||||
let xsk_map = XskMap::try_from(map).map_err(EbpfError::MapOperationError)?;
|
||||
let ingress_xsk_map = XskMap::try_from(ingress_map).map_err(EbpfError::MapOperationError)?;
|
||||
|
||||
let egress_map = egress_ebpf.take_map("EGRESS_XSKS_MAP").ok_or(EbpfError::MapNotFound)?;
|
||||
let egress_xsk_map = XskMap::try_from(egress_map).map_err(EbpfError::MapOperationError)?;
|
||||
|
||||
Ok(Self {
|
||||
app_config,
|
||||
xsk_map: Mutex::new(Some(xsk_map)),
|
||||
ingress_xsk_map: Mutex::new(Some(ingress_xsk_map)),
|
||||
egress_xsk_map: Mutex::new(Some(egress_xsk_map)),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn unavailable(app_config: Arc<AppConfig>) -> Self {
|
||||
pub fn unavailable(app_config: Arc<ArcSwap<AppConfig>>) -> Self {
|
||||
Self {
|
||||
app_config,
|
||||
xsk_map: Mutex::new(None),
|
||||
ingress_xsk_map: Mutex::new(None),
|
||||
egress_xsk_map: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
@ -99,14 +102,15 @@ impl XskManager {
|
||||
drop_monitor: Option<Arc<DropMonitor>>,
|
||||
shutdowns: &SegQueue<oneshot::Sender<()>>,
|
||||
) -> Result<(), Error> {
|
||||
// todo need to check logic
|
||||
// If eBPF failed to load, there are no XSK maps to bind and no queues
|
||||
// to start — skip silently. AF_XDP would have no maps to attach sockets
|
||||
// to, and ML sees no packets, which is the designed behaviour.
|
||||
if self.xsk_map.lock().is_none() || self.egress_xsk_map.lock().is_none() {
|
||||
if self.ingress_xsk_map.lock().is_none() || self.egress_xsk_map.lock().is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let network = self.app_config.network.clone();
|
||||
let network = self.app_config.load().ebpf.clone();
|
||||
let combined_queue_count = network.combined_queue_count;
|
||||
|
||||
for queue_id in 0..combined_queue_count {
|
||||
@ -119,7 +123,6 @@ impl XskManager {
|
||||
network.clone(),
|
||||
queue_id,
|
||||
&network.ingress_ifname,
|
||||
&network.egress_ifname,
|
||||
Direction::Ingress,
|
||||
sink.clone(),
|
||||
dns_filter.clone(),
|
||||
@ -130,20 +133,19 @@ impl XskManager {
|
||||
network.clone(),
|
||||
queue_id,
|
||||
&network.egress_ifname,
|
||||
&network.ingress_ifname,
|
||||
Direction::Egress,
|
||||
sink,
|
||||
None,
|
||||
drop_monitor.clone(),
|
||||
)?;
|
||||
|
||||
let mut xsk_guard = self.xsk_map.lock();
|
||||
let mut ingress_guard = self.ingress_xsk_map.lock();
|
||||
let mut egress_guard = self.egress_xsk_map.lock();
|
||||
let xsk_map = xsk_guard.as_mut().ok_or(EbpfError::NotLoaded)?;
|
||||
let ingress_xsk_map = ingress_guard.as_mut().ok_or(EbpfError::NotLoaded)?;
|
||||
let egress_xsk_map = egress_guard.as_mut().ok_or(EbpfError::NotLoaded)?;
|
||||
|
||||
let ingress_fd = ingress_xsk.rx.fd().as_raw_fd();
|
||||
xsk_map
|
||||
ingress_xsk_map
|
||||
.set(queue_id, ingress_fd, 0)
|
||||
.map_err(EbpfError::AfXdpSetFailed)?;
|
||||
|
||||
@ -152,7 +154,7 @@ impl XskManager {
|
||||
.set(queue_id, egress_fd, 0)
|
||||
.map_err(EbpfError::AfXdpSetFailed)?;
|
||||
|
||||
drop(xsk_guard);
|
||||
drop(ingress_guard);
|
||||
drop(egress_guard);
|
||||
|
||||
let ingress_shutdown = ingress_xsk.run(ingress_to_egress_tx, egress_to_ingress_rx)?;
|
||||
@ -181,15 +183,18 @@ pub struct XskPair {
|
||||
drop_monitor: Option<Arc<DropMonitor>>,
|
||||
packet_buffer_size: usize,
|
||||
buffer_pool_capacity: usize,
|
||||
completion_batch_size: usize,
|
||||
rx_batch_size: usize,
|
||||
tx_batch_size: usize,
|
||||
tx_packet_buf: Vec<Vec<u8>>,
|
||||
tx_frame_buf: Vec<FrameDesc>,
|
||||
}
|
||||
|
||||
impl XskPair {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
config: NetworkConfig,
|
||||
config: EbpfConfig,
|
||||
queue_id: u32,
|
||||
rx_ifname: &str,
|
||||
_tx_ifname: &str,
|
||||
direction: Direction,
|
||||
sink: Option<Arc<dyn PacketSink>>,
|
||||
dns_filter: Option<Arc<dyn DnsQueryFilter>>,
|
||||
@ -253,6 +258,11 @@ impl XskPair {
|
||||
drop_monitor,
|
||||
packet_buffer_size: config.packet_buffer_size,
|
||||
buffer_pool_capacity: config.buffer_pool_capacity,
|
||||
completion_batch_size: config.xsk_completion_batch_size,
|
||||
rx_batch_size: config.xsk_rx_batch_size,
|
||||
tx_batch_size: config.xsk_tx_batch_size,
|
||||
tx_packet_buf: Vec::with_capacity(config.xsk_tx_batch_size),
|
||||
tx_frame_buf: Vec::with_capacity(config.xsk_tx_batch_size),
|
||||
};
|
||||
|
||||
Ok(xsk_pair)
|
||||
@ -273,8 +283,8 @@ impl XskPair {
|
||||
let mut shutdown_rx = Some(shutdown_rx);
|
||||
let mut idle_count: u32 = 0;
|
||||
let mut buffer_pool = BufferPool::new(self.buffer_pool_capacity, self.packet_buffer_size);
|
||||
let mut comp_descs = vec![FrameDesc::default(); 256];
|
||||
let mut rx_descs = vec![FrameDesc::default(); 64];
|
||||
let mut comp_descs = vec![FrameDesc::default(); self.completion_batch_size];
|
||||
let mut rx_descs = vec![FrameDesc::default(); self.rx_batch_size];
|
||||
|
||||
loop {
|
||||
if let Some(ref mut rx) = shutdown_rx {
|
||||
@ -309,13 +319,14 @@ impl XskPair {
|
||||
idle_count = 0;
|
||||
}
|
||||
|
||||
let sleep_us = match idle_count {
|
||||
0..=10 => 1,
|
||||
11..=100 => 10,
|
||||
_ => 100,
|
||||
};
|
||||
|
||||
thread::sleep(Duration::from_micros(sleep_us));
|
||||
if idle_count > 0 {
|
||||
let sleep_us = match idle_count {
|
||||
1..=10 => 1,
|
||||
11..=100 => 10,
|
||||
_ => 100,
|
||||
};
|
||||
thread::sleep(Duration::from_micros(sleep_us));
|
||||
}
|
||||
}
|
||||
|
||||
log!(EbpfLog::XSKShutdown);
|
||||
@ -418,15 +429,15 @@ impl XskPair {
|
||||
buffer_pool: &mut BufferPool,
|
||||
comp_descs: &mut [FrameDesc],
|
||||
) -> Result<usize, EbpfError> {
|
||||
let mut packets_to_send = Vec::with_capacity(64);
|
||||
self.tx_packet_buf.clear();
|
||||
while let Ok(packet) = forward_rx.try_recv() {
|
||||
packets_to_send.push(packet);
|
||||
if packets_to_send.len() >= 64 {
|
||||
self.tx_packet_buf.push(packet);
|
||||
if self.tx_packet_buf.len() >= self.tx_batch_size {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if packets_to_send.is_empty() {
|
||||
if self.tx_packet_buf.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
@ -434,10 +445,10 @@ impl XskPair {
|
||||
log!(EbpfLog::CompQueueError(format!("{:?}", e)));
|
||||
}
|
||||
|
||||
let total_packets = packets_to_send.len();
|
||||
let total_packets = self.tx_packet_buf.len();
|
||||
|
||||
if self.frame_pool.is_empty() {
|
||||
for pkt in packets_to_send {
|
||||
for pkt in self.tx_packet_buf.drain(..) {
|
||||
buffer_pool.put(pkt);
|
||||
}
|
||||
log!(EbpfLog::FramePoolExhausted(total_packets));
|
||||
@ -445,17 +456,19 @@ impl XskPair {
|
||||
}
|
||||
|
||||
let available = self.frame_pool.len().min(total_packets);
|
||||
let mut frames: Vec<FrameDesc> = self.frame_pool.drain(self.frame_pool.len() - available..).collect();
|
||||
self.tx_frame_buf.clear();
|
||||
self.tx_frame_buf
|
||||
.extend(self.frame_pool.drain(self.frame_pool.len() - available..));
|
||||
|
||||
if frames.is_empty() {
|
||||
for pkt in packets_to_send {
|
||||
if self.tx_frame_buf.is_empty() {
|
||||
for pkt in self.tx_packet_buf.drain(..) {
|
||||
buffer_pool.put(pkt);
|
||||
}
|
||||
log!(EbpfLog::NoFramesAvailable);
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
for (frame, packet) in frames.iter_mut().zip(packets_to_send.iter()) {
|
||||
for (frame, packet) in self.tx_frame_buf.iter_mut().zip(self.tx_packet_buf.iter()) {
|
||||
unsafe {
|
||||
self.umem
|
||||
.data_mut(frame)
|
||||
@ -465,11 +478,11 @@ impl XskPair {
|
||||
}
|
||||
}
|
||||
|
||||
let nb_submitted = unsafe { self.tx.produce(&frames) };
|
||||
let nb_submitted = unsafe { self.tx.produce(&self.tx_frame_buf) };
|
||||
|
||||
// Return unsubmitted frames to pool to prevent frame leak
|
||||
if nb_submitted < frames.len() {
|
||||
for frame in frames[nb_submitted..].iter() {
|
||||
if nb_submitted < self.tx_frame_buf.len() {
|
||||
for frame in self.tx_frame_buf[nb_submitted..].iter() {
|
||||
self.frame_pool.push(*frame);
|
||||
}
|
||||
}
|
||||
@ -491,7 +504,7 @@ impl XskPair {
|
||||
}
|
||||
|
||||
// Return all buffers to pool
|
||||
for pkt in packets_to_send {
|
||||
for pkt in self.tx_packet_buf.drain(..) {
|
||||
buffer_pool.put(pkt);
|
||||
}
|
||||
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
use actix_web::{HttpResponse, Scope, web};
|
||||
|
||||
use crate::adapter::http::middleware::extractor::AuthClaims;
|
||||
use crate::adapter::persistence::Database;
|
||||
use crate::core::auth::extractor::AuthClaims;
|
||||
use crate::interface::port::audit::AuditRepo;
|
||||
use crate::model::error::Error;
|
||||
use crate::model::error::database::DatabaseError;
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::domain::common::error::database::DatabaseError;
|
||||
use crate::interface::audit::AuditRepo;
|
||||
|
||||
pub fn initialize() -> Scope {
|
||||
web::scope("/audit")
|
||||
@ -13,7 +13,7 @@ pub fn initialize() -> Scope {
|
||||
}
|
||||
|
||||
async fn list_audit_logs(_auth: AuthClaims, db: web::Data<Database>) -> HttpResponse {
|
||||
match db.list_audit_logs() {
|
||||
match db.list_audit_logs().await {
|
||||
Ok(entries) => {
|
||||
let json: Vec<serde_json::Value> = entries
|
||||
.into_iter()
|
||||
@ -40,8 +40,8 @@ async fn list_audit_logs(_auth: AuthClaims, db: web::Data<Database>) -> HttpResp
|
||||
/// integrity without shell access. Any mismatch returns the offending
|
||||
/// row id inside `error` so the dashboard can link straight to it.
|
||||
async fn verify_chain(_auth: AuthClaims, audit: web::Data<dyn AuditRepo>) -> HttpResponse {
|
||||
match audit.verify_audit_log_chain() {
|
||||
Ok(count) => HttpResponse::Ok().json(serde_json::json!({
|
||||
match audit.verify_audit_log_chain(0).await {
|
||||
Ok((count, _last_id)) => HttpResponse::Ok().json(serde_json::json!({
|
||||
"chain_intact": true,
|
||||
"verified": count,
|
||||
})),
|
||||
|
||||
@ -3,9 +3,10 @@ use std::net::{SocketAddrV4, SocketAddrV6};
|
||||
use actix_web::{HttpResponse, Responder, Scope, web};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::core::acl_service::AclService;
|
||||
use crate::model::access_control::list_type::ListType;
|
||||
use crate::model::monitoring::direction::FlowDirection;
|
||||
use crate::adapter::http::helpers::ok_or_error;
|
||||
use crate::core::data_plane::acl_service::AclService;
|
||||
use crate::domain::data_plane::direction::FlowDirection;
|
||||
use crate::domain::data_plane::list_type::ListType;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CountryCodesRequest {
|
||||
@ -43,10 +44,7 @@ async fn add_ipv4_list(
|
||||
acl: web::Data<AclService>,
|
||||
) -> impl Responder {
|
||||
let (direction, list_type) = path.into_inner();
|
||||
match acl.add_ipv4(direction, list_type, address.into_inner()) {
|
||||
Ok(_) => HttpResponse::Ok().finish(),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
|
||||
}
|
||||
ok_or_error(acl.add_ipv4(direction, list_type, address.into_inner()).await)
|
||||
}
|
||||
|
||||
async fn add_ipv6_list(
|
||||
@ -55,10 +53,7 @@ async fn add_ipv6_list(
|
||||
acl: web::Data<AclService>,
|
||||
) -> impl Responder {
|
||||
let (direction, list_type) = path.into_inner();
|
||||
match acl.add_ipv6(direction, list_type, address.into_inner()) {
|
||||
Ok(_) => HttpResponse::Ok().finish(),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
|
||||
}
|
||||
ok_or_error(acl.add_ipv6(direction, list_type, address.into_inner()).await)
|
||||
}
|
||||
|
||||
async fn remove_ipv4_list(
|
||||
@ -67,10 +62,7 @@ async fn remove_ipv4_list(
|
||||
acl: web::Data<AclService>,
|
||||
) -> impl Responder {
|
||||
let (direction, list_type) = path.into_inner();
|
||||
match acl.remove_ipv4(direction, list_type, address.into_inner()) {
|
||||
Ok(_) => HttpResponse::Ok().finish(),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
|
||||
}
|
||||
ok_or_error(acl.remove_ipv4(direction, list_type, address.into_inner()).await)
|
||||
}
|
||||
|
||||
async fn remove_ipv6_list(
|
||||
@ -79,10 +71,7 @@ async fn remove_ipv6_list(
|
||||
acl: web::Data<AclService>,
|
||||
) -> impl Responder {
|
||||
let (direction, list_type) = path.into_inner();
|
||||
match acl.remove_ipv6(direction, list_type, address.into_inner()) {
|
||||
Ok(_) => HttpResponse::Ok().finish(),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
|
||||
}
|
||||
ok_or_error(acl.remove_ipv6(direction, list_type, address.into_inner()).await)
|
||||
}
|
||||
|
||||
async fn get_geo_blocked(acl: web::Data<AclService>) -> impl Responder {
|
||||
@ -91,7 +80,7 @@ async fn get_geo_blocked(acl: web::Data<AclService>) -> impl Responder {
|
||||
|
||||
async fn block_geo_countries(body: web::Json<CountryCodesRequest>, acl: web::Data<AclService>) -> impl Responder {
|
||||
let codes = body.into_inner().country_codes;
|
||||
match acl.block_geo_countries(&codes) {
|
||||
match acl.block_geo_countries(&codes).await {
|
||||
Ok(total_prefixes) => HttpResponse::Ok().json(serde_json::json!({
|
||||
"blocked_countries": acl.get_blocked_countries(),
|
||||
"total_prefixes": total_prefixes,
|
||||
@ -102,7 +91,7 @@ async fn block_geo_countries(body: web::Json<CountryCodesRequest>, acl: web::Dat
|
||||
|
||||
async fn unblock_geo_countries(body: web::Json<CountryCodesRequest>, acl: web::Data<AclService>) -> impl Responder {
|
||||
let codes = body.into_inner().country_codes;
|
||||
match acl.unblock_geo_countries(&codes) {
|
||||
match acl.unblock_geo_countries(&codes).await {
|
||||
Ok(total_prefixes) => HttpResponse::Ok().json(serde_json::json!({
|
||||
"blocked_countries": acl.get_blocked_countries(),
|
||||
"total_prefixes": total_prefixes,
|
||||
229
net-guardia/src/adapter/http/data_plane/filter.rs
Normal file
229
net-guardia/src/adapter/http/data_plane/filter.rs
Normal file
@ -0,0 +1,229 @@
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
|
||||
use actix_web::{HttpResponse, Responder, Scope, web};
|
||||
use common::model::http_method::HttpMethod;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::adapter::http::helpers::ok_or_error;
|
||||
use crate::core::data_plane::dns_filter_service::DnsFilterService;
|
||||
use crate::interface::protocol_filter::{IpVersion, ProtocolFilterPort};
|
||||
|
||||
pub fn initialize() -> Scope {
|
||||
web::scope("/filter")
|
||||
.service(http_scope())
|
||||
.service(ssh_scope())
|
||||
.service(dns_scope())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DnsDomainsRequest {
|
||||
domains: Vec<String>,
|
||||
}
|
||||
|
||||
fn parse_ip_version(path: &str) -> Option<IpVersion> {
|
||||
match path {
|
||||
"ipv4" => Some(IpVersion::V4),
|
||||
"ipv6" => Some(IpVersion::V6),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn dns_scope() -> Scope {
|
||||
web::scope("/dns").service(
|
||||
web::scope("/blacklist")
|
||||
.route("", web::get().to(get_dns_blacklist))
|
||||
.route("", web::put().to(add_dns_blacklist))
|
||||
.route("", web::delete().to(remove_dns_blacklist)),
|
||||
)
|
||||
}
|
||||
|
||||
async fn get_dns_blacklist(service: web::Data<DnsFilterService>) -> impl Responder {
|
||||
HttpResponse::Ok().json(serde_json::json!({"domains": service.list_domains()}))
|
||||
}
|
||||
|
||||
async fn add_dns_blacklist(
|
||||
payload: web::Json<DnsDomainsRequest>,
|
||||
service: web::Data<DnsFilterService>,
|
||||
) -> impl Responder {
|
||||
let domains = payload.into_inner().domains;
|
||||
match service.add_domains(&domains).await {
|
||||
Ok(count) => HttpResponse::Ok().json(serde_json::json!({"added": count})),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove_dns_blacklist(
|
||||
payload: web::Json<DnsDomainsRequest>,
|
||||
service: web::Data<DnsFilterService>,
|
||||
) -> impl Responder {
|
||||
let domains = payload.into_inner().domains;
|
||||
match service.remove_domains(&domains).await {
|
||||
Ok(count) => HttpResponse::Ok().json(serde_json::json!({"removed": count})),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
|
||||
}
|
||||
}
|
||||
|
||||
fn http_scope() -> Scope {
|
||||
web::scope("/http")
|
||||
.route("/{version}", web::get().to(get_http_service))
|
||||
.route("/{version}", web::put().to(add_http_service))
|
||||
.route("/{version}", web::delete().to(remove_http_service))
|
||||
}
|
||||
|
||||
fn ssh_scope() -> Scope {
|
||||
web::scope("/ssh")
|
||||
.route("/{version}", web::get().to(get_ssh_service))
|
||||
.route("/{version}", web::put().to(add_ssh_service))
|
||||
.route("/{version}", web::delete().to(remove_ssh_service))
|
||||
.service(ssh_whitelist_scope())
|
||||
.service(ssh_blacklist_scope())
|
||||
}
|
||||
|
||||
fn ssh_whitelist_scope() -> Scope {
|
||||
web::scope("/whitelist")
|
||||
.route("/status", web::get().to(is_ssh_white_list_enable))
|
||||
.route("/enable", web::post().to(enable_ssh_white_list))
|
||||
.route("/disable", web::post().to(disable_ssh_white_list))
|
||||
.route("/{version}", web::get().to(get_ssh_white_list))
|
||||
.route("/{version}", web::put().to(add_ssh_white_list))
|
||||
.route("/{version}", web::delete().to(remove_ssh_white_list))
|
||||
}
|
||||
|
||||
fn ssh_blacklist_scope() -> Scope {
|
||||
web::scope("/blacklist")
|
||||
.route("/{version}", web::get().to(get_ssh_black_list))
|
||||
.route("/{version}", web::put().to(add_ssh_black_list))
|
||||
.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 {
|
||||
let Some(version) = parse_ip_version(&path) else {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid IP version"}));
|
||||
};
|
||||
HttpResponse::Ok().json(service.get_http_service(version))
|
||||
}
|
||||
|
||||
async fn add_http_service(
|
||||
path: web::Path<String>,
|
||||
payload: web::Json<(SocketAddr, Vec<HttpMethod>)>,
|
||||
service: web::Data<dyn ProtocolFilterPort>,
|
||||
) -> impl Responder {
|
||||
let Some(version) = parse_ip_version(&path) else {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid IP version"}));
|
||||
};
|
||||
let (addr, methods) = payload.into_inner();
|
||||
ok_or_error(service.add_http_service(version, addr, methods))
|
||||
}
|
||||
|
||||
async fn remove_http_service(
|
||||
path: web::Path<String>,
|
||||
payload: web::Json<(SocketAddr, Vec<HttpMethod>)>,
|
||||
service: web::Data<dyn ProtocolFilterPort>,
|
||||
) -> impl Responder {
|
||||
let Some(version) = parse_ip_version(&path) else {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid IP version"}));
|
||||
};
|
||||
let (addr, methods) = payload.into_inner();
|
||||
ok_or_error(service.remove_http_service(version, addr, methods))
|
||||
}
|
||||
|
||||
async fn get_ssh_service(path: web::Path<String>, service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
|
||||
let Some(version) = parse_ip_version(&path) else {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid IP version"}));
|
||||
};
|
||||
HttpResponse::Ok().json(service.get_ssh_service(version))
|
||||
}
|
||||
|
||||
async fn add_ssh_service(
|
||||
path: web::Path<String>,
|
||||
payload: web::Json<SocketAddr>,
|
||||
service: web::Data<dyn ProtocolFilterPort>,
|
||||
) -> impl Responder {
|
||||
let Some(version) = parse_ip_version(&path) else {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid IP version"}));
|
||||
};
|
||||
ok_or_error(service.add_ssh_service(version, payload.into_inner()))
|
||||
}
|
||||
|
||||
async fn remove_ssh_service(
|
||||
path: web::Path<String>,
|
||||
payload: web::Json<SocketAddr>,
|
||||
service: web::Data<dyn ProtocolFilterPort>,
|
||||
) -> impl Responder {
|
||||
let Some(version) = parse_ip_version(&path) else {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid IP version"}));
|
||||
};
|
||||
ok_or_error(service.remove_ssh_service(version, payload.into_inner()))
|
||||
}
|
||||
|
||||
async fn is_ssh_white_list_enable(service: web::Data<dyn ProtocolFilterPort>) -> 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 {
|
||||
ok_or_error(service.enable_ssh_white_list())
|
||||
}
|
||||
|
||||
async fn disable_ssh_white_list(service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
|
||||
ok_or_error(service.disable_ssh_white_list())
|
||||
}
|
||||
|
||||
async fn get_ssh_white_list(path: web::Path<String>, service: web::Data<dyn ProtocolFilterPort>) -> impl Responder {
|
||||
let Some(version) = parse_ip_version(&path) else {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid IP version"}));
|
||||
};
|
||||
HttpResponse::Ok().json(service.get_ssh_white_list(version))
|
||||
}
|
||||
|
||||
async fn add_ssh_white_list(
|
||||
path: web::Path<String>,
|
||||
payload: web::Json<IpAddr>,
|
||||
service: web::Data<dyn ProtocolFilterPort>,
|
||||
) -> impl Responder {
|
||||
let Some(version) = parse_ip_version(&path) else {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid IP version"}));
|
||||
};
|
||||
ok_or_error(service.add_ssh_white_list(version, payload.into_inner()))
|
||||
}
|
||||
|
||||
async fn remove_ssh_white_list(
|
||||
path: web::Path<String>,
|
||||
payload: web::Json<IpAddr>,
|
||||
service: web::Data<dyn ProtocolFilterPort>,
|
||||
) -> impl Responder {
|
||||
let Some(version) = parse_ip_version(&path) else {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid IP version"}));
|
||||
};
|
||||
ok_or_error(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 {
|
||||
let Some(version) = parse_ip_version(&path) else {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid IP version"}));
|
||||
};
|
||||
HttpResponse::Ok().json(service.get_ssh_black_list(version))
|
||||
}
|
||||
|
||||
async fn add_ssh_black_list(
|
||||
path: web::Path<String>,
|
||||
payload: web::Json<IpAddr>,
|
||||
service: web::Data<dyn ProtocolFilterPort>,
|
||||
) -> impl Responder {
|
||||
let Some(version) = parse_ip_version(&path) else {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid IP version"}));
|
||||
};
|
||||
ok_or_error(service.add_ssh_black_list(version, payload.into_inner()))
|
||||
}
|
||||
|
||||
async fn remove_ssh_black_list(
|
||||
path: web::Path<String>,
|
||||
payload: web::Json<IpAddr>,
|
||||
service: web::Data<dyn ProtocolFilterPort>,
|
||||
) -> impl Responder {
|
||||
let Some(version) = parse_ip_version(&path) else {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({"error": "invalid IP version"}));
|
||||
};
|
||||
ok_or_error(service.remove_ssh_black_list(version, payload.into_inner()))
|
||||
}
|
||||
3
net-guardia/src/adapter/http/data_plane/mod.rs
Normal file
3
net-guardia/src/adapter/http/data_plane/mod.rs
Normal file
@ -0,0 +1,3 @@
|
||||
pub mod acl;
|
||||
pub mod filter;
|
||||
pub mod rate_limit;
|
||||
@ -1,8 +1,9 @@
|
||||
use actix_web::{HttpResponse, Responder, Scope, web};
|
||||
use common::define::setting::*;
|
||||
|
||||
use crate::core::rate_limit_service::RateLimitService;
|
||||
use crate::model::system::rate_limit_settings::RateLimitSettings;
|
||||
use crate::adapter::http::helpers::ok_or_error;
|
||||
use crate::core::data_plane::rate_limit_service::RateLimitService;
|
||||
use crate::domain::common::system::rate_limit_settings::RateLimitSettings;
|
||||
|
||||
pub fn initialize() -> Scope {
|
||||
web::scope("/rate-limit")
|
||||
@ -21,8 +22,5 @@ async fn get_config(service: web::Data<RateLimitService>) -> impl Responder {
|
||||
}
|
||||
|
||||
async fn set_config(settings: web::Json<RateLimitSettings>, service: web::Data<RateLimitService>) -> impl Responder {
|
||||
match service.update(&settings.into_inner()) {
|
||||
Ok(()) => HttpResponse::Ok().json(serde_json::json!({"status": "ok"})),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
|
||||
}
|
||||
ok_or_error(service.update(&settings.into_inner()).await)
|
||||
}
|
||||
@ -6,8 +6,8 @@
|
||||
|
||||
use actix_web::{HttpResponse, Scope, web};
|
||||
|
||||
use crate::core::auth::extractor::AuthClaims;
|
||||
use crate::core::ml::feature_extractor::feature_registry_names;
|
||||
use crate::adapter::http::middleware::extractor::AuthClaims;
|
||||
use crate::domain::detection::feature_extractor::feature_registry_names;
|
||||
|
||||
pub fn initialize() -> Scope {
|
||||
web::scope("/byo").route("/feature-registry", web::get().to(get_feature_registry))
|
||||
@ -10,8 +10,9 @@ use std::path::{Path, PathBuf};
|
||||
use actix_files::NamedFile;
|
||||
use actix_web::{HttpRequest, HttpResponse, Responder, Scope, web};
|
||||
|
||||
use crate::core::ml::engine::Engine;
|
||||
use crate::core::ml::traffic_logger::{FLOW_TRACE_FILE_EXT, FLOW_TRACE_FILE_MARKER, list_flow_trace_files};
|
||||
use crate::core::inference::engine::Engine;
|
||||
use crate::core::inference::traffic_logger::list_flow_trace_files;
|
||||
use crate::domain::common::config::constants::{FLOW_TRACE_FILE_EXT, FLOW_TRACE_FILE_MARKER};
|
||||
|
||||
pub fn initialize() -> Scope {
|
||||
web::scope("/flow-trace")
|
||||
@ -8,24 +8,13 @@
|
||||
//! logs by hand.
|
||||
|
||||
use actix_web::{HttpRequest, HttpResponse, Responder, Scope, web};
|
||||
use arc_swap::ArcSwap;
|
||||
|
||||
use crate::core::detection::metrics::FusionMetrics;
|
||||
use crate::interface::port::audit::{AuditLogEntry, AuditRepo};
|
||||
|
||||
/// Maximum audit rows scanned per explain request. Caps DB work in
|
||||
/// case the audit chain grows large enough that a naive full-table
|
||||
/// scan would be noticeable.
|
||||
const FUSION_EXPLAIN_SCAN_LIMIT: i64 = 5_000;
|
||||
|
||||
/// Upper cap on entries returned to the client per explain request.
|
||||
/// Guards against a UI rendering path that chokes on enormous JSON.
|
||||
const FUSION_EXPLAIN_RESPONSE_CAP: usize = 200;
|
||||
|
||||
/// Stable audit action string the fusion engine emits — kept in sync
|
||||
/// with `core::detection::orchestrator::FUSION_AUDIT_ACTION`. If that
|
||||
/// constant changes, the explain endpoint silently returns nothing, so
|
||||
/// keep this updated at the same time.
|
||||
const FUSION_AUDIT_ACTION: &str = "fused_threat_emitted";
|
||||
use crate::domain::common::audit::AuditLogEntry;
|
||||
use crate::domain::common::config::AppConfig;
|
||||
use crate::domain::common::config::constants::FUSION_AUDIT_ACTION;
|
||||
use crate::interface::audit::AuditRepo;
|
||||
|
||||
pub fn initialize() -> Scope {
|
||||
web::scope("/fusion")
|
||||
@ -44,7 +33,11 @@ async fn get_metrics(metrics: web::Data<FusionMetrics>) -> impl Responder {
|
||||
/// Scans the WORM audit chain for `fused_threat_emitted` entries that
|
||||
/// match `src_ip`, returning them oldest-first so the UI can render a
|
||||
/// chronological "why was this IP blocked" view.
|
||||
async fn explain_ip(req: HttpRequest, audit: web::Data<dyn AuditRepo>) -> impl Responder {
|
||||
async fn explain_ip(
|
||||
req: HttpRequest,
|
||||
audit: web::Data<dyn AuditRepo>,
|
||||
app_config: web::Data<ArcSwap<AppConfig>>,
|
||||
) -> impl Responder {
|
||||
let src_ip = match req.match_info().get("src_ip") {
|
||||
Some(ip) => ip.to_string(),
|
||||
None => {
|
||||
@ -54,7 +47,11 @@ async fn explain_ip(req: HttpRequest, audit: web::Data<dyn AuditRepo>) -> impl R
|
||||
}
|
||||
};
|
||||
|
||||
let entries = match audit.list_audit_logs_by_action(FUSION_AUDIT_ACTION, FUSION_EXPLAIN_SCAN_LIMIT) {
|
||||
let obs = app_config.load().observability.clone();
|
||||
let entries = match audit
|
||||
.list_audit_logs_by_action(FUSION_AUDIT_ACTION, obs.fusion_explain_scan_limit)
|
||||
.await
|
||||
{
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
return HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
@ -63,7 +60,7 @@ async fn explain_ip(req: HttpRequest, audit: web::Data<dyn AuditRepo>) -> impl R
|
||||
}
|
||||
};
|
||||
|
||||
let (matches, truncated) = filter_fusion_evidence_for_ip(&entries, &src_ip, FUSION_EXPLAIN_RESPONSE_CAP);
|
||||
let (matches, truncated) = filter_fusion_evidence_for_ip(&entries, &src_ip, obs.fusion_explain_response_cap);
|
||||
HttpResponse::Ok().json(serde_json::json!({
|
||||
"src_ip": src_ip,
|
||||
"match_count": matches.len(),
|
||||
@ -1,11 +1,12 @@
|
||||
use actix_web::{HttpResponse, Responder, Scope, web};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use crate::core::auth::extractor::AuthClaims;
|
||||
use crate::core::ml::adapter::ModelSourceState;
|
||||
use crate::core::ml::engine::Engine;
|
||||
use crate::core::ml::inference::Inference;
|
||||
use crate::infrastructure::communication_manager::CommunicationManager;
|
||||
use crate::model::event::AuditEvent;
|
||||
use crate::adapter::http::middleware::extractor::AuthClaims;
|
||||
use crate::core::inference::engine::Engine;
|
||||
use crate::core::inference::model_adapter::ModelSourceState;
|
||||
use crate::core::inference::runner::Inference;
|
||||
use crate::domain::common::config::constants::AUDIT_ACTOR_SECURITY_ADMIN_PREFIX;
|
||||
use crate::domain::common::event::AuditEvent;
|
||||
|
||||
/// Permission required to forcibly revert the active ML source to dormant.
|
||||
/// Mirrors the upload handler's gate so swap-out and revert are symmetric:
|
||||
@ -13,11 +14,6 @@ use crate::model::event::AuditEvent;
|
||||
/// detector silently while the upload path required `users:admin`.
|
||||
const DORMANT_REQUIRED_PERMISSION: &str = "users:admin";
|
||||
|
||||
/// Actor prefix recorded on the WORM chain when an admin reverts the ML
|
||||
/// source. Matches the prefix used by `model_swap` so downstream filters
|
||||
/// see both events in the same admin-action stream.
|
||||
const AUDIT_ACTOR_SECURITY_ADMIN_PREFIX: &str = "SecurityAdmin";
|
||||
|
||||
/// Action recorded on the WORM chain when the ML source is forced
|
||||
/// dormant via this endpoint. Stable wire string — UI/audit tooling
|
||||
/// filters on it, paired with `model_swap` from the upload path.
|
||||
@ -48,7 +44,7 @@ async fn get_status(engine: web::Data<Engine>) -> impl Responder {
|
||||
/// `GET /api/ml/models/current` — wire-format snapshot of the ML source
|
||||
/// state the dashboard's ML Status panel renders.
|
||||
async fn get_current_model(inference: web::Data<Inference>) -> impl Responder {
|
||||
let status = inference.current_status();
|
||||
let status = inference.model_source_status();
|
||||
let label = if status.is_active() {
|
||||
"active"
|
||||
} else if status.is_dormant() {
|
||||
@ -69,7 +65,7 @@ async fn get_current_model(inference: web::Data<Inference>) -> impl Responder {
|
||||
/// the upload path's `model_swap` so both swap-in and revert are auditable.
|
||||
async fn delete_current_model(
|
||||
inference: web::Data<Inference>,
|
||||
comm: web::Data<CommunicationManager>,
|
||||
audit_tx: web::Data<broadcast::Sender<AuditEvent>>,
|
||||
claims: AuthClaims,
|
||||
) -> impl Responder {
|
||||
if !claims.permissions.iter().any(|p| p == DORMANT_REQUIRED_PERMISSION) {
|
||||
@ -78,7 +74,7 @@ async fn delete_current_model(
|
||||
}));
|
||||
}
|
||||
|
||||
let before_status = inference.current_status();
|
||||
let before_status = inference.model_source_status();
|
||||
if before_status.is_dormant() {
|
||||
return HttpResponse::Ok().json(serde_json::json!({
|
||||
"already_dormant": true,
|
||||
@ -91,13 +87,11 @@ async fn delete_current_model(
|
||||
"before": serde_json::to_value(&before_status).unwrap_or(serde_json::Value::Null),
|
||||
})
|
||||
.to_string();
|
||||
let _ = comm
|
||||
.publish_event(AuditEvent {
|
||||
actor: format!("{AUDIT_ACTOR_SECURITY_ADMIN_PREFIX}@{}", claims.username),
|
||||
action: AUDIT_ACTION_MODEL_DORMANT.to_string(),
|
||||
detail: audit_detail,
|
||||
})
|
||||
.await;
|
||||
let _ = audit_tx.send(AuditEvent {
|
||||
actor: format!("{AUDIT_ACTOR_SECURITY_ADMIN_PREFIX}@{}", claims.username),
|
||||
action: AUDIT_ACTION_MODEL_DORMANT.to_string(),
|
||||
detail: audit_detail,
|
||||
});
|
||||
|
||||
HttpResponse::Ok().json(serde_json::json!({
|
||||
"already_dormant": false,
|
||||
7
net-guardia/src/adapter/http/detection/mod.rs
Normal file
7
net-guardia/src/adapter/http/detection/mod.rs
Normal file
@ -0,0 +1,7 @@
|
||||
pub mod byo;
|
||||
pub mod flow_trace;
|
||||
pub mod fusion;
|
||||
pub mod health;
|
||||
pub mod ml;
|
||||
pub mod model_upload;
|
||||
pub mod stats;
|
||||
@ -15,33 +15,35 @@
|
||||
//! down on any error path so failed uploads don't pile up in
|
||||
//! `models/.staging/`.
|
||||
|
||||
use std::fs as std_fs;
|
||||
use std::fs::File as StdFile;
|
||||
use std::io;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, SystemTime};
|
||||
use std::time::Duration;
|
||||
|
||||
use actix_multipart::Multipart;
|
||||
use actix_web::{HttpResponse, Responder, Scope, web};
|
||||
use arc_swap::ArcSwap;
|
||||
use futures_util::TryStreamExt;
|
||||
use serde_json::Value as JsonValue;
|
||||
use sha2::{Digest, Sha256};
|
||||
use tokio::fs;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::task;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::core::auth::extractor::AuthClaims;
|
||||
use crate::core::ml::inference::Inference;
|
||||
use crate::core::ml::manifest::{AdapterKind, ModelManifest};
|
||||
use crate::core::ml::model_loader::build_adapter;
|
||||
use crate::infrastructure::app_config::AppConfig;
|
||||
use crate::infrastructure::communication_manager::CommunicationManager;
|
||||
use crate::model::config::constants::{MANIFEST_FILENAME, MODELS_DIR, STAGING_SUBDIR};
|
||||
use crate::model::event::AuditEvent;
|
||||
use crate::model::system::config::MLInferenceConfig;
|
||||
use crate::adapter::http::middleware::extractor::AuthClaims;
|
||||
use crate::core::inference::model_loader::build_adapter;
|
||||
use crate::core::inference::runner::Inference;
|
||||
use crate::domain::common::config::AppConfig;
|
||||
use crate::domain::common::config::constants::{
|
||||
AUDIT_ACTOR_SECURITY_ADMIN_PREFIX, MANIFEST_FILENAME, MODELS_DIR, STAGING_SUBDIR,
|
||||
};
|
||||
use crate::domain::common::event::AuditEvent;
|
||||
use crate::domain::detection::manifest::{AdapterKind, ModelManifest};
|
||||
use crate::domain::detection::ml_inference_config::MLInferenceConfig;
|
||||
|
||||
/// Multipart field names the client must use. Stable wire contract —
|
||||
/// the frontend form generator depends on these exact strings.
|
||||
@ -61,14 +63,6 @@ const ONNX_SNIFF_BYTES: usize = 16;
|
||||
/// swap the ML source.
|
||||
const PROMOTE_REQUIRED_PERMISSION: &str = "users:admin";
|
||||
|
||||
/// Actor prefix on the WORM `model_swap` audit entry. Administrators
|
||||
/// that trigger the upload endpoint land on the chain as
|
||||
/// `SecurityAdmin@<username>` so downstream filters can separate
|
||||
/// system-driven entries (actor="system") from human-driven ones
|
||||
/// without parsing free-form text. Stable across releases — renaming
|
||||
/// breaks downstream audit tooling that filters on this prefix.
|
||||
const AUDIT_ACTOR_SECURITY_ADMIN_PREFIX: &str = "SecurityAdmin";
|
||||
|
||||
/// Action recorded on the WORM chain when a promote succeeds. Stable
|
||||
/// wire string — fusion-explain tooling and future "who swapped the
|
||||
/// model" views filter on it, so the rename must go through the audit
|
||||
@ -136,9 +130,9 @@ pub fn initialize() -> Scope {
|
||||
/// pre-swap ML source state. The staging directory is always torn
|
||||
/// down on the way out, even on success (post-promote it's empty).
|
||||
async fn upload(
|
||||
app_config: web::Data<AppConfig>,
|
||||
app_config: web::Data<ArcSwap<AppConfig>>,
|
||||
inference: web::Data<Inference>,
|
||||
comm: web::Data<CommunicationManager>,
|
||||
audit_tx: web::Data<broadcast::Sender<AuditEvent>>,
|
||||
promote_lock: web::Data<PromoteGate>,
|
||||
claims: AuthClaims,
|
||||
payload: Multipart,
|
||||
@ -153,11 +147,15 @@ async fn upload(
|
||||
let staging_id = Uuid::new_v4().to_string();
|
||||
let staging_dir = staging_root.join(&staging_id);
|
||||
|
||||
let config = app_config.load();
|
||||
let caps = UploadCaps {
|
||||
manifest: app_config.inference.model_upload_max_manifest_bytes,
|
||||
onnx: app_config.inference.model_upload_max_onnx_bytes,
|
||||
scaler: app_config.inference.model_upload_max_scaler_bytes,
|
||||
manifest: config.ml.model_upload_max_manifest_bytes,
|
||||
onnx: config.ml.model_upload_max_onnx_bytes,
|
||||
scaler: config.ml.model_upload_max_scaler_bytes,
|
||||
};
|
||||
let batch_size = config.ml.inference_batch_size;
|
||||
let onnx_load_timeout = Duration::from_secs(config.ml.onnx_load_timeout_secs);
|
||||
drop(config);
|
||||
let summary = match ingest_multipart(payload, &staging_dir, caps).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
@ -165,17 +163,16 @@ async fn upload(
|
||||
return e.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let batch_size = app_config.inference.inference_batch_size;
|
||||
let outcome = validate_and_promote(
|
||||
&staging_dir,
|
||||
&summary,
|
||||
inference.get_ref(),
|
||||
comm.get_ref(),
|
||||
promote_lock.get_ref(),
|
||||
&claims.username,
|
||||
let outcome = validate_and_promote(&PromoteContext {
|
||||
staging_dir: &staging_dir,
|
||||
summary: &summary,
|
||||
inference: inference.get_ref(),
|
||||
audit_tx: audit_tx.get_ref(),
|
||||
promote_lock: promote_lock.get_ref(),
|
||||
actor_username: &claims.username,
|
||||
batch_size,
|
||||
)
|
||||
onnx_load_timeout,
|
||||
})
|
||||
.await;
|
||||
|
||||
// Always sweep staging — successful promote renames the files out,
|
||||
@ -514,15 +511,26 @@ pub fn looks_like_onnx(first_chunk: &[u8]) -> bool {
|
||||
/// logged but does not roll back the rename; the chain prefers a
|
||||
/// missing audit entry to a rolled-back promote that a downstream
|
||||
/// subscriber may already have reacted to.
|
||||
async fn validate_and_promote(
|
||||
staging_dir: &Path,
|
||||
summary: &UploadSummary,
|
||||
inference: &Inference,
|
||||
comm: &CommunicationManager,
|
||||
promote_lock: &PromoteGate,
|
||||
actor_username: &str,
|
||||
struct PromoteContext<'a> {
|
||||
staging_dir: &'a Path,
|
||||
summary: &'a UploadSummary,
|
||||
inference: &'a Inference,
|
||||
audit_tx: &'a broadcast::Sender<AuditEvent>,
|
||||
promote_lock: &'a PromoteGate,
|
||||
actor_username: &'a str,
|
||||
batch_size: usize,
|
||||
) -> Result<PromoteReport, PromoteError> {
|
||||
onnx_load_timeout: Duration,
|
||||
}
|
||||
|
||||
async fn validate_and_promote(ctx: &PromoteContext<'_>) -> Result<PromoteReport, PromoteError> {
|
||||
let staging_dir = ctx.staging_dir;
|
||||
let summary = ctx.summary;
|
||||
let inference = ctx.inference;
|
||||
let audit_tx = ctx.audit_tx;
|
||||
let promote_lock = ctx.promote_lock;
|
||||
let actor_username = ctx.actor_username;
|
||||
let batch_size = ctx.batch_size;
|
||||
let onnx_load_timeout = ctx.onnx_load_timeout;
|
||||
let staging_manifest = staging_dir.join(MANIFEST_FILENAME);
|
||||
|
||||
// Structural manifest validation. The full `build_adapter` pipeline
|
||||
@ -558,8 +566,14 @@ async fn validate_and_promote(
|
||||
// features, tract optimize+runnable under the 5s load budget.
|
||||
let (config, manifest) = MLInferenceConfig::from_manifest_with_sidecar(&staging_manifest)
|
||||
.map_err(|e| PromoteError::ValidationFailed(e.to_string()))?;
|
||||
let _adapter = build_adapter(&manifest, Some(&staging_manifest), &config, batch_size)
|
||||
.map_err(|e| PromoteError::ValidationFailed(e.to_string()))?;
|
||||
let _adapter = build_adapter(
|
||||
&manifest,
|
||||
Some(&staging_manifest),
|
||||
&config,
|
||||
batch_size,
|
||||
onnx_load_timeout,
|
||||
)
|
||||
.map_err(|e| PromoteError::ValidationFailed(e.to_string()))?;
|
||||
|
||||
let manifest_sha256 = sha256_file(&staging_manifest)
|
||||
.await
|
||||
@ -568,28 +582,34 @@ async fn validate_and_promote(
|
||||
.await
|
||||
.map_err(|e| PromoteError::StagingIo(format!("sha256 onnx: {e}")))?;
|
||||
|
||||
let before_status = inference.current_status();
|
||||
let before_status = inference.model_source_status();
|
||||
|
||||
let _guard = promote_lock.try_acquire().ok_or(PromoteError::ConcurrentPromote)?;
|
||||
let models_dir = PathBuf::from(MODELS_DIR);
|
||||
let target_onnx = models_dir.join(&declared_onnx);
|
||||
fs::rename(&staged_onnx, &target_onnx)
|
||||
.await
|
||||
.map_err(|e| PromoteError::PromoteIo(format!("rename onnx into models/: {e}")))?;
|
||||
|
||||
if let Some(ref pp) = manifest.preprocessing {
|
||||
validate_manifest_basename("preprocessing.scaler_sidecar", &pp.scaler_sidecar)?;
|
||||
let src = staging_dir.join(&pp.scaler_sidecar);
|
||||
let dst = models_dir.join(&pp.scaler_sidecar);
|
||||
fs::rename(&src, &dst)
|
||||
.await
|
||||
.map_err(|e| PromoteError::PromoteIo(format!("rename sidecar into models/: {e}")))?;
|
||||
}
|
||||
|
||||
let target_manifest = models_dir.join(MANIFEST_FILENAME);
|
||||
fs::rename(&staging_manifest, &target_manifest)
|
||||
.await
|
||||
.map_err(|e| PromoteError::PromoteIo(format!("rename manifest into models/: {e}")))?;
|
||||
let staged_sidecar = if let Some(ref pp) = manifest.preprocessing {
|
||||
validate_manifest_basename("preprocessing.scaler_sidecar", &pp.scaler_sidecar)?;
|
||||
Some(staging_dir.join(&pp.scaler_sidecar))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let target_sidecar = manifest
|
||||
.preprocessing
|
||||
.as_ref()
|
||||
.map(|pp| models_dir.join(&pp.scaler_sidecar));
|
||||
|
||||
let _guard = promote_lock.try_acquire().ok_or(PromoteError::ConcurrentPromote)?;
|
||||
let backup_dir = models_dir.join(format!(".promote-backup-{}", Uuid::new_v4()));
|
||||
promote_files_atomically(&PromoteFileSet {
|
||||
staging_manifest: staging_manifest.clone(),
|
||||
staged_onnx,
|
||||
staged_sidecar,
|
||||
target_manifest,
|
||||
target_onnx,
|
||||
target_sidecar,
|
||||
backup_dir,
|
||||
})
|
||||
.await?;
|
||||
drop(_guard);
|
||||
|
||||
let audit_detail = serde_json::json!({
|
||||
@ -600,13 +620,11 @@ async fn validate_and_promote(
|
||||
"before": serde_json::to_value(&before_status).unwrap_or(JsonValue::Null),
|
||||
})
|
||||
.to_string();
|
||||
let _ = comm
|
||||
.publish_event(AuditEvent {
|
||||
actor: format!("{AUDIT_ACTOR_SECURITY_ADMIN_PREFIX}@{actor_username}"),
|
||||
action: AUDIT_ACTION_MODEL_SWAP.to_string(),
|
||||
detail: audit_detail,
|
||||
})
|
||||
.await;
|
||||
let _ = audit_tx.send(AuditEvent {
|
||||
actor: format!("{AUDIT_ACTOR_SECURITY_ADMIN_PREFIX}@{actor_username}"),
|
||||
action: AUDIT_ACTION_MODEL_SWAP.to_string(),
|
||||
detail: audit_detail,
|
||||
});
|
||||
|
||||
Ok(PromoteReport {
|
||||
manifest_name: manifest.name,
|
||||
@ -616,6 +634,105 @@ async fn validate_and_promote(
|
||||
})
|
||||
}
|
||||
|
||||
struct PromoteFileSet {
|
||||
staging_manifest: PathBuf,
|
||||
staged_onnx: PathBuf,
|
||||
staged_sidecar: Option<PathBuf>,
|
||||
target_manifest: PathBuf,
|
||||
target_onnx: PathBuf,
|
||||
target_sidecar: Option<PathBuf>,
|
||||
backup_dir: PathBuf,
|
||||
}
|
||||
|
||||
async fn promote_files_atomically(files: &PromoteFileSet) -> Result<(), PromoteError> {
|
||||
fs::create_dir_all(&files.backup_dir)
|
||||
.await
|
||||
.map_err(|e| PromoteError::PromoteIo(format!("create promote backup dir: {e}")))?;
|
||||
|
||||
let backup_manifest = backup_existing(&files.target_manifest, &files.backup_dir, "manifest.yaml").await?;
|
||||
let backup_onnx = backup_existing(&files.target_onnx, &files.backup_dir, "model.onnx").await?;
|
||||
let backup_sidecar = match &files.target_sidecar {
|
||||
Some(target) => Some(backup_existing(target, &files.backup_dir, "sidecar").await?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let result = async {
|
||||
move_file(&files.staged_onnx, &files.target_onnx, "rename onnx into models/").await?;
|
||||
if let (Some(src), Some(dst)) = (&files.staged_sidecar, &files.target_sidecar) {
|
||||
move_file(src, dst, "rename sidecar into models/").await?;
|
||||
}
|
||||
move_file(
|
||||
&files.staging_manifest,
|
||||
&files.target_manifest,
|
||||
"rename manifest into models/",
|
||||
)
|
||||
.await
|
||||
}
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
let _ = fs::remove_dir_all(&files.backup_dir).await;
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => {
|
||||
rollback_promote(files, backup_manifest, backup_onnx, backup_sidecar).await;
|
||||
let _ = fs::remove_dir_all(&files.backup_dir).await;
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn backup_existing(target: &Path, backup_dir: &Path, backup_name: &str) -> Result<Option<PathBuf>, PromoteError> {
|
||||
if !target
|
||||
.try_exists()
|
||||
.map_err(|e| PromoteError::PromoteIo(format!("check existing target {}: {e}", target.display())))?
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let backup = backup_dir.join(backup_name);
|
||||
fs::rename(target, &backup)
|
||||
.await
|
||||
.map_err(|e| PromoteError::PromoteIo(format!("backup existing target {}: {e}", target.display())))?;
|
||||
Ok(Some(backup))
|
||||
}
|
||||
|
||||
async fn move_file(src: &Path, dst: &Path, op: &str) -> Result<(), PromoteError> {
|
||||
fs::rename(src, dst)
|
||||
.await
|
||||
.map_err(|e| PromoteError::PromoteIo(format!("{op}: {e}")))
|
||||
}
|
||||
|
||||
async fn rollback_promote(
|
||||
files: &PromoteFileSet,
|
||||
backup_manifest: Option<PathBuf>,
|
||||
backup_onnx: Option<PathBuf>,
|
||||
backup_sidecar: Option<Option<PathBuf>>,
|
||||
) {
|
||||
remove_if_exists(&files.target_manifest).await;
|
||||
remove_if_exists(&files.target_onnx).await;
|
||||
if let Some(target) = &files.target_sidecar {
|
||||
remove_if_exists(target).await;
|
||||
}
|
||||
restore_backup(backup_manifest, &files.target_manifest).await;
|
||||
restore_backup(backup_onnx, &files.target_onnx).await;
|
||||
if let (Some(backup), Some(target)) = (backup_sidecar.flatten(), &files.target_sidecar) {
|
||||
restore_backup(Some(backup), target).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove_if_exists(path: &Path) {
|
||||
if let Ok(true) = path.try_exists() {
|
||||
let _ = fs::remove_file(path).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn restore_backup(backup: Option<PathBuf>, target: &Path) {
|
||||
if let Some(backup) = backup {
|
||||
let _ = fs::rename(backup, target).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Metadata surfaced back to the client when the promote succeeds.
|
||||
#[derive(Debug)]
|
||||
struct PromoteReport {
|
||||
@ -692,36 +809,12 @@ async fn sha256_file(path: &Path) -> io::Result<String> {
|
||||
.unwrap_or_else(|e| Err(io::Error::other(format!("sha256 join: {e}"))))
|
||||
}
|
||||
|
||||
/// Remove staging subdirectories older than `max_age`. Runs on startup
|
||||
/// and on a periodic timer so failed uploads don't accumulate.
|
||||
pub fn clean_staging_orphans(staging_root: &Path, max_age: Duration) -> io::Result<usize> {
|
||||
if !staging_root.exists() {
|
||||
return Ok(0);
|
||||
}
|
||||
let now = SystemTime::now();
|
||||
let mut cleaned = 0usize;
|
||||
for entry in std_fs::read_dir(staging_root)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let metadata = entry.metadata()?;
|
||||
let mtime = metadata.modified()?;
|
||||
let age = now.duration_since(mtime).unwrap_or_default();
|
||||
if age >= max_age {
|
||||
std_fs::remove_dir_all(&path)?;
|
||||
cleaned += 1;
|
||||
}
|
||||
}
|
||||
Ok(cleaned)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
use super::*;
|
||||
use crate::utils::staging::clean_staging_orphans;
|
||||
|
||||
#[test]
|
||||
fn onnx_sniff_rejects_empty() {
|
||||
@ -870,6 +963,48 @@ mod tests {
|
||||
assert_eq!(resp.status().as_u16(), 500);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn promote_files_rolls_back_active_files_when_sidecar_move_fails() {
|
||||
let tmp = std::env::temp_dir().join(format!("nguardia-promote-rollback-{}", Uuid::new_v4()));
|
||||
let staging = tmp.join("staging");
|
||||
let models = tmp.join("models");
|
||||
let backup = models.join(".promote-backup-test");
|
||||
fs::create_dir_all(&staging).await.unwrap();
|
||||
fs::create_dir_all(&models).await.unwrap();
|
||||
|
||||
let staging_manifest = staging.join(MANIFEST_FILENAME);
|
||||
let staged_onnx = staging.join("model.onnx");
|
||||
let missing_sidecar = staging.join("missing-scaler.json");
|
||||
let target_manifest = models.join(MANIFEST_FILENAME);
|
||||
let target_onnx = models.join("model.onnx");
|
||||
let target_sidecar = models.join("scaler.json");
|
||||
|
||||
fs::write(&staging_manifest, b"new manifest").await.unwrap();
|
||||
fs::write(&staged_onnx, b"new onnx").await.unwrap();
|
||||
fs::write(&target_manifest, b"old manifest").await.unwrap();
|
||||
fs::write(&target_onnx, b"old onnx").await.unwrap();
|
||||
fs::write(&target_sidecar, b"old sidecar").await.unwrap();
|
||||
|
||||
let err = promote_files_atomically(&PromoteFileSet {
|
||||
staging_manifest: staging_manifest.clone(),
|
||||
staged_onnx,
|
||||
staged_sidecar: Some(missing_sidecar),
|
||||
target_manifest: target_manifest.clone(),
|
||||
target_onnx: target_onnx.clone(),
|
||||
target_sidecar: Some(target_sidecar.clone()),
|
||||
backup_dir: backup,
|
||||
})
|
||||
.await
|
||||
.expect_err("missing sidecar should fail promote");
|
||||
|
||||
assert!(matches!(err, PromoteError::PromoteIo(_)));
|
||||
assert_eq!(fs::read(&target_manifest).await.unwrap(), b"old manifest");
|
||||
assert_eq!(fs::read(&target_onnx).await.unwrap(), b"old onnx");
|
||||
assert_eq!(fs::read(&target_sidecar).await.unwrap(), b"old sidecar");
|
||||
|
||||
fs::remove_dir_all(&tmp).await.ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upload_error_scaler_too_large_maps_to_413_and_echoes_cap() {
|
||||
let resp = UploadError::ScalerTooLarge(1234).into_response();
|
||||
@ -1,7 +1,7 @@
|
||||
use actix_web::{HttpResponse, Responder, Scope, web};
|
||||
|
||||
use crate::adapter::ebpf::drop_monitor::DropMonitor;
|
||||
use crate::infrastructure::statistics::FlowStatistics;
|
||||
use crate::core::common::statistics::FlowStatistics;
|
||||
use crate::interface::drop_stats::DropStatsPort;
|
||||
|
||||
pub fn initialize() -> Scope {
|
||||
web::scope("/stats")
|
||||
@ -24,6 +24,6 @@ async fn get_summary(stats: web::Data<FlowStatistics>) -> impl Responder {
|
||||
HttpResponse::Ok().json(stats.get_summary())
|
||||
}
|
||||
|
||||
async fn get_drop_stats(monitor: web::Data<DropMonitor>) -> impl Responder {
|
||||
async fn get_drop_stats(monitor: web::Data<dyn DropStatsPort>) -> impl Responder {
|
||||
HttpResponse::Ok().json(monitor.get_counters())
|
||||
}
|
||||
@ -1,259 +0,0 @@
|
||||
use std::fmt;
|
||||
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddrV4, SocketAddrV6};
|
||||
|
||||
use actix_web::{HttpResponse, Responder, Scope, web};
|
||||
use common::model::http_method::HttpMethod;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::adapter::ebpf::protocol_filter::ProtocolFilter;
|
||||
use crate::core::dns_filter_service::DnsFilterService;
|
||||
|
||||
/// Convert a fallible result into an Ok (200) or InternalServerError (500) response.
|
||||
fn ok_or_error<T, E: fmt::Display>(result: Result<T, E>) -> HttpResponse {
|
||||
match result {
|
||||
Ok(_) => HttpResponse::Ok().finish(),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn initialize() -> Scope {
|
||||
web::scope("/filter")
|
||||
.service(http_scope())
|
||||
.service(ssh_scope())
|
||||
.service(dns_scope())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DnsDomainsPayload {
|
||||
domains: Vec<String>,
|
||||
}
|
||||
|
||||
fn dns_scope() -> Scope {
|
||||
web::scope("/dns").service(
|
||||
web::scope("/blacklist")
|
||||
.route("", web::get().to(get_dns_blacklist))
|
||||
.route("", web::put().to(add_dns_blacklist))
|
||||
.route("", web::delete().to(remove_dns_blacklist)),
|
||||
)
|
||||
}
|
||||
|
||||
async fn get_dns_blacklist(service: web::Data<DnsFilterService>) -> impl Responder {
|
||||
HttpResponse::Ok().json(serde_json::json!({"domains": service.list_domains()}))
|
||||
}
|
||||
|
||||
async fn add_dns_blacklist(
|
||||
payload: web::Json<DnsDomainsPayload>,
|
||||
service: web::Data<DnsFilterService>,
|
||||
) -> impl Responder {
|
||||
let domains = payload.into_inner().domains;
|
||||
match service.add_domains(&domains) {
|
||||
Ok(count) => HttpResponse::Ok().json(serde_json::json!({"added": count})),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove_dns_blacklist(
|
||||
payload: web::Json<DnsDomainsPayload>,
|
||||
service: web::Data<DnsFilterService>,
|
||||
) -> impl Responder {
|
||||
let domains = payload.into_inner().domains;
|
||||
match service.remove_domains(&domains) {
|
||||
Ok(count) => HttpResponse::Ok().json(serde_json::json!({"removed": count})),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
|
||||
}
|
||||
}
|
||||
|
||||
fn http_scope() -> Scope {
|
||||
web::scope("/http")
|
||||
.route("/ipv4", web::get().to(get_ipv4_http_service))
|
||||
.route("/ipv6", web::get().to(get_ipv6_http_service))
|
||||
.route("/ipv4", web::put().to(add_ipv4_http_service))
|
||||
.route("/ipv6", web::put().to(add_ipv6_http_service))
|
||||
.route("/ipv4", web::delete().to(remove_ipv4_http_service))
|
||||
.route("/ipv6", web::delete().to(remove_ipv6_http_service))
|
||||
}
|
||||
|
||||
fn ssh_scope() -> Scope {
|
||||
web::scope("/ssh")
|
||||
.route("/ipv4", web::get().to(get_ipv4_ssh_service))
|
||||
.route("/ipv6", web::get().to(get_ipv6_ssh_service))
|
||||
.route("/ipv4", web::put().to(add_ipv4_ssh_service))
|
||||
.route("/ipv6", web::put().to(add_ipv6_ssh_service))
|
||||
.route("/ipv4", web::delete().to(remove_ipv4_ssh_service))
|
||||
.route("/ipv6", web::delete().to(remove_ipv6_ssh_service))
|
||||
.service(ssh_whitelist_scope())
|
||||
.service(ssh_blacklist_scope())
|
||||
}
|
||||
|
||||
fn ssh_whitelist_scope() -> Scope {
|
||||
web::scope("/whitelist")
|
||||
.route("/status", web::get().to(is_ssh_white_list_enable))
|
||||
.route("/enable", web::post().to(enable_ssh_white_list))
|
||||
.route("/disable", web::post().to(disable_ssh_white_list))
|
||||
.route("/ipv4", web::get().to(get_ipv4_ssh_white_list))
|
||||
.route("/ipv6", web::get().to(get_ipv6_ssh_white_list))
|
||||
.route("/ipv4", web::put().to(add_ipv4_ssh_white_list))
|
||||
.route("/ipv6", web::put().to(add_ipv6_ssh_white_list))
|
||||
.route("/ipv4", web::delete().to(remove_ipv4_ssh_white_list))
|
||||
.route("/ipv6", web::delete().to(remove_ipv6_ssh_white_list))
|
||||
}
|
||||
|
||||
fn ssh_blacklist_scope() -> Scope {
|
||||
web::scope("/blacklist")
|
||||
.route("/ipv4", web::get().to(get_ipv4_ssh_black_list))
|
||||
.route("/ipv6", web::get().to(get_ipv6_ssh_black_list))
|
||||
.route("/ipv4", web::put().to(add_ipv4_ssh_black_list))
|
||||
.route("/ipv6", web::put().to(add_ipv6_ssh_black_list))
|
||||
.route("/ipv4", web::delete().to(remove_ipv4_ssh_black_list))
|
||||
.route("/ipv6", web::delete().to(remove_ipv6_ssh_black_list))
|
||||
}
|
||||
|
||||
// --- HTTP service handlers ---
|
||||
|
||||
async fn get_ipv4_http_service(service: web::Data<ProtocolFilter>) -> impl Responder {
|
||||
HttpResponse::Ok().json(service.get_ipv4_http_service())
|
||||
}
|
||||
|
||||
async fn get_ipv6_http_service(service: web::Data<ProtocolFilter>) -> impl Responder {
|
||||
HttpResponse::Ok().json(service.get_ipv6_http_service())
|
||||
}
|
||||
|
||||
async fn add_ipv4_http_service(
|
||||
payload: web::Json<(SocketAddrV4, Vec<HttpMethod>)>,
|
||||
service: web::Data<ProtocolFilter>,
|
||||
) -> impl Responder {
|
||||
let (addr, methods) = payload.into_inner();
|
||||
ok_or_error(service.add_ipv4_http_service(addr, methods))
|
||||
}
|
||||
|
||||
async fn add_ipv6_http_service(
|
||||
payload: web::Json<(SocketAddrV6, Vec<HttpMethod>)>,
|
||||
service: web::Data<ProtocolFilter>,
|
||||
) -> impl Responder {
|
||||
let (addr, methods) = payload.into_inner();
|
||||
ok_or_error(service.add_ipv6_http_service(addr, methods))
|
||||
}
|
||||
|
||||
async fn remove_ipv4_http_service(
|
||||
payload: web::Json<(SocketAddrV4, Vec<HttpMethod>)>,
|
||||
service: web::Data<ProtocolFilter>,
|
||||
) -> impl Responder {
|
||||
let (addr, methods) = payload.into_inner();
|
||||
ok_or_error(service.remove_ipv4_http_service(addr, methods))
|
||||
}
|
||||
|
||||
async fn remove_ipv6_http_service(
|
||||
payload: web::Json<(SocketAddrV6, Vec<HttpMethod>)>,
|
||||
service: web::Data<ProtocolFilter>,
|
||||
) -> impl Responder {
|
||||
let (addr, methods) = payload.into_inner();
|
||||
ok_or_error(service.remove_ipv6_http_service(addr, methods))
|
||||
}
|
||||
|
||||
// --- SSH service handlers ---
|
||||
|
||||
async fn get_ipv4_ssh_service(service: web::Data<ProtocolFilter>) -> impl Responder {
|
||||
HttpResponse::Ok().json(service.get_ipv4_ssh_service())
|
||||
}
|
||||
|
||||
async fn get_ipv6_ssh_service(service: web::Data<ProtocolFilter>) -> impl Responder {
|
||||
HttpResponse::Ok().json(service.get_ipv6_ssh_service())
|
||||
}
|
||||
|
||||
async fn add_ipv4_ssh_service(ip_addr: web::Json<SocketAddrV4>, service: web::Data<ProtocolFilter>) -> impl Responder {
|
||||
ok_or_error(service.add_ipv4_ssh_service(ip_addr.into_inner()))
|
||||
}
|
||||
|
||||
async fn add_ipv6_ssh_service(ip_addr: web::Json<SocketAddrV6>, service: web::Data<ProtocolFilter>) -> impl Responder {
|
||||
ok_or_error(service.add_ipv6_ssh_service(ip_addr.into_inner()))
|
||||
}
|
||||
|
||||
async fn remove_ipv4_ssh_service(
|
||||
ip_addr: web::Json<SocketAddrV4>,
|
||||
service: web::Data<ProtocolFilter>,
|
||||
) -> impl Responder {
|
||||
ok_or_error(service.remove_ipv4_ssh_service(ip_addr.into_inner()))
|
||||
}
|
||||
|
||||
async fn remove_ipv6_ssh_service(
|
||||
ip_addr: web::Json<SocketAddrV6>,
|
||||
service: web::Data<ProtocolFilter>,
|
||||
) -> impl Responder {
|
||||
ok_or_error(service.remove_ipv6_ssh_service(ip_addr.into_inner()))
|
||||
}
|
||||
|
||||
// --- SSH whitelist handlers ---
|
||||
|
||||
async fn is_ssh_white_list_enable(service: web::Data<ProtocolFilter>) -> impl Responder {
|
||||
HttpResponse::Ok().json(service.is_ssh_white_list_enable())
|
||||
}
|
||||
|
||||
async fn enable_ssh_white_list(service: web::Data<ProtocolFilter>) -> impl Responder {
|
||||
ok_or_error(service.enable_ssh_white_list())
|
||||
}
|
||||
|
||||
async fn disable_ssh_white_list(service: web::Data<ProtocolFilter>) -> impl Responder {
|
||||
ok_or_error(service.disable_ssh_white_list())
|
||||
}
|
||||
|
||||
async fn get_ipv4_ssh_white_list(service: web::Data<ProtocolFilter>) -> impl Responder {
|
||||
HttpResponse::Ok().json(service.get_ipv4_ssh_white_list())
|
||||
}
|
||||
|
||||
async fn get_ipv6_ssh_white_list(service: web::Data<ProtocolFilter>) -> impl Responder {
|
||||
HttpResponse::Ok().json(service.get_ipv6_ssh_white_list())
|
||||
}
|
||||
|
||||
async fn add_ipv4_ssh_white_list(ip_addr: web::Json<Ipv4Addr>, service: web::Data<ProtocolFilter>) -> impl Responder {
|
||||
ok_or_error(service.add_ipv4_ssh_white_list(ip_addr.into_inner()))
|
||||
}
|
||||
|
||||
async fn add_ipv6_ssh_white_list(ip_addr: web::Json<Ipv6Addr>, service: web::Data<ProtocolFilter>) -> impl Responder {
|
||||
ok_or_error(service.add_ipv6_ssh_white_list(ip_addr.into_inner()))
|
||||
}
|
||||
|
||||
async fn remove_ipv4_ssh_white_list(
|
||||
ip_addr: web::Json<Ipv4Addr>,
|
||||
service: web::Data<ProtocolFilter>,
|
||||
) -> impl Responder {
|
||||
ok_or_error(service.remove_ipv4_ssh_white_list(ip_addr.into_inner()))
|
||||
}
|
||||
|
||||
async fn remove_ipv6_ssh_white_list(
|
||||
ip_addr: web::Json<Ipv6Addr>,
|
||||
service: web::Data<ProtocolFilter>,
|
||||
) -> impl Responder {
|
||||
ok_or_error(service.remove_ipv6_ssh_white_list(ip_addr.into_inner()))
|
||||
}
|
||||
|
||||
// --- SSH blacklist handlers ---
|
||||
|
||||
async fn get_ipv4_ssh_black_list(service: web::Data<ProtocolFilter>) -> impl Responder {
|
||||
HttpResponse::Ok().json(service.get_ipv4_ssh_black_list())
|
||||
}
|
||||
|
||||
async fn get_ipv6_ssh_black_list(service: web::Data<ProtocolFilter>) -> impl Responder {
|
||||
HttpResponse::Ok().json(service.get_ipv6_ssh_black_list())
|
||||
}
|
||||
|
||||
async fn add_ipv4_ssh_black_list(ip_addr: web::Json<Ipv4Addr>, service: web::Data<ProtocolFilter>) -> impl Responder {
|
||||
ok_or_error(service.add_ipv4_ssh_black_list(ip_addr.into_inner()))
|
||||
}
|
||||
|
||||
async fn add_ipv6_ssh_black_list(ip_addr: web::Json<Ipv6Addr>, service: web::Data<ProtocolFilter>) -> impl Responder {
|
||||
ok_or_error(service.add_ipv6_ssh_black_list(ip_addr.into_inner()))
|
||||
}
|
||||
|
||||
async fn remove_ipv4_ssh_black_list(
|
||||
ip_addr: web::Json<Ipv4Addr>,
|
||||
service: web::Data<ProtocolFilter>,
|
||||
) -> impl Responder {
|
||||
ok_or_error(service.remove_ipv4_ssh_black_list(ip_addr.into_inner()))
|
||||
}
|
||||
|
||||
async fn remove_ipv6_ssh_black_list(
|
||||
ip_addr: web::Json<Ipv6Addr>,
|
||||
service: web::Data<ProtocolFilter>,
|
||||
) -> impl Responder {
|
||||
ok_or_error(service.remove_ipv6_ssh_black_list(ip_addr.into_inner()))
|
||||
}
|
||||
18
net-guardia/src/adapter/http/helpers.rs
Normal file
18
net-guardia/src/adapter/http/helpers.rs
Normal file
@ -0,0 +1,18 @@
|
||||
use std::fmt;
|
||||
|
||||
use actix_web::HttpResponse;
|
||||
use serde::Serialize;
|
||||
|
||||
pub fn ok_or_error<T, E: fmt::Display>(result: Result<T, E>) -> HttpResponse {
|
||||
match result {
|
||||
Ok(_) => HttpResponse::Ok().finish(),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ok_json_or_error<T: Serialize, E: fmt::Display>(result: Result<T, E>) -> HttpResponse {
|
||||
match result {
|
||||
Ok(value) => HttpResponse::Ok().json(value),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
|
||||
}
|
||||
}
|
||||
@ -1,8 +1,9 @@
|
||||
use actix_web::{HttpResponse, Scope, web};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::core::auth::extractor::AuthClaims;
|
||||
use crate::interface::port::api_key::ApiKeyRepo;
|
||||
use crate::adapter::http::middleware::extractor::AuthClaims;
|
||||
use crate::domain::identity::auth::PermissionLevel;
|
||||
use crate::interface::api_key::ApiKeyRepo;
|
||||
|
||||
pub fn initialize() -> Scope {
|
||||
web::scope("/api-keys")
|
||||
@ -12,17 +13,17 @@ pub fn initialize() -> Scope {
|
||||
}
|
||||
|
||||
async fn list_keys(_auth: AuthClaims, db: web::Data<dyn ApiKeyRepo>) -> HttpResponse {
|
||||
match db.list_api_keys() {
|
||||
match db.list_api_keys().await {
|
||||
Ok(keys) => {
|
||||
let responses: Vec<serde_json::Value> = keys
|
||||
.into_iter()
|
||||
.map(|(id, name, level, created, last_used)| {
|
||||
.map(|k| {
|
||||
serde_json::json!({
|
||||
"id": id,
|
||||
"name": name,
|
||||
"permission_level": level,
|
||||
"created_at": created,
|
||||
"last_used_at": last_used,
|
||||
"id": k.id,
|
||||
"name": k.name,
|
||||
"permission_level": k.permission_level,
|
||||
"created_at": k.created_at,
|
||||
"last_used_at": k.last_used_at,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@ -54,19 +55,19 @@ async fn generate_key(
|
||||
|
||||
let key_hash = db.hmac_api_key(&raw_key);
|
||||
|
||||
let level = body.level.as_deref().unwrap_or("read_only");
|
||||
if !matches!(level, "read_only" | "read_write" | "full_access") {
|
||||
let raw_level = body.level.as_deref().unwrap_or("read_only");
|
||||
let Some(level) = PermissionLevel::from_str(raw_level) else {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"error": "Invalid permission level. Must be: read_only, read_write, or full_access"
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
match db.insert_api_key(&key_hash, &body.name, level) {
|
||||
match db.insert_api_key(&key_hash, &body.name, level.as_str()).await {
|
||||
Ok(id) => HttpResponse::Created().json(serde_json::json!({
|
||||
"id": id,
|
||||
"key": raw_key,
|
||||
"name": body.name,
|
||||
"permission_level": level,
|
||||
"permission_level": level.as_str(),
|
||||
})),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
|
||||
}
|
||||
@ -74,7 +75,7 @@ async fn generate_key(
|
||||
|
||||
async fn delete_key(_auth: AuthClaims, db: web::Data<dyn ApiKeyRepo>, path: web::Path<i64>) -> HttpResponse {
|
||||
let id = path.into_inner();
|
||||
match db.delete_api_key(id) {
|
||||
match db.delete_api_key(id).await {
|
||||
Ok(true) => HttpResponse::Ok().json(serde_json::json!({"deleted": true})),
|
||||
Ok(false) => HttpResponse::NotFound().json(serde_json::json!({"error": "Key not found"})),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
|
||||
@ -1,15 +1,20 @@
|
||||
use actix_web::{HttpResponse, Responder, Scope, web};
|
||||
use macros::log;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::core::auth::extractor::AuthClaims;
|
||||
use crate::core::auth::jwt::JwtService;
|
||||
use crate::core::auth::password;
|
||||
use crate::interface::port::app_repo::AppRepo;
|
||||
use crate::model::error::auth::AuthError;
|
||||
use crate::adapter::http::helpers::ok_or_error;
|
||||
use crate::adapter::http::middleware::extractor::AuthClaims;
|
||||
use crate::core::identity::auth_service::{AuthService, LoginError, RegisterError};
|
||||
use crate::domain::identity::auth::{DEFAULT_ADMIN_USERNAME, GROUP_ADMIN, GROUP_VIEWER, ROLE_ADMIN, ROLE_VIEWER};
|
||||
use crate::domain::identity::password;
|
||||
use crate::domain::identity::validation::validate_password;
|
||||
use crate::interface::app_repo::AppRepo;
|
||||
|
||||
type Repo = dyn AppRepo;
|
||||
|
||||
fn parse_permissions(raw: &str) -> serde_json::Value {
|
||||
serde_json::from_str(raw).unwrap_or(serde_json::json!([]))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LoginRequest {
|
||||
username: String,
|
||||
@ -47,157 +52,50 @@ pub fn initialize() -> Scope {
|
||||
.route("/groups/{id}", web::delete().to(delete_group))
|
||||
}
|
||||
|
||||
fn validate_username(username: &str) -> Result<(), &'static str> {
|
||||
if username.is_empty() || username.len() > 32 {
|
||||
return Err("Username must be 1-32 characters");
|
||||
}
|
||||
if !username.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
|
||||
return Err("Username must contain only alphanumeric characters and underscores");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_password(password: &str) -> Result<(), &'static str> {
|
||||
if password.len() < 8 {
|
||||
return Err("Password must be at least 8 characters");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Dummy Argon2 hash used to prevent timing-based username enumeration.
|
||||
/// When a user doesn't exist, we still run verify_password against this
|
||||
/// so the response time is indistinguishable from a real user lookup.
|
||||
const DUMMY_HASH: &str = "$argon2id$v=19$m=19456,t=2,p=1$dW5rbm93bg$QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE";
|
||||
|
||||
async fn login(body: web::Json<LoginRequest>, db: web::Data<Repo>, jwt: web::Data<JwtService>) -> impl Responder {
|
||||
async fn login(body: web::Json<LoginRequest>, auth_svc: web::Data<AuthService>) -> impl Responder {
|
||||
let req = body.into_inner();
|
||||
|
||||
// Check login lockout
|
||||
match db.check_login_locked(&req.username) {
|
||||
Ok(Some(remaining_secs)) => {
|
||||
return HttpResponse::TooManyRequests().json(serde_json::json!({
|
||||
"error": "Account temporarily locked due to too many failed login attempts",
|
||||
"retry_after_secs": remaining_secs,
|
||||
}));
|
||||
}
|
||||
Err(_) => {}
|
||||
Ok(None) => {}
|
||||
}
|
||||
|
||||
let user = match db.find_user(&req.username) {
|
||||
Ok(Some(u)) => u,
|
||||
_ => {
|
||||
// Run dummy hash verification to prevent timing-based username enumeration
|
||||
let _ = password::verify_password(&req.password, DUMMY_HASH);
|
||||
if let Err(e) = db.record_login_failure(&req.username) {
|
||||
log!(AuthError::LoginFailureTrackingError(e));
|
||||
}
|
||||
return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid credentials"}));
|
||||
}
|
||||
};
|
||||
|
||||
let (id, username, hash, _db_role, force_password_change) = user;
|
||||
|
||||
match password::verify_password(&req.password, &hash) {
|
||||
Ok(true) => {}
|
||||
_ => {
|
||||
if let Err(e) = db.record_login_failure(&req.username) {
|
||||
log!(AuthError::LoginFailureTrackingError(e));
|
||||
}
|
||||
return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid credentials"}));
|
||||
}
|
||||
}
|
||||
|
||||
// Clear login failures on success
|
||||
if let Err(e) = db.clear_login_failures(&req.username) {
|
||||
log!(AuthError::LoginClearError(e));
|
||||
}
|
||||
|
||||
// Permissions come exclusively from groups — no role-based fallback
|
||||
let permissions = db.get_user_permissions(id).unwrap_or_default();
|
||||
|
||||
let groups = db.get_user_groups(id).unwrap_or_default();
|
||||
let role = if groups.iter().any(|(_id, name, _desc, _perms)| name == "Administrator") {
|
||||
"admin".to_string()
|
||||
} else {
|
||||
"viewer".to_string()
|
||||
};
|
||||
|
||||
match jwt.create_token(id, &username, &role, permissions) {
|
||||
Ok(token) => HttpResponse::Ok().json(serde_json::json!({
|
||||
"token": token,
|
||||
"role": role,
|
||||
"force_password_change": force_password_change,
|
||||
match auth_svc.login(&req.username, &req.password).await {
|
||||
Ok(result) => HttpResponse::Ok().json(result),
|
||||
Err(LoginError::Locked { retry_after_secs }) => HttpResponse::TooManyRequests().json(serde_json::json!({
|
||||
"error": "Account temporarily locked due to too many failed login attempts",
|
||||
"retry_after_secs": retry_after_secs,
|
||||
})),
|
||||
Err(_) => HttpResponse::InternalServerError().json(serde_json::json!({"error": "Failed to create token"})),
|
||||
Err(LoginError::InvalidCredentials) => {
|
||||
HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid credentials"}))
|
||||
}
|
||||
Err(LoginError::InternalError) => {
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Failed to create token"}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn register(auth: AuthClaims, body: web::Json<RegisterRequest>, db: web::Data<Repo>) -> impl Responder {
|
||||
async fn register(
|
||||
auth: AuthClaims,
|
||||
body: web::Json<RegisterRequest>,
|
||||
auth_svc: web::Data<AuthService>,
|
||||
) -> impl Responder {
|
||||
let reg = body.into_inner();
|
||||
|
||||
// Validate input
|
||||
if let Err(msg) = validate_username(®.username) {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({"error": msg}));
|
||||
}
|
||||
if let Err(msg) = validate_password(®.password) {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({"error": msg}));
|
||||
}
|
||||
|
||||
// Validate role
|
||||
if reg.role != "admin" && reg.role != "viewer" {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({"error": "Role must be 'admin' or 'viewer'"}));
|
||||
}
|
||||
|
||||
// Only admins can create admin accounts
|
||||
if reg.role == "admin" && auth.role != "admin" {
|
||||
return HttpResponse::Forbidden()
|
||||
.json(serde_json::json!({"error": "Only administrators can create admin accounts"}));
|
||||
}
|
||||
|
||||
let hash = match password::hash_password(®.password) {
|
||||
Ok(h) => h,
|
||||
Err(_) => {
|
||||
return HttpResponse::InternalServerError().json(serde_json::json!({"error": "Failed to hash password"}));
|
||||
match auth_svc
|
||||
.register(®.username, ®.password, ®.role, &auth.role)
|
||||
.await
|
||||
{
|
||||
Ok(_) => HttpResponse::Created().json(serde_json::json!({"username": reg.username, "role": reg.role})),
|
||||
Err(RegisterError::Validation(msg)) => HttpResponse::BadRequest().json(serde_json::json!({"error": msg})),
|
||||
Err(RegisterError::InvalidRole) => {
|
||||
HttpResponse::BadRequest().json(serde_json::json!({"error": "Role must be 'admin' or 'viewer'"}))
|
||||
}
|
||||
};
|
||||
|
||||
match db.insert_user(®.username, &hash, ®.role, false) {
|
||||
Ok(new_user_id) => {
|
||||
// Auto-assign to default group based on role
|
||||
let default_group_name = if reg.role == "admin" { "Administrator" } else { "Viewer" };
|
||||
if let Ok(groups) = db.list_user_groups()
|
||||
&& let Some((group_id, _, _, _, _)) =
|
||||
groups.into_iter().find(|(_, name, _, _, _)| name == default_group_name)
|
||||
&& let Err(e) = db.set_user_groups(new_user_id, &[group_id])
|
||||
{
|
||||
log!(AuthError::GroupAssignmentFailed(e));
|
||||
}
|
||||
HttpResponse::Created().json(serde_json::json!({"username": reg.username, "role": reg.role}))
|
||||
Err(RegisterError::Forbidden) => HttpResponse::Forbidden()
|
||||
.json(serde_json::json!({"error": "Only administrators can create admin accounts"})),
|
||||
Err(RegisterError::HashFailed) => {
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Failed to hash password"}))
|
||||
}
|
||||
Err(e) => HttpResponse::Conflict().json(serde_json::json!({"error": e.to_string()})),
|
||||
Err(RegisterError::Conflict(e)) => HttpResponse::Conflict().json(serde_json::json!({"error": e.to_string()})),
|
||||
}
|
||||
}
|
||||
|
||||
async fn me(auth: AuthClaims, db: web::Data<Repo>) -> impl Responder {
|
||||
let user_groups = db.get_user_groups(auth.sub).unwrap_or_default();
|
||||
let group_names: Vec<String> = user_groups
|
||||
.iter()
|
||||
.map(|(_id, name, _desc, _perms)| name.clone())
|
||||
.collect();
|
||||
let role = if group_names.iter().any(|n| n == "Administrator") {
|
||||
"admin"
|
||||
} else {
|
||||
"viewer"
|
||||
};
|
||||
let permissions = db.get_user_permissions(auth.sub).unwrap_or_default();
|
||||
HttpResponse::Ok().json(serde_json::json!({
|
||||
"id": auth.sub,
|
||||
"username": auth.username,
|
||||
"role": role,
|
||||
"permissions": permissions,
|
||||
"groups": group_names,
|
||||
}))
|
||||
async fn me(auth: AuthClaims, auth_svc: web::Data<AuthService>) -> impl Responder {
|
||||
let profile = auth_svc.user_profile(auth.sub, &auth.username).await;
|
||||
HttpResponse::Ok().json(profile)
|
||||
}
|
||||
|
||||
async fn change_password(
|
||||
@ -205,32 +103,26 @@ async fn change_password(
|
||||
body: web::Json<ChangePasswordRequest>,
|
||||
db: web::Data<Repo>,
|
||||
) -> impl Responder {
|
||||
let claims = &*auth;
|
||||
let change_req = body.into_inner();
|
||||
|
||||
// Validate new password
|
||||
if let Err(msg) = validate_password(&change_req.new_password) {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({"error": msg}));
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
let user = match db.find_user(&claims.username) {
|
||||
let user = match db.find_user(&auth.username).await {
|
||||
Ok(Some(u)) => u,
|
||||
_ => {
|
||||
return HttpResponse::InternalServerError().json(serde_json::json!({"error": "User not found"}));
|
||||
}
|
||||
};
|
||||
|
||||
let (_id, _username, hash, _role, _force) = user;
|
||||
|
||||
match password::verify_password(&change_req.current_password, &hash) {
|
||||
match password::verify_password(&change_req.current_password, &user.password_hash) {
|
||||
Ok(true) => {}
|
||||
_ => {
|
||||
return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Current password is incorrect"}));
|
||||
}
|
||||
}
|
||||
|
||||
// Hash and update
|
||||
let new_hash = match password::hash_password(&change_req.new_password) {
|
||||
Ok(h) => h,
|
||||
Err(_) => {
|
||||
@ -238,35 +130,34 @@ async fn change_password(
|
||||
}
|
||||
};
|
||||
|
||||
match db.update_user_password(claims.sub, &new_hash) {
|
||||
match db.update_user_password(auth.sub, &new_hash).await {
|
||||
Ok(_) => HttpResponse::Ok().json(serde_json::json!({"message": "Password changed successfully"})),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
|
||||
}
|
||||
}
|
||||
|
||||
// --- User Management (admin only) ---
|
||||
|
||||
async fn list_users(_auth: AuthClaims, db: web::Data<Repo>) -> impl Responder {
|
||||
match db.list_users_with_groups() {
|
||||
match db.list_users_with_groups().await {
|
||||
Ok(users) => {
|
||||
let result: Vec<serde_json::Value> = users
|
||||
.into_iter()
|
||||
.map(|(id, username, _role, force_pw, created_at, user_groups)| {
|
||||
let groups: Vec<serde_json::Value> = user_groups
|
||||
.map(|u| {
|
||||
let groups: Vec<serde_json::Value> = u
|
||||
.groups
|
||||
.iter()
|
||||
.map(|(gid, name)| serde_json::json!({"id": gid, "name": name}))
|
||||
.map(|g| serde_json::json!({"id": g.group_id, "name": g.group_name}))
|
||||
.collect();
|
||||
let role = if user_groups.iter().any(|(_id, name)| name == "Administrator") {
|
||||
"admin"
|
||||
let role = if u.groups.iter().any(|g| g.group_name == GROUP_ADMIN) {
|
||||
ROLE_ADMIN
|
||||
} else {
|
||||
"viewer"
|
||||
ROLE_VIEWER
|
||||
};
|
||||
serde_json::json!({
|
||||
"id": id,
|
||||
"username": username,
|
||||
"id": u.id,
|
||||
"username": u.username,
|
||||
"role": role,
|
||||
"force_password_change": force_pw,
|
||||
"created_at": created_at,
|
||||
"force_password_change": u.force_password_change,
|
||||
"created_at": u.created_at,
|
||||
"groups": groups,
|
||||
})
|
||||
})
|
||||
@ -280,21 +171,19 @@ async fn list_users(_auth: AuthClaims, db: web::Data<Repo>) -> impl Responder {
|
||||
async fn delete_user(_auth: AuthClaims, path: web::Path<i64>, db: web::Data<Repo>) -> impl Responder {
|
||||
let user_id = path.into_inner();
|
||||
|
||||
// Can't delete self
|
||||
if _auth.sub == user_id {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({"error": "Cannot delete your own account"}));
|
||||
}
|
||||
|
||||
// Protect the built-in admin account
|
||||
match db.find_user_by_id(user_id) {
|
||||
Ok(Some((_, ref username, _, _, _))) if username == "admin" => {
|
||||
match db.find_user_by_id(user_id).await {
|
||||
Ok(Some(ref u)) if u.username == DEFAULT_ADMIN_USERNAME => {
|
||||
return HttpResponse::Forbidden()
|
||||
.json(serde_json::json!({"error": "Cannot delete the built-in admin account"}));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
match db.delete_user(user_id) {
|
||||
match db.delete_user(user_id).await {
|
||||
Ok(true) => HttpResponse::Ok().json(serde_json::json!({"message": "User deleted successfully"})),
|
||||
Ok(false) => HttpResponse::NotFound().json(serde_json::json!({"error": "User not found"})),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
|
||||
@ -309,20 +198,18 @@ async fn update_role(
|
||||
) -> impl Responder {
|
||||
let user_id = path.into_inner();
|
||||
|
||||
// Can't change own role
|
||||
if _auth.sub == user_id {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({"error": "Cannot change your own role"}));
|
||||
}
|
||||
|
||||
let role = match body.get("role").and_then(|v| v.as_str()) {
|
||||
Some(r) if r == "admin" || r == "viewer" => r,
|
||||
Some(r) if r == ROLE_ADMIN || r == ROLE_VIEWER => r,
|
||||
_ => {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({"error": "Role must be 'admin' or 'viewer'"}));
|
||||
}
|
||||
};
|
||||
|
||||
// Check target user exists
|
||||
match db.find_user_by_id(user_id) {
|
||||
match db.find_user_by_id(user_id).await {
|
||||
Ok(Some(_)) => {}
|
||||
Ok(None) => {
|
||||
return HttpResponse::NotFound().json(serde_json::json!({"error": "User not found"}));
|
||||
@ -332,7 +219,7 @@ async fn update_role(
|
||||
}
|
||||
}
|
||||
|
||||
match db.update_user_role(user_id, role) {
|
||||
match db.update_user_role(user_id, role).await {
|
||||
Ok(_) => HttpResponse::Ok().json(serde_json::json!({"message": "Role updated successfully", "role": role})),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
|
||||
}
|
||||
@ -361,8 +248,7 @@ async fn reset_password(
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({"error": msg}));
|
||||
}
|
||||
|
||||
// Check target user exists
|
||||
match db.find_user_by_id(user_id) {
|
||||
match db.find_user_by_id(user_id).await {
|
||||
Ok(Some(_)) => {}
|
||||
Ok(None) => {
|
||||
return HttpResponse::NotFound().json(serde_json::json!({"error": "User not found"}));
|
||||
@ -379,37 +265,31 @@ async fn reset_password(
|
||||
}
|
||||
};
|
||||
|
||||
match db.reset_user_password(user_id, &hash) {
|
||||
Ok(_) => HttpResponse::Ok().json(serde_json::json!({"message": "Password reset successfully"})),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
|
||||
}
|
||||
ok_or_error(db.reset_user_password(user_id, &hash).await)
|
||||
}
|
||||
|
||||
// --- User Group Management (users:admin required) ---
|
||||
|
||||
async fn list_groups(_auth: AuthClaims, db: web::Data<Repo>) -> impl Responder {
|
||||
match db.list_user_groups() {
|
||||
match db.list_user_groups().await {
|
||||
Ok(groups) => {
|
||||
let result: Vec<serde_json::Value> = groups
|
||||
.into_iter()
|
||||
.map(|(id, name, description, permissions, created_at)| {
|
||||
let perms: serde_json::Value = serde_json::from_str(&permissions).unwrap_or(serde_json::json!([]));
|
||||
let members: Vec<serde_json::Value> = db
|
||||
.get_group_members(id)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|(uid, username)| serde_json::json!({"id": uid, "username": username}))
|
||||
.collect();
|
||||
serde_json::json!({
|
||||
"id": id,
|
||||
"name": name,
|
||||
"description": description,
|
||||
"permissions": perms,
|
||||
"created_at": created_at,
|
||||
"members": members,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut result = Vec::with_capacity(groups.len());
|
||||
for g in groups {
|
||||
let perms: serde_json::Value = parse_permissions(&g.permissions);
|
||||
let members: Vec<serde_json::Value> = db
|
||||
.list_group_members(g.id)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|m| serde_json::json!({"id": m.id, "username": m.username}))
|
||||
.collect();
|
||||
result.push(serde_json::json!({
|
||||
"id": g.id,
|
||||
"name": g.name,
|
||||
"description": g.description,
|
||||
"permissions": perms,
|
||||
"created_at": g.created_at,
|
||||
"members": members,
|
||||
}));
|
||||
}
|
||||
HttpResponse::Ok().json(result)
|
||||
}
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
|
||||
@ -430,12 +310,12 @@ async fn create_group(_auth: AuthClaims, body: web::Json<serde_json::Value>, db:
|
||||
_ => "[]".to_string(),
|
||||
};
|
||||
|
||||
match db.create_user_group(name, description, &permissions) {
|
||||
match db.create_user_group(name, description, &permissions).await {
|
||||
Ok(id) => HttpResponse::Created().json(serde_json::json!({
|
||||
"id": id,
|
||||
"name": name,
|
||||
"description": description,
|
||||
"permissions": serde_json::from_str::<serde_json::Value>(&permissions).unwrap_or(serde_json::json!([])),
|
||||
"permissions": parse_permissions(&permissions),
|
||||
})),
|
||||
Err(e) => HttpResponse::Conflict().json(serde_json::json!({"error": e.to_string()})),
|
||||
}
|
||||
@ -444,16 +324,16 @@ async fn create_group(_auth: AuthClaims, body: web::Json<serde_json::Value>, db:
|
||||
async fn get_group(_auth: AuthClaims, path: web::Path<i64>, db: web::Data<Repo>) -> impl Responder {
|
||||
let group_id = path.into_inner();
|
||||
|
||||
match db.get_user_group(group_id) {
|
||||
Ok(Some((id, name, description, permissions, created_at))) => {
|
||||
let perms: serde_json::Value = serde_json::from_str(&permissions).unwrap_or(serde_json::json!([]));
|
||||
let members = db.get_group_member_ids(group_id).unwrap_or_default();
|
||||
match db.get_user_group(group_id).await {
|
||||
Ok(Some(g)) => {
|
||||
let perms: serde_json::Value = parse_permissions(&g.permissions);
|
||||
let members = db.list_group_member_ids(group_id).await.unwrap_or_default();
|
||||
HttpResponse::Ok().json(serde_json::json!({
|
||||
"id": id,
|
||||
"name": name,
|
||||
"description": description,
|
||||
"id": g.id,
|
||||
"name": g.name,
|
||||
"description": g.description,
|
||||
"permissions": perms,
|
||||
"created_at": created_at,
|
||||
"created_at": g.created_at,
|
||||
"members": members,
|
||||
}))
|
||||
}
|
||||
@ -470,11 +350,9 @@ async fn update_group(
|
||||
) -> impl Responder {
|
||||
let group_id = path.into_inner();
|
||||
|
||||
// Check group exists
|
||||
let existing = match db.get_user_group(group_id) {
|
||||
let existing = match db.get_user_group(group_id).await {
|
||||
Ok(Some(g)) => {
|
||||
// Protect built-in groups
|
||||
if g.1 == "Administrator" || g.1 == "Viewer" {
|
||||
if g.name == GROUP_ADMIN || g.name == GROUP_VIEWER {
|
||||
return HttpResponse::Forbidden().json(serde_json::json!({"error": "Cannot modify built-in groups"}));
|
||||
}
|
||||
g
|
||||
@ -487,19 +365,22 @@ async fn update_group(
|
||||
}
|
||||
};
|
||||
|
||||
let name = body.get("name").and_then(|v| v.as_str()).unwrap_or(&existing.1);
|
||||
let description = body.get("description").and_then(|v| v.as_str()).unwrap_or(&existing.2);
|
||||
let name = body.get("name").and_then(|v| v.as_str()).unwrap_or(&existing.name);
|
||||
let description = body
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&existing.description);
|
||||
let permissions = match body.get("permissions") {
|
||||
Some(p) if p.is_array() => p.to_string(),
|
||||
_ => existing.3.clone(),
|
||||
_ => existing.permissions.clone(),
|
||||
};
|
||||
|
||||
match db.update_user_group(group_id, name, description, &permissions) {
|
||||
match db.update_user_group(group_id, name, description, &permissions).await {
|
||||
Ok(_) => HttpResponse::Ok().json(serde_json::json!({
|
||||
"id": group_id,
|
||||
"name": name,
|
||||
"description": description,
|
||||
"permissions": serde_json::from_str::<serde_json::Value>(&permissions).unwrap_or(serde_json::json!([])),
|
||||
"permissions": parse_permissions(&permissions),
|
||||
})),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
|
||||
}
|
||||
@ -508,15 +389,14 @@ async fn update_group(
|
||||
async fn delete_group(_auth: AuthClaims, path: web::Path<i64>, db: web::Data<Repo>) -> impl Responder {
|
||||
let group_id = path.into_inner();
|
||||
|
||||
// Protect built-in groups
|
||||
match db.get_user_group(group_id) {
|
||||
Ok(Some(g)) if g.1 == "Administrator" || g.1 == "Viewer" => {
|
||||
match db.get_user_group(group_id).await {
|
||||
Ok(Some(ref g)) if g.name == GROUP_ADMIN || g.name == GROUP_VIEWER => {
|
||||
return HttpResponse::Forbidden().json(serde_json::json!({"error": "Cannot delete built-in groups"}));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
match db.delete_user_group(group_id) {
|
||||
match db.delete_user_group(group_id).await {
|
||||
Ok(true) => HttpResponse::Ok().json(serde_json::json!({"message": "Group deleted successfully"})),
|
||||
Ok(false) => HttpResponse::NotFound().json(serde_json::json!({"error": "Group not found"})),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
|
||||
@ -531,9 +411,8 @@ async fn set_user_groups(
|
||||
) -> impl Responder {
|
||||
let user_id = path.into_inner();
|
||||
|
||||
// Protect the default admin account
|
||||
match db.find_user_by_id(user_id) {
|
||||
Ok(Some((_, ref username, _, _, _))) if username == "admin" => {
|
||||
match db.find_user_by_id(user_id).await {
|
||||
Ok(Some(ref u)) if u.username == DEFAULT_ADMIN_USERNAME => {
|
||||
return HttpResponse::Forbidden()
|
||||
.json(serde_json::json!({"error": "Cannot modify groups for the built-in admin account"}));
|
||||
}
|
||||
@ -553,7 +432,7 @@ async fn set_user_groups(
|
||||
}
|
||||
};
|
||||
|
||||
match db.set_user_groups(user_id, &group_ids) {
|
||||
match db.set_user_groups(user_id, &group_ids).await {
|
||||
Ok(_) => HttpResponse::Ok()
|
||||
.json(serde_json::json!({"message": "User groups updated successfully", "group_ids": group_ids})),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
|
||||
@ -562,52 +441,13 @@ async fn set_user_groups(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_validate_username_valid() {
|
||||
assert!(validate_username("admin").is_ok());
|
||||
assert!(validate_username("user_123").is_ok());
|
||||
assert!(validate_username("a").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_username_empty() {
|
||||
assert!(validate_username("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_username_too_long() {
|
||||
let long = "a".repeat(33);
|
||||
assert!(validate_username(&long).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_username_special_chars() {
|
||||
assert!(validate_username("admin@host").is_err());
|
||||
assert!(validate_username("user name").is_err());
|
||||
assert!(validate_username("user-name").is_err());
|
||||
assert!(validate_username("用戶").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_password_valid() {
|
||||
assert!(validate_password("12345678").is_ok());
|
||||
assert!(validate_password("a very long password").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_password_too_short() {
|
||||
assert!(validate_password("").is_err());
|
||||
assert!(validate_password("1234567").is_err());
|
||||
assert!(validate_password("a").is_err());
|
||||
}
|
||||
use crate::domain::identity::validation::{validate_password, validate_username};
|
||||
|
||||
#[test]
|
||||
fn test_dummy_hash_is_valid_argon2() {
|
||||
use argon2::password_hash::PasswordHash;
|
||||
// DUMMY_HASH must be parseable as a valid Argon2 hash structure
|
||||
// so that timing-based username enumeration is prevented
|
||||
|
||||
use crate::core::identity::auth_service::DUMMY_HASH;
|
||||
let parsed = PasswordHash::new(DUMMY_HASH);
|
||||
assert!(
|
||||
parsed.is_ok(),
|
||||
@ -615,4 +455,15 @@ mod tests {
|
||||
parsed.err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_username_valid() {
|
||||
assert!(validate_username("admin").is_ok());
|
||||
assert!(validate_username("user_123").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_password_valid() {
|
||||
assert!(validate_password("12345678").is_ok());
|
||||
}
|
||||
}
|
||||
2
net-guardia/src/adapter/http/identity/mod.rs
Normal file
2
net-guardia/src/adapter/http/identity/mod.rs
Normal file
@ -0,0 +1,2 @@
|
||||
pub mod api_keys;
|
||||
pub mod auth;
|
||||
@ -3,10 +3,11 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode, errors::ErrorKind};
|
||||
|
||||
use crate::interface::port::secret_store::SecretStorePort;
|
||||
use crate::model::error::Error;
|
||||
use crate::model::error::auth::AuthError;
|
||||
use crate::model::identity::auth::Claims;
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::domain::identity::auth::Claims;
|
||||
use crate::domain::identity::error::AuthError;
|
||||
use crate::interface::secret_store::SecretStorePort;
|
||||
use crate::interface::token_minter::TokenMinter;
|
||||
|
||||
pub struct JwtService {
|
||||
encoding_key: EncodingKey,
|
||||
@ -63,21 +64,33 @@ impl JwtService {
|
||||
}
|
||||
}
|
||||
|
||||
impl TokenMinter for JwtService {
|
||||
fn create_token(
|
||||
&self,
|
||||
user_id: i64,
|
||||
username: &str,
|
||||
role: &str,
|
||||
permissions: Vec<String>,
|
||||
) -> Result<String, Error> {
|
||||
self.create_token(user_id, username, role, permissions)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::adapter::persistence::Database;
|
||||
use crate::infrastructure::secret_store::SecretStore;
|
||||
|
||||
fn test_jwt_service() -> JwtService {
|
||||
let db = Arc::new(Database::new(":memory:").unwrap());
|
||||
async fn test_jwt_service() -> JwtService {
|
||||
let db = Arc::new(Database::new(":memory:").await.unwrap());
|
||||
let secrets: Arc<dyn SecretStorePort> = Arc::new(SecretStore::new(db));
|
||||
JwtService::new(&secrets, 24).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_and_validate_token() {
|
||||
let jwt = test_jwt_service();
|
||||
#[tokio::test]
|
||||
async fn test_create_and_validate_token() {
|
||||
let jwt = test_jwt_service().await;
|
||||
let perms = vec!["dashboard:read".to_string()];
|
||||
let token = jwt.create_token(1, "admin", "admin", perms.clone()).unwrap();
|
||||
let claims = jwt.validate_token(&token).unwrap();
|
||||
@ -87,16 +100,16 @@ mod tests {
|
||||
assert_eq!(claims.permissions, perms);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_token() {
|
||||
let jwt = test_jwt_service();
|
||||
#[tokio::test]
|
||||
async fn test_invalid_token() {
|
||||
let jwt = test_jwt_service().await;
|
||||
let result = jwt.validate_token("invalid.token.here");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expired_token() {
|
||||
let db = Arc::new(Database::new(":memory:").unwrap());
|
||||
#[tokio::test]
|
||||
async fn test_expired_token() {
|
||||
let db = Arc::new(Database::new(":memory:").await.unwrap());
|
||||
let secrets: Arc<dyn SecretStorePort> = Arc::new(SecretStore::new(db));
|
||||
let jwt = JwtService::new(&secrets, 0).unwrap(); // 0 hours = immediate expiry
|
||||
|
||||
@ -113,9 +126,9 @@ mod tests {
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_jwt_secret_changes_on_new_instance() {
|
||||
let db = Arc::new(Database::new(":memory:").unwrap());
|
||||
#[tokio::test]
|
||||
async fn test_jwt_secret_changes_on_new_instance() {
|
||||
let db = Arc::new(Database::new(":memory:").await.unwrap());
|
||||
let secrets: Arc<dyn SecretStorePort> = Arc::new(SecretStore::new(db));
|
||||
|
||||
let jwt1 = JwtService::new(&secrets, 24).unwrap();
|
||||
@ -127,10 +140,10 @@ mod tests {
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_different_secrets_reject() {
|
||||
let jwt1 = test_jwt_service();
|
||||
let jwt2 = test_jwt_service(); // different in-memory DB = different secret
|
||||
#[tokio::test]
|
||||
async fn test_different_secrets_reject() {
|
||||
let jwt1 = test_jwt_service().await;
|
||||
let jwt2 = test_jwt_service().await; // different in-memory DB = different secret
|
||||
|
||||
let token = jwt1.create_token(1, "admin", "admin", vec![]).unwrap();
|
||||
let result = jwt2.validate_token(&token);
|
||||
@ -1,27 +1,14 @@
|
||||
use std::fs;
|
||||
use std::io::ErrorKind;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use actix_web::{HttpResponse, Scope, web};
|
||||
use arc_swap::ArcSwap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::core::observability::log_buffer::{self, LogEntry};
|
||||
|
||||
/// Hardcoded log directory — not configurable via API to prevent directory traversal.
|
||||
const LOG_DIR: &str = "logs";
|
||||
|
||||
/// Maximum downloadable log file size (50 MB). Prevents OOM from reading huge files.
|
||||
const MAX_DOWNLOAD_SIZE: u64 = 50 * 1024 * 1024;
|
||||
|
||||
/// Default page size for `/live` when the client does not specify `limit`.
|
||||
/// Chosen so a 2 s poll against a DEBUG-chatty deployment catches up in
|
||||
/// one round-trip without being absurd payload-wise.
|
||||
const LIVE_DEFAULT_LIMIT: usize = 500;
|
||||
|
||||
/// Hard cap on `/live?limit=` — prevents pathological clients from asking
|
||||
/// for the entire buffer at once.
|
||||
const LIVE_MAX_LIMIT: usize = 2_000;
|
||||
use crate::domain::common::config::AppConfig;
|
||||
use crate::infrastructure::log_buffer::{self, LogBuffer, LogEntry};
|
||||
|
||||
/// Validate log filename: only alphanumeric, dots, underscores, hyphens.
|
||||
/// Prevents path traversal.
|
||||
@ -58,16 +45,24 @@ struct LiveResponse {
|
||||
dropped_oldest: bool,
|
||||
}
|
||||
|
||||
async fn live_logs(query: web::Query<LiveQuery>) -> HttpResponse {
|
||||
async fn live_logs(
|
||||
query: web::Query<LiveQuery>,
|
||||
app_config: web::Data<ArcSwap<AppConfig>>,
|
||||
buf: web::Data<LogBuffer>,
|
||||
) -> HttpResponse {
|
||||
let since_id = query.since_id.unwrap_or(0);
|
||||
let limit = query.limit.unwrap_or(LIVE_DEFAULT_LIMIT).clamp(1, LIVE_MAX_LIMIT);
|
||||
let obs = app_config.load().observability.clone();
|
||||
let limit = query
|
||||
.limit
|
||||
.unwrap_or(obs.log_live_default_limit)
|
||||
.clamp(1, obs.log_live_max_limit.max(1));
|
||||
let min_severity = query
|
||||
.min_level
|
||||
.as_deref()
|
||||
.map(|s| log_buffer::level_severity(&s.to_ascii_uppercase()))
|
||||
.unwrap_or(log_buffer::level_severity("TRACE"));
|
||||
|
||||
let snap = log_buffer::snapshot(since_id, min_severity, limit);
|
||||
let snap = buf.snapshot(since_id, min_severity, limit);
|
||||
// Signal to the UI that it lagged enough for the ring to evict rows
|
||||
// between polls. Frontend can warn "older entries dropped" without
|
||||
// silently skipping a gap.
|
||||
@ -89,9 +84,9 @@ struct LogFileEntry {
|
||||
modified: Option<u64>,
|
||||
}
|
||||
|
||||
async fn list_logs() -> HttpResponse {
|
||||
let log_dir = LOG_DIR;
|
||||
let entries = match fs::read_dir(log_dir) {
|
||||
async fn list_logs(app_config: web::Data<ArcSwap<AppConfig>>) -> HttpResponse {
|
||||
let log_dir = app_config.load().system.log_dir.clone();
|
||||
let entries = match fs::read_dir(&log_dir) {
|
||||
Ok(dir) => dir
|
||||
.filter_map(|e| e.ok())
|
||||
.filter_map(|e| {
|
||||
@ -118,7 +113,10 @@ async fn list_logs() -> HttpResponse {
|
||||
HttpResponse::Ok().json(serde_json::json!({ "files": entries }))
|
||||
}
|
||||
|
||||
async fn download_log(path: web::Path<String>) -> HttpResponse {
|
||||
async fn download_log(path: web::Path<String>, app_config: web::Data<ArcSwap<AppConfig>>) -> HttpResponse {
|
||||
let config = app_config.load();
|
||||
let max_download_size = config.observability.log_max_download_size;
|
||||
let log_dir = PathBuf::from(&config.system.log_dir);
|
||||
let filename = path.into_inner();
|
||||
|
||||
if !is_valid_log_filename(&filename) {
|
||||
@ -127,7 +125,7 @@ async fn download_log(path: web::Path<String>) -> HttpResponse {
|
||||
}));
|
||||
}
|
||||
|
||||
let file_path = Path::new(LOG_DIR).join(&filename);
|
||||
let file_path = log_dir.join(&filename);
|
||||
|
||||
// Canonicalize to prevent symlink traversal
|
||||
let canonical = match fs::canonicalize(&file_path) {
|
||||
@ -138,7 +136,7 @@ async fn download_log(path: web::Path<String>) -> HttpResponse {
|
||||
}));
|
||||
}
|
||||
};
|
||||
if let Ok(log_dir_canonical) = fs::canonicalize(LOG_DIR)
|
||||
if let Ok(log_dir_canonical) = fs::canonicalize(&log_dir)
|
||||
&& !canonical.starts_with(&log_dir_canonical)
|
||||
{
|
||||
return HttpResponse::Forbidden().json(serde_json::json!({
|
||||
@ -148,9 +146,9 @@ async fn download_log(path: web::Path<String>) -> HttpResponse {
|
||||
|
||||
// Check file size before reading to prevent OOM on large logs
|
||||
match fs::metadata(&canonical) {
|
||||
Ok(meta) if meta.len() > MAX_DOWNLOAD_SIZE => {
|
||||
Ok(meta) if meta.len() > max_download_size => {
|
||||
return HttpResponse::PayloadTooLarge().json(serde_json::json!({
|
||||
"error": format!("Log file exceeds maximum download size ({}MB)", MAX_DOWNLOAD_SIZE / 1024 / 1024)
|
||||
"error": format!("Log file exceeds maximum download size ({}MB)", max_download_size / 1024 / 1024)
|
||||
}));
|
||||
}
|
||||
Err(e) if e.kind() == ErrorKind::NotFound => {
|
||||
|
||||
246
net-guardia/src/adapter/http/middleware/auth.rs
Normal file
246
net-guardia/src/adapter/http/middleware/auth.rs
Normal file
@ -0,0 +1,246 @@
|
||||
use std::future::{Future, Ready, ready};
|
||||
use std::pin::Pin;
|
||||
use std::rc::Rc;
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
use actix_web::body::EitherBody;
|
||||
use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
|
||||
use actix_web::http::Method;
|
||||
use actix_web::{Error as ActixError, HttpMessage, HttpResponse, web};
|
||||
use macros::log;
|
||||
|
||||
use crate::adapter::http::jwt::JwtService;
|
||||
use crate::domain::identity::error::AuthError;
|
||||
use crate::interface::api_key::ApiKeyRepo;
|
||||
use crate::interface::app_repo::AppRepo;
|
||||
|
||||
pub struct AuthMiddleware;
|
||||
|
||||
impl<S, B> Transform<S, ServiceRequest> for AuthMiddleware
|
||||
where
|
||||
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
|
||||
B: 'static,
|
||||
{
|
||||
type Response = ServiceResponse<EitherBody<B>>;
|
||||
type Error = ActixError;
|
||||
type Transform = AuthMiddlewareService<S>;
|
||||
type InitError = ();
|
||||
type Future = Ready<Result<Self::Transform, Self::InitError>>;
|
||||
|
||||
fn new_transform(&self, service: S) -> Self::Future {
|
||||
ready(Ok(AuthMiddlewareService {
|
||||
service: Rc::new(service),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AuthMiddlewareService<S> {
|
||||
service: Rc<S>,
|
||||
}
|
||||
|
||||
fn required_permission(path: &str, method: &Method) -> Option<String> {
|
||||
let resource = if path == "/api/auth/login" || path == "/api/auth/me" || path == "/api/auth/change-password" {
|
||||
return None;
|
||||
} else if path.starts_with("/api/auth/") {
|
||||
return Some("users:admin".to_string());
|
||||
} else if path.starts_with("/api/health/") || path.starts_with("/api/stats/") {
|
||||
"dashboard"
|
||||
} else if path.starts_with("/api/ml/") || path.starts_with("/api/byo/") {
|
||||
"ai_detection"
|
||||
} else if path.starts_with("/api/fusion/") {
|
||||
"fusion"
|
||||
} else if path.starts_with("/api/flow-trace/") {
|
||||
"flow_trace"
|
||||
} else if path.starts_with("/api/acl/geo/") {
|
||||
"geo_block"
|
||||
} else if path.starts_with("/api/acl/") {
|
||||
"access_control"
|
||||
} else if path.starts_with("/api/filter/dns/") {
|
||||
"dns_filter"
|
||||
} else if path.starts_with("/api/filter/http/") || path.starts_with("/api/filter/ssh/") {
|
||||
"protocol_filter"
|
||||
} else if path.starts_with("/api/rate-limit/") {
|
||||
"rate_limit"
|
||||
} 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());
|
||||
} else if path.contains("/soar/blocks/") && path.ends_with("/unblock") {
|
||||
return Some("access_control:write".to_string());
|
||||
} else if path.starts_with("/api/soar/")
|
||||
|| path.starts_with("/api/notifications/")
|
||||
|| path.starts_with("/api/report/")
|
||||
|| path.starts_with("/api/logs/")
|
||||
|| path.starts_with("/api/audit/")
|
||||
{
|
||||
"system"
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
|
||||
let action = match *method {
|
||||
Method::GET => "read",
|
||||
_ => "write",
|
||||
};
|
||||
|
||||
Some(format!("{}:{}", resource, action))
|
||||
}
|
||||
|
||||
impl<S, B> Service<ServiceRequest> for AuthMiddlewareService<S>
|
||||
where
|
||||
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
|
||||
B: 'static,
|
||||
{
|
||||
type Response = ServiceResponse<EitherBody<B>>;
|
||||
type Error = ActixError;
|
||||
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;
|
||||
|
||||
fn poll_ready(&self, ctx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
|
||||
self.service.poll_ready(ctx)
|
||||
}
|
||||
|
||||
fn call(&self, req: ServiceRequest) -> Self::Future {
|
||||
let service = Rc::clone(&self.service);
|
||||
|
||||
Box::pin(async move {
|
||||
let path = req.path().to_string();
|
||||
|
||||
// Skip auth for public endpoints
|
||||
if path == "/api/auth/login" || path.starts_with("/api/setup/") || !path.starts_with("/api/") {
|
||||
let res = service.call(req).await?.map_into_left_body();
|
||||
return Ok(res);
|
||||
}
|
||||
|
||||
// Extract JWT service from app data
|
||||
let jwt_service = match req.app_data::<web::Data<JwtService>>() {
|
||||
Some(s) => s.clone(),
|
||||
None => {
|
||||
let resp =
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": "Auth not configured"}));
|
||||
return Ok(req.into_response(resp).map_into_right_body());
|
||||
}
|
||||
};
|
||||
|
||||
// Try JWT first, then fall back to API key
|
||||
let claims = if let Some(auth_header) = req.headers().get("Authorization") {
|
||||
// JWT Bearer token auth
|
||||
let val_str = auth_header.to_str().unwrap_or("");
|
||||
let token = match val_str.strip_prefix("Bearer ") {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
let resp = HttpResponse::Unauthorized()
|
||||
.json(serde_json::json!({"error": "Invalid authorization header"}));
|
||||
return Ok(req.into_response(resp).map_into_right_body());
|
||||
}
|
||||
};
|
||||
match jwt_service.validate_token(token) {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
let resp =
|
||||
HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid or expired token"}));
|
||||
return Ok(req.into_response(resp).map_into_right_body());
|
||||
}
|
||||
}
|
||||
} else if let Some(api_key_header) = req.headers().get("X-API-Key") {
|
||||
// API key auth with rate limiting
|
||||
let api_key = api_key_header.to_str().unwrap_or("");
|
||||
let api_key_port = match req.app_data::<web::Data<dyn ApiKeyRepo>>() {
|
||||
Some(d) => d.clone(),
|
||||
None => {
|
||||
let resp = HttpResponse::InternalServerError()
|
||||
.json(serde_json::json!({"error": "ApiKeyRepo not configured"}));
|
||||
return Ok(req.into_response(resp).map_into_right_body());
|
||||
}
|
||||
};
|
||||
let repo = match req.app_data::<web::Data<dyn AppRepo>>() {
|
||||
Some(d) => d.clone(),
|
||||
None => {
|
||||
let resp = HttpResponse::InternalServerError()
|
||||
.json(serde_json::json!({"error": "AppRepo not configured"}));
|
||||
return Ok(req.into_response(resp).map_into_right_body());
|
||||
}
|
||||
};
|
||||
|
||||
// Rate limit check for API key attempts (reuse login failure tracking)
|
||||
let rate_key = format!(
|
||||
"apikey:{}",
|
||||
req.peer_addr().map(|a| a.ip().to_string()).unwrap_or_default()
|
||||
);
|
||||
if let Ok(Some(remaining)) = repo.check_login_locked(&rate_key).await {
|
||||
let resp = HttpResponse::TooManyRequests().json(serde_json::json!({
|
||||
"error": "Too many failed API key attempts",
|
||||
"retry_after_secs": remaining,
|
||||
}));
|
||||
return Ok(req.into_response(resp).map_into_right_body());
|
||||
}
|
||||
|
||||
match api_key_port.validate_api_key(api_key).await {
|
||||
Ok(Some(key_claims)) => {
|
||||
if let Err(e) = 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 {
|
||||
log!(AuthError::LoginFailureTrackingError(e));
|
||||
}
|
||||
let resp = HttpResponse::Unauthorized()
|
||||
.json(serde_json::json!({"error": "Invalid or revoked API key"}));
|
||||
return Ok(req.into_response(resp).map_into_right_body());
|
||||
}
|
||||
Err(_) => {
|
||||
let resp = HttpResponse::InternalServerError()
|
||||
.json(serde_json::json!({"error": "API key validation failed"}));
|
||||
return Ok(req.into_response(resp).map_into_right_body());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let resp =
|
||||
HttpResponse::Unauthorized().json(serde_json::json!({"error": "Missing authorization header"}));
|
||||
return Ok(req.into_response(resp).map_into_right_body());
|
||||
};
|
||||
|
||||
// Permission-based RBAC check
|
||||
if let Some(required) = required_permission(&path, req.method())
|
||||
&& !claims.permissions.contains(&required)
|
||||
{
|
||||
let resp = HttpResponse::Forbidden().json(serde_json::json!({"error": "Insufficient permissions"}));
|
||||
return Ok(req.into_response(resp).map_into_right_body());
|
||||
}
|
||||
|
||||
// Store claims in request extensions
|
||||
req.extensions_mut().insert(claims);
|
||||
|
||||
let res = service.call(req).await?.map_into_left_body();
|
||||
Ok(res)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use actix_web::http::Method;
|
||||
|
||||
use super::required_permission;
|
||||
|
||||
#[test]
|
||||
fn api_key_collection_requires_admin_permission() {
|
||||
assert_eq!(
|
||||
required_permission("/api/api-keys", &Method::GET),
|
||||
Some("api_keys:admin".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_key_subroutes_require_admin_permission() {
|
||||
assert_eq!(
|
||||
required_permission("/api/api-keys/generate", &Method::POST),
|
||||
Some("api_keys:admin".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
required_permission("/api/api-keys/1", &Method::DELETE),
|
||||
Some("api_keys:admin".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -5,7 +5,7 @@ use actix_web::dev::Payload;
|
||||
use actix_web::error::ErrorUnauthorized;
|
||||
use actix_web::{Error as ActixError, FromRequest, HttpMessage, HttpRequest};
|
||||
|
||||
use crate::model::identity::auth::Claims;
|
||||
use crate::domain::identity::auth::Claims;
|
||||
|
||||
/// Actix-web extractor that pulls `Claims` from request extensions.
|
||||
///
|
||||
@ -2,8 +2,7 @@ use std::future::{Future, Ready, ready};
|
||||
use std::net::IpAddr;
|
||||
use std::pin::Pin;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
use actix_web::body::EitherBody;
|
||||
@ -11,8 +10,7 @@ use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
|
||||
use actix_web::http::header;
|
||||
use actix_web::{Error as ActixError, HttpResponse, web};
|
||||
|
||||
/// Shared flag: when true, non-HTTPS requests are redirected.
|
||||
pub type ForceHttpsFlag = Arc<AtomicBool>;
|
||||
use crate::infrastructure::http_server::ForceHttpsFlag;
|
||||
|
||||
/// Validate that the host is safe to use in a redirect Location header.
|
||||
/// Only allows: private IPs (RFC 1918), loopback, .local hostnames, and bare hostnames
|
||||
@ -100,7 +98,7 @@ where
|
||||
// Check if force_https is enabled
|
||||
let force = req
|
||||
.app_data::<web::Data<ForceHttpsFlag>>()
|
||||
.map(|flag| flag.load(Ordering::Relaxed))
|
||||
.map(|flag| flag.0.load(Ordering::Relaxed))
|
||||
.unwrap_or(false);
|
||||
|
||||
if !force {
|
||||
@ -7,13 +7,12 @@ use actix_web::body::EitherBody;
|
||||
use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
|
||||
use actix_web::http::Method;
|
||||
use actix_web::{Error as ActixError, HttpMessage, HttpResponse, web};
|
||||
|
||||
use macros::log;
|
||||
|
||||
use crate::core::auth::jwt::JwtService;
|
||||
use crate::adapter::http::middleware::jwt::JwtService;
|
||||
use crate::domain::identity::error::AuthError;
|
||||
use crate::interface::port::api_key::ApiKeyRepo;
|
||||
use crate::interface::port::app_repo::AppRepo;
|
||||
use crate::model::error::auth::AuthError;
|
||||
|
||||
pub struct AuthMiddleware;
|
||||
|
||||
@ -41,9 +40,8 @@ pub struct AuthMiddlewareService<S> {
|
||||
|
||||
fn required_permission(path: &str, method: &Method) -> Option<String> {
|
||||
let resource = if path == "/api/auth/login" || path == "/api/auth/me" || path == "/api/auth/change-password" {
|
||||
return None; // Public auth endpoints: login (no auth), me/change-password (auth-only, no RBAC)
|
||||
return None;
|
||||
} else if path.starts_with("/api/auth/") {
|
||||
// User/group management requires users:admin
|
||||
return Some("users:admin".to_string());
|
||||
} else if path.starts_with("/api/health/") || path.starts_with("/api/stats/") {
|
||||
"dashboard"
|
||||
@ -66,7 +64,6 @@ fn required_permission(path: &str, method: &Method) -> Option<String> {
|
||||
} else if path.starts_with("/api/system/") {
|
||||
"system"
|
||||
} else if path.contains("/soar/blocks/") && path.ends_with("/unblock") {
|
||||
// manual_unblock needs access_control:write (always POST)
|
||||
return Some("access_control:write".to_string());
|
||||
} else if path.starts_with("/api/soar/")
|
||||
|| path.starts_with("/api/notifications/")
|
||||
@ -1,7 +1,5 @@
|
||||
pub mod auth;
|
||||
pub mod csrf;
|
||||
pub mod extractor;
|
||||
pub mod https_redirect;
|
||||
pub mod jwt;
|
||||
pub mod middleware;
|
||||
pub mod password;
|
||||
pub mod setup_guard;
|
||||
@ -1,17 +1,14 @@
|
||||
use std::future::{Future, Ready, ready};
|
||||
use std::pin::Pin;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
use actix_web::body::EitherBody;
|
||||
use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform};
|
||||
use actix_web::{Error as ActixError, HttpResponse, web};
|
||||
|
||||
/// Shared flag indicating whether setup has completed.
|
||||
/// When false, only setup wizard routes are allowed; all others get 503.
|
||||
pub type SetupCompleteFlag = Arc<AtomicBool>;
|
||||
use crate::infrastructure::http_server::SetupCompleteFlag;
|
||||
|
||||
pub struct SetupGuard;
|
||||
|
||||
@ -59,8 +56,8 @@ where
|
||||
// Check setup_complete flag from app data
|
||||
let setup_complete = req
|
||||
.app_data::<web::Data<SetupCompleteFlag>>()
|
||||
.map(|flag| flag.load(Ordering::SeqCst))
|
||||
.unwrap_or(true); // Default to true if flag not found
|
||||
.map(|flag| flag.0.load(Ordering::SeqCst))
|
||||
.unwrap_or(true);
|
||||
|
||||
if setup_complete {
|
||||
// Normal mode: pass through, but block setup mutation endpoints.
|
||||
@ -1,20 +1,13 @@
|
||||
pub mod acl;
|
||||
pub mod api_keys;
|
||||
pub mod audit;
|
||||
pub mod auth;
|
||||
pub mod byo;
|
||||
pub mod data_plane;
|
||||
pub mod default;
|
||||
pub mod filter;
|
||||
pub mod flow_trace;
|
||||
pub mod fusion;
|
||||
pub mod health;
|
||||
pub mod detection;
|
||||
pub mod helpers;
|
||||
pub mod identity;
|
||||
pub mod jwt;
|
||||
pub mod logs;
|
||||
pub mod ml;
|
||||
pub mod model_upload;
|
||||
pub mod notification;
|
||||
pub mod rate_limit;
|
||||
pub mod report;
|
||||
pub mod middleware;
|
||||
pub mod ready;
|
||||
pub mod response;
|
||||
pub mod setup;
|
||||
pub mod soar;
|
||||
pub mod stats;
|
||||
pub mod system;
|
||||
|
||||
23
net-guardia/src/adapter/http/ready.rs
Normal file
23
net-guardia/src/adapter/http/ready.rs
Normal file
@ -0,0 +1,23 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering::SeqCst;
|
||||
|
||||
use actix_web::{HttpResponse, web};
|
||||
|
||||
use crate::infrastructure::readiness::ReadinessState;
|
||||
|
||||
pub async fn health_ready(ready: web::Data<Arc<AtomicBool>>, state: web::Data<ReadinessState>) -> HttpResponse {
|
||||
let is_ready = ready.load(SeqCst);
|
||||
let uptime_secs = state.started_at.elapsed().as_secs();
|
||||
|
||||
HttpResponse::Ok().json(serde_json::json!({
|
||||
"ready": is_ready,
|
||||
"subsystems": {
|
||||
"db_connected": state.db_connected.load(SeqCst),
|
||||
"ml_model_loaded": state.ml_model_loaded.load(SeqCst),
|
||||
"soar_engine_running": state.soar_engine_running.load(SeqCst),
|
||||
"ebpf_attached": state.ebpf_attached.load(SeqCst),
|
||||
},
|
||||
"uptime_secs": uptime_secs,
|
||||
}))
|
||||
}
|
||||
3
net-guardia/src/adapter/http/response/mod.rs
Normal file
3
net-guardia/src/adapter/http/response/mod.rs
Normal file
@ -0,0 +1,3 @@
|
||||
pub mod notification;
|
||||
pub mod report;
|
||||
pub mod soar;
|
||||
@ -1,8 +1,9 @@
|
||||
use actix_web::{HttpResponse, Scope, web};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::core::auth::extractor::AuthClaims;
|
||||
use crate::core::notification_service::NotificationService;
|
||||
use crate::adapter::http::helpers::{ok_json_or_error, ok_or_error};
|
||||
use crate::adapter::http::middleware::extractor::AuthClaims;
|
||||
use crate::core::common::notification_service::NotificationService;
|
||||
|
||||
pub fn initialize() -> Scope {
|
||||
web::scope("/notifications")
|
||||
@ -13,10 +14,7 @@ pub fn initialize() -> Scope {
|
||||
}
|
||||
|
||||
async fn get_telegram_config(_auth: AuthClaims, svc: web::Data<NotificationService>) -> HttpResponse {
|
||||
match svc.get_telegram_config() {
|
||||
Ok(config) => HttpResponse::Ok().json(config),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
|
||||
}
|
||||
ok_json_or_error(svc.get_telegram_config().await)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@ -30,10 +28,7 @@ async fn set_telegram_config(
|
||||
svc: web::Data<NotificationService>,
|
||||
body: web::Json<TelegramConfigRequest>,
|
||||
) -> HttpResponse {
|
||||
match svc.set_telegram_config(&body.bot_token, &body.chat_id) {
|
||||
Ok(()) => HttpResponse::Ok().json(serde_json::json!({"saved": true})),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
|
||||
}
|
||||
ok_or_error(svc.set_telegram_config(&body.bot_token, &body.chat_id).await)
|
||||
}
|
||||
|
||||
async fn test_telegram(_auth: AuthClaims, svc: web::Data<NotificationService>) -> HttpResponse {
|
||||
@ -44,7 +39,7 @@ async fn test_telegram(_auth: AuthClaims, svc: web::Data<NotificationService>) -
|
||||
}
|
||||
|
||||
async fn test_smtp(_auth: AuthClaims, svc: web::Data<NotificationService>) -> HttpResponse {
|
||||
match svc.test_smtp() {
|
||||
match svc.test_smtp().await {
|
||||
Ok(msg) => HttpResponse::Ok().json(serde_json::json!({"success": true, "message": msg})),
|
||||
Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"success": false, "error": e.to_string()})),
|
||||
}
|
||||
@ -1,17 +1,22 @@
|
||||
use std::fs;
|
||||
|
||||
use actix_web::{HttpResponse, Scope, web};
|
||||
use arc_swap::ArcSwap;
|
||||
use chrono::Local;
|
||||
use tokio::task::spawn_blocking;
|
||||
|
||||
use crate::adapter::http::helpers::ok_json_or_error;
|
||||
use crate::adapter::http::middleware::extractor::AuthClaims;
|
||||
use crate::adapter::notification::smtp::SmtpClient;
|
||||
use crate::adapter::persistence::Database;
|
||||
use crate::core::auth::extractor::AuthClaims;
|
||||
use crate::core::email::report::generate_weekly_report;
|
||||
use crate::core::email::scheduler::SmtpClient;
|
||||
use crate::core::report::engine;
|
||||
use crate::core::reporting::email_report::generate_weekly_report;
|
||||
use crate::core::reporting::report_engine;
|
||||
use crate::domain::common::config::AppConfig;
|
||||
use crate::domain::common::error::misc::MiscError;
|
||||
use crate::infrastructure::secret_store::SecretStore;
|
||||
use crate::interface::port::secret_store::SecretStorePort;
|
||||
use crate::interface::port::setting::SettingRepo;
|
||||
use crate::interface::report_snapshot::ReportSnapshotRepo;
|
||||
use crate::interface::secret_store::SecretStorePort;
|
||||
|
||||
pub fn initialize() -> Scope {
|
||||
web::scope("/report")
|
||||
.route("/generate", web::post().to(generate_report))
|
||||
@ -19,19 +24,19 @@ pub fn initialize() -> Scope {
|
||||
.route("/send", web::post().to(send_report))
|
||||
}
|
||||
|
||||
async fn generate_report(_auth: AuthClaims, db: web::Data<Database>) -> HttpResponse {
|
||||
let report_dir = db
|
||||
.get_setting("report_dir")
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| "/var/lib/netguardia/reports".to_string());
|
||||
async fn generate_report(
|
||||
_auth: AuthClaims,
|
||||
db: web::Data<Database>,
|
||||
config: web::Data<ArcSwap<AppConfig>>,
|
||||
) -> HttpResponse {
|
||||
let report_dir = config.load().system.report_dir.clone();
|
||||
if let Err(e) = fs::create_dir_all(&report_dir) {
|
||||
return HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": format!("Failed to create report directory: {}", e)
|
||||
}));
|
||||
}
|
||||
let db_ref = db.get_ref();
|
||||
match engine::generate_html_report(db_ref as &dyn SettingRepo, &report_dir) {
|
||||
match report_engine::generate_html_report(db_ref as &dyn ReportSnapshotRepo, &report_dir).await {
|
||||
Ok(path) => match fs::read(&path) {
|
||||
Ok(content) => HttpResponse::Ok()
|
||||
.content_type("text/html; charset=utf-8")
|
||||
@ -57,18 +62,21 @@ async fn generate_report(_auth: AuthClaims, db: web::Data<Database>) -> HttpResp
|
||||
|
||||
async fn report_data(_auth: AuthClaims, db: web::Data<Database>) -> HttpResponse {
|
||||
let db_ref = db.get_ref();
|
||||
match engine::generate_report_json(db_ref as &dyn SettingRepo) {
|
||||
Ok(data) => HttpResponse::Ok().json(data),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
|
||||
}
|
||||
ok_json_or_error(report_engine::generate_report_json(db_ref as &dyn ReportSnapshotRepo).await)
|
||||
}
|
||||
|
||||
/// Manually trigger: generate the weekly report and send it via SMTP now.
|
||||
async fn send_report(_auth: AuthClaims, db: web::Data<Database>, secrets: web::Data<SecretStore>) -> HttpResponse {
|
||||
let db_ref = db.get_ref() as &dyn SettingRepo;
|
||||
async fn send_report(
|
||||
_auth: AuthClaims,
|
||||
db: web::Data<Database>,
|
||||
config: web::Data<ArcSwap<AppConfig>>,
|
||||
secrets: web::Data<SecretStore>,
|
||||
) -> HttpResponse {
|
||||
let db_ref = db.get_ref() as &dyn ReportSnapshotRepo;
|
||||
let secrets_ref = secrets.get_ref() as &dyn SecretStorePort;
|
||||
let smtp_cfg = config.load().notification.smtp.clone();
|
||||
|
||||
let smtp = match SmtpClient::from_database(db_ref, Some(secrets_ref)) {
|
||||
let smtp = match SmtpClient::from_config(&smtp_cfg, Some(secrets_ref)).await {
|
||||
Ok(Some(client)) => client,
|
||||
Ok(None) => {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({
|
||||
@ -84,17 +92,15 @@ async fn send_report(_auth: AuthClaims, db: web::Data<Database>, secrets: web::D
|
||||
}
|
||||
};
|
||||
|
||||
let recipient = match db_ref.get_setting("smtp_recipient") {
|
||||
Ok(Some(r)) if !r.is_empty() => r,
|
||||
_ => {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"success": false,
|
||||
"error": "No smtp_recipient configured."
|
||||
}));
|
||||
}
|
||||
};
|
||||
let recipient = smtp_cfg.recipient;
|
||||
if recipient.is_empty() {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({
|
||||
"success": false,
|
||||
"error": MiscError::ValidationError("No smtp_recipient configured.").to_string()
|
||||
}));
|
||||
}
|
||||
|
||||
let html = match generate_weekly_report(db_ref) {
|
||||
let html = match generate_weekly_report(db_ref).await {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
return HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
@ -1,13 +1,16 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use actix_web::{HttpResponse, Scope, web};
|
||||
use arc_swap::ArcSwap;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::core::auth::extractor::AuthClaims;
|
||||
use crate::core::playbook_service::PlaybookService;
|
||||
use crate::core::soar::engine::SoarEngine;
|
||||
use crate::model::event::{DetectionSource, ThreatDetectedEvent};
|
||||
use crate::model::soar::playbook_data::{CreateConditionInput, CreatePlaybookInput};
|
||||
use crate::adapter::http::helpers::{ok_json_or_error, ok_or_error};
|
||||
use crate::adapter::http::middleware::extractor::AuthClaims;
|
||||
use crate::core::response::engine::SoarEngine;
|
||||
use crate::core::response::playbook_service::PlaybookService;
|
||||
use crate::domain::common::config::AppConfig;
|
||||
use crate::domain::common::event::{DetectionSource, ThreatDetectedEvent};
|
||||
use crate::domain::response::playbook_data::{CreateConditionInput, CreatePlaybookInput};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CreatePlaybookRequest {
|
||||
@ -35,6 +38,47 @@ struct CreateConditionRequest {
|
||||
value2: Option<String>,
|
||||
}
|
||||
|
||||
fn map_request_to_input(body: &CreatePlaybookRequest, fallback_cooldown: i64) -> CreatePlaybookInput {
|
||||
let actions = body
|
||||
.actions
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let params_str = a
|
||||
.params
|
||||
.as_ref()
|
||||
.map(|v| serde_json::to_string(v).unwrap_or_else(|_| "{}".into()))
|
||||
.unwrap_or_else(|| "{}".into());
|
||||
(a.action_type.clone(), params_str)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let conditions = body
|
||||
.conditions
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(|c| {
|
||||
CreateConditionInput::new(
|
||||
c.condition_type.clone(),
|
||||
c.operator.clone(),
|
||||
c.value.clone(),
|
||||
c.value2.clone(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
CreatePlaybookInput {
|
||||
name: body.name.clone(),
|
||||
trigger_event: body.trigger_event.clone(),
|
||||
condition_threshold: body.condition_threshold,
|
||||
condition_count: body.condition_count,
|
||||
condition_window_secs: body.condition_window_secs,
|
||||
cooldown_secs: body.cooldown_secs.unwrap_or(fallback_cooldown),
|
||||
actions,
|
||||
conditions,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn initialize() -> Scope {
|
||||
web::scope("/soar")
|
||||
.route("/playbooks", web::get().to(list_playbooks))
|
||||
@ -52,107 +96,17 @@ pub fn initialize() -> Scope {
|
||||
}
|
||||
|
||||
async fn list_playbooks(_auth: AuthClaims, svc: web::Data<PlaybookService>) -> HttpResponse {
|
||||
match svc.list_playbooks() {
|
||||
Ok(playbooks) => {
|
||||
let responses: Vec<serde_json::Value> = playbooks
|
||||
.into_iter()
|
||||
.map(|pb| {
|
||||
let actions: Vec<serde_json::Value> = pb
|
||||
.actions
|
||||
.into_iter()
|
||||
.map(|a| {
|
||||
serde_json::json!({
|
||||
"id": a.id,
|
||||
"action_order": a.action_order,
|
||||
"action_type": a.action_type,
|
||||
"params": a.params,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let conditions: Vec<serde_json::Value> = pb
|
||||
.conditions
|
||||
.into_iter()
|
||||
.map(|c| {
|
||||
serde_json::json!({
|
||||
"id": c.id,
|
||||
"condition_type": c.condition_type,
|
||||
"operator": c.operator,
|
||||
"value": c.value,
|
||||
"value2": c.value2,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
serde_json::json!({
|
||||
"id": pb.id,
|
||||
"name": pb.name,
|
||||
"enabled": pb.enabled,
|
||||
"trigger_event": pb.trigger_event,
|
||||
"condition_threshold": pb.condition_threshold,
|
||||
"condition_count": pb.condition_count,
|
||||
"condition_window_secs": pb.condition_window_secs,
|
||||
"cooldown_secs": pb.cooldown_secs,
|
||||
"actions": actions,
|
||||
"conditions": conditions,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
HttpResponse::Ok().json(responses)
|
||||
}
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
|
||||
}
|
||||
ok_json_or_error(svc.list_playbooks().await)
|
||||
}
|
||||
|
||||
async fn create_playbook(
|
||||
_auth: AuthClaims,
|
||||
svc: web::Data<PlaybookService>,
|
||||
app_config: web::Data<ArcSwap<AppConfig>>,
|
||||
body: web::Json<CreatePlaybookRequest>,
|
||||
) -> HttpResponse {
|
||||
let actions: Vec<(String, String)> = body
|
||||
.actions
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let params_str = a
|
||||
.params
|
||||
.as_ref()
|
||||
.map(|v| serde_json::to_string(v).unwrap_or_else(|_| "{}".into()))
|
||||
.unwrap_or_else(|| "{}".into());
|
||||
(a.action_type.clone(), params_str)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let conditions: Vec<CreateConditionInput> = body
|
||||
.conditions
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let default_op = match c.condition_type.as_str() {
|
||||
"threshold" | "frequency" => ">=",
|
||||
"source_country" | "ip_pattern" => "in",
|
||||
"repeat_offender" => "==",
|
||||
_ => ">=",
|
||||
};
|
||||
CreateConditionInput {
|
||||
condition_type: c.condition_type.clone(),
|
||||
operator: c.operator.clone().unwrap_or_else(|| default_op.to_string()),
|
||||
value: c.value.clone(),
|
||||
value2: c.value2.clone(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let input = CreatePlaybookInput {
|
||||
name: body.name.clone(),
|
||||
trigger_event: body.trigger_event.clone(),
|
||||
condition_threshold: body.condition_threshold,
|
||||
condition_count: body.condition_count,
|
||||
condition_window_secs: body.condition_window_secs,
|
||||
cooldown_secs: body.cooldown_secs.unwrap_or(300),
|
||||
actions,
|
||||
conditions,
|
||||
};
|
||||
|
||||
match svc.create_playbook(&input) {
|
||||
let input = map_request_to_input(&body, app_config.load().soar.fallback_cooldown_secs);
|
||||
match svc.create_playbook(&input).await {
|
||||
Ok(id) => HttpResponse::Created().json(serde_json::json!({"id": id})),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
|
||||
}
|
||||
@ -161,57 +115,13 @@ async fn create_playbook(
|
||||
async fn update_playbook(
|
||||
_auth: AuthClaims,
|
||||
svc: web::Data<PlaybookService>,
|
||||
app_config: web::Data<ArcSwap<AppConfig>>,
|
||||
path: web::Path<i64>,
|
||||
body: web::Json<CreatePlaybookRequest>,
|
||||
) -> HttpResponse {
|
||||
let id = path.into_inner();
|
||||
|
||||
let actions: Vec<(String, String)> = body
|
||||
.actions
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let params_str = a
|
||||
.params
|
||||
.as_ref()
|
||||
.map(|v| serde_json::to_string(v).unwrap_or_else(|_| "{}".into()))
|
||||
.unwrap_or_else(|| "{}".into());
|
||||
(a.action_type.clone(), params_str)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let conditions: Vec<CreateConditionInput> = body
|
||||
.conditions
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let default_op = match c.condition_type.as_str() {
|
||||
"threshold" | "frequency" => ">=",
|
||||
"source_country" | "ip_pattern" => "in",
|
||||
"repeat_offender" => "==",
|
||||
_ => ">=",
|
||||
};
|
||||
CreateConditionInput {
|
||||
condition_type: c.condition_type.clone(),
|
||||
operator: c.operator.clone().unwrap_or_else(|| default_op.to_string()),
|
||||
value: c.value.clone(),
|
||||
value2: c.value2.clone(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let input = CreatePlaybookInput {
|
||||
name: body.name.clone(),
|
||||
trigger_event: body.trigger_event.clone(),
|
||||
condition_threshold: body.condition_threshold,
|
||||
condition_count: body.condition_count,
|
||||
condition_window_secs: body.condition_window_secs,
|
||||
cooldown_secs: body.cooldown_secs.unwrap_or(300),
|
||||
actions,
|
||||
conditions,
|
||||
};
|
||||
|
||||
match svc.update_playbook(id, &input) {
|
||||
let input = map_request_to_input(&body, app_config.load().soar.fallback_cooldown_secs);
|
||||
match svc.update_playbook(id, &input).await {
|
||||
Ok(true) => HttpResponse::Ok().json(serde_json::json!({"updated": true})),
|
||||
Ok(false) => HttpResponse::NotFound().json(serde_json::json!({"error": "Playbook not found"})),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
|
||||
@ -229,7 +139,7 @@ async fn toggle_playbook(
|
||||
path: web::Path<i64>,
|
||||
body: web::Json<TogglePlaybookRequest>,
|
||||
) -> HttpResponse {
|
||||
match svc.toggle_playbook(path.into_inner(), body.enabled) {
|
||||
match svc.toggle_playbook(path.into_inner(), body.enabled).await {
|
||||
Ok(true) => HttpResponse::Ok().json(serde_json::json!({"updated": true})),
|
||||
Ok(false) => HttpResponse::NotFound().json(serde_json::json!({"error": "Playbook not found"})),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
|
||||
@ -237,7 +147,7 @@ async fn toggle_playbook(
|
||||
}
|
||||
|
||||
async fn delete_playbook(_auth: AuthClaims, svc: web::Data<PlaybookService>, path: web::Path<i64>) -> HttpResponse {
|
||||
match svc.delete_playbook(path.into_inner()) {
|
||||
match svc.delete_playbook(path.into_inner()).await {
|
||||
Ok(true) => HttpResponse::Ok().json(serde_json::json!({"deleted": true})),
|
||||
Ok(false) => HttpResponse::NotFound().json(serde_json::json!({"error": "Playbook not found"})),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
|
||||
@ -245,59 +155,23 @@ async fn delete_playbook(_auth: AuthClaims, svc: web::Data<PlaybookService>, pat
|
||||
}
|
||||
|
||||
async fn list_active_blocks(_auth: AuthClaims, svc: web::Data<PlaybookService>) -> HttpResponse {
|
||||
match svc.list_active_blocks() {
|
||||
Ok(blocks) => {
|
||||
let responses: Vec<serde_json::Value> = blocks
|
||||
.into_iter()
|
||||
.map(|b| {
|
||||
serde_json::json!({
|
||||
"id": b.id,
|
||||
"source_ip": b.source_ip,
|
||||
"playbook_id": b.playbook_id,
|
||||
"expires_at": b.expires_at,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
HttpResponse::Ok().json(responses)
|
||||
}
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
|
||||
}
|
||||
ok_json_or_error(svc.list_active_blocks().await)
|
||||
}
|
||||
|
||||
async fn manual_unblock(_auth: AuthClaims, svc: web::Data<PlaybookService>, path: web::Path<i64>) -> HttpResponse {
|
||||
match svc.manual_unblock(path.into_inner()).await {
|
||||
Ok(()) => HttpResponse::Ok().json(serde_json::json!({"unblocked": true})),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
|
||||
}
|
||||
ok_or_error(svc.manual_unblock(path.into_inner()).await)
|
||||
}
|
||||
|
||||
async fn list_executions(_auth: AuthClaims, svc: web::Data<PlaybookService>) -> HttpResponse {
|
||||
match svc.list_executions(100) {
|
||||
Ok(executions) => {
|
||||
let responses: Vec<serde_json::Value> = executions
|
||||
.into_iter()
|
||||
.map(|ex| {
|
||||
serde_json::json!({
|
||||
"id": ex.id,
|
||||
"playbook_id": ex.playbook_id,
|
||||
"source_ip": ex.source_ip,
|
||||
"trigger_event": ex.trigger_event,
|
||||
"actions_executed": ex.actions_executed,
|
||||
"created_at": ex.created_at,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
HttpResponse::Ok().json(responses)
|
||||
}
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
|
||||
}
|
||||
async fn list_executions(
|
||||
_auth: AuthClaims,
|
||||
svc: web::Data<PlaybookService>,
|
||||
app_config: web::Data<ArcSwap<AppConfig>>,
|
||||
) -> HttpResponse {
|
||||
ok_json_or_error(svc.list_executions(app_config.load().soar.execution_list_limit).await)
|
||||
}
|
||||
|
||||
async fn list_whitelist(_auth: AuthClaims, svc: web::Data<PlaybookService>) -> HttpResponse {
|
||||
match svc.list_whitelist() {
|
||||
Ok(ips) => HttpResponse::Ok().json(ips),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
|
||||
}
|
||||
ok_json_or_error(svc.list_whitelist().await)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@ -310,17 +184,14 @@ async fn add_whitelist(
|
||||
svc: web::Data<PlaybookService>,
|
||||
body: web::Json<WhitelistRequest>,
|
||||
) -> HttpResponse {
|
||||
match svc.add_whitelist(&body.ip) {
|
||||
match svc.add_whitelist(&body.ip).await {
|
||||
Ok(()) => HttpResponse::Created().json(serde_json::json!({"added": true})),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove_whitelist(_auth: AuthClaims, svc: web::Data<PlaybookService>, path: web::Path<String>) -> HttpResponse {
|
||||
match svc.remove_whitelist(&path.into_inner()) {
|
||||
Ok(()) => HttpResponse::Ok().json(serde_json::json!({"removed": true})),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
|
||||
}
|
||||
ok_or_error(svc.remove_whitelist(&path.into_inner()).await)
|
||||
}
|
||||
|
||||
/// Client shape for `POST /api/soar/dry-run`. Only the fields a SOAR
|
||||
@ -8,12 +8,14 @@ use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::adapter::persistence::Database;
|
||||
use crate::core::auth::password;
|
||||
use crate::core::auth::setup_guard::SetupCompleteFlag;
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::domain::common::error::system::SystemError;
|
||||
use crate::domain::identity::auth::DEFAULT_ADMIN_USERNAME;
|
||||
use crate::domain::identity::password;
|
||||
use crate::infrastructure::http_server::SetupCompleteFlag;
|
||||
use crate::infrastructure::secret_store::SecretStore;
|
||||
use crate::interface::port::secret_store::SecretStorePort;
|
||||
use crate::model::error::Error;
|
||||
use crate::model::error::system::SystemError;
|
||||
use crate::interface::secret_store::SecretStorePort;
|
||||
use crate::interface::system_state::SystemStateRepo;
|
||||
|
||||
pub fn initialize() -> Scope {
|
||||
web::scope("/setup")
|
||||
@ -23,7 +25,7 @@ pub fn initialize() -> Scope {
|
||||
}
|
||||
|
||||
async fn setup_status(setup_flag: web::Data<SetupCompleteFlag>) -> HttpResponse {
|
||||
let complete = setup_flag.load(Ordering::SeqCst);
|
||||
let complete = setup_flag.0.load(Ordering::SeqCst);
|
||||
HttpResponse::Ok().json(serde_json::json!({
|
||||
"setup_complete": complete,
|
||||
}))
|
||||
@ -87,8 +89,7 @@ async fn complete_setup(
|
||||
setup_flag: web::Data<SetupCompleteFlag>,
|
||||
body: web::Json<SetupRequest>,
|
||||
) -> HttpResponse {
|
||||
// Check if already completed (concurrent access protection)
|
||||
if setup_flag.load(Ordering::SeqCst) {
|
||||
if setup_flag.0.load(Ordering::SeqCst) {
|
||||
return HttpResponse::Conflict().json(serde_json::json!({
|
||||
"error": "Setup already completed"
|
||||
}));
|
||||
@ -133,7 +134,7 @@ async fn complete_setup(
|
||||
}
|
||||
|
||||
// Save configuration to database
|
||||
if let Err(e) = save_config(&db, secret_store.as_ref(), &body) {
|
||||
if let Err(e) = save_config(&db, secret_store.as_ref(), &body).await {
|
||||
return HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": format!("Failed to save configuration: {}", e)
|
||||
}));
|
||||
@ -143,14 +144,10 @@ async fn complete_setup(
|
||||
match password::hash_password(&body.admin_password) {
|
||||
Ok(hash) => {
|
||||
// Find admin user and update password
|
||||
if let Ok(Some(user)) = db.find_user("admin") {
|
||||
if let Err(e) = db.update_user_password(user.0, &hash) {
|
||||
log!(SystemError::SetupPasswordUpdateFailed(e));
|
||||
}
|
||||
// Clear force_password_change since setup wizard set the password
|
||||
if let Err(e) = db.reset_user_password(user.0, &hash) {
|
||||
log!(SystemError::SetupPasswordUpdateFailed(e));
|
||||
}
|
||||
if let Ok(Some(user)) = db.find_user(DEFAULT_ADMIN_USERNAME).await
|
||||
&& let Err(e) = db.update_user_password(user.id, &hash).await
|
||||
{
|
||||
log!(SystemError::SetupPasswordUpdateFailed(e));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
@ -161,10 +158,11 @@ async fn complete_setup(
|
||||
}
|
||||
|
||||
// Mark setup as complete
|
||||
if let Err(e) = db.set_setting("setup_complete", "true") {
|
||||
let state_repo = db.get_ref() as &dyn SystemStateRepo;
|
||||
if let Err(e) = state_repo.set_system_state("setup_complete", "true").await {
|
||||
log!(SystemError::SetupCompleteFlagFailed(e));
|
||||
}
|
||||
setup_flag.store(true, Ordering::SeqCst);
|
||||
setup_flag.0.store(true, Ordering::SeqCst);
|
||||
|
||||
// System::run() polls the setup_complete flag and will automatically
|
||||
// start eBPF, ML, and SOAR services once this flag becomes true.
|
||||
@ -176,43 +174,43 @@ async fn complete_setup(
|
||||
}))
|
||||
}
|
||||
|
||||
fn save_config(db: &Database, secrets: &dyn SecretStorePort, req: &SetupRequest) -> Result<(), Error> {
|
||||
async fn save_config(db: &Database, secrets: &dyn SecretStorePort, req: &SetupRequest) -> Result<(), Error> {
|
||||
// Save network config
|
||||
db.set_setting("ingress_interface", &req.ingress_interface)?;
|
||||
db.set_setting("egress_interface", &req.egress_interface)?;
|
||||
db.set_config_value("ingress_interface", &req.ingress_interface).await?;
|
||||
db.set_config_value("egress_interface", &req.egress_interface).await?;
|
||||
|
||||
if let Some(port) = req.http_port {
|
||||
db.set_setting("http_port", &port.to_string())?;
|
||||
db.set_config_value("http_port", &port.to_string()).await?;
|
||||
}
|
||||
|
||||
// Save SMTP config (non-secret fields go to settings)
|
||||
if let Some(host) = &req.smtp_host {
|
||||
db.set_setting("smtp_host", host)?;
|
||||
db.set_config_value("smtp_host", host).await?;
|
||||
}
|
||||
if let Some(port) = req.smtp_port {
|
||||
db.set_setting("smtp_port", &port.to_string())?;
|
||||
db.set_config_value("smtp_port", &port.to_string()).await?;
|
||||
}
|
||||
if let Some(user) = &req.smtp_username {
|
||||
db.set_setting("smtp_username", user)?;
|
||||
db.set_config_value("smtp_username", user).await?;
|
||||
}
|
||||
if let Some(pass) = &req.smtp_password {
|
||||
// Store password through secret store (encrypted)
|
||||
secrets.set_secret("smtp_password", pass)?;
|
||||
db.set_setting("smtp_password", "__encrypted__")?;
|
||||
secrets.set_secret("smtp_password", pass).await?;
|
||||
db.set_config_value("smtp_password", "__encrypted__").await?;
|
||||
}
|
||||
if let Some(recipient) = &req.smtp_recipient {
|
||||
db.set_setting("smtp_recipient", recipient)?;
|
||||
db.set_config_value("smtp_recipient", recipient).await?;
|
||||
}
|
||||
|
||||
// Save Telegram config (bot_token through secret store, chat_id in JSON)
|
||||
if let (Some(token), Some(chat_id)) = (&req.telegram_bot_token, &req.telegram_chat_id) {
|
||||
secrets.set_secret("telegram_bot_token", token)?;
|
||||
secrets.set_secret("telegram_bot_token", token).await?;
|
||||
let config_json = serde_json::json!({
|
||||
"bot_token": "__encrypted__",
|
||||
"chat_id": chat_id,
|
||||
})
|
||||
.to_string();
|
||||
db.set_notification_config("telegram", &config_json)?;
|
||||
db.set_notification_config("telegram", &config_json).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@ -1,17 +1,16 @@
|
||||
use actix_web::{HttpResponse, Responder, Scope, web};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::core::auth::extractor::AuthClaims;
|
||||
use crate::core::config_service::ConfigService;
|
||||
use crate::infrastructure::communication_manager::CommunicationManager;
|
||||
use crate::adapter::http::middleware::extractor::AuthClaims;
|
||||
use crate::core::common::config_service::ConfigService;
|
||||
use crate::core::common::enforce_mode_handler::EnforceModeHandler;
|
||||
use crate::domain::common::config::constants::{
|
||||
ENFORCE_MODE_ENFORCE, ENFORCE_MODE_ML_ONLY, ENFORCE_MODE_MONITOR, PERMISSION_SYSTEM_ADMIN,
|
||||
};
|
||||
use crate::infrastructure::logger::Logger;
|
||||
use crate::infrastructure::runtime_state::RuntimeState;
|
||||
use crate::infrastructure::system::{ShutdownHandle, ShutdownMode};
|
||||
use crate::interface::communication::command_types::ChangeEnforceModeCommand;
|
||||
use crate::interface::communication::query_types::GetEnforceModeQuery;
|
||||
use crate::interface::port::app_repo::AppRepo;
|
||||
use crate::utils::boot_time;
|
||||
use crate::utils::logging::Logging;
|
||||
|
||||
type Repo = dyn AppRepo;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EnforceModeRequest {
|
||||
@ -36,54 +35,42 @@ async fn get_boot_time() -> impl Responder {
|
||||
HttpResponse::Ok().json(boot_time::boot_time())
|
||||
}
|
||||
|
||||
async fn get_enforce_mode(comm: web::Data<CommunicationManager>) -> impl Responder {
|
||||
match comm.send_query(GetEnforceModeQuery).await {
|
||||
Ok(mode) => HttpResponse::Ok().json(serde_json::json!({"mode": mode})),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
|
||||
}
|
||||
async fn get_enforce_mode(handler: web::Data<EnforceModeHandler>) -> impl Responder {
|
||||
HttpResponse::Ok().json(serde_json::json!({"mode": handler.get_mode()}))
|
||||
}
|
||||
|
||||
async fn set_enforce_mode(
|
||||
body: web::Json<EnforceModeRequest>,
|
||||
comm: web::Data<CommunicationManager>,
|
||||
handler: web::Data<EnforceModeHandler>,
|
||||
) -> impl Responder {
|
||||
let mode = &body.mode;
|
||||
if mode != "monitor" && mode != "ml_only" && mode != "enforce" {
|
||||
if mode != ENFORCE_MODE_MONITOR && mode != ENFORCE_MODE_ML_ONLY && mode != ENFORCE_MODE_ENFORCE {
|
||||
return HttpResponse::BadRequest()
|
||||
.json(serde_json::json!({"error": "Mode must be 'monitor', 'ml_only', or 'enforce'"}));
|
||||
}
|
||||
|
||||
match comm.send_command(ChangeEnforceModeCommand { mode: mode.clone() }).await {
|
||||
match handler.change_mode(mode.clone()).await {
|
||||
Ok(_) => HttpResponse::Ok().json(serde_json::json!({"mode": mode})),
|
||||
Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_xdp_mode(db: web::Data<Repo>) -> impl Responder {
|
||||
let ingress = db
|
||||
.get_setting("xdp_ingress_mode")
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let egress = db
|
||||
.get_setting("xdp_egress_mode")
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
async fn get_xdp_mode(runtime_state: web::Data<arc_swap::ArcSwap<RuntimeState>>) -> impl Responder {
|
||||
let xdp = runtime_state.load().xdp.clone();
|
||||
|
||||
HttpResponse::Ok().json(serde_json::json!({
|
||||
"ingress_mode": ingress,
|
||||
"egress_mode": egress,
|
||||
"ingress_mode": xdp.ingress_mode,
|
||||
"egress_mode": xdp.egress_mode,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn get_config(svc: web::Data<ConfigService>) -> impl Responder {
|
||||
HttpResponse::Ok().json(svc.get_config())
|
||||
HttpResponse::Ok().json(svc.get_config().await)
|
||||
}
|
||||
|
||||
async fn get_log_level() -> impl Responder {
|
||||
async fn get_log_level(logging: web::Data<Logger>) -> impl Responder {
|
||||
HttpResponse::Ok().json(serde_json::json!({
|
||||
"level": Logging::current_level(),
|
||||
"level": logging.current_level(),
|
||||
}))
|
||||
}
|
||||
|
||||
@ -92,8 +79,8 @@ struct LogLevelRequest {
|
||||
level: String,
|
||||
}
|
||||
|
||||
async fn set_log_level(body: web::Json<LogLevelRequest>) -> impl Responder {
|
||||
match Logging::set_level(&body.level) {
|
||||
async fn set_log_level(body: web::Json<LogLevelRequest>, logging: web::Data<Logger>) -> impl Responder {
|
||||
match logging.set_level(&body.level) {
|
||||
Ok(new_level) => HttpResponse::Ok().json(serde_json::json!({
|
||||
"level": new_level,
|
||||
"message": "Log level updated",
|
||||
@ -110,7 +97,7 @@ async fn update_config(
|
||||
svc: web::Data<ConfigService>,
|
||||
handle: web::Data<ShutdownHandle>,
|
||||
) -> impl Responder {
|
||||
match svc.update_config(&body) {
|
||||
match svc.update_config(&body).await {
|
||||
Ok(updated) => {
|
||||
let needs_restart = updated.iter().any(|k| HTTP_RELOAD_KEYS.contains(&k.as_str()));
|
||||
if needs_restart {
|
||||
@ -137,7 +124,7 @@ async fn update_config(
|
||||
}
|
||||
|
||||
async fn shutdown(auth: AuthClaims, handle: web::Data<ShutdownHandle>) -> impl Responder {
|
||||
if !auth.permissions.iter().any(|p| p == "system:admin") {
|
||||
if !auth.permissions.iter().any(|p| p == PERMISSION_SYSTEM_ADMIN) {
|
||||
return HttpResponse::Forbidden().json(serde_json::json!({"error": "Requires system:admin permission"}));
|
||||
}
|
||||
if handle.trigger(ShutdownMode::Shutdown) {
|
||||
@ -148,7 +135,7 @@ async fn shutdown(auth: AuthClaims, handle: web::Data<ShutdownHandle>) -> impl R
|
||||
}
|
||||
|
||||
async fn restart(auth: AuthClaims, handle: web::Data<ShutdownHandle>) -> impl Responder {
|
||||
if !auth.permissions.iter().any(|p| p == "system:admin") {
|
||||
if !auth.permissions.iter().any(|p| p == PERMISSION_SYSTEM_ADMIN) {
|
||||
return HttpResponse::Forbidden().json(serde_json::json!({"error": "Requires system:admin permission"}));
|
||||
}
|
||||
if handle.trigger(ShutdownMode::Restart) {
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
pub mod access_control_adapter;
|
||||
pub mod access_control;
|
||||
pub mod ebpf;
|
||||
pub mod http;
|
||||
pub mod model_loading;
|
||||
pub mod notification;
|
||||
pub mod persistence;
|
||||
pub mod telegram;
|
||||
pub mod websocket;
|
||||
|
||||
@ -2,9 +2,9 @@ use std::collections::{BTreeMap, HashMap};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::manifest::{AdapterKind, LabelSpec, ModelManifest};
|
||||
use crate::model::error::ml::MLError;
|
||||
use crate::model::system::config::MLInferenceConfig;
|
||||
use crate::domain::detection::error::MLError;
|
||||
use crate::domain::detection::manifest::{AdapterKind, LabelSpec, ModelManifest};
|
||||
use crate::domain::detection::ml_inference_config::MLInferenceConfig;
|
||||
|
||||
impl MLInferenceConfig {
|
||||
pub fn load_file(file: &str) -> Result<Self, MLError> {
|
||||
@ -145,7 +145,7 @@ mod tests {
|
||||
use std::io::Write;
|
||||
|
||||
use super::*;
|
||||
use crate::model::detection::ml_detection::ClipParams;
|
||||
use crate::domain::detection::ml_detection::ClipParams;
|
||||
|
||||
/// Integration test: the shipped `models/manifest.yaml` must successfully pair
|
||||
/// with its scaler sidecar to yield a valid `MLInferenceConfig`. Skipped silently
|
||||
17
net-guardia/src/adapter/model_loading/manifest.rs
Normal file
17
net-guardia/src/adapter/model_loading/manifest.rs
Normal file
@ -0,0 +1,17 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::domain::detection::error::MLError;
|
||||
use crate::domain::detection::manifest::ModelManifest;
|
||||
|
||||
impl ModelManifest {
|
||||
pub fn load(path: impl AsRef<Path>) -> Result<Self, MLError> {
|
||||
let path = path.as_ref();
|
||||
let content = fs::read_to_string(path)
|
||||
.map_err(|e| MLError::ManifestInvalid(path.to_path_buf(), format!("read failed: {e}")))?;
|
||||
let manifest: ModelManifest = serde_yaml_ng::from_str(&content)
|
||||
.map_err(|e| MLError::ManifestInvalid(path.to_path_buf(), format!("YAML parse: {e}")))?;
|
||||
manifest.validate(path)?;
|
||||
Ok(manifest)
|
||||
}
|
||||
}
|
||||
2
net-guardia/src/adapter/model_loading/mod.rs
Normal file
2
net-guardia/src/adapter/model_loading/mod.rs
Normal file
@ -0,0 +1,2 @@
|
||||
pub mod config_loader;
|
||||
pub mod manifest;
|
||||
1
net-guardia/src/adapter/notification/mod.rs
Normal file
1
net-guardia/src/adapter/notification/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod smtp;
|
||||
112
net-guardia/src/adapter/notification/smtp.rs
Normal file
112
net-guardia/src/adapter/notification/smtp.rs
Normal file
@ -0,0 +1,112 @@
|
||||
use lettre::message::header::ContentType;
|
||||
use lettre::transport::smtp::authentication::Credentials;
|
||||
use lettre::{Message, SmtpTransport, Transport};
|
||||
|
||||
use crate::domain::common::config::notification::SmtpConfig;
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::domain::common::error::notification::NotificationError;
|
||||
use crate::interface::email_sender::{EmailSender, EmailSenderFactory};
|
||||
use crate::interface::secret_store::SecretStorePort;
|
||||
|
||||
pub struct SmtpClient {
|
||||
host: String,
|
||||
port: u16,
|
||||
username: String,
|
||||
password: String,
|
||||
sender: String,
|
||||
}
|
||||
|
||||
impl SmtpClient {
|
||||
pub async fn from_config(cfg: &SmtpConfig, secrets: Option<&dyn SecretStorePort>) -> Result<Option<Self>, Error> {
|
||||
if cfg.host.is_empty() || cfg.username.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let secret = match secrets {
|
||||
Some(ss) => ss.get_secret("smtp_password").await?,
|
||||
None => None,
|
||||
};
|
||||
let password = match secret {
|
||||
Some(pw) if !pw.is_empty() => pw,
|
||||
_ => return Ok(None),
|
||||
};
|
||||
|
||||
let sender = if cfg.sender.is_empty() {
|
||||
cfg.username.clone()
|
||||
} else {
|
||||
cfg.sender.clone()
|
||||
};
|
||||
|
||||
if !sender.contains('@') {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(Self {
|
||||
host: cfg.host.clone(),
|
||||
port: cfg.port,
|
||||
username: cfg.username.clone(),
|
||||
password,
|
||||
sender,
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn send(&self, to: &str, subject: &str, html_body: &str) -> Result<(), Error> {
|
||||
let from_addr = self
|
||||
.sender
|
||||
.parse()
|
||||
.map_err(|e| NotificationError::InvalidAddress("from", e))?;
|
||||
let to_addr = to.parse().map_err(|e| NotificationError::InvalidAddress("to", e))?;
|
||||
|
||||
let email = Message::builder()
|
||||
.from(from_addr)
|
||||
.to(to_addr)
|
||||
.subject(subject)
|
||||
.header(ContentType::TEXT_HTML)
|
||||
.body(html_body.to_string())
|
||||
.map_err(NotificationError::MessageBuildFailed)?;
|
||||
|
||||
let creds = Credentials::new(self.username.clone(), self.password.clone());
|
||||
|
||||
let mailer = match self.port {
|
||||
465 => SmtpTransport::relay(&self.host)
|
||||
.map_err(NotificationError::SmtpConnectionFailed)?
|
||||
.port(self.port)
|
||||
.credentials(creds)
|
||||
.build(),
|
||||
25 | 587 => SmtpTransport::starttls_relay(&self.host)
|
||||
.map_err(NotificationError::SmtpConnectionFailed)?
|
||||
.port(self.port)
|
||||
.credentials(creds)
|
||||
.build(),
|
||||
_ => SmtpTransport::builder_dangerous(&self.host)
|
||||
.port(self.port)
|
||||
.credentials(creds)
|
||||
.build(),
|
||||
};
|
||||
|
||||
mailer.send(&email).map_err(NotificationError::SmtpSendFailed)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl EmailSender for SmtpClient {
|
||||
fn send(&self, to: &str, subject: &str, html_body: &str) -> Result<(), Error> {
|
||||
self.send(to, subject, html_body)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SmtpClientFactory;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl EmailSenderFactory for SmtpClientFactory {
|
||||
async fn build_smtp_sender(
|
||||
&self,
|
||||
cfg: &SmtpConfig,
|
||||
secrets: Option<&dyn SecretStorePort>,
|
||||
) -> Result<Option<Box<dyn EmailSender>>, Error> {
|
||||
SmtpClient::from_config(cfg, secrets)
|
||||
.await
|
||||
.map(|opt| opt.map(|c| Box::new(c) as Box<dyn EmailSender>))
|
||||
}
|
||||
}
|
||||
185
net-guardia/src/adapter/persistence/acl.rs
Normal file
185
net-guardia/src/adapter/persistence/acl.rs
Normal file
@ -0,0 +1,185 @@
|
||||
use async_trait::async_trait;
|
||||
use rusqlite::params;
|
||||
|
||||
use super::Database;
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::domain::data_plane::acl_rule::AclRuleView;
|
||||
use crate::interface::acl::AclRepo;
|
||||
|
||||
impl Database {
|
||||
pub async fn insert_acl_rule(
|
||||
&self,
|
||||
ip_version: u8,
|
||||
direction: &str,
|
||||
list_type: &str,
|
||||
ip_address: &str,
|
||||
port: u16,
|
||||
) -> Result<(), Error> {
|
||||
let direction = direction.to_string();
|
||||
let list_type = list_type.to_string();
|
||||
let ip_address = ip_address.to_string();
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO acl_rules (ip_version, direction, list_type, ip_address, port) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![ip_version, direction, list_type, ip_address, port as i64],
|
||||
)?;
|
||||
if direction == "source" && list_type == "blacklist" {
|
||||
conn.execute(
|
||||
"UPDATE soar_block_rules
|
||||
SET preserve_acl_on_unblock = 1
|
||||
WHERE source_ip = ?1 AND unblocked_at IS NULL",
|
||||
params![ip_address],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_acl_rule(
|
||||
&self,
|
||||
ip_version: u8,
|
||||
direction: &str,
|
||||
list_type: &str,
|
||||
ip_address: &str,
|
||||
port: u16,
|
||||
) -> Result<(), Error> {
|
||||
let direction = direction.to_string();
|
||||
let list_type = list_type.to_string();
|
||||
let ip_address = ip_address.to_string();
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
conn.execute(
|
||||
"DELETE FROM acl_rules WHERE ip_version = ?1 AND direction = ?2 AND list_type = ?3 AND ip_address = ?4 AND port = ?5",
|
||||
params![ip_version, direction, list_type, ip_address, port as i64],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_acl_rules(&self) -> Result<Vec<AclRuleView>, Error> {
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT ip_version, direction, list_type, ip_address, port FROM acl_rules")?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok(AclRuleView {
|
||||
ip_version: row.get(0)?,
|
||||
direction: row.get(1)?,
|
||||
list_type: row.get(2)?,
|
||||
ip_address: row.get(3)?,
|
||||
port: row.get::<_, i64>(4)? as u16,
|
||||
})
|
||||
})?;
|
||||
let mut results = Vec::new();
|
||||
for row in rows {
|
||||
results.push(row?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn has_manual_acl_rule(&self, ip_address: &str) -> Result<bool, Error> {
|
||||
let ip_address = ip_address.to_string();
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
let count: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM acl_rules
|
||||
WHERE ip_address = ?1 AND list_type = 'blacklist'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM soar_block_rules
|
||||
WHERE soar_block_rules.source_ip = acl_rules.ip_address
|
||||
AND soar_block_rules.unblocked_at IS NULL
|
||||
AND (
|
||||
soar_block_rules.preserve_acl_on_unblock = 1
|
||||
OR soar_block_rules.created_acl_rule = 0
|
||||
)
|
||||
)",
|
||||
params![ip_address],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
Ok(count > 0)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_admin_whitelist(&self) -> Result<Vec<String>, Error> {
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
let mut stmt = conn.prepare("SELECT ip FROM admin_whitelist")?;
|
||||
let rows = stmt.query_map([], |row| row.get::<_, String>(0))?;
|
||||
let mut result = Vec::new();
|
||||
for row in rows {
|
||||
result.push(row?);
|
||||
}
|
||||
Ok(result)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn insert_admin_whitelist(&self, ip: &str) -> Result<(), Error> {
|
||||
let ip = ip.to_string();
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
conn.execute("INSERT OR IGNORE INTO admin_whitelist (ip) VALUES (?1)", params![ip])?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_admin_whitelist(&self, ip: &str) -> Result<(), Error> {
|
||||
let ip = ip.to_string();
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
conn.execute("DELETE FROM admin_whitelist WHERE ip = ?1", params![ip])?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AclRepo for Database {
|
||||
async fn insert_acl_rule(
|
||||
&self,
|
||||
ip_version: u8,
|
||||
direction: &str,
|
||||
list_type: &str,
|
||||
ip_address: &str,
|
||||
port: u16,
|
||||
) -> Result<(), Error> {
|
||||
self.insert_acl_rule(ip_version, direction, list_type, ip_address, port)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn delete_acl_rule(
|
||||
&self,
|
||||
ip_version: u8,
|
||||
direction: &str,
|
||||
list_type: &str,
|
||||
ip_address: &str,
|
||||
port: u16,
|
||||
) -> Result<(), Error> {
|
||||
self.delete_acl_rule(ip_version, direction, list_type, ip_address, port)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn has_manual_acl_rule(&self, ip_address: &str) -> Result<bool, Error> {
|
||||
self.has_manual_acl_rule(ip_address).await
|
||||
}
|
||||
|
||||
async fn list_admin_whitelist(&self) -> Result<Vec<String>, Error> {
|
||||
self.list_admin_whitelist().await
|
||||
}
|
||||
|
||||
async fn insert_admin_whitelist(&self, ip: &str) -> Result<(), Error> {
|
||||
self.insert_admin_whitelist(ip).await
|
||||
}
|
||||
|
||||
async fn delete_admin_whitelist(&self, ip: &str) -> Result<(), Error> {
|
||||
self.delete_admin_whitelist(ip).await
|
||||
}
|
||||
}
|
||||
174
net-guardia/src/adapter/persistence/api_key.rs
Normal file
174
net-guardia/src/adapter/persistence/api_key.rs
Normal file
@ -0,0 +1,174 @@
|
||||
use std::fmt::Write;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use hmac::{Hmac, Mac};
|
||||
use rusqlite::{Error as RusqliteError, params};
|
||||
use sha2::Sha256;
|
||||
|
||||
use super::Database;
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::domain::identity::auth::{Claims, PermissionLevel};
|
||||
use crate::domain::identity::user::ApiKeyView;
|
||||
use crate::interface::api_key::ApiKeyRepo;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
impl Database {
|
||||
/// Compute HMAC-SHA256 of an API key using the derived secret.
|
||||
pub fn hmac_api_key(&self, raw_key: &str) -> String {
|
||||
let mut mac = HmacSha256::new_from_slice(&self.api_key_hmac).unwrap_or_else(|_| unreachable!());
|
||||
mac.update(raw_key.as_bytes());
|
||||
let result = mac.finalize().into_bytes();
|
||||
|
||||
let mut hex = String::with_capacity(64);
|
||||
for byte in result {
|
||||
let _ = write!(&mut hex, "{:02x}", byte);
|
||||
}
|
||||
hex
|
||||
}
|
||||
|
||||
pub async fn validate_api_key(&self, api_key: &str) -> Result<Option<Claims>, Error> {
|
||||
let digest = self.hmac_api_key(api_key);
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
let result = conn.query_row(
|
||||
"SELECT id, name, permission_level FROM api_keys WHERE key_hash = ?1",
|
||||
params![digest],
|
||||
|row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
))
|
||||
},
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok((id, name, level)) => {
|
||||
let _ = conn.execute(
|
||||
"UPDATE api_keys SET last_used_at = datetime('now') WHERE id = ?1",
|
||||
params![id],
|
||||
);
|
||||
|
||||
let perm_level = PermissionLevel::from_str(&level).unwrap_or(PermissionLevel::ReadOnly);
|
||||
let permissions: Vec<String> =
|
||||
perm_level.permissions().iter().map(|s| (*s).to_string()).collect();
|
||||
|
||||
Ok(Some(Claims {
|
||||
sub: -id,
|
||||
username: format!("api:{}", name),
|
||||
role: level,
|
||||
permissions,
|
||||
exp: usize::MAX,
|
||||
}))
|
||||
}
|
||||
Err(RusqliteError::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn insert_api_key(&self, key_hash: &str, name: &str, permission_level: &str) -> Result<i64, Error> {
|
||||
let key_hash = key_hash.to_string();
|
||||
let name = name.to_string();
|
||||
let permission_level = permission_level.to_string();
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO api_keys (key_hash, name, permission_level) VALUES (?1, ?2, ?3)",
|
||||
params![key_hash, name, permission_level],
|
||||
)?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_api_keys(&self) -> Result<Vec<ApiKeyView>, Error> {
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT id, name, permission_level, created_at, last_used_at FROM api_keys")?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok(ApiKeyView {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
permission_level: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
last_used_at: row.get(4)?,
|
||||
})
|
||||
})?;
|
||||
let mut result = Vec::new();
|
||||
for row in rows {
|
||||
result.push(row?);
|
||||
}
|
||||
Ok(result)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_api_key(&self, id: i64) -> Result<bool, Error> {
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
let affected = conn.execute("DELETE FROM api_keys WHERE id = ?1", params![id])?;
|
||||
Ok(affected > 0)
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[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 insert_api_key(&self, key_hash: &str, name: &str, permission_level: &str) -> Result<i64, Error> {
|
||||
self.insert_api_key(key_hash, name, permission_level).await
|
||||
}
|
||||
|
||||
async fn list_api_keys(&self) -> Result<Vec<ApiKeyView>, Error> {
|
||||
self.list_api_keys().await
|
||||
}
|
||||
|
||||
async fn delete_api_key(&self, id: i64) -> Result<bool, Error> {
|
||||
self.delete_api_key(id).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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 raw_key = "ng-test-full-access";
|
||||
let digest = db.hmac_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");
|
||||
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()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn validate_read_write_api_key_does_not_grant_admin_permissions() {
|
||||
let db = Database::new(":memory:").await.expect("test db");
|
||||
let raw_key = "ng-test-read-write";
|
||||
let digest = db.hmac_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");
|
||||
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()));
|
||||
}
|
||||
}
|
||||
160
net-guardia/src/adapter/persistence/audit.rs
Normal file
160
net-guardia/src/adapter/persistence/audit.rs
Normal file
@ -0,0 +1,160 @@
|
||||
use std::fmt::Write;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use rusqlite::params;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use super::Database;
|
||||
use crate::domain::common::audit::AuditLogEntry;
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::domain::common::error::database::DatabaseError;
|
||||
use crate::interface::audit::AuditRepo;
|
||||
|
||||
fn audit_row_hash(ts: &str, actor: &str, action: &str, detail: &str, prev_hash: &str) -> String {
|
||||
let mut h = Sha256::new();
|
||||
for part in [ts, actor, action, detail, prev_hash] {
|
||||
h.update(part.as_bytes());
|
||||
h.update([0u8]);
|
||||
}
|
||||
let out = h.finalize();
|
||||
let mut hex = String::with_capacity(64);
|
||||
for byte in out {
|
||||
let _ = write!(&mut hex, "{:02x}", byte);
|
||||
}
|
||||
hex
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub async fn insert_audit_log(&self, actor: &str, action: &str, detail: &str) -> Result<(), Error> {
|
||||
let actor = actor.to_string();
|
||||
let action = action.to_string();
|
||||
let detail = detail.to_string();
|
||||
self.pool
|
||||
.conn_mut_and_then(move |conn| {
|
||||
let ts = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
let tx = conn.transaction()?;
|
||||
let prev_hash: String = tx
|
||||
.query_row("SELECT row_hash FROM audit_log ORDER BY id DESC LIMIT 1", [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let row_hash = audit_row_hash(&ts, &actor, &action, &detail, &prev_hash);
|
||||
tx.execute(
|
||||
"INSERT INTO audit_log (ts, actor, action, detail, prev_hash, row_hash) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
params![ts, actor, action, detail, prev_hash, row_hash],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_audit_logs(&self) -> Result<Vec<AuditLogEntry>, Error> {
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT id, actor, action, detail, ts FROM audit_log ORDER BY id DESC LIMIT 200")?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(AuditLogEntry {
|
||||
id: row.get(0)?,
|
||||
actor: row.get(1)?,
|
||||
action: row.get(2)?,
|
||||
detail: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
})
|
||||
})?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
Ok(rows)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_audit_logs_by_action(&self, action: &str, limit: i64) -> Result<Vec<AuditLogEntry>, Error> {
|
||||
let action = action.to_string();
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, actor, action, detail, ts FROM audit_log WHERE action = ?1 ORDER BY id DESC LIMIT ?2",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map(params![action, limit], |row| {
|
||||
Ok(AuditLogEntry {
|
||||
id: row.get(0)?,
|
||||
actor: row.get(1)?,
|
||||
action: row.get(2)?,
|
||||
detail: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
})
|
||||
})?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
Ok(rows)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn verify_audit_log_chain(&self, after_id: i64) -> Result<(usize, i64), Error> {
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
let mut expected_prev = if after_id > 0 {
|
||||
conn.query_row(
|
||||
"SELECT row_hash FROM audit_log WHERE id = ?1",
|
||||
params![after_id],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, ts, actor, action, detail, prev_hash, row_hash \
|
||||
FROM audit_log WHERE id > ?1 ORDER BY id ASC",
|
||||
)?;
|
||||
let mut rows = stmt.query(params![after_id])?;
|
||||
|
||||
let mut count = 0usize;
|
||||
let mut last_id = after_id;
|
||||
while let Some(row) = rows.next()? {
|
||||
let id: i64 = row.get(0)?;
|
||||
let ts: String = row.get(1)?;
|
||||
let actor: String = row.get(2)?;
|
||||
let action: String = row.get(3)?;
|
||||
let detail: String = row.get(4)?;
|
||||
let prev_hash: String = row.get(5)?;
|
||||
let row_hash: String = row.get(6)?;
|
||||
|
||||
if prev_hash != expected_prev {
|
||||
return Err(DatabaseError::AuditPrevHashMismatch(id, expected_prev, prev_hash).into());
|
||||
}
|
||||
let computed = audit_row_hash(&ts, &actor, &action, &detail, &prev_hash);
|
||||
if computed != row_hash {
|
||||
return Err(DatabaseError::AuditRowHashMismatch(id, computed, row_hash).into());
|
||||
}
|
||||
expected_prev = row_hash;
|
||||
last_id = id;
|
||||
count += 1;
|
||||
}
|
||||
Ok((count, last_id))
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AuditRepo for Database {
|
||||
async fn insert_audit_log(&self, actor: &str, action: &str, detail: &str) -> Result<(), Error> {
|
||||
self.insert_audit_log(actor, action, detail).await
|
||||
}
|
||||
|
||||
async fn list_audit_logs_by_action(&self, action: &str, limit: i64) -> Result<Vec<AuditLogEntry>, Error> {
|
||||
self.list_audit_logs_by_action(action, limit).await
|
||||
}
|
||||
|
||||
async fn verify_audit_log_chain(&self, after_id: i64) -> Result<(usize, i64), Error> {
|
||||
self.verify_audit_log_chain(after_id).await
|
||||
}
|
||||
}
|
||||
153
net-guardia/src/adapter/persistence/config.rs
Normal file
153
net-guardia/src/adapter/persistence/config.rs
Normal file
@ -0,0 +1,153 @@
|
||||
use async_trait::async_trait;
|
||||
use rusqlite::{Error as RusqliteError, params};
|
||||
|
||||
use super::Database;
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::interface::config_repo::ConfigRepo;
|
||||
|
||||
impl Database {
|
||||
pub async fn get_config_value(&self, key: &str) -> Result<Option<String>, Error> {
|
||||
let key = key.to_string();
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
let result = conn.query_row("SELECT value FROM settings WHERE key = ?1", params![key], |row| {
|
||||
row.get(0)
|
||||
});
|
||||
match result {
|
||||
Ok(val) => Ok(Some(val)),
|
||||
Err(RusqliteError::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn set_config_value(&self, key: &str, value: &str) -> Result<(), Error> {
|
||||
let key = key.to_string();
|
||||
let value = value.to_string();
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
|
||||
params![key, value],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_app_secret(&self, key: &str) -> Result<Option<String>, Error> {
|
||||
let key = key.to_string();
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
let result = conn.query_row("SELECT value FROM app_secrets WHERE key = ?1", params![key], |row| {
|
||||
row.get(0)
|
||||
});
|
||||
match result {
|
||||
Ok(val) => Ok(Some(val)),
|
||||
Err(RusqliteError::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn set_app_secret(&self, key: &str, value: &str) -> Result<(), Error> {
|
||||
let key = key.to_string();
|
||||
let value = value.to_string();
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO app_secrets (key, value) VALUES (?1, ?2)",
|
||||
params![key, value],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_notification_config(&self, channel: &str) -> Result<Option<String>, Error> {
|
||||
let channel = channel.to_string();
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
match conn.query_row(
|
||||
"SELECT config_json FROM notification_config WHERE channel = ?1 AND enabled = 1",
|
||||
params![channel],
|
||||
|row| row.get::<_, String>(0),
|
||||
) {
|
||||
Ok(json) => Ok(Some(json)),
|
||||
Err(RusqliteError::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn set_notification_config(&self, channel: &str, config_json: &str) -> Result<(), Error> {
|
||||
let channel = channel.to_string();
|
||||
let config_json = config_json.to_string();
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO notification_config (channel, config_json) VALUES (?1, ?2) \
|
||||
ON CONFLICT(channel) DO UPDATE SET config_json = ?2, updated_at = datetime('now')",
|
||||
params![channel, config_json],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ConfigRepo for Database {
|
||||
async fn get_config_value(&self, key: &str) -> Result<Option<String>, Error> {
|
||||
self.get_config_value(key).await
|
||||
}
|
||||
|
||||
async fn set_config_value(&self, key: &str, value: &str) -> Result<(), Error> {
|
||||
self.set_config_value(key, value).await
|
||||
}
|
||||
|
||||
async fn get_app_secret(&self, key: &str) -> Result<Option<String>, Error> {
|
||||
self.get_app_secret(key).await
|
||||
}
|
||||
|
||||
async fn set_app_secret(&self, key: &str, plaintext: &str) -> Result<(), Error> {
|
||||
self.set_app_secret(key, plaintext).await
|
||||
}
|
||||
|
||||
async fn get_notification_config(&self, channel: &str) -> Result<Option<String>, Error> {
|
||||
self.get_notification_config(channel).await
|
||||
}
|
||||
|
||||
async fn set_notification_config(&self, channel: &str, config_json: &str) -> Result<(), Error> {
|
||||
self.set_notification_config(channel, config_json).await
|
||||
}
|
||||
|
||||
async fn update_config_values_atomically(
|
||||
&self,
|
||||
config_values: Vec<(String, String)>,
|
||||
secrets: Vec<(String, String)>,
|
||||
) -> Result<(), Error> {
|
||||
self.pool
|
||||
.conn_mut_and_then(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
for (key, value) in config_values {
|
||||
tx.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
|
||||
params![key, value],
|
||||
)?;
|
||||
}
|
||||
for (key, value) in secrets {
|
||||
tx.execute(
|
||||
"INSERT OR REPLACE INTO app_secrets (key, value) VALUES (?1, ?2)",
|
||||
params![key, value],
|
||||
)?;
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
143
net-guardia/src/adapter/persistence/enforcement.rs
Normal file
143
net-guardia/src/adapter/persistence/enforcement.rs
Normal file
@ -0,0 +1,143 @@
|
||||
use async_trait::async_trait;
|
||||
use rusqlite::params;
|
||||
|
||||
use super::Database;
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::interface::enforcement::EnforcementRepo;
|
||||
|
||||
impl Database {
|
||||
pub async fn set_rate_limit(&self, key: &str, value: u64) -> Result<(), Error> {
|
||||
let key = key.to_string();
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO rate_limit_config (key, value) VALUES (?1, ?2)",
|
||||
params![key, value as i64],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn load_rate_limit_config(&self) -> Result<Vec<(String, u64)>, Error> {
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
let mut stmt = conn.prepare("SELECT key, value FROM rate_limit_config")?;
|
||||
let rows = stmt.query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as u64)))?;
|
||||
let mut results = Vec::new();
|
||||
for row in rows {
|
||||
results.push(row?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn insert_dns_domains(&self, domains: &[String]) -> Result<(), Error> {
|
||||
let domains = domains.to_vec();
|
||||
self.pool
|
||||
.conn_mut_and_then(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
for domain in domains {
|
||||
tx.execute(
|
||||
"INSERT OR IGNORE INTO dns_blacklist (domain) VALUES (?1)",
|
||||
params![domain],
|
||||
)?;
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_dns_domains(&self, domains: &[String]) -> Result<(), Error> {
|
||||
let domains = domains.to_vec();
|
||||
self.pool
|
||||
.conn_mut_and_then(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
for domain in domains {
|
||||
tx.execute("DELETE FROM dns_blacklist WHERE domain = ?1", params![domain])?;
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn load_dns_domains(&self) -> Result<Vec<String>, Error> {
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
let mut stmt = conn.prepare("SELECT domain FROM dns_blacklist")?;
|
||||
let rows = stmt.query_map([], |row| row.get(0))?;
|
||||
let mut results = Vec::new();
|
||||
for row in rows {
|
||||
results.push(row?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn insert_geo_country(&self, code: &str) -> Result<(), Error> {
|
||||
let code = code.to_string();
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO geo_blocked_countries (country_code) VALUES (?1)",
|
||||
params![code],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_geo_country(&self, code: &str) -> Result<(), Error> {
|
||||
let code = code.to_string();
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
conn.execute(
|
||||
"DELETE FROM geo_blocked_countries WHERE country_code = ?1",
|
||||
params![code],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn load_geo_countries(&self) -> Result<Vec<String>, Error> {
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
let mut stmt = conn.prepare("SELECT country_code FROM geo_blocked_countries")?;
|
||||
let rows = stmt.query_map([], |row| row.get(0))?;
|
||||
let mut results = Vec::new();
|
||||
for row in rows {
|
||||
results.push(row?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl EnforcementRepo for Database {
|
||||
async fn set_rate_limit(&self, key: &str, value: u64) -> Result<(), Error> {
|
||||
self.set_rate_limit(key, value).await
|
||||
}
|
||||
|
||||
async fn insert_dns_domains(&self, domains: &[String]) -> Result<(), Error> {
|
||||
self.insert_dns_domains(domains).await
|
||||
}
|
||||
|
||||
async fn delete_dns_domains(&self, domains: &[String]) -> Result<(), Error> {
|
||||
self.delete_dns_domains(domains).await
|
||||
}
|
||||
|
||||
async fn insert_geo_country(&self, code: &str) -> Result<(), Error> {
|
||||
self.insert_geo_country(code).await
|
||||
}
|
||||
|
||||
async fn delete_geo_country(&self, code: &str) -> Result<(), Error> {
|
||||
self.delete_geo_country(code).await
|
||||
}
|
||||
}
|
||||
@ -1,3 +1,603 @@
|
||||
pub mod repository;
|
||||
mod acl;
|
||||
mod api_key;
|
||||
mod audit;
|
||||
mod config;
|
||||
mod enforcement;
|
||||
mod report_snapshot;
|
||||
mod soar;
|
||||
mod soar_block;
|
||||
mod stats;
|
||||
mod system_state;
|
||||
mod user;
|
||||
|
||||
pub use repository::Database;
|
||||
use std::env;
|
||||
|
||||
use async_sqlite::{Client, ClientBuilder};
|
||||
use macros::log;
|
||||
use rusqlite::{self, Connection, params};
|
||||
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::domain::common::error::database::DatabaseError;
|
||||
use crate::domain::common::log::misc::MiscLog;
|
||||
|
||||
impl From<rusqlite::Error> for DatabaseError {
|
||||
fn from(e: rusqlite::Error) -> Self {
|
||||
DatabaseError::QueryFailed(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<rusqlite::Error> for Error {
|
||||
fn from(e: rusqlite::Error) -> Self {
|
||||
Self::Database(DatabaseError::from(e))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<async_sqlite::Error> for Error {
|
||||
fn from(e: async_sqlite::Error) -> Self {
|
||||
Self::Database(DatabaseError::QueryFailed(e))
|
||||
}
|
||||
}
|
||||
use crate::domain::identity::auth::{ADMIN_PERMISSIONS, GROUP_ADMIN, GROUP_VIEWER, VIEWER_PERMISSIONS};
|
||||
|
||||
/// Reads the SQLCipher encryption key from the environment variable `NETGUARDIA_DB_KEY`.
|
||||
/// Returns `Some(key)` if set and non-empty, `None` otherwise (dev / unencrypted mode).
|
||||
fn db_encryption_key() -> Option<String> {
|
||||
match env::var("NETGUARDIA_DB_KEY") {
|
||||
Ok(k) if !k.is_empty() => Some(k),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Database {
|
||||
pool: Client,
|
||||
/// HMAC-SHA256 key for API key hashing, derived from NETGUARDIA_SECRETS_KEY.
|
||||
api_key_hmac: [u8; 32],
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub async fn new(path: &str) -> Result<Self, Error> {
|
||||
let encryption_key = db_encryption_key();
|
||||
|
||||
if path != ":memory:" && encryption_key.is_none() {
|
||||
log!(MiscLog::DbEncryptionDisabled);
|
||||
}
|
||||
|
||||
let builder = if path == ":memory:" {
|
||||
ClientBuilder::new()
|
||||
} else {
|
||||
ClientBuilder::new().path(path)
|
||||
};
|
||||
|
||||
let pool = builder.open().await.map_err(DatabaseError::QueryFailed)?;
|
||||
let key_for_pragmas = encryption_key.clone();
|
||||
pool.conn(move |conn| {
|
||||
if let Some(ref key) = key_for_pragmas {
|
||||
conn.pragma_update(None, "key", key)?;
|
||||
}
|
||||
conn.execute_batch(
|
||||
"PRAGMA journal_mode=WAL; \
|
||||
PRAGMA synchronous=NORMAL; \
|
||||
PRAGMA busy_timeout=5000; \
|
||||
PRAGMA foreign_keys=ON;",
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(DatabaseError::QueryFailed)?;
|
||||
|
||||
// Verify the pool is actually usable (catches wrong key / corrupt DB early).
|
||||
pool.conn(|conn| conn.query_row("SELECT count(*) FROM sqlite_master", [], |_| Ok(())))
|
||||
.await
|
||||
.map_err(|_| DatabaseError::EncryptionKeyInvalid)?;
|
||||
|
||||
let api_key_hmac = Self::derive_api_key_hmac();
|
||||
let db = Self { pool, api_key_hmac };
|
||||
db.create_tables().await?;
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
/// Derive HMAC-SHA256 key for API key hashing from NETGUARDIA_SECRETS_KEY.
|
||||
/// Falls back to a static dev key if the env var is unset.
|
||||
fn derive_api_key_hmac() -> [u8; 32] {
|
||||
use hkdf::Hkdf;
|
||||
use sha2::Sha256;
|
||||
|
||||
let root_key = env::var("NETGUARDIA_SECRETS_KEY")
|
||||
.ok()
|
||||
.filter(|k| !k.is_empty())
|
||||
.or_else(|| env::var("NETGUARDIA_DB_KEY").ok().filter(|k| !k.is_empty()))
|
||||
.unwrap_or_else(|| "netguardia-dev-api-key-secret".to_string());
|
||||
|
||||
let hk = Hkdf::<Sha256>::new(Some(b"netguardia-v1-salt"), root_key.as_bytes());
|
||||
let mut okm = [0u8; 32];
|
||||
// SAFETY: 32 bytes is a valid output length for HKDF-SHA256
|
||||
hk.expand(b"netguardia-apikey-hmac-v1", &mut okm).unwrap();
|
||||
okm
|
||||
}
|
||||
|
||||
/// Export an encrypted database to a plaintext copy.
|
||||
/// The original file is NOT modified.
|
||||
pub fn decrypt_to_file(src_path: &str, key: &str, dest_path: &str) -> Result<(), Error> {
|
||||
let conn = Connection::open(src_path).map_err(DatabaseError::QueryFailed)?;
|
||||
conn.pragma_update(None, "key", key)
|
||||
.map_err(DatabaseError::QueryFailed)?;
|
||||
// Verify we can read the encrypted DB
|
||||
conn.query_row("SELECT count(*) FROM sqlite_master", [], |_| Ok(()))
|
||||
.map_err(|_| DatabaseError::DatabaseNotReadable)?;
|
||||
// Attach a plaintext destination (empty key = no encryption)
|
||||
conn.execute("ATTACH DATABASE ?1 AS plaintext KEY '';", params![dest_path])
|
||||
.map_err(DatabaseError::QueryFailed)?;
|
||||
conn.query_row("SELECT sqlcipher_export('plaintext')", [], |_| Ok(()))
|
||||
.map_err(DatabaseError::QueryFailed)?;
|
||||
conn.execute_batch("DETACH DATABASE plaintext;")
|
||||
.map_err(DatabaseError::QueryFailed)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Encrypt a plaintext database to a new encrypted copy.
|
||||
/// The original file is NOT modified.
|
||||
pub fn encrypt_to_file(src_path: &str, key: &str, dest_path: &str) -> Result<(), Error> {
|
||||
let conn = Connection::open(src_path).map_err(DatabaseError::QueryFailed)?;
|
||||
// Verify it's readable as plaintext
|
||||
conn.query_row("SELECT count(*) FROM sqlite_master", [], |_| Ok(()))
|
||||
.map_err(|_| DatabaseError::SourceDatabaseNotReadable)?;
|
||||
conn.execute("ATTACH DATABASE ?1 AS encrypted KEY ?2;", params![dest_path, key])
|
||||
.map_err(DatabaseError::QueryFailed)?;
|
||||
conn.query_row("SELECT sqlcipher_export('encrypted')", [], |_| Ok(()))
|
||||
.map_err(DatabaseError::QueryFailed)?;
|
||||
conn.execute_batch("DETACH DATABASE encrypted;")
|
||||
.map_err(DatabaseError::QueryFailed)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn create_tables(&self) -> Result<(), Error> {
|
||||
self.pool
|
||||
.conn_mut_and_then(|conn| {
|
||||
conn.execute_batch(
|
||||
"
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'viewer',
|
||||
force_password_change INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS acl_rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ip_version INTEGER NOT NULL,
|
||||
direction TEXT NOT NULL,
|
||||
list_type TEXT NOT NULL,
|
||||
ip_address TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
UNIQUE(ip_version, direction, list_type, ip_address, port)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS rate_limit_config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS dns_blacklist (
|
||||
domain TEXT PRIMARY KEY
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS geo_blocked_countries (
|
||||
country_code TEXT PRIMARY KEY
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS system_state (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS report_snapshots (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS login_attempts (
|
||||
username TEXT PRIMARY KEY,
|
||||
failure_count INTEGER NOT NULL DEFAULT 0,
|
||||
locked_until INTEGER
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS user_groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
permissions TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS user_group_members (
|
||||
user_id INTEGER NOT NULL,
|
||||
group_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (user_id, group_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||
FOREIGN KEY (group_id) REFERENCES user_groups(id)
|
||||
);
|
||||
|
||||
-- SOAR tables
|
||||
CREATE TABLE IF NOT EXISTS playbooks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
trigger_event TEXT NOT NULL,
|
||||
condition_threshold REAL,
|
||||
condition_count INTEGER,
|
||||
condition_window_secs INTEGER,
|
||||
cooldown_secs INTEGER DEFAULT 300,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS playbook_actions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
playbook_id INTEGER NOT NULL REFERENCES playbooks(id) ON DELETE CASCADE,
|
||||
action_order INTEGER NOT NULL,
|
||||
action_type TEXT NOT NULL,
|
||||
params TEXT NOT NULL DEFAULT '{}',
|
||||
UNIQUE(playbook_id, action_order)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS playbook_conditions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
playbook_id INTEGER NOT NULL REFERENCES playbooks(id) ON DELETE CASCADE,
|
||||
condition_type TEXT NOT NULL,
|
||||
operator TEXT NOT NULL DEFAULT '>=',
|
||||
value TEXT NOT NULL,
|
||||
value2 TEXT,
|
||||
UNIQUE(playbook_id, condition_type)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS soar_block_rules (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source_ip TEXT NOT NULL,
|
||||
playbook_id INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
expires_at TEXT NOT NULL,
|
||||
unblocked_at TEXT,
|
||||
created_acl_rule INTEGER NOT NULL DEFAULT 1,
|
||||
preserve_acl_on_unblock INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS soar_executions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
playbook_id INTEGER NOT NULL,
|
||||
source_ip TEXT,
|
||||
trigger_event TEXT NOT NULL,
|
||||
actions_executed TEXT NOT NULL DEFAULT '[]',
|
||||
executed_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS admin_whitelist (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ip TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- MCP API keys
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key_hash TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
permission_level TEXT NOT NULL DEFAULT 'read_only',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
last_used_at TEXT
|
||||
);
|
||||
|
||||
-- Notification config (Telegram bot token, etc.)
|
||||
CREATE TABLE IF NOT EXISTS notification_config (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
channel TEXT NOT NULL UNIQUE,
|
||||
config_json TEXT NOT NULL,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- App secrets (encryption keys for sensitive data)
|
||||
CREATE TABLE IF NOT EXISTS app_secrets (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Pending unblock queue for orphan eBPF block recovery
|
||||
CREATE TABLE IF NOT EXISTS pending_unblock (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source_ip TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
retry_count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- Audit trail (WORM: hash-chained, triggers block UPDATE/DELETE)
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts TEXT NOT NULL,
|
||||
actor TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
detail TEXT NOT NULL DEFAULT '{}',
|
||||
prev_hash TEXT NOT NULL DEFAULT '',
|
||||
row_hash TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_action
|
||||
ON audit_log(action, id DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_soar_block_active_expires
|
||||
ON soar_block_rules(expires_at)
|
||||
WHERE unblocked_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_soar_block_created_at
|
||||
ON soar_block_rules(created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_soar_block_unblocked_at
|
||||
ON soar_block_rules(unblocked_at)
|
||||
WHERE unblocked_at IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_soar_block_source_created
|
||||
ON soar_block_rules(source_ip, created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_soar_executions_executed_at
|
||||
ON soar_executions(executed_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_soar_executions_trigger_executed
|
||||
ON soar_executions(trigger_event, executed_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_user_group_members_group_id
|
||||
ON user_group_members(group_id, user_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_key_hash
|
||||
ON api_keys(key_hash);
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS audit_log_no_update
|
||||
BEFORE UPDATE ON audit_log BEGIN
|
||||
SELECT RAISE(ABORT, 'audit_log is append-only (WORM)');
|
||||
END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS audit_log_no_delete
|
||||
BEFORE DELETE ON audit_log BEGIN
|
||||
SELECT RAISE(ABORT, 'audit_log is append-only (WORM)');
|
||||
END;
|
||||
",
|
||||
)?;
|
||||
|
||||
if !Self::column_exists(conn, "soar_block_rules", "created_acl_rule")? {
|
||||
conn.execute(
|
||||
"ALTER TABLE soar_block_rules ADD COLUMN created_acl_rule INTEGER NOT NULL DEFAULT 1",
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
if !Self::column_exists(conn, "soar_block_rules", "preserve_acl_on_unblock")? {
|
||||
conn.execute(
|
||||
"ALTER TABLE soar_block_rules ADD COLUMN preserve_acl_on_unblock INTEGER NOT NULL DEFAULT 0",
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
|
||||
Self::migrate_legacy_settings_state(conn)?;
|
||||
|
||||
let group_count: i64 = conn.query_row("SELECT COUNT(*) FROM user_groups", [], |row| row.get(0))?;
|
||||
if group_count == 0 {
|
||||
let all_permissions =
|
||||
serde_json::to_string(&ADMIN_PERMISSIONS).unwrap_or_else(|_| "[]".to_string());
|
||||
let viewer_permissions =
|
||||
serde_json::to_string(&VIEWER_PERMISSIONS).unwrap_or_else(|_| "[]".to_string());
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO user_groups (name, description, permissions) VALUES (?1, ?2, ?3)",
|
||||
params![GROUP_ADMIN, "Full system access with all permissions", &all_permissions],
|
||||
)?;
|
||||
conn.execute(
|
||||
"INSERT INTO user_groups (name, description, permissions) VALUES (?1, ?2, ?3)",
|
||||
params![GROUP_VIEWER, "Read-only access to all modules", &viewer_permissions],
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok::<(), Error>(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn column_exists(conn: &Connection, table: &str, column: &str) -> Result<bool, Error> {
|
||||
let mut stmt = conn.prepare(&format!("PRAGMA table_info({})", table))?;
|
||||
let rows = stmt.query_map([], |row| row.get::<_, String>(1))?;
|
||||
for row in rows {
|
||||
if row? == column {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn migrate_legacy_settings_state(conn: &Connection) -> Result<(), Error> {
|
||||
conn.execute_batch(
|
||||
"
|
||||
INSERT OR IGNORE INTO system_state (key, value)
|
||||
SELECT key, value FROM settings WHERE key = 'setup_complete';
|
||||
|
||||
INSERT OR IGNORE INTO report_snapshots (key, value)
|
||||
SELECT key, value FROM settings
|
||||
WHERE key IN (
|
||||
'weekly_threats_count',
|
||||
'weekly_top_ips',
|
||||
'weekly_threat_breakdown',
|
||||
'weekly_system_health',
|
||||
'system_uptime_percent',
|
||||
'active_rules_count',
|
||||
'weekly_geo_distribution',
|
||||
'weekly_soar_blocks',
|
||||
'weekly_soar_triggers',
|
||||
'weekly_soar_unblocks',
|
||||
'weekly_blocked_count'
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO login_attempts (username, failure_count)
|
||||
SELECT substr(key, length('login_failures:') + 1), CAST(value AS INTEGER)
|
||||
FROM settings
|
||||
WHERE key LIKE 'login_failures:%';
|
||||
|
||||
INSERT INTO login_attempts (username, locked_until)
|
||||
SELECT substr(key, length('login_locked_until:') + 1), CAST(value AS INTEGER)
|
||||
FROM settings
|
||||
WHERE key LIKE 'login_locked_until:%'
|
||||
ON CONFLICT(username) DO UPDATE SET
|
||||
locked_until = COALESCE(login_attempts.locked_until, excluded.locked_until);
|
||||
|
||||
DELETE FROM settings
|
||||
WHERE key = 'setup_complete'
|
||||
OR key IN (
|
||||
'weekly_threats_count',
|
||||
'weekly_top_ips',
|
||||
'weekly_threat_breakdown',
|
||||
'weekly_system_health',
|
||||
'system_uptime_percent',
|
||||
'active_rules_count',
|
||||
'weekly_geo_distribution',
|
||||
'weekly_soar_blocks',
|
||||
'weekly_soar_triggers',
|
||||
'weekly_soar_unblocks',
|
||||
'weekly_blocked_count'
|
||||
)
|
||||
OR key LIKE 'login_failures:%'
|
||||
OR key LIKE 'login_locked_until:%';
|
||||
",
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::interface::acl::AclRepo;
|
||||
use crate::interface::config_repo::ConfigRepo;
|
||||
use crate::interface::identity::UserRepo;
|
||||
|
||||
pub(super) async fn test_db() -> Database {
|
||||
Database::new(":memory:").await.expect("Failed to create test database")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_tables() {
|
||||
let _db = test_db().await;
|
||||
}
|
||||
|
||||
/// Verify that Database satisfies each aggregate Repo trait contract
|
||||
/// (AclRepo / ConfigRepo / UserRepo). Exercises the trait-object
|
||||
/// path so callers that take `Arc<dyn XxxRepo>` compile end-to-end.
|
||||
#[tokio::test]
|
||||
async fn test_aggregate_repo_trait_objects() {
|
||||
let db = test_db().await;
|
||||
|
||||
let setting: &dyn ConfigRepo = &db;
|
||||
setting.set_config_value("test_key", "test_value").await.unwrap();
|
||||
assert_eq!(
|
||||
setting.get_config_value("test_key").await.unwrap(),
|
||||
Some("test_value".to_string())
|
||||
);
|
||||
|
||||
let acl: &dyn AclRepo = &db;
|
||||
acl.insert_acl_rule(4, "source", "blacklist", "10.0.0.1", 443)
|
||||
.await
|
||||
.unwrap();
|
||||
// load_acl_rules is an inherent Database method (not on AclRepo),
|
||||
// so go through `&db` directly for this read-back assertion.
|
||||
let rules = db.list_acl_rules().await.unwrap();
|
||||
assert_eq!(rules.len(), 1);
|
||||
|
||||
let identity: &dyn UserRepo = &db;
|
||||
// user_count is inherent — inserts still go through the trait so
|
||||
// the vtable has something to exercise.
|
||||
assert_eq!(db.user_count().await.unwrap(), 0);
|
||||
identity.insert_user("test", "hash", "viewer", false).await.unwrap();
|
||||
assert_eq!(db.user_count().await.unwrap(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_non_config_settings_are_backfilled() {
|
||||
let unique = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
|
||||
let path = std::env::temp_dir().join(format!("netguardia-legacy-settings-{}-{unique}.db", std::process::id()));
|
||||
let locked_until = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() + 3600;
|
||||
|
||||
{
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
conn.execute_batch(
|
||||
"
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
",
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO settings (key, value) VALUES (?1, ?2)",
|
||||
params!["setup_complete", "true"],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO settings (key, value) VALUES (?1, ?2)",
|
||||
params!["weekly_threats_count", "7"],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO settings (key, value) VALUES (?1, ?2)",
|
||||
params!["login_failures:alice", "4"],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO settings (key, value) VALUES (?1, ?2)",
|
||||
params!["login_locked_until:alice", locked_until.to_string()],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let db = Database::new(path.to_str().unwrap()).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
db.get_system_state("setup_complete").await.unwrap(),
|
||||
Some("true".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
db.get_report_snapshot("weekly_threats_count").await.unwrap(),
|
||||
Some("7".to_string())
|
||||
);
|
||||
assert_eq!(db.get_config_value("setup_complete").await.unwrap(), None);
|
||||
assert_eq!(db.get_config_value("weekly_threats_count").await.unwrap(), None);
|
||||
|
||||
let remaining_lock = db.check_login_locked("alice").await.unwrap();
|
||||
assert!(remaining_lock.is_some_and(|remaining| remaining > 0));
|
||||
assert_eq!(db.get_config_value("login_failures:alice").await.unwrap(), None);
|
||||
|
||||
drop(db);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(path.with_extension("db-wal"));
|
||||
let _ = std::fs::remove_file(path.with_extension("db-shm"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_tables_installs_query_shape_indexes() {
|
||||
let db = test_db().await;
|
||||
let expected = [
|
||||
("soar_block_rules", "idx_soar_block_active_expires"),
|
||||
("soar_block_rules", "idx_soar_block_created_at"),
|
||||
("soar_block_rules", "idx_soar_block_unblocked_at"),
|
||||
("soar_block_rules", "idx_soar_block_source_created"),
|
||||
("soar_executions", "idx_soar_executions_executed_at"),
|
||||
("soar_executions", "idx_soar_executions_trigger_executed"),
|
||||
("user_group_members", "idx_user_group_members_group_id"),
|
||||
("api_keys", "idx_api_keys_key_hash"),
|
||||
];
|
||||
|
||||
db.pool
|
||||
.conn_and_then(move |conn| {
|
||||
for (table, index) in expected {
|
||||
let count: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND tbl_name = ?1 AND name = ?2",
|
||||
params![table, index],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
assert_eq!(count, 1, "missing index {index} on {table}");
|
||||
}
|
||||
Ok::<(), Error>(())
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
51
net-guardia/src/adapter/persistence/report_snapshot.rs
Normal file
51
net-guardia/src/adapter/persistence/report_snapshot.rs
Normal file
@ -0,0 +1,51 @@
|
||||
use async_trait::async_trait;
|
||||
use rusqlite::{Error as RusqliteError, params};
|
||||
|
||||
use super::Database;
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::interface::report_snapshot::ReportSnapshotRepo;
|
||||
|
||||
impl Database {
|
||||
pub async fn get_report_snapshot(&self, key: &str) -> Result<Option<String>, Error> {
|
||||
let key = key.to_string();
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
let result = conn.query_row(
|
||||
"SELECT value FROM report_snapshots WHERE key = ?1",
|
||||
params![key],
|
||||
|row| row.get(0),
|
||||
);
|
||||
match result {
|
||||
Ok(value) => Ok(Some(value)),
|
||||
Err(RusqliteError::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn set_report_snapshot(&self, key: &str, value: &str) -> Result<(), Error> {
|
||||
let key = key.to_string();
|
||||
let value = value.to_string();
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO report_snapshots (key, value) VALUES (?1, ?2)",
|
||||
params![key, value],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ReportSnapshotRepo for Database {
|
||||
async fn get_report_snapshot(&self, key: &str) -> Result<Option<String>, Error> {
|
||||
self.get_report_snapshot(key).await
|
||||
}
|
||||
|
||||
async fn set_report_snapshot(&self, key: &str, value: &str) -> Result<(), Error> {
|
||||
self.set_report_snapshot(key, value).await
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
459
net-guardia/src/adapter/persistence/soar.rs
Normal file
459
net-guardia/src/adapter/persistence/soar.rs
Normal file
@ -0,0 +1,459 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use rusqlite::params;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::Database;
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::domain::response::defaults::DEFAULT_PLAYBOOKS;
|
||||
use crate::domain::response::playbook_data::{
|
||||
ActionInput, ActionView, ActiveBlockView, ConditionView, CreateConditionInput, CreatePlaybookInput, ExecutionView,
|
||||
PendingUnblock, PlaybookView, UpdatePlaybookInput,
|
||||
};
|
||||
use crate::interface::soar::SoarRepo;
|
||||
|
||||
struct PlaybookActionRow {
|
||||
pb_id: i64,
|
||||
name: String,
|
||||
enabled: bool,
|
||||
trigger_event: String,
|
||||
condition_threshold: Option<f64>,
|
||||
condition_count: Option<i64>,
|
||||
condition_window_secs: Option<i64>,
|
||||
cooldown_secs: i64,
|
||||
action_id: Option<i64>,
|
||||
action_order: Option<i64>,
|
||||
action_type: Option<String>,
|
||||
action_params: Option<String>,
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub async fn insert_playbook(
|
||||
&self,
|
||||
name: &str,
|
||||
trigger_event: &str,
|
||||
threshold: Option<f64>,
|
||||
count: Option<i64>,
|
||||
window: Option<i64>,
|
||||
cooldown: i64,
|
||||
) -> Result<i64, Error> {
|
||||
let name = name.to_string();
|
||||
let trigger_event = trigger_event.to_string();
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO playbooks (name, trigger_event, condition_threshold, condition_count, condition_window_secs, cooldown_secs) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
params![name, trigger_event, threshold, count, window, cooldown],
|
||||
)?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn insert_playbook_action(
|
||||
&self,
|
||||
playbook_id: i64,
|
||||
action_order: i64,
|
||||
action_type: &str,
|
||||
params_json: &str,
|
||||
) -> Result<i64, Error> {
|
||||
let action_type = action_type.to_string();
|
||||
let params_json = params_json.to_string();
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO playbook_actions (playbook_id, action_order, action_type, params) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![playbook_id, action_order, action_type, params_json],
|
||||
)?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn list_playbooks_with_actions(&self) -> Result<Vec<PlaybookActionRow>, Error> {
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT p.id, p.name, p.enabled, p.trigger_event, p.condition_threshold, \
|
||||
p.condition_count, p.condition_window_secs, p.cooldown_secs, \
|
||||
a.id, a.action_order, a.action_type, a.params \
|
||||
FROM playbooks p \
|
||||
LEFT JOIN playbook_actions a ON a.playbook_id = p.id \
|
||||
ORDER BY p.id, a.action_order",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok(PlaybookActionRow {
|
||||
pb_id: row.get::<_, i64>(0)?,
|
||||
name: row.get::<_, String>(1)?,
|
||||
enabled: row.get::<_, bool>(2)?,
|
||||
trigger_event: row.get::<_, String>(3)?,
|
||||
condition_threshold: row.get::<_, Option<f64>>(4)?,
|
||||
condition_count: row.get::<_, Option<i64>>(5)?,
|
||||
condition_window_secs: row.get::<_, Option<i64>>(6)?,
|
||||
cooldown_secs: row.get::<_, i64>(7)?,
|
||||
action_id: row.get::<_, Option<i64>>(8)?,
|
||||
action_order: row.get::<_, Option<i64>>(9)?,
|
||||
action_type: row.get::<_, Option<String>>(10)?,
|
||||
action_params: row.get::<_, Option<String>>(11)?,
|
||||
})
|
||||
})?;
|
||||
let mut result = Vec::new();
|
||||
for row in rows {
|
||||
result.push(row?);
|
||||
}
|
||||
Ok(result)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn update_playbook_enabled(&self, id: i64, enabled: bool) -> Result<bool, Error> {
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
let rows = conn.execute(
|
||||
"UPDATE playbooks SET enabled = ?2, updated_at = datetime('now') WHERE id = ?1",
|
||||
params![id, enabled as i32],
|
||||
)?;
|
||||
Ok(rows > 0)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_playbook(&self, id: i64) -> Result<bool, Error> {
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
let rows = conn.execute("DELETE FROM playbooks WHERE id = ?1", params![id])?;
|
||||
Ok(rows > 0)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn insert_playbook_condition(
|
||||
&self,
|
||||
playbook_id: i64,
|
||||
condition_type: &str,
|
||||
operator: &str,
|
||||
value: &str,
|
||||
value2: Option<&str>,
|
||||
) -> Result<i64, Error> {
|
||||
let condition_type = condition_type.to_string();
|
||||
let operator = operator.to_string();
|
||||
let value = value.to_string();
|
||||
let value2 = value2.map(str::to_string);
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO playbook_conditions (playbook_id, condition_type, operator, value, value2) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![playbook_id, condition_type, operator, value, value2],
|
||||
)?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn list_all_playbook_conditions(&self) -> Result<Vec<(i64, ConditionView)>, Error> {
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, playbook_id, condition_type, operator, value, value2 \
|
||||
FROM playbook_conditions ORDER BY playbook_id, id",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(1)?,
|
||||
ConditionView {
|
||||
id: row.get::<_, i64>(0)?,
|
||||
condition_type: row.get::<_, String>(2)?,
|
||||
operator: row.get::<_, String>(3)?,
|
||||
value: row.get::<_, String>(4)?,
|
||||
value2: row.get::<_, Option<String>>(5)?,
|
||||
},
|
||||
))
|
||||
})?;
|
||||
let mut result = Vec::new();
|
||||
for row in rows {
|
||||
result.push(row?);
|
||||
}
|
||||
Ok(result)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn insert_soar_execution(
|
||||
&self,
|
||||
playbook_id: i64,
|
||||
source_ip: Option<&str>,
|
||||
trigger_event: &str,
|
||||
actions_json: &str,
|
||||
) -> Result<i64, Error> {
|
||||
let source_ip = source_ip.map(str::to_string);
|
||||
let trigger_event = trigger_event.to_string();
|
||||
let actions_json = actions_json.to_string();
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO soar_executions (playbook_id, source_ip, trigger_event, actions_executed) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![playbook_id, source_ip, trigger_event, actions_json],
|
||||
)?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_soar_executions(&self, limit: i64) -> Result<Vec<ExecutionView>, Error> {
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, playbook_id, source_ip, trigger_event, actions_executed, executed_at FROM soar_executions ORDER BY executed_at DESC LIMIT ?1"
|
||||
)?;
|
||||
let rows = stmt.query_map(params![limit], |row| {
|
||||
let actions_str: String = row.get(4)?;
|
||||
Ok(ExecutionView {
|
||||
id: row.get::<_, i64>(0)?,
|
||||
playbook_id: row.get::<_, i64>(1)?,
|
||||
source_ip: row.get::<_, Option<String>>(2)?,
|
||||
trigger_event: row.get::<_, String>(3)?,
|
||||
actions_executed: serde_json::from_str(&actions_str).unwrap_or(Value::Null),
|
||||
created_at: row.get::<_, String>(5)?,
|
||||
})
|
||||
})?;
|
||||
let mut result = Vec::new();
|
||||
for row in rows {
|
||||
result.push(row?);
|
||||
}
|
||||
Ok(result)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn seed_default_playbooks(&self) -> Result<(), Error> {
|
||||
let count: i64 = self
|
||||
.pool
|
||||
.conn_and_then(move |conn| {
|
||||
Ok::<i64, Error>(conn.query_row("SELECT COUNT(*) FROM playbooks", [], |row| row.get(0))?)
|
||||
})
|
||||
.await?;
|
||||
if count > 0 {
|
||||
return Ok(());
|
||||
}
|
||||
for def in DEFAULT_PLAYBOOKS {
|
||||
let pb_id = self
|
||||
.insert_playbook(
|
||||
def.name,
|
||||
def.trigger_event,
|
||||
def.threshold,
|
||||
def.count,
|
||||
def.window,
|
||||
def.cooldown,
|
||||
)
|
||||
.await?;
|
||||
for action in def.actions {
|
||||
self.insert_playbook_action(pb_id, action.order, action.action_type, action.params)
|
||||
.await?;
|
||||
}
|
||||
for cond in def.conditions {
|
||||
self.insert_playbook_condition(pb_id, cond.condition_type, cond.operator, cond.value, cond.value2)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SoarRepo 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();
|
||||
for row in rows {
|
||||
let pb = if let Some(position) = result.iter().position(|p| p.id == row.pb_id) {
|
||||
&mut result[position]
|
||||
} else {
|
||||
result.push(PlaybookView {
|
||||
id: row.pb_id,
|
||||
name: row.name,
|
||||
enabled: row.enabled,
|
||||
trigger_event: row.trigger_event,
|
||||
condition_threshold: row.condition_threshold,
|
||||
condition_count: row.condition_count,
|
||||
condition_window_secs: row.condition_window_secs,
|
||||
cooldown_secs: row.cooldown_secs,
|
||||
actions: Vec::new(),
|
||||
conditions: Vec::new(),
|
||||
});
|
||||
result.last_mut().unwrap_or_else(|| unreachable!())
|
||||
};
|
||||
if let (Some(aid), Some(order), Some(atype), Some(params_str)) =
|
||||
(row.action_id, row.action_order, row.action_type, row.action_params)
|
||||
{
|
||||
pb.actions.push(ActionView {
|
||||
id: aid,
|
||||
action_order: order,
|
||||
action_type: atype,
|
||||
params: serde_json::from_str(¶ms_str).unwrap_or(Value::Null),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let cond_rows = self.list_all_playbook_conditions().await?;
|
||||
let mut cond_map: HashMap<i64, Vec<ConditionView>> = HashMap::new();
|
||||
for (pb_id, cond) in cond_rows {
|
||||
cond_map.entry(pb_id).or_default().push(cond);
|
||||
}
|
||||
for pb in &mut result {
|
||||
if let Some(conds) = cond_map.remove(&pb.id) {
|
||||
pb.conditions = conds;
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn update_playbook_enabled(&self, id: i64, enabled: bool) -> Result<bool, Error> {
|
||||
self.update_playbook_enabled(id, enabled).await
|
||||
}
|
||||
|
||||
async fn delete_playbook(&self, id: i64) -> Result<bool, Error> {
|
||||
self.delete_playbook(id).await
|
||||
}
|
||||
|
||||
async fn seed_default_playbooks(&self) -> Result<(), Error> {
|
||||
self.seed_default_playbooks().await
|
||||
}
|
||||
|
||||
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 mark_soar_block_unblocked(&self, id: i64) -> Result<(), Error> {
|
||||
self.mark_soar_block_unblocked(id).await
|
||||
}
|
||||
|
||||
async fn insert_pending_unblock(&self, source_ip: &str) -> Result<i64, Error> {
|
||||
self.insert_pending_unblock(source_ip).await
|
||||
}
|
||||
|
||||
async fn list_pending_unblocks(&self) -> Result<Vec<PendingUnblock>, Error> {
|
||||
self.list_pending_unblocks().await
|
||||
}
|
||||
|
||||
async fn delete_pending_unblock(&self, id: i64) -> Result<(), Error> {
|
||||
self.delete_pending_unblock(id).await
|
||||
}
|
||||
|
||||
async fn increment_pending_unblock_retry(&self, id: i64) -> Result<(), Error> {
|
||||
self.increment_pending_unblock_retry(id).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 list_soar_executions(&self, limit: i64) -> Result<Vec<ExecutionView>, Error> {
|
||||
self.list_soar_executions(limit).await
|
||||
}
|
||||
|
||||
async fn insert_playbook_atomic(
|
||||
&self,
|
||||
input: &CreatePlaybookInput,
|
||||
actions: &[ActionInput],
|
||||
conditions: &[CreateConditionInput],
|
||||
) -> Result<i64, Error> {
|
||||
let input = input.clone();
|
||||
let actions = actions.to_vec();
|
||||
let conditions = conditions.to_vec();
|
||||
self.pool
|
||||
.conn_mut_and_then(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(
|
||||
"INSERT INTO playbooks (name, trigger_event, condition_threshold, condition_count, condition_window_secs, cooldown_secs) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
params![input.name, input.trigger_event, input.condition_threshold, input.condition_count, input.condition_window_secs, input.cooldown_secs],
|
||||
)?;
|
||||
let playbook_id = tx.last_insert_rowid();
|
||||
for a in actions {
|
||||
tx.execute(
|
||||
"INSERT INTO playbook_actions (playbook_id, action_order, action_type, params) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![playbook_id, a.action_order, a.action_type, a.params_json],
|
||||
)?;
|
||||
}
|
||||
for c in conditions {
|
||||
tx.execute(
|
||||
"INSERT INTO playbook_conditions (playbook_id, condition_type, operator, value, value2) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![playbook_id, c.condition_type, c.operator, c.value, c.value2.as_deref()],
|
||||
)?;
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(playbook_id)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn update_playbook_atomic(
|
||||
&self,
|
||||
id: i64,
|
||||
row: &UpdatePlaybookInput,
|
||||
actions: &[ActionInput],
|
||||
conditions: &[CreateConditionInput],
|
||||
) -> Result<bool, Error> {
|
||||
let row = row.clone();
|
||||
let actions = actions.to_vec();
|
||||
let conditions = conditions.to_vec();
|
||||
self.pool
|
||||
.conn_mut_and_then(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
let rows_updated = tx.execute(
|
||||
"UPDATE playbooks SET name = ?2, trigger_event = ?3, condition_threshold = ?4, \
|
||||
condition_count = ?5, condition_window_secs = ?6, cooldown_secs = ?7, \
|
||||
updated_at = datetime('now') WHERE id = ?1",
|
||||
params![
|
||||
id,
|
||||
row.name,
|
||||
row.trigger_event,
|
||||
row.condition_threshold,
|
||||
row.condition_count,
|
||||
row.condition_window_secs,
|
||||
row.cooldown_secs
|
||||
],
|
||||
)?;
|
||||
if rows_updated == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
tx.execute("DELETE FROM playbook_actions WHERE playbook_id = ?1", params![id])?;
|
||||
tx.execute("DELETE FROM playbook_conditions WHERE playbook_id = ?1", params![id])?;
|
||||
for a in actions {
|
||||
tx.execute(
|
||||
"INSERT INTO playbook_actions (playbook_id, action_order, action_type, params) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![id, a.action_order, a.action_type, a.params_json],
|
||||
)?;
|
||||
}
|
||||
for c in conditions {
|
||||
tx.execute(
|
||||
"INSERT INTO playbook_conditions (playbook_id, condition_type, operator, value, value2) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![id, c.condition_type, c.operator, c.value, c.value2.as_deref()],
|
||||
)?;
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(true)
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
322
net-guardia/src/adapter/persistence/soar_block.rs
Normal file
322
net-guardia/src/adapter/persistence/soar_block.rs
Normal file
@ -0,0 +1,322 @@
|
||||
use async_trait::async_trait;
|
||||
use rusqlite::params;
|
||||
|
||||
use super::Database;
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::domain::response::playbook_data::{ActiveBlockView, PendingUnblock};
|
||||
use crate::interface::db_admin::DbAdminRepo;
|
||||
|
||||
fn active_block_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<ActiveBlockView> {
|
||||
Ok(ActiveBlockView {
|
||||
id: row.get::<_, i64>(0)?,
|
||||
source_ip: row.get::<_, String>(1)?,
|
||||
playbook_id: row.get::<_, i64>(2)?,
|
||||
expires_at: row.get::<_, String>(3)?,
|
||||
})
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub async fn count_active_soar_blocks(&self) -> Result<u32, Error> {
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
let count: u32 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM soar_block_rules WHERE unblocked_at IS NULL AND expires_at > datetime('now')",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
Ok(count)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_expired_soar_blocks(&self) -> Result<Vec<ActiveBlockView>, Error> {
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, source_ip, playbook_id, expires_at FROM soar_block_rules WHERE expires_at <= datetime('now') AND unblocked_at IS NULL",
|
||||
)?;
|
||||
let rows = stmt.query_map([], active_block_from_row)?;
|
||||
let mut result = Vec::new();
|
||||
for row in rows {
|
||||
result.push(row?);
|
||||
}
|
||||
Ok(result)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_soar_block_by_id(&self, id: i64) -> Result<Option<ActiveBlockView>, Error> {
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
let result = conn.query_row(
|
||||
"SELECT id, source_ip, playbook_id, expires_at FROM soar_block_rules WHERE id = ?1",
|
||||
params![id],
|
||||
active_block_from_row,
|
||||
);
|
||||
match result {
|
||||
Ok(block) => Ok(Some(block)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn mark_soar_block_unblocked(&self, id: i64) -> Result<(), Error> {
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
conn.execute(
|
||||
"UPDATE soar_block_rules SET unblocked_at = datetime('now') WHERE id = ?1",
|
||||
params![id],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_active_soar_blocks(&self) -> Result<Vec<ActiveBlockView>, Error> {
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, source_ip, playbook_id, expires_at FROM soar_block_rules WHERE unblocked_at IS NULL AND expires_at > datetime('now')"
|
||||
)?;
|
||||
let rows = stmt.query_map([], active_block_from_row)?;
|
||||
let mut result = Vec::new();
|
||||
for row in rows {
|
||||
result.push(row?);
|
||||
}
|
||||
Ok(result)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn insert_pending_unblock(&self, source_ip: &str) -> Result<i64, Error> {
|
||||
let source_ip = source_ip.to_string();
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO pending_unblock (source_ip) VALUES (?1)",
|
||||
params![source_ip],
|
||||
)?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_pending_unblocks(&self) -> Result<Vec<PendingUnblock>, Error> {
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
let mut stmt = conn.prepare("SELECT id, source_ip, retry_count FROM pending_unblock ORDER BY id")?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok(PendingUnblock {
|
||||
id: row.get::<_, i64>(0)?,
|
||||
source_ip: row.get::<_, String>(1)?,
|
||||
retry_count: row.get::<_, i64>(2)?,
|
||||
})
|
||||
})?;
|
||||
let mut result = Vec::new();
|
||||
for row in rows {
|
||||
result.push(row?);
|
||||
}
|
||||
Ok(result)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_pending_unblock(&self, id: i64) -> Result<(), Error> {
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
conn.execute("DELETE FROM pending_unblock WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn increment_pending_unblock_retry(&self, id: i64) -> Result<(), Error> {
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
conn.execute(
|
||||
"UPDATE pending_unblock SET retry_count = retry_count + 1 WHERE id = ?1",
|
||||
params![id],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn commit_soar_block_to_db(
|
||||
&self,
|
||||
source_ip: &str,
|
||||
ip_version: u8,
|
||||
playbook_id: i64,
|
||||
expires_at: &str,
|
||||
) -> Result<i64, Error> {
|
||||
let source_ip = source_ip.to_string();
|
||||
let expires_at = expires_at.to_string();
|
||||
self.pool
|
||||
.conn_mut_and_then(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
let existing_acl_count: i64 = tx.query_row(
|
||||
"SELECT COUNT(*) FROM acl_rules
|
||||
WHERE ip_version = ?1 AND direction = ?2 AND list_type = ?3 AND ip_address = ?4 AND port = ?5",
|
||||
params![ip_version, "source", "blacklist", source_ip.as_str(), 0i64],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
let active_soar_owned_count: i64 = tx.query_row(
|
||||
"SELECT COUNT(*) FROM soar_block_rules
|
||||
WHERE source_ip = ?1
|
||||
AND unblocked_at IS NULL
|
||||
AND expires_at > datetime('now')
|
||||
AND created_acl_rule = 1
|
||||
AND preserve_acl_on_unblock = 0",
|
||||
params![source_ip.as_str()],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
let created_acl_rule = existing_acl_count == 0 || active_soar_owned_count > 0;
|
||||
tx.execute(
|
||||
"INSERT INTO soar_block_rules (source_ip, playbook_id, expires_at, created_acl_rule)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
params![source_ip.as_str(), playbook_id, expires_at, created_acl_rule as i64],
|
||||
)?;
|
||||
let soar_block_id = tx.last_insert_rowid();
|
||||
tx.execute(
|
||||
"INSERT OR IGNORE INTO acl_rules (ip_version, direction, list_type, ip_address, port) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![ip_version, "source", "blacklist", source_ip.as_str(), 0i64],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(soar_block_id)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn commit_soar_unblock_to_db(
|
||||
&self,
|
||||
soar_block_id: i64,
|
||||
ip_version: u8,
|
||||
source_ip: &str,
|
||||
) -> Result<(), Error> {
|
||||
let source_ip = source_ip.to_string();
|
||||
self.pool
|
||||
.conn_mut_and_then(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
let should_delete_acl: bool = tx.query_row(
|
||||
"SELECT created_acl_rule = 1
|
||||
AND preserve_acl_on_unblock = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM soar_block_rules
|
||||
WHERE id != ?1
|
||||
AND source_ip = ?2
|
||||
AND unblocked_at IS NULL
|
||||
AND expires_at > datetime('now')
|
||||
)
|
||||
FROM soar_block_rules WHERE id = ?1",
|
||||
params![soar_block_id, source_ip.as_str()],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if should_delete_acl {
|
||||
tx.execute(
|
||||
"DELETE FROM acl_rules
|
||||
WHERE ip_version = ?1 AND direction = ?2 AND list_type = ?3 AND ip_address = ?4 AND port = ?5",
|
||||
params![ip_version, "source", "blacklist", source_ip.as_str(), 0i64],
|
||||
)?;
|
||||
}
|
||||
tx.execute(
|
||||
"UPDATE soar_block_rules SET unblocked_at = datetime('now') WHERE id = ?1",
|
||||
params![soar_block_id],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DbAdminRepo for Database {
|
||||
async fn commit_soar_block_to_db(
|
||||
&self,
|
||||
source_ip: &str,
|
||||
ip_version: u8,
|
||||
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: u8,
|
||||
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::*;
|
||||
|
||||
const SOURCE_IP: &str = "198.51.100.42";
|
||||
const ACTIVE_UNTIL: &str = "2999-01-01 00:00:00";
|
||||
|
||||
fn acl_contains(rules: &[AclRuleViewForTest], ip: &str) -> bool {
|
||||
rules
|
||||
.iter()
|
||||
.any(|rule| rule.ip_address == ip && rule.direction == "source" && rule.list_type == "blacklist")
|
||||
}
|
||||
|
||||
type AclRuleViewForTest = crate::domain::data_plane::acl_rule::AclRuleView;
|
||||
|
||||
#[tokio::test]
|
||||
async fn overlapping_soar_blocks_keep_acl_until_last_block_unblocks() {
|
||||
let db = Database::new(":memory:").await.expect("database");
|
||||
let first = db
|
||||
.commit_soar_block_to_db(SOURCE_IP, 4, 1, ACTIVE_UNTIL)
|
||||
.await
|
||||
.expect("first block");
|
||||
let second = db
|
||||
.commit_soar_block_to_db(SOURCE_IP, 4, 2, ACTIVE_UNTIL)
|
||||
.await
|
||||
.expect("second block");
|
||||
|
||||
db.commit_soar_unblock_to_db(first, 4, SOURCE_IP)
|
||||
.await
|
||||
.expect("first unblock");
|
||||
assert!(
|
||||
acl_contains(&db.list_acl_rules().await.expect("acl after first unblock"), SOURCE_IP),
|
||||
"ACL row must stay while an overlapping SOAR block is active"
|
||||
);
|
||||
|
||||
db.commit_soar_unblock_to_db(second, 4, SOURCE_IP)
|
||||
.await
|
||||
.expect("second unblock");
|
||||
assert!(
|
||||
!acl_contains(&db.list_acl_rules().await.expect("acl after second unblock"), SOURCE_IP),
|
||||
"last SOAR-owned block should remove the SOAR-owned ACL row"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_acl_existing_before_soar_block_is_preserved_on_unblock() {
|
||||
let db = Database::new(":memory:").await.expect("database");
|
||||
db.insert_acl_rule(4, "source", "blacklist", SOURCE_IP, 0)
|
||||
.await
|
||||
.expect("manual acl");
|
||||
let block = db
|
||||
.commit_soar_block_to_db(SOURCE_IP, 4, 1, ACTIVE_UNTIL)
|
||||
.await
|
||||
.expect("soar block");
|
||||
|
||||
db.commit_soar_unblock_to_db(block, 4, SOURCE_IP)
|
||||
.await
|
||||
.expect("unblock");
|
||||
|
||||
assert!(
|
||||
acl_contains(&db.list_acl_rules().await.expect("acl rules"), SOURCE_IP),
|
||||
"pre-existing manual ACL row must remain after SOAR unblock"
|
||||
);
|
||||
}
|
||||
}
|
||||
126
net-guardia/src/adapter/persistence/stats.rs
Normal file
126
net-guardia/src/adapter/persistence/stats.rs
Normal file
@ -0,0 +1,126 @@
|
||||
use async_trait::async_trait;
|
||||
use rusqlite::params;
|
||||
|
||||
use super::Database;
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::domain::report::data::{ThreatBreakdownEntry, TopIpEntry};
|
||||
use crate::interface::stats::StatsRepo;
|
||||
|
||||
impl Database {
|
||||
pub async fn count_weekly_executions(&self, days: i64) -> Result<u64, Error> {
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
let count: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM soar_executions WHERE executed_at >= datetime('now', ?1)",
|
||||
params![format!("-{} days", days)],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
Ok(count as u64)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn count_weekly_blocks(&self, days: i64) -> Result<u64, Error> {
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
let count: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM soar_block_rules WHERE created_at >= datetime('now', ?1)",
|
||||
params![format!("-{} days", days)],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
Ok(count as u64)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn count_weekly_unblocks(&self, days: i64) -> Result<u64, Error> {
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
let count: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM soar_block_rules WHERE unblocked_at IS NOT NULL AND unblocked_at >= datetime('now', ?1)",
|
||||
params![format!("-{} days", days)],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
Ok(count as u64)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn weekly_threat_breakdown(&self, days: i64) -> Result<Vec<ThreatBreakdownEntry>, Error> {
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT trigger_event, COUNT(*) FROM soar_executions WHERE executed_at >= datetime('now', ?1) GROUP BY trigger_event ORDER BY COUNT(*) DESC"
|
||||
)?;
|
||||
let rows = stmt.query_map(params![format!("-{} days", days)], |row| {
|
||||
Ok(ThreatBreakdownEntry {
|
||||
threat_type: row.get(0)?,
|
||||
count: row.get::<_, i64>(1)? as u64,
|
||||
})
|
||||
})?;
|
||||
let mut result = Vec::new();
|
||||
for row in rows {
|
||||
result.push(row?);
|
||||
}
|
||||
Ok(result)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn weekly_top_ips(&self, days: i64, limit: i64) -> Result<Vec<TopIpEntry>, Error> {
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT source_ip, COUNT(*) as cnt FROM soar_block_rules WHERE created_at >= datetime('now', ?1) GROUP BY source_ip ORDER BY cnt DESC LIMIT ?2"
|
||||
)?;
|
||||
let rows = stmt.query_map(params![format!("-{} days", days), limit], |row| {
|
||||
Ok(TopIpEntry {
|
||||
ip: row.get(0)?,
|
||||
count: row.get::<_, i64>(1)? as u64,
|
||||
})
|
||||
})?;
|
||||
let mut result = Vec::new();
|
||||
for row in rows {
|
||||
result.push(row?);
|
||||
}
|
||||
Ok(result)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn count_acl_rules(&self) -> Result<u64, Error> {
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
let count: i64 = conn.query_row("SELECT COUNT(*) FROM acl_rules", [], |row| row.get(0))?;
|
||||
Ok(count as u64)
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl StatsRepo for Database {
|
||||
async fn count_weekly_executions(&self, days: i64) -> Result<u64, Error> {
|
||||
self.count_weekly_executions(days).await
|
||||
}
|
||||
|
||||
async fn count_weekly_blocks(&self, days: i64) -> Result<u64, Error> {
|
||||
self.count_weekly_blocks(days).await
|
||||
}
|
||||
|
||||
async fn count_weekly_unblocks(&self, days: i64) -> Result<u64, Error> {
|
||||
self.count_weekly_unblocks(days).await
|
||||
}
|
||||
|
||||
async fn weekly_threat_breakdown(&self, days: i64) -> Result<Vec<ThreatBreakdownEntry>, Error> {
|
||||
self.weekly_threat_breakdown(days).await
|
||||
}
|
||||
|
||||
async fn weekly_top_ips(&self, days: i64, limit: i64) -> Result<Vec<TopIpEntry>, Error> {
|
||||
self.weekly_top_ips(days, limit).await
|
||||
}
|
||||
|
||||
async fn count_acl_rules(&self) -> Result<u64, Error> {
|
||||
self.count_acl_rules().await
|
||||
}
|
||||
}
|
||||
49
net-guardia/src/adapter/persistence/system_state.rs
Normal file
49
net-guardia/src/adapter/persistence/system_state.rs
Normal file
@ -0,0 +1,49 @@
|
||||
use async_trait::async_trait;
|
||||
use rusqlite::{Error as RusqliteError, params};
|
||||
|
||||
use super::Database;
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::interface::system_state::SystemStateRepo;
|
||||
|
||||
impl Database {
|
||||
pub async fn get_system_state(&self, key: &str) -> Result<Option<String>, Error> {
|
||||
let key = key.to_string();
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
let result = conn.query_row("SELECT value FROM system_state WHERE key = ?1", params![key], |row| {
|
||||
row.get(0)
|
||||
});
|
||||
match result {
|
||||
Ok(value) => Ok(Some(value)),
|
||||
Err(RusqliteError::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn set_system_state(&self, key: &str, value: &str) -> Result<(), Error> {
|
||||
let key = key.to_string();
|
||||
let value = value.to_string();
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO system_state (key, value) VALUES (?1, ?2)",
|
||||
params![key, value],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl SystemStateRepo for Database {
|
||||
async fn get_system_state(&self, key: &str) -> Result<Option<String>, Error> {
|
||||
self.get_system_state(key).await
|
||||
}
|
||||
|
||||
async fn set_system_state(&self, key: &str, value: &str) -> Result<(), Error> {
|
||||
self.set_system_state(key, value).await
|
||||
}
|
||||
}
|
||||
579
net-guardia/src/adapter/persistence/user.rs
Normal file
579
net-guardia/src/adapter/persistence/user.rs
Normal file
@ -0,0 +1,579 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use rusqlite::{Error as RusqliteError, params};
|
||||
|
||||
use super::Database;
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::domain::common::error::database::DatabaseError;
|
||||
use crate::domain::identity::auth::{GROUP_ADMIN, GROUP_VIEWER, LOGIN_LOCKOUT_SECS, LOGIN_MAX_FAILURES, ROLE_ADMIN};
|
||||
use crate::domain::identity::user::{
|
||||
GroupMemberView, UserGroupMembership, UserGroupView, UserView, UserWithGroupsView,
|
||||
};
|
||||
use crate::interface::identity::{LoginAttemptRepo, UserGroupRepo, UserRepo};
|
||||
|
||||
fn user_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<UserView> {
|
||||
Ok(UserView {
|
||||
id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
password_hash: row.get(2)?,
|
||||
force_password_change: row.get::<_, i64>(3)? != 0,
|
||||
})
|
||||
}
|
||||
|
||||
fn group_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<UserGroupView> {
|
||||
Ok(UserGroupView {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
description: row.get(2)?,
|
||||
permissions: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
})
|
||||
}
|
||||
|
||||
impl Database {
|
||||
pub async fn find_user(&self, username: &str) -> Result<Option<UserView>, Error> {
|
||||
let username = username.to_string();
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
match conn.query_row(
|
||||
"SELECT id, username, password_hash, force_password_change FROM users WHERE username = ?1",
|
||||
params![username],
|
||||
user_from_row,
|
||||
) {
|
||||
Ok(user) => Ok(Some(user)),
|
||||
Err(RusqliteError::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn insert_user(
|
||||
&self,
|
||||
username: &str,
|
||||
password_hash: &str,
|
||||
role: &str,
|
||||
force_password_change: bool,
|
||||
) -> Result<i64, Error> {
|
||||
let username = username.to_string();
|
||||
let password_hash = password_hash.to_string();
|
||||
let role = role.to_string();
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO users (username, password_hash, role, force_password_change) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![username, password_hash, role, force_password_change as i64],
|
||||
)
|
||||
.map_err(|e| -> Error {
|
||||
if e.to_string().contains("UNIQUE constraint") {
|
||||
DatabaseError::UserAlreadyExists(username.clone()).into()
|
||||
} else {
|
||||
e.into()
|
||||
}
|
||||
})?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn update_user_password(&self, user_id: i64, password_hash: &str) -> Result<(), Error> {
|
||||
let password_hash = password_hash.to_string();
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
conn.execute(
|
||||
"UPDATE users SET password_hash = ?1, force_password_change = 0 WHERE id = ?2",
|
||||
params![password_hash, user_id],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn user_count(&self) -> Result<i64, Error> {
|
||||
self.pool
|
||||
.conn_and_then(move |conn| Ok(conn.query_row("SELECT COUNT(*) FROM users", [], |row| row.get(0))?))
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_users_with_groups(&self) -> Result<Vec<UserWithGroupsView>, Error> {
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT u.id, u.username, u.force_password_change, u.created_at, \
|
||||
g.id, g.name \
|
||||
FROM users u \
|
||||
LEFT JOIN user_group_members m ON u.id = m.user_id \
|
||||
LEFT JOIN user_groups g ON g.id = m.group_id \
|
||||
ORDER BY u.id, g.id",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, i64>(2)? != 0,
|
||||
row.get::<_, String>(3)?,
|
||||
row.get::<_, Option<i64>>(4)?,
|
||||
row.get::<_, Option<String>>(5)?,
|
||||
))
|
||||
})?;
|
||||
|
||||
let mut user_map: HashMap<i64, UserWithGroupsView> = HashMap::new();
|
||||
let mut order: Vec<i64> = Vec::new();
|
||||
for row in rows {
|
||||
let (id, username, force_pw, created_at, group_id, group_name) = row?;
|
||||
let entry = user_map.entry(id).or_insert_with(|| {
|
||||
order.push(id);
|
||||
UserWithGroupsView {
|
||||
id,
|
||||
username,
|
||||
force_password_change: force_pw,
|
||||
created_at,
|
||||
groups: Vec::new(),
|
||||
}
|
||||
});
|
||||
if let (Some(gid), Some(gname)) = (group_id, group_name) {
|
||||
entry.groups.push(UserGroupMembership {
|
||||
group_id: gid,
|
||||
group_name: gname,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(order.into_iter().filter_map(|id| user_map.remove(&id)).collect())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_user(&self, user_id: i64) -> Result<bool, Error> {
|
||||
self.pool
|
||||
.conn_mut_and_then(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute("DELETE FROM user_group_members WHERE user_id = ?1", params![user_id])?;
|
||||
let affected = tx.execute("DELETE FROM users WHERE id = ?1", params![user_id])?;
|
||||
tx.commit()?;
|
||||
Ok(affected > 0)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn update_user_role(&self, user_id: i64, role: &str) -> Result<(), Error> {
|
||||
let role = role.to_string();
|
||||
self.pool
|
||||
.conn_mut_and_then(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute("UPDATE users SET role = ?1 WHERE id = ?2", params![role, user_id])?;
|
||||
let group_name = if role == ROLE_ADMIN { GROUP_ADMIN } else { GROUP_VIEWER };
|
||||
let group_id: i64 = tx.query_row(
|
||||
"SELECT id FROM user_groups WHERE name = ?1",
|
||||
params![group_name],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
tx.execute("DELETE FROM user_group_members WHERE user_id = ?1", params![user_id])?;
|
||||
tx.execute(
|
||||
"INSERT INTO user_group_members (user_id, group_id) VALUES (?1, ?2)",
|
||||
params![user_id, group_id],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn reset_user_password(&self, user_id: i64, password_hash: &str) -> Result<(), Error> {
|
||||
let password_hash = password_hash.to_string();
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
conn.execute(
|
||||
"UPDATE users SET password_hash = ?1, force_password_change = 1 WHERE id = ?2",
|
||||
params![password_hash, user_id],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn find_user_by_id(&self, user_id: i64) -> Result<Option<UserView>, Error> {
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
match conn.query_row(
|
||||
"SELECT id, username, password_hash, force_password_change FROM users WHERE id = ?1",
|
||||
params![user_id],
|
||||
user_from_row,
|
||||
) {
|
||||
Ok(user) => Ok(Some(user)),
|
||||
Err(RusqliteError::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_user_groups(&self) -> Result<Vec<UserGroupView>, Error> {
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT id, name, description, permissions, created_at FROM user_groups ORDER BY id")?;
|
||||
let rows = stmt.query_map([], group_from_row)?;
|
||||
let mut results = Vec::new();
|
||||
for row in rows {
|
||||
results.push(row?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_user_group(&self, name: &str, description: &str, permissions: &str) -> Result<i64, Error> {
|
||||
let name = name.to_string();
|
||||
let description = description.to_string();
|
||||
let permissions = permissions.to_string();
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO user_groups (name, description, permissions) VALUES (?1, ?2, ?3)",
|
||||
params![name, description, permissions],
|
||||
)
|
||||
.map_err(|e| -> Error {
|
||||
if e.to_string().contains("UNIQUE constraint") {
|
||||
DatabaseError::GroupAlreadyExists(name.clone()).into()
|
||||
} else {
|
||||
e.into()
|
||||
}
|
||||
})?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn update_user_group(
|
||||
&self,
|
||||
id: i64,
|
||||
name: &str,
|
||||
description: &str,
|
||||
permissions: &str,
|
||||
) -> Result<(), Error> {
|
||||
let name = name.to_string();
|
||||
let description = description.to_string();
|
||||
let permissions = permissions.to_string();
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
conn.execute(
|
||||
"UPDATE user_groups SET name = ?1, description = ?2, permissions = ?3 WHERE id = ?4",
|
||||
params![name, description, permissions, id],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_user_group(&self, id: i64) -> Result<bool, Error> {
|
||||
self.pool
|
||||
.conn_mut_and_then(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute("DELETE FROM user_group_members WHERE group_id = ?1", params![id])?;
|
||||
let affected = tx.execute("DELETE FROM user_groups WHERE id = ?1", params![id])?;
|
||||
tx.commit()?;
|
||||
Ok(affected > 0)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_user_group(&self, id: i64) -> Result<Option<UserGroupView>, Error> {
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
match conn.query_row(
|
||||
"SELECT id, name, description, permissions, created_at FROM user_groups WHERE id = ?1",
|
||||
params![id],
|
||||
group_from_row,
|
||||
) {
|
||||
Ok(group) => Ok(Some(group)),
|
||||
Err(RusqliteError::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_groups_for_user(&self, user_id: i64) -> Result<Vec<UserGroupView>, Error> {
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT g.id, g.name, g.description, g.permissions, g.created_at FROM user_groups g \
|
||||
INNER JOIN user_group_members m ON g.id = m.group_id \
|
||||
WHERE m.user_id = ?1 ORDER BY g.id",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![user_id], group_from_row)?;
|
||||
let mut results = Vec::new();
|
||||
for row in rows {
|
||||
results.push(row?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn set_user_groups(&self, user_id: i64, group_ids: &[i64]) -> Result<(), Error> {
|
||||
let group_ids = group_ids.to_vec();
|
||||
self.pool
|
||||
.conn_mut_and_then(move |conn| {
|
||||
let tx = conn.transaction()?;
|
||||
for &gid in &group_ids {
|
||||
tx.query_row("SELECT id FROM user_groups WHERE id = ?1", params![gid], |row| {
|
||||
row.get::<_, i64>(0)
|
||||
})?;
|
||||
}
|
||||
tx.execute("DELETE FROM user_group_members WHERE user_id = ?1", params![user_id])?;
|
||||
for &gid in &group_ids {
|
||||
tx.execute(
|
||||
"INSERT INTO user_group_members (user_id, group_id) VALUES (?1, ?2)",
|
||||
params![user_id, gid],
|
||||
)?;
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_user_permissions(&self, user_id: i64) -> Result<Vec<String>, Error> {
|
||||
let groups = self.list_groups_for_user(user_id).await?;
|
||||
let mut all_perms = HashSet::new();
|
||||
for g in groups {
|
||||
if let Ok(perms) = serde_json::from_str::<Vec<String>>(&g.permissions) {
|
||||
for p in perms {
|
||||
all_perms.insert(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut result: Vec<String> = all_perms.into_iter().collect();
|
||||
result.sort();
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn list_group_member_ids(&self, group_id: i64) -> Result<Vec<i64>, Error> {
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
let mut stmt = conn.prepare("SELECT user_id FROM user_group_members WHERE group_id = ?1")?;
|
||||
let rows = stmt.query_map(params![group_id], |row| row.get::<_, i64>(0))?;
|
||||
let mut results = Vec::new();
|
||||
for row in rows {
|
||||
results.push(row?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_group_members(&self, group_id: i64) -> Result<Vec<GroupMemberView>, Error> {
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT u.id, u.username FROM users u \
|
||||
INNER JOIN user_group_members m ON u.id = m.user_id \
|
||||
WHERE m.group_id = ?1 ORDER BY u.username",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![group_id], |row| {
|
||||
Ok(GroupMemberView {
|
||||
id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
})
|
||||
})?;
|
||||
let mut results = Vec::new();
|
||||
for row in rows {
|
||||
results.push(row?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn record_login_failure(&self, username: &str) -> Result<(u32, Option<u64>), Error> {
|
||||
let username = username.to_string();
|
||||
let count: u32 = self
|
||||
.pool
|
||||
.conn_and_then({
|
||||
let username = username.clone();
|
||||
move |conn| {
|
||||
let count = conn
|
||||
.query_row(
|
||||
"SELECT failure_count FROM login_attempts WHERE username = ?1",
|
||||
params![username],
|
||||
|row| row.get::<_, u32>(0),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
+ 1;
|
||||
conn.execute(
|
||||
"INSERT INTO login_attempts (username, failure_count) VALUES (?1, ?2) \
|
||||
ON CONFLICT(username) DO UPDATE SET failure_count = ?2",
|
||||
params![username, count],
|
||||
)?;
|
||||
Ok::<u32, Error>(count)
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
if count >= LOGIN_MAX_FAILURES {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or(Duration::ZERO)
|
||||
.as_secs();
|
||||
let locked_until = now + LOGIN_LOCKOUT_SECS;
|
||||
let locked_until_db = locked_until as i64;
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
conn.execute(
|
||||
"UPDATE login_attempts SET locked_until = ?2 WHERE username = ?1",
|
||||
params![username, locked_until_db],
|
||||
)?;
|
||||
Ok::<(), Error>(())
|
||||
})
|
||||
.await?;
|
||||
Ok((count, Some(locked_until)))
|
||||
} else {
|
||||
Ok((count, None))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn check_login_locked(&self, username: &str) -> Result<Option<u64>, Error> {
|
||||
let username = username.to_string();
|
||||
if let Some(locked_until) = self
|
||||
.pool
|
||||
.conn_and_then({
|
||||
let username = username.clone();
|
||||
move |conn| {
|
||||
let result = conn.query_row(
|
||||
"SELECT locked_until FROM login_attempts WHERE username = ?1",
|
||||
params![username],
|
||||
|row| row.get::<_, Option<i64>>(0),
|
||||
);
|
||||
match result {
|
||||
Ok(value) => Ok::<Option<u64>, Error>(value.and_then(|v| u64::try_from(v).ok())),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok::<Option<u64>, Error>(None),
|
||||
Err(e) => Err(Error::from(e)),
|
||||
}
|
||||
}
|
||||
})
|
||||
.await?
|
||||
{
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or(Duration::ZERO)
|
||||
.as_secs();
|
||||
if now < locked_until {
|
||||
return Ok(Some(locked_until - now));
|
||||
}
|
||||
self.clear_login_failures(&username).await?;
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub async fn clear_login_failures(&self, username: &str) -> Result<(), Error> {
|
||||
let username = username.to_string();
|
||||
self.pool
|
||||
.conn_and_then(move |conn| {
|
||||
conn.execute("DELETE FROM login_attempts WHERE username = ?1", params![username])?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserRepo for Database {
|
||||
async fn find_user(&self, username: &str) -> Result<Option<UserView>, Error> {
|
||||
self.find_user(username).await
|
||||
}
|
||||
|
||||
async fn find_user_by_id(&self, user_id: i64) -> Result<Option<UserView>, Error> {
|
||||
self.find_user_by_id(user_id).await
|
||||
}
|
||||
|
||||
async fn insert_user(
|
||||
&self,
|
||||
username: &str,
|
||||
password_hash: &str,
|
||||
role: &str,
|
||||
force_password_change: bool,
|
||||
) -> Result<i64, Error> {
|
||||
self.insert_user(username, password_hash, role, force_password_change)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn update_user_password(&self, user_id: i64, password_hash: &str) -> Result<(), Error> {
|
||||
self.update_user_password(user_id, password_hash).await
|
||||
}
|
||||
|
||||
async fn list_users_with_groups(&self) -> Result<Vec<UserWithGroupsView>, Error> {
|
||||
self.list_users_with_groups().await
|
||||
}
|
||||
|
||||
async fn delete_user(&self, user_id: i64) -> Result<bool, Error> {
|
||||
self.delete_user(user_id).await
|
||||
}
|
||||
|
||||
async fn update_user_role(&self, user_id: i64, role: &str) -> Result<(), Error> {
|
||||
self.update_user_role(user_id, role).await
|
||||
}
|
||||
|
||||
async fn reset_user_password(&self, user_id: i64, password_hash: &str) -> Result<(), Error> {
|
||||
self.reset_user_password(user_id, password_hash).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserGroupRepo for Database {
|
||||
async fn list_user_groups(&self) -> Result<Vec<UserGroupView>, Error> {
|
||||
self.list_user_groups().await
|
||||
}
|
||||
|
||||
async fn create_user_group(&self, name: &str, description: &str, permissions: &str) -> Result<i64, Error> {
|
||||
self.create_user_group(name, description, permissions).await
|
||||
}
|
||||
|
||||
async fn update_user_group(&self, id: i64, name: &str, description: &str, permissions: &str) -> Result<(), Error> {
|
||||
self.update_user_group(id, name, description, permissions).await
|
||||
}
|
||||
|
||||
async fn delete_user_group(&self, id: i64) -> Result<bool, Error> {
|
||||
self.delete_user_group(id).await
|
||||
}
|
||||
|
||||
async fn get_user_group(&self, id: i64) -> Result<Option<UserGroupView>, Error> {
|
||||
self.get_user_group(id).await
|
||||
}
|
||||
|
||||
async fn list_groups_for_user(&self, user_id: i64) -> Result<Vec<UserGroupView>, Error> {
|
||||
self.list_groups_for_user(user_id).await
|
||||
}
|
||||
|
||||
async fn set_user_groups(&self, user_id: i64, group_ids: &[i64]) -> Result<(), Error> {
|
||||
self.set_user_groups(user_id, group_ids).await
|
||||
}
|
||||
|
||||
async fn list_user_permissions(&self, user_id: i64) -> Result<Vec<String>, Error> {
|
||||
self.list_user_permissions(user_id).await
|
||||
}
|
||||
|
||||
async fn list_group_member_ids(&self, group_id: i64) -> Result<Vec<i64>, Error> {
|
||||
self.list_group_member_ids(group_id).await
|
||||
}
|
||||
|
||||
async fn list_group_members(&self, group_id: i64) -> Result<Vec<GroupMemberView>, Error> {
|
||||
self.list_group_members(group_id).await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LoginAttemptRepo for Database {
|
||||
async fn record_login_failure(&self, username: &str) -> Result<(u32, Option<u64>), Error> {
|
||||
self.record_login_failure(username).await
|
||||
}
|
||||
|
||||
async fn check_login_locked(&self, username: &str) -> Result<Option<u64>, Error> {
|
||||
self.check_login_locked(username).await
|
||||
}
|
||||
|
||||
async fn clear_login_failures(&self, username: &str) -> Result<(), Error> {
|
||||
self.clear_login_failures(username).await
|
||||
}
|
||||
}
|
||||
@ -2,25 +2,26 @@ use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
use async_trait::async_trait;
|
||||
use macros::log;
|
||||
use reqwest::Client;
|
||||
use tokio::time::sleep;
|
||||
|
||||
use crate::interface::port::app_repo::AppRepo;
|
||||
use crate::interface::port::notification::{AlertNotifier, AlertNotifierFactory, AlertPayload};
|
||||
use crate::interface::port::secret_store::SecretStorePort;
|
||||
use crate::interface::port::setting::SettingRepo;
|
||||
use crate::model::config::constants::TELEGRAM_MAX_RETRIES;
|
||||
use crate::model::error::Error;
|
||||
use crate::model::error::notification::NotificationError;
|
||||
use crate::model::log::system::SystemLog;
|
||||
use crate::domain::common::config::AppConfig;
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::domain::common::error::notification::NotificationError;
|
||||
use crate::domain::common::log::system::SystemLog;
|
||||
use crate::domain::common::notification::AlertPayload;
|
||||
use crate::interface::config_repo::ConfigRepo;
|
||||
use crate::interface::notification::{AlertNotifier, AlertNotifierFactory};
|
||||
use crate::interface::secret_store::SecretStorePort;
|
||||
|
||||
/// Telegram Bot API adapter implementing AlertNotifier.
|
||||
pub struct TelegramAdapter {
|
||||
client: Client,
|
||||
notif: Arc<dyn SettingRepo>,
|
||||
repo: Arc<dyn AppRepo>,
|
||||
notif: Arc<dyn ConfigRepo + Send + Sync>,
|
||||
config: Arc<ArcSwap<AppConfig>>,
|
||||
secrets: Option<Arc<dyn SecretStorePort>>,
|
||||
/// Packed rate-limit state: high 32 bits = window-start unix seconds,
|
||||
/// low 32 bits = count consumed in this window. Updated via CAS so the
|
||||
@ -30,8 +31,8 @@ pub struct TelegramAdapter {
|
||||
|
||||
impl TelegramAdapter {
|
||||
pub fn new(
|
||||
notif: Arc<dyn SettingRepo>,
|
||||
repo: Arc<dyn AppRepo>,
|
||||
notif: Arc<dyn ConfigRepo + Send + Sync>,
|
||||
config: Arc<ArcSwap<AppConfig>>,
|
||||
secrets: Option<Arc<dyn SecretStorePort>>,
|
||||
) -> Result<Self, Error> {
|
||||
let client = Client::builder()
|
||||
@ -42,7 +43,7 @@ impl TelegramAdapter {
|
||||
Ok(Self {
|
||||
client,
|
||||
notif,
|
||||
repo,
|
||||
config,
|
||||
secrets,
|
||||
rate_state: AtomicU64::new(0),
|
||||
})
|
||||
@ -50,8 +51,8 @@ impl TelegramAdapter {
|
||||
|
||||
/// Get bot token and chat ID from DB. Returns None if not configured.
|
||||
/// If the bot_token in JSON is `"__encrypted__"`, reads from the secret store.
|
||||
fn get_config(&self) -> Result<Option<(String, String)>, Error> {
|
||||
match self.notif.get_notification_config("telegram")? {
|
||||
async fn get_config(&self) -> Result<Option<(String, String)>, Error> {
|
||||
match self.notif.get_notification_config("telegram").await? {
|
||||
Some(json_str) => {
|
||||
let config: serde_json::Value =
|
||||
serde_json::from_str(&json_str).map_err(NotificationError::TelegramRequestFailed)?;
|
||||
@ -59,11 +60,10 @@ impl TelegramAdapter {
|
||||
let chat_id = config.get("chat_id").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||
|
||||
// If token is the encrypted sentinel, resolve from secret store
|
||||
if token.as_deref() == Some("__encrypted__") {
|
||||
token = self
|
||||
.secrets
|
||||
.as_ref()
|
||||
.and_then(|ss| ss.get_secret("telegram_bot_token").ok().flatten());
|
||||
if token.as_deref() == Some("__encrypted__")
|
||||
&& let Some(ss) = self.secrets.as_ref()
|
||||
{
|
||||
token = ss.get_secret("telegram_bot_token").await?;
|
||||
}
|
||||
|
||||
match (token, chat_id) {
|
||||
@ -75,24 +75,13 @@ impl TelegramAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the configured per-window message cap from settings.
|
||||
fn rate_limit_max_messages(&self) -> u32 {
|
||||
self.repo
|
||||
.get_setting("telegram_rate_limit_max_messages")
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(20)
|
||||
self.config.load().notification.telegram.rate_limit_max_messages
|
||||
}
|
||||
|
||||
/// Read the configured window length (seconds) from settings.
|
||||
/// Read the configured window length (seconds) from the live config.
|
||||
fn rate_limit_window_secs(&self) -> u32 {
|
||||
self.repo
|
||||
.get_setting("telegram_rate_limit_window_secs")
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(60)
|
||||
self.config.load().notification.telegram.rate_limit_window_secs
|
||||
}
|
||||
|
||||
/// Check rate limit. Returns true if send is allowed.
|
||||
@ -134,8 +123,9 @@ impl TelegramAdapter {
|
||||
/// Send a message via Telegram Bot API with retry on 429.
|
||||
async fn send_message(&self, bot_token: &str, chat_id: &str, text: &str) -> Result<(), Error> {
|
||||
let url = format!("https://api.telegram.org/bot{}/sendMessage", bot_token);
|
||||
let max_retries = self.config.load().notification.telegram.max_retries;
|
||||
|
||||
for attempt in 0..=TELEGRAM_MAX_RETRIES {
|
||||
for attempt in 0..=max_retries {
|
||||
let resp = self
|
||||
.client
|
||||
.post(&url)
|
||||
@ -181,11 +171,11 @@ impl TelegramAdapter {
|
||||
.and_then(|r| r.as_u64())
|
||||
.unwrap_or(5);
|
||||
|
||||
if attempt < TELEGRAM_MAX_RETRIES {
|
||||
if attempt < max_retries {
|
||||
log!(SystemLog::TelegramRateLimitedRetry(
|
||||
retry_after,
|
||||
attempt + 1,
|
||||
TELEGRAM_MAX_RETRIES,
|
||||
max_retries,
|
||||
));
|
||||
sleep(Duration::from_secs(retry_after)).await;
|
||||
continue;
|
||||
@ -227,7 +217,7 @@ impl TelegramAdapter {
|
||||
#[async_trait]
|
||||
impl AlertNotifier for TelegramAdapter {
|
||||
async fn send_alert(&self, payload: &AlertPayload) -> Result<(), Error> {
|
||||
let (bot_token, chat_id) = match self.get_config()? {
|
||||
let (bot_token, chat_id) = match self.get_config().await? {
|
||||
Some(config) => config,
|
||||
None => {
|
||||
log!(SystemLog::TelegramNotConfiguredSkipped);
|
||||
@ -249,7 +239,7 @@ impl AlertNotifier for TelegramAdapter {
|
||||
}
|
||||
|
||||
async fn send_test_message(&self) -> Result<(), Error> {
|
||||
let (bot_token, chat_id) = match self.get_config()? {
|
||||
let (bot_token, chat_id) = match self.get_config().await? {
|
||||
Some(config) => config,
|
||||
None => Err(NotificationError::NotConfigured("telegram"))?,
|
||||
};
|
||||
@ -268,20 +258,24 @@ impl AlertNotifier for TelegramAdapter {
|
||||
/// call instantiates a fresh `TelegramAdapter` so the test path observes
|
||||
/// whatever config the user just saved.
|
||||
pub struct TelegramAdapterFactory {
|
||||
notif: Arc<dyn SettingRepo>,
|
||||
repo: Arc<dyn AppRepo>,
|
||||
notif: Arc<dyn ConfigRepo + Send + Sync>,
|
||||
config: Arc<ArcSwap<AppConfig>>,
|
||||
secrets: Option<Arc<dyn SecretStorePort>>,
|
||||
}
|
||||
|
||||
impl TelegramAdapterFactory {
|
||||
pub fn new(notif: Arc<dyn SettingRepo>, repo: Arc<dyn AppRepo>, secrets: Option<Arc<dyn SecretStorePort>>) -> Self {
|
||||
Self { notif, repo, secrets }
|
||||
pub fn new(
|
||||
notif: Arc<dyn ConfigRepo + Send + Sync>,
|
||||
config: Arc<ArcSwap<AppConfig>>,
|
||||
secrets: Option<Arc<dyn SecretStorePort>>,
|
||||
) -> Self {
|
||||
Self { notif, config, secrets }
|
||||
}
|
||||
}
|
||||
|
||||
impl AlertNotifierFactory for TelegramAdapterFactory {
|
||||
fn create(&self) -> Result<Arc<dyn AlertNotifier>, Error> {
|
||||
let adapter = TelegramAdapter::new(self.notif.clone(), self.repo.clone(), self.secrets.clone())?;
|
||||
let adapter = TelegramAdapter::new(self.notif.clone(), self.config.clone(), self.secrets.clone())?;
|
||||
Ok(Arc::new(adapter))
|
||||
}
|
||||
}
|
||||
@ -1,89 +1,13 @@
|
||||
use actix_web::rt::spawn;
|
||||
use actix_web::{HttpRequest, HttpResponse, Result, web};
|
||||
use actix_ws::{Message, MessageStream, Session, handle};
|
||||
use futures_util::StreamExt;
|
||||
use macros::log;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
use actix_ws::handle;
|
||||
|
||||
use crate::core::ml::alert::MLAlert;
|
||||
use crate::model::detection::ml_detection::AlertMessage;
|
||||
use crate::model::error::http::HttpError;
|
||||
use crate::model::error::misc::MiscError;
|
||||
use crate::model::log::http::HttpLog;
|
||||
use crate::adapter::websocket::ws_bridge;
|
||||
use crate::core::inference::alert::MLAlert;
|
||||
|
||||
pub async fn websocket_alert(req: HttpRequest, body: web::Payload, ai: web::Data<MLAlert>) -> Result<HttpResponse> {
|
||||
let (response, session, msg_stream) = handle(&req, body)?;
|
||||
|
||||
let broadcast_rx = ai.subscribe_to_alerts();
|
||||
|
||||
spawn(async move {
|
||||
handle_alert_connection(session, msg_stream, broadcast_rx).await;
|
||||
});
|
||||
|
||||
let rx = ai.subscribe_to_alerts();
|
||||
spawn(ws_bridge::broadcast_json(session, msg_stream, rx));
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn handle_alert_connection(
|
||||
mut session: Session,
|
||||
mut msg_stream: MessageStream,
|
||||
mut broadcast_rx: broadcast::Receiver<AlertMessage>,
|
||||
) {
|
||||
loop {
|
||||
tokio::select! {
|
||||
msg_result = msg_stream.next() => {
|
||||
if !handle_client_message(&mut session, msg_result).await {
|
||||
break;
|
||||
}
|
||||
},
|
||||
broadcast_result = broadcast_rx.recv() => {
|
||||
match broadcast_result {
|
||||
Ok(alert) => {
|
||||
if !send_alert(&mut session, &alert).await {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(RecvError::Lagged(skipped)) => {
|
||||
log!(HttpLog::WebSocketLagged(skipped));
|
||||
continue;
|
||||
}
|
||||
Err(RecvError::Closed) => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let _ = session.close(None).await;
|
||||
}
|
||||
|
||||
async fn handle_client_message(
|
||||
session: &mut Session,
|
||||
msg_result: Option<Result<Message, actix_ws::ProtocolError>>,
|
||||
) -> bool {
|
||||
match msg_result {
|
||||
Some(Ok(Message::Text(_))) => true,
|
||||
Some(Ok(Message::Ping(bytes))) => session.pong(&bytes).await.is_ok(),
|
||||
Some(Ok(Message::Close(reason))) => {
|
||||
let _ = (session.clone()).close(reason).await;
|
||||
false
|
||||
}
|
||||
Some(Err(err)) => {
|
||||
log!(HttpError::WebSocketError(err));
|
||||
false
|
||||
}
|
||||
None => false,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_alert(session: &mut Session, alert: &AlertMessage) -> bool {
|
||||
match serde_json::to_string(alert) {
|
||||
Ok(json) => session.text(json).await.is_ok(),
|
||||
Err(err) => {
|
||||
log!(MiscError::SerializeError(err));
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,16 +1,9 @@
|
||||
use actix_web::rt::spawn;
|
||||
use actix_web::{HttpRequest, HttpResponse, Result, web};
|
||||
use actix_ws::{Message, MessageStream, Session, handle};
|
||||
use futures_util::StreamExt;
|
||||
use macros::log;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
use actix_ws::handle;
|
||||
|
||||
use super::ws_bridge;
|
||||
use crate::adapter::ebpf::drop_monitor::DropMonitor;
|
||||
use crate::model::error::http::HttpError;
|
||||
use crate::model::error::misc::MiscError;
|
||||
use crate::model::log::http::HttpLog;
|
||||
use crate::model::monitoring::drop_event::DropEventMessage;
|
||||
|
||||
pub async fn websocket_drops(
|
||||
req: HttpRequest,
|
||||
@ -18,76 +11,7 @@ pub async fn websocket_drops(
|
||||
monitor: web::Data<DropMonitor>,
|
||||
) -> Result<HttpResponse> {
|
||||
let (response, session, msg_stream) = handle(&req, body)?;
|
||||
|
||||
let broadcast_rx = monitor.subscribe();
|
||||
|
||||
spawn(async move {
|
||||
handle_drop_connection(session, msg_stream, broadcast_rx).await;
|
||||
});
|
||||
|
||||
let rx = monitor.subscribe();
|
||||
spawn(ws_bridge::broadcast_json(session, msg_stream, rx));
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn handle_drop_connection(
|
||||
mut session: Session,
|
||||
mut msg_stream: MessageStream,
|
||||
mut broadcast_rx: broadcast::Receiver<DropEventMessage>,
|
||||
) {
|
||||
loop {
|
||||
tokio::select! {
|
||||
msg_result = msg_stream.next() => {
|
||||
if !handle_client_message(&mut session, msg_result).await {
|
||||
break;
|
||||
}
|
||||
},
|
||||
broadcast_result = broadcast_rx.recv() => {
|
||||
match broadcast_result {
|
||||
Ok(event) => {
|
||||
if !send_drop_event(&mut session, &event).await {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(RecvError::Lagged(skipped)) => {
|
||||
log!(HttpLog::WebSocketLagged(skipped));
|
||||
continue;
|
||||
}
|
||||
Err(RecvError::Closed) => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let _ = session.close(None).await;
|
||||
}
|
||||
|
||||
async fn handle_client_message(
|
||||
session: &mut Session,
|
||||
msg_result: Option<Result<Message, actix_ws::ProtocolError>>,
|
||||
) -> bool {
|
||||
match msg_result {
|
||||
Some(Ok(Message::Text(_))) => true,
|
||||
Some(Ok(Message::Ping(bytes))) => session.pong(&bytes).await.is_ok(),
|
||||
Some(Ok(Message::Close(reason))) => {
|
||||
let _ = (session.clone()).close(reason).await;
|
||||
false
|
||||
}
|
||||
Some(Err(err)) => {
|
||||
log!(HttpError::WebSocketError(err));
|
||||
false
|
||||
}
|
||||
None => false,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_drop_event(session: &mut Session, event: &DropEventMessage) -> bool {
|
||||
match serde_json::to_string(event) {
|
||||
Ok(json) => session.text(json).await.is_ok(),
|
||||
Err(err) => {
|
||||
log!(MiscError::SerializeError(err));
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,8 +6,8 @@ use actix_ws::Message;
|
||||
use futures_util::StreamExt;
|
||||
use tokio::time::interval;
|
||||
|
||||
use crate::infrastructure::statistics::FlowStatistics;
|
||||
use crate::model::monitoring::flow_stats::FlowSubscription;
|
||||
use crate::core::common::statistics::FlowStatistics;
|
||||
use crate::domain::data_plane::flow_stats::FlowSubscription;
|
||||
|
||||
/// Default subscription: all flows, no filter, 5 second interval
|
||||
fn default_subscription() -> FlowSubscription {
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
//! WebSocket bridge for post-fusion threat events.
|
||||
//!
|
||||
//! `/ws/fusion` subscribes to the `ThreatDetectedEvent` broadcast that the
|
||||
//! `DetectionOrchestrator` already publishes through `CommunicationManager`
|
||||
//! (the same stream SOAR consumes). Each event is wrapped with a server-side
|
||||
//! `/ws/fusion` subscribes to the `ThreatDetectedEvent` broadcast channel
|
||||
//! that the `DetectionOrchestrator` publishes to (the same stream SOAR
|
||||
//! consumes). Each event is wrapped with a server-side
|
||||
//! `ts` (unix seconds) so the dashboard can render relative timestamps
|
||||
//! without doing the conversion itself.
|
||||
//!
|
||||
@ -16,102 +16,26 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use actix_web::rt::spawn;
|
||||
use actix_web::{HttpRequest, HttpResponse, Result, web};
|
||||
use actix_ws::{Message, MessageStream, Session, handle};
|
||||
use futures_util::StreamExt;
|
||||
use actix_ws::handle;
|
||||
use macros::log;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
|
||||
use crate::infrastructure::communication_manager::CommunicationManager;
|
||||
use crate::model::error::http::HttpError;
|
||||
use crate::model::error::misc::MiscError;
|
||||
use crate::model::event::ThreatDetectedEvent;
|
||||
use crate::model::log::http::HttpLog;
|
||||
use super::ws_bridge;
|
||||
use crate::domain::common::error::misc::MiscError;
|
||||
use crate::domain::common::event::ThreatDetectedEvent;
|
||||
|
||||
pub async fn websocket_fusion(
|
||||
req: HttpRequest,
|
||||
body: web::Payload,
|
||||
comm: web::Data<CommunicationManager>,
|
||||
threat_tx: web::Data<broadcast::Sender<ThreatDetectedEvent>>,
|
||||
) -> Result<HttpResponse> {
|
||||
let (response, session, msg_stream) = handle(&req, body)?;
|
||||
|
||||
let broadcast_rx = match comm.subscribe_event::<ThreatDetectedEvent>() {
|
||||
Ok(rx) => rx,
|
||||
Err(e) => {
|
||||
log!(HttpLog::FusionSubscribeFailed(e.to_string()));
|
||||
return Ok(HttpResponse::InternalServerError().json(serde_json::json!({
|
||||
"error": "fusion event channel not registered",
|
||||
})));
|
||||
}
|
||||
};
|
||||
|
||||
spawn(async move {
|
||||
handle_fusion_connection(session, msg_stream, broadcast_rx).await;
|
||||
});
|
||||
|
||||
let rx = threat_tx.subscribe();
|
||||
spawn(ws_bridge::broadcast_loop(session, msg_stream, rx, envelope_with_ts));
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn handle_fusion_connection(
|
||||
mut session: Session,
|
||||
mut msg_stream: MessageStream,
|
||||
mut broadcast_rx: broadcast::Receiver<ThreatDetectedEvent>,
|
||||
) {
|
||||
loop {
|
||||
tokio::select! {
|
||||
msg_result = msg_stream.next() => {
|
||||
if !handle_client_message(&mut session, msg_result).await {
|
||||
break;
|
||||
}
|
||||
},
|
||||
broadcast_result = broadcast_rx.recv() => {
|
||||
match broadcast_result {
|
||||
Ok(event) => {
|
||||
if !send_event(&mut session, &event).await {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(RecvError::Lagged(skipped)) => {
|
||||
log!(HttpLog::WebSocketLagged(skipped));
|
||||
continue;
|
||||
}
|
||||
Err(RecvError::Closed) => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let _ = session.close(None).await;
|
||||
}
|
||||
|
||||
async fn handle_client_message(
|
||||
session: &mut Session,
|
||||
msg_result: Option<Result<Message, actix_ws::ProtocolError>>,
|
||||
) -> bool {
|
||||
match msg_result {
|
||||
Some(Ok(Message::Text(_))) => true,
|
||||
Some(Ok(Message::Ping(bytes))) => session.pong(&bytes).await.is_ok(),
|
||||
Some(Ok(Message::Close(reason))) => {
|
||||
let _ = (session.clone()).close(reason).await;
|
||||
false
|
||||
}
|
||||
Some(Err(err)) => {
|
||||
log!(HttpError::WebSocketError(err));
|
||||
false
|
||||
}
|
||||
None => false,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap each event in `{ts, ...event_fields}`. The `ts` is a server-stamped
|
||||
/// unix-seconds value so the client can render "5s ago" without inferring
|
||||
/// the time from the audit chain. All declared fields of
|
||||
/// `ThreatDetectedEvent` flow through verbatim via the event's own
|
||||
/// `Serialize` derive — no field whitelist to drift out of date.
|
||||
async fn send_event(session: &mut Session, event: &ThreatDetectedEvent) -> bool {
|
||||
fn envelope_with_ts(event: &ThreatDetectedEvent) -> Option<String> {
|
||||
let ts = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
@ -122,21 +46,18 @@ async fn send_event(session: &mut Session, event: &ThreatDetectedEvent) -> bool
|
||||
map.insert("ts".to_string(), serde_json::Value::from(ts));
|
||||
serde_json::Value::Object(map)
|
||||
}
|
||||
// The derived Serialize on a struct always produces an Object —
|
||||
// this branch only fires if the type changes shape in a future
|
||||
// refactor. Falling back to the raw value keeps the stream alive.
|
||||
Ok(other) => other,
|
||||
Err(err) => {
|
||||
log!(MiscError::SerializeError(err));
|
||||
return false;
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
match serde_json::to_string(&payload) {
|
||||
Ok(json) => session.text(json).await.is_ok(),
|
||||
Ok(json) => Some(json),
|
||||
Err(err) => {
|
||||
log!(MiscError::SerializeError(err));
|
||||
false
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -144,7 +65,7 @@ async fn send_event(session: &mut Session, event: &ThreatDetectedEvent) -> bool
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::event::DetectionSource;
|
||||
use crate::domain::common::event::DetectionSource;
|
||||
|
||||
fn sample_event() -> ThreatDetectedEvent {
|
||||
ThreatDetectedEvent {
|
||||
@ -179,14 +100,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn envelope_adds_ts_field_to_event_object() {
|
||||
// The `send_event` wire path inserts `ts` into the event's own
|
||||
// serde object; mirror that here without an actix session so the
|
||||
// wrapping logic stays covered when the orchestrator schema evolves.
|
||||
let event = sample_event();
|
||||
let mut value = serde_json::to_value(&event).expect("serialize event");
|
||||
let object = value.as_object_mut().expect("expected object shape");
|
||||
object.insert("ts".to_string(), serde_json::Value::from(1_700_000_000_u64));
|
||||
assert_eq!(value["ts"], 1_700_000_000_u64);
|
||||
let json = envelope_with_ts(&sample_event()).expect("should serialize");
|
||||
let value: serde_json::Value = serde_json::from_str(&json).expect("valid json");
|
||||
assert!(value["ts"].is_u64());
|
||||
assert_eq!(value["attack_type"], "brute_force");
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,16 +1,9 @@
|
||||
use actix_web::rt::spawn;
|
||||
use actix_web::{HttpRequest, HttpResponse, Result, web};
|
||||
use actix_ws::{Message, MessageStream, Session, handle};
|
||||
use futures_util::StreamExt;
|
||||
use macros::log;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
use actix_ws::handle;
|
||||
|
||||
use super::ws_bridge;
|
||||
use crate::infrastructure::health::SystemHealth;
|
||||
use crate::model::error::http::HttpError;
|
||||
use crate::model::error::misc::MiscError;
|
||||
use crate::model::log::http::HttpLog;
|
||||
use crate::model::system::health::SystemHealthMetrics;
|
||||
|
||||
pub async fn websocket_system_health(
|
||||
req: HttpRequest,
|
||||
@ -18,76 +11,7 @@ pub async fn websocket_system_health(
|
||||
health: web::Data<SystemHealth>,
|
||||
) -> Result<HttpResponse> {
|
||||
let (response, session, msg_stream) = handle(&req, body)?;
|
||||
|
||||
let broadcast_rx = health.subscribe_to_metrics();
|
||||
|
||||
spawn(async move {
|
||||
handle_health_connection(session, msg_stream, broadcast_rx).await;
|
||||
});
|
||||
|
||||
let rx = health.subscribe_to_metrics();
|
||||
spawn(ws_bridge::broadcast_json(session, msg_stream, rx));
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn handle_health_connection(
|
||||
mut session: Session,
|
||||
mut msg_stream: MessageStream,
|
||||
mut broadcast_rx: broadcast::Receiver<SystemHealthMetrics>,
|
||||
) {
|
||||
loop {
|
||||
tokio::select! {
|
||||
msg_result = msg_stream.next() => {
|
||||
if !handle_client_message(&mut session, msg_result).await {
|
||||
break;
|
||||
}
|
||||
},
|
||||
broadcast_result = broadcast_rx.recv() => {
|
||||
match broadcast_result {
|
||||
Ok(metrics) => {
|
||||
if !send_metrics(&mut session, &metrics).await {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(RecvError::Lagged(skipped)) => {
|
||||
log!(HttpLog::WebSocketLagged(skipped));
|
||||
continue;
|
||||
}
|
||||
Err(RecvError::Closed) => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let _ = session.close(None).await;
|
||||
}
|
||||
|
||||
async fn handle_client_message(
|
||||
session: &mut Session,
|
||||
msg_result: Option<Result<Message, actix_ws::ProtocolError>>,
|
||||
) -> bool {
|
||||
match msg_result {
|
||||
Some(Ok(Message::Text(_))) => true,
|
||||
Some(Ok(Message::Ping(bytes))) => session.pong(&bytes).await.is_ok(),
|
||||
Some(Ok(Message::Close(reason))) => {
|
||||
let _ = (session.clone()).close(reason).await;
|
||||
false
|
||||
}
|
||||
Some(Err(err)) => {
|
||||
log!(HttpError::WebSocketError(err));
|
||||
false
|
||||
}
|
||||
None => false,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_metrics(session: &mut Session, metrics: &SystemHealthMetrics) -> bool {
|
||||
match serde_json::to_string(metrics) {
|
||||
Ok(json) => session.text(json).await.is_ok(),
|
||||
Err(err) => {
|
||||
log!(MiscError::SerializeError(err));
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,3 +4,4 @@ pub mod flow_websocket;
|
||||
pub mod fusion_websocket;
|
||||
pub mod health_websocket;
|
||||
pub mod routes;
|
||||
pub mod ws_bridge;
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
use actix_web::{HttpRequest, HttpResponse, Responder, Scope, web};
|
||||
use serde::Deserialize;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use super::{alert_websocket, drop_websocket, flow_websocket, fusion_websocket, health_websocket};
|
||||
use crate::adapter::ebpf::drop_monitor::DropMonitor;
|
||||
use crate::core::auth::jwt::JwtService;
|
||||
use crate::core::ml::alert::MLAlert;
|
||||
use crate::infrastructure::communication_manager::CommunicationManager;
|
||||
use crate::adapter::http::jwt::JwtService;
|
||||
use crate::core::common::statistics::FlowStatistics;
|
||||
use crate::core::inference::alert::MLAlert;
|
||||
use crate::domain::common::event::ThreatDetectedEvent;
|
||||
use crate::infrastructure::health::SystemHealth;
|
||||
use crate::infrastructure::statistics::FlowStatistics;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct WsQuery {
|
||||
@ -41,10 +42,7 @@ fn validate_ws_token(
|
||||
Some(ref t) => jwt
|
||||
.validate_token(t)
|
||||
.map(|_| ())
|
||||
.map_err(|_| {
|
||||
HttpResponse::Unauthorized()
|
||||
.json(serde_json::json!({"error": "Invalid or expired token"}))
|
||||
}),
|
||||
.map_err(|_| HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid or expired token"}))),
|
||||
None => Err(HttpResponse::Unauthorized()
|
||||
.json(serde_json::json!({"error": "Missing authentication: provide Authorization header or token query parameter"}))),
|
||||
}
|
||||
@ -89,14 +87,14 @@ async fn alerts_ws(
|
||||
async fn fusion_ws(
|
||||
req: HttpRequest,
|
||||
stream: web::Payload,
|
||||
comm: web::Data<CommunicationManager>,
|
||||
threat_tx: web::Data<broadcast::Sender<ThreatDetectedEvent>>,
|
||||
query: web::Query<WsQuery>,
|
||||
jwt: web::Data<JwtService>,
|
||||
) -> impl Responder {
|
||||
if let Err(resp) = validate_ws_token(&req, &query, &jwt) {
|
||||
return resp;
|
||||
}
|
||||
match fusion_websocket::websocket_fusion(req, stream, comm).await {
|
||||
match fusion_websocket::websocket_fusion(req, stream, threat_tx).await {
|
||||
Ok(response) => response,
|
||||
Err(err) => {
|
||||
HttpResponse::InternalServerError().json(serde_json::json!({"error": format!("WebSocket error: {}", err)}))
|
||||
|
||||
86
net-guardia/src/adapter/websocket/ws_bridge.rs
Normal file
86
net-guardia/src/adapter/websocket/ws_bridge.rs
Normal file
@ -0,0 +1,86 @@
|
||||
use actix_ws::{Message, MessageStream, Session};
|
||||
use futures_util::StreamExt;
|
||||
use macros::log;
|
||||
use serde::Serialize;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::sync::broadcast::error::RecvError;
|
||||
|
||||
use crate::domain::common::error::http::HttpError;
|
||||
use crate::domain::common::error::misc::MiscError;
|
||||
use crate::domain::common::log::http::HttpLog;
|
||||
|
||||
async fn handle_client_message(
|
||||
session: &mut Session,
|
||||
msg_result: Option<Result<Message, actix_ws::ProtocolError>>,
|
||||
) -> bool {
|
||||
match msg_result {
|
||||
Some(Ok(Message::Text(_))) => true,
|
||||
Some(Ok(Message::Ping(bytes))) => session.pong(&bytes).await.is_ok(),
|
||||
Some(Ok(Message::Close(reason))) => {
|
||||
let _ = (session.clone()).close(reason).await;
|
||||
false
|
||||
}
|
||||
Some(Err(err)) => {
|
||||
log!(HttpError::WebSocketError(err));
|
||||
false
|
||||
}
|
||||
None => false,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn serialize_json<T: Serialize>(value: &T) -> Option<String> {
|
||||
match serde_json::to_string(value) {
|
||||
Ok(json) => Some(json),
|
||||
Err(err) => {
|
||||
log!(MiscError::SerializeError(err));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn broadcast_json<T: Serialize + Clone + Send + 'static>(
|
||||
session: Session,
|
||||
msg_stream: MessageStream,
|
||||
rx: broadcast::Receiver<T>,
|
||||
) {
|
||||
broadcast_loop(session, msg_stream, rx, |event| serialize_json(event)).await;
|
||||
}
|
||||
|
||||
pub async fn broadcast_loop<T: Clone + Send + 'static>(
|
||||
mut session: Session,
|
||||
mut msg_stream: MessageStream,
|
||||
mut rx: broadcast::Receiver<T>,
|
||||
to_json: impl Fn(&T) -> Option<String>,
|
||||
) {
|
||||
loop {
|
||||
tokio::select! {
|
||||
msg_result = msg_stream.next() => {
|
||||
if !handle_client_message(&mut session, msg_result).await {
|
||||
break;
|
||||
}
|
||||
},
|
||||
broadcast_result = rx.recv() => {
|
||||
match broadcast_result {
|
||||
Ok(event) => {
|
||||
let Some(json) = to_json(&event) else {
|
||||
break;
|
||||
};
|
||||
if session.text(json).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(RecvError::Lagged(skipped)) => {
|
||||
log!(HttpLog::WebSocketLagged(skipped));
|
||||
continue;
|
||||
}
|
||||
Err(RecvError::Closed) => {
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let _ = session.close(None).await;
|
||||
}
|
||||
204
net-guardia/src/core/common/config_service.rs
Normal file
204
net-guardia/src/core/common/config_service.rs
Normal file
@ -0,0 +1,204 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::domain::common::config::AppConfig;
|
||||
use crate::domain::common::config::section::ConfigSection;
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::domain::common::error::misc::MiscError;
|
||||
use crate::interface::app_repo::AppRepo;
|
||||
use crate::interface::config_repo::ConfigRepo;
|
||||
use crate::interface::secret_store::SecretStorePort;
|
||||
|
||||
const SECRET_KEYS: &[&str] = &["smtp_password"];
|
||||
|
||||
const VALID_PIPELINE_STAGES: &[&str] = &["access_control", "rate_limit", "service"];
|
||||
|
||||
pub struct ConfigService {
|
||||
db: Arc<dyn AppRepo>,
|
||||
secrets: Option<Arc<dyn SecretStorePort>>,
|
||||
app_config: Arc<ArcSwap<AppConfig>>,
|
||||
}
|
||||
|
||||
struct CandidateConfigRepo {
|
||||
base: Arc<dyn AppRepo>,
|
||||
values: HashMap<String, String>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ConfigRepo for CandidateConfigRepo {
|
||||
async fn get_config_value(&self, key: &str) -> Result<Option<String>, Error> {
|
||||
if let Some(value) = self.values.get(key) {
|
||||
return Ok(Some(value.clone()));
|
||||
}
|
||||
self.base.get_config_value(key).await
|
||||
}
|
||||
|
||||
async fn set_config_value(&self, key: &str, value: &str) -> Result<(), Error> {
|
||||
self.base.set_config_value(key, value).await
|
||||
}
|
||||
|
||||
async fn get_app_secret(&self, key: &str) -> Result<Option<String>, Error> {
|
||||
self.base.get_app_secret(key).await
|
||||
}
|
||||
|
||||
async fn set_app_secret(&self, key: &str, plaintext: &str) -> Result<(), Error> {
|
||||
self.base.set_app_secret(key, plaintext).await
|
||||
}
|
||||
|
||||
async fn get_notification_config(&self, channel: &str) -> Result<Option<String>, Error> {
|
||||
self.base.get_notification_config(channel).await
|
||||
}
|
||||
|
||||
async fn set_notification_config(&self, channel: &str, config_json: &str) -> Result<(), Error> {
|
||||
self.base.set_notification_config(channel, config_json).await
|
||||
}
|
||||
|
||||
async fn update_config_values_atomically(
|
||||
&self,
|
||||
config_values: Vec<(String, String)>,
|
||||
secrets: Vec<(String, String)>,
|
||||
) -> Result<(), Error> {
|
||||
self.base.update_config_values_atomically(config_values, secrets).await
|
||||
}
|
||||
}
|
||||
|
||||
impl ConfigService {
|
||||
pub fn new(db: Arc<dyn AppRepo>, app_config: Arc<ArcSwap<AppConfig>>) -> Self {
|
||||
Self {
|
||||
db,
|
||||
secrets: None,
|
||||
app_config,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_secret_store(mut self, secrets: Arc<dyn SecretStorePort>) -> Self {
|
||||
self.secrets = Some(secrets);
|
||||
self
|
||||
}
|
||||
|
||||
pub async fn get_config(&self) -> serde_json::Value {
|
||||
let cfg = self.app_config.load();
|
||||
let values = cfg.api_setting_values();
|
||||
let mut root = serde_json::Map::new();
|
||||
for section in ConfigSection::ALL {
|
||||
let mut section_obj = serde_json::Map::new();
|
||||
for key in section.keys() {
|
||||
let value = values.get(key).cloned().unwrap_or_default();
|
||||
section_obj.insert(key.to_string(), serde_json::Value::String(value));
|
||||
}
|
||||
root.insert(section.name().to_string(), serde_json::Value::Object(section_obj));
|
||||
}
|
||||
root.insert(
|
||||
"pipeline".to_string(),
|
||||
serde_json::json!({
|
||||
"ingress": cfg.pipeline.ingress.join(","),
|
||||
"egress": cfg.pipeline.egress.join(","),
|
||||
}),
|
||||
);
|
||||
serde_json::Value::Object(root)
|
||||
}
|
||||
|
||||
pub async fn update_config(&self, body: &serde_json::Value) -> Result<Vec<String>, Error> {
|
||||
let mut updated: Vec<String> = Vec::new();
|
||||
let mut config_values = Vec::new();
|
||||
let mut secrets_to_save = Vec::new();
|
||||
|
||||
for section in ConfigSection::ALL {
|
||||
if let Some(section_obj) = body.get(section.name()).and_then(|v| v.as_object()) {
|
||||
for key in section.keys() {
|
||||
if let Some(val) = section_obj.get(*key).and_then(json_value_as_string) {
|
||||
config_values.push((key.to_string(), val));
|
||||
updated.push(key.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref secrets) = self.secrets {
|
||||
for key in SECRET_KEYS {
|
||||
let section = key.split('_').next().unwrap_or("");
|
||||
if let Some(val) = body
|
||||
.get(section)
|
||||
.and_then(|v| v.as_object())
|
||||
.and_then(|obj| obj.get(*key))
|
||||
.and_then(json_value_as_string)
|
||||
{
|
||||
let envelope = secrets.encrypt_envelope(&val)?;
|
||||
secrets_to_save.push((key.to_string(), envelope));
|
||||
config_values.push((key.to_string(), String::new()));
|
||||
updated.push(key.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(pipeline_obj) = body.get("pipeline").and_then(|v| v.as_object()) {
|
||||
for (field, db_key) in [("ingress", "pipeline_ingress"), ("egress", "pipeline_egress")] {
|
||||
if let Some(val) = pipeline_obj.get(field).and_then(|v| v.as_str()) {
|
||||
if !val.is_empty() {
|
||||
let stages: Vec<&str> = val.split(',').map(|s| s.trim()).collect();
|
||||
for stage in &stages {
|
||||
if !stage.is_empty() && !VALID_PIPELINE_STAGES.contains(stage) {
|
||||
Err(MiscError::ValidationError(format!(
|
||||
"Invalid pipeline stage '{}'. Valid stages: {}",
|
||||
stage,
|
||||
VALID_PIPELINE_STAGES.join(", ")
|
||||
)))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
config_values.push((db_key.to_string(), val.to_string()));
|
||||
updated.push(db_key.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let candidate_repo = CandidateConfigRepo {
|
||||
base: self.db.clone(),
|
||||
values: config_values.iter().cloned().collect(),
|
||||
};
|
||||
let new_cfg = AppConfig::from_config_repo(&candidate_repo).await?;
|
||||
|
||||
self.db
|
||||
.update_config_values_atomically(config_values, secrets_to_save)
|
||||
.await?;
|
||||
self.app_config.store(Arc::new(new_cfg));
|
||||
|
||||
Ok(updated)
|
||||
}
|
||||
}
|
||||
|
||||
fn json_value_as_string(v: &serde_json::Value) -> Option<String> {
|
||||
match v {
|
||||
serde_json::Value::String(s) if !s.is_empty() => Some(s.clone()),
|
||||
serde_json::Value::Bool(b) => Some(b.to_string()),
|
||||
serde_json::Value::Number(n) => Some(n.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::adapter::persistence::Database;
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_config_update_is_not_persisted() {
|
||||
let db = Arc::new(Database::new(":memory:").await.expect("test db"));
|
||||
let app_config = Arc::new(ArcSwap::from_pointee(
|
||||
AppConfig::from_config_repo(db.as_ref()).await.expect("initial config"),
|
||||
));
|
||||
let service = ConfigService::new(db.clone() as Arc<dyn AppRepo>, app_config);
|
||||
|
||||
let body = serde_json::json!({
|
||||
"http": {
|
||||
"http_port": 0
|
||||
}
|
||||
});
|
||||
|
||||
assert!(service.update_config(&body).await.is_err());
|
||||
assert_eq!(db.get_config_value("http_port").await.unwrap(), None);
|
||||
}
|
||||
}
|
||||
119
net-guardia/src/core/common/enforce_mode_handler.rs
Normal file
119
net-guardia/src/core/common/enforce_mode_handler.rs
Normal file
@ -0,0 +1,119 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
|
||||
use arc_swap::ArcSwap;
|
||||
use macros::log;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use crate::domain::common::config::AppConfig;
|
||||
use crate::domain::common::config::constants::enforce_mode_to_u8;
|
||||
use crate::domain::common::error::Error;
|
||||
use crate::domain::common::event::AuditEvent;
|
||||
use crate::domain::common::log::system::SystemLog;
|
||||
use crate::interface::app_repo::AppRepo;
|
||||
|
||||
/// Handles enforce-mode commands and queries by delegating to the repository.
|
||||
pub struct EnforceModeHandler {
|
||||
db: Arc<dyn AppRepo>,
|
||||
app_config: Arc<ArcSwap<AppConfig>>,
|
||||
audit_tx: broadcast::Sender<AuditEvent>,
|
||||
/// Shared AtomicU8 cache: Monitor=0, MlOnly=1, Enforce=2.
|
||||
enforce_cache: Arc<AtomicU8>,
|
||||
}
|
||||
|
||||
impl EnforceModeHandler {
|
||||
pub fn new(
|
||||
db: Arc<dyn AppRepo>,
|
||||
app_config: Arc<ArcSwap<AppConfig>>,
|
||||
audit_tx: broadcast::Sender<AuditEvent>,
|
||||
enforce_cache: Arc<AtomicU8>,
|
||||
) -> Self {
|
||||
Self {
|
||||
db,
|
||||
app_config,
|
||||
audit_tx,
|
||||
enforce_cache,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn change_mode(&self, mode: String) -> Result<(), Error> {
|
||||
self.db.set_config_value("enforce_mode", &mode).await?;
|
||||
self.enforce_cache.store(enforce_mode_to_u8(&mode), Ordering::SeqCst);
|
||||
let mut cfg = (**self.app_config.load()).clone();
|
||||
cfg.system.enforce_mode = mode.clone();
|
||||
self.app_config.store(Arc::new(cfg));
|
||||
log!(SystemLog::EnforceModeChanged(mode.clone()));
|
||||
|
||||
// Publish audit event for the mode change
|
||||
let _ = self.audit_tx.send(AuditEvent {
|
||||
actor: "admin".to_string(),
|
||||
action: "enforce_mode_changed".to_string(),
|
||||
detail: serde_json::json!({ "new_mode": mode }).to_string(),
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_mode(&self) -> String {
|
||||
self.app_config.load().system.enforce_mode.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::adapter::persistence::Database;
|
||||
use crate::domain::common::config::constants::EVENT_CHANNEL_CAPACITY;
|
||||
|
||||
async fn test_handler() -> EnforceModeHandler {
|
||||
let db = Arc::new(Database::new(":memory:").await.unwrap());
|
||||
let app_config = Arc::new(ArcSwap::from_pointee(
|
||||
AppConfig::from_config_repo(db.as_ref()).await.unwrap(),
|
||||
));
|
||||
let cache = Arc::new(AtomicU8::new(0));
|
||||
let (audit_tx, _rx) = broadcast::channel::<AuditEvent>(EVENT_CHANNEL_CAPACITY);
|
||||
EnforceModeHandler::new(db as Arc<dyn AppRepo>, app_config, audit_tx, cache)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_default_mode_is_monitor() {
|
||||
let handler = test_handler().await;
|
||||
let mode = handler.get_mode();
|
||||
assert_eq!(mode, "monitor");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_change_to_enforce() {
|
||||
let handler = test_handler().await;
|
||||
handler.change_mode("enforce".into()).await.unwrap();
|
||||
let mode = handler.get_mode();
|
||||
assert_eq!(mode, "enforce");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_change_back_to_monitor() {
|
||||
let handler = test_handler().await;
|
||||
handler.change_mode("enforce".into()).await.unwrap();
|
||||
handler.change_mode("monitor".into()).await.unwrap();
|
||||
let mode = handler.get_mode();
|
||||
assert_eq!(mode, "monitor");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_change_to_ml_only() {
|
||||
let handler = test_handler().await;
|
||||
handler.change_mode("ml_only".into()).await.unwrap();
|
||||
let mode = handler.get_mode();
|
||||
assert_eq!(mode, "ml_only");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cycle_all_modes() {
|
||||
let handler = test_handler().await;
|
||||
for mode_str in ["enforce", "ml_only", "monitor"] {
|
||||
handler.change_mode(mode_str.into()).await.unwrap();
|
||||
let mode = handler.get_mode();
|
||||
assert_eq!(mode, mode_str);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user