From fbaef940822e975007eb93bf1e23f03c6c8d0ac6 Mon Sep 17 00:00:00 2001 From: DaLaw2 Date: Fri, 3 Apr 2026 15:56:33 +0800 Subject: [PATCH] feat: architecture, detection, security, SOAR, operations (#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Phase 2-5 — architecture, detection, security, SOAR, operations Architecture: - Hexagonal port traits (10 modules migrated from Arc) - Domain model types moved to model/ directory - Constants centralized + 7 made runtime-configurable via DB - Dead Error/Log variants cleaned up, SystemLog split Detection (Phase 5): - Detection orchestrator with dedup + enrichment + source attribution - Cross-flow correlation engine: botnet, scan, lateral movement (T9) - Temporal beaconing detector: CV-based C2 periodicity (T10) - LRU flow eviction replacing O(n) min_by_key scan (T12) Security hardening: - 7 fixes: alg:none, config secret leak, HTTPS open redirect, log traversal, HKDF salt, SOAR whitelist+cooldown, operator validation - 4 memory safety fixes: LRU dedup, frequency cleanup, drift cap, clock - Envelope encryption for secrets (AES-256-GCM + HKDF) - 17 new tests (SecretStore + SOAR conditions) SOAR (Phase 3): - Multi-condition playbooks (5 condition types, AND logic) - Playbook update API (PUT + toggle endpoints) Operations (Phase 4): - Dynamic log level, system control APIs (shutdown/restart) - HTTP config hot reload, spawn_blocking for CPU-bound work - CLI encrypt-db / decrypt-db commands - Audit log API Log level audit: - 16 variants adjusted (noisy hot-path → TRACE/DEBUG) - 5 dead variants removed Co-Authored-By: Claude Opus 4.6 (1M context) * fix: address Copilot review — 6 issues from PR #18 1. Botnet detector source_ip was set to victim dst_ip, causing SOAR to block the victim instead of the attacker 2. HTTPS redirect host header injection: validate host is private IP, localhost, or .local hostname before constructing redirect URL 3. smtp_password plaintext residue: clear settings table after writing to SecretStore to prevent pre-migration plaintext from persisting 4. install.sh: add apt-get update before install on Debian/Ubuntu 5. download_log OOM risk: add 50MB file size limit before reading 6. update_config restart trigger: check return value, report if shutdown already in progress instead of claiming success Co-Authored-By: Claude Opus 4.6 (1M context) * fix: address Agent Team review — security, perf, correctness Security: - S1: Add RBAC permission check for /api/logs/ and /api/audit/ endpoints (previously any authenticated user could access) - S2/S3: Remove report_dir and log_dir from configurable settings to prevent arbitrary directory write via config API - A2: Pin DNS-resolved IPs in webhook reqwest client to prevent DNS rebinding TOCTOU attack (resolve() instead of re-resolving) Performance: - P7: Add 50K key cap to FrequencyTracker to prevent unbounded growth under DDoS (was unbounded, worst case 1.6GB) - P9: Increase ML alert broadcast capacity 100 → 1024 to prevent lost alerts during DDoS spikes (3 subscribers contend on 100-slot buffer) - P2: Reduce FLOW_MAX_PERIODS 10000 → 1000 (saves 144KB/flow, feature extraction only uses aggregate stats) - P1: Remove unnecessary FlowKey clone on hot path (~1.9MB/s saved) - P5: Beaconing detector: split analyze_and_alert into read-lock scan + selective write-lock update (reduces DashMap contention) Correctness: - A4: Capture correlation counts inside DashMap guard before dropping, eliminating TOCTOU in logged values (botnet, scan, lateral) - A6: Log warning when SOAR playbook action params JSON is malformed instead of silently replacing with empty object Co-Authored-By: Claude Opus 4.6 (1M context) * chore: add trainer submodule, update frontend submodule - Add net-guardia-trainer submodule (ParrotXray/NetGuardia-Trainer@dalaw2-dev) - Update frontend submodule with code quality fixes Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- .gitignore | 9 + .gitmodules | 4 + Cargo.lock | 125 +- Cargo.toml | 6 +- cli/Cargo.toml | 2 +- cli/src/main.rs | 340 ++--- config.toml.example | 36 - deploy/compose/Containerfile.netguardia | 1 + deploy/netguardia.service | 2 +- deploy/packer/cloud-init/user-data | 12 +- deploy/packer/netguardia.pkr.hcl | 109 +- deploy/scripts/{setup.sh => dev.sh} | 0 deploy/scripts/install.sh | 134 ++ deploy/setup-wizard.sh | 313 ----- mcp-server/src/main.rs | 65 +- net-guardia-frontend | 2 +- net-guardia-trainer | 1 + net-guardia/Cargo.toml | 5 +- net-guardia/build.rs | 23 +- .../src/adapter/access_control_adapter.rs | 12 +- net-guardia/src/adapter/http/acl.rs | 28 +- .../adapter/http/{mcp_keys.rs => api_keys.rs} | 65 +- net-guardia/src/adapter/http/audit.rs | 29 + net-guardia/src/adapter/http/auth.rs | 288 ++-- net-guardia/src/adapter/http/filter.rs | 74 +- net-guardia/src/adapter/http/health.rs | 2 +- net-guardia/src/adapter/http/logs.rs | 157 +++ net-guardia/src/adapter/http/ml.rs | 13 +- net-guardia/src/adapter/http/mod.rs | 4 +- net-guardia/src/adapter/http/notification.rs | 17 +- net-guardia/src/adapter/http/rate_limit.rs | 10 +- net-guardia/src/adapter/http/report.rs | 129 +- net-guardia/src/adapter/http/setup.rs | 57 +- net-guardia/src/adapter/http/soar.rs | 259 +++- net-guardia/src/adapter/http/stats.rs | 7 +- net-guardia/src/adapter/http/system.rs | 99 +- .../src/adapter/persistence/repository.rs | 1215 ++++++++++++++--- net-guardia/src/adapter/telegram/mod.rs | 105 +- .../src/adapter/websocket/alert_websocket.rs | 14 +- .../src/adapter/websocket/drop_websocket.rs | 4 +- .../src/adapter/websocket/flow_websocket.rs | 6 +- .../src/adapter/websocket/health_websocket.rs | 8 +- net-guardia/src/adapter/websocket/routes.rs | 22 +- net-guardia/src/core/acl_service.rs | 32 +- net-guardia/src/core/auth/extractor.rs | 2 +- net-guardia/src/core/auth/https_redirect.rs | 147 ++ net-guardia/src/core/auth/jwt.rs | 49 +- net-guardia/src/core/auth/middleware.rs | 72 +- net-guardia/src/core/auth/mod.rs | 1 + net-guardia/src/core/auth/password.rs | 8 +- net-guardia/src/core/auth/setup_guard.rs | 23 +- net-guardia/src/core/config_service.rs | 107 +- net-guardia/src/core/correlation/botnet.rs | 190 +++ net-guardia/src/core/correlation/engine.rs | 76 ++ net-guardia/src/core/correlation/lateral.rs | 229 ++++ net-guardia/src/core/correlation/mod.rs | 4 + net-guardia/src/core/correlation/scan.rs | 173 +++ net-guardia/src/core/detection/beaconing.rs | 277 ++++ net-guardia/src/core/detection/mod.rs | 2 + .../src/core/detection/orchestrator.rs | 205 +++ net-guardia/src/core/dns_filter_service.rs | 16 +- net-guardia/src/core/ebpf/access_control.rs | 10 +- net-guardia/src/core/ebpf/dns_filter.rs | 11 +- net-guardia/src/core/ebpf/drop_monitor.rs | 20 +- net-guardia/src/core/ebpf/geo_block.rs | 37 +- net-guardia/src/core/ebpf/mod.rs | 8 +- net-guardia/src/core/ebpf/protocol_filter.rs | 10 +- net-guardia/src/core/ebpf/rate_limit.rs | 58 +- net-guardia/src/core/ebpf/xsk_manager.rs | 54 +- net-guardia/src/core/email/report.rs | 44 +- net-guardia/src/core/email/scheduler.rs | 174 ++- net-guardia/src/core/ml/aggregator.rs | 78 +- net-guardia/src/core/ml/alert.rs | 11 +- net-guardia/src/core/ml/config_loader.rs | 9 +- net-guardia/src/core/ml/drift_detector.rs | 156 +++ net-guardia/src/core/ml/engine.rs | 65 +- net-guardia/src/core/ml/feature_extractor.rs | 201 +-- net-guardia/src/core/ml/flow_tracker.rs | 270 +++- net-guardia/src/core/ml/inference.rs | 24 +- net-guardia/src/core/ml/mod.rs | 17 +- net-guardia/src/core/ml/model_loader.rs | 14 +- net-guardia/src/core/ml/traffic_logger.rs | 2 +- net-guardia/src/core/mod.rs | 30 +- net-guardia/src/core/notification_service.rs | 89 +- net-guardia/src/core/playbook_service.rs | 232 ++-- net-guardia/src/core/rate_limit_service.rs | 11 +- net-guardia/src/core/report/data.rs | 162 +-- net-guardia/src/core/report/engine.rs | 33 +- net-guardia/src/core/report/mod.rs | 2 +- net-guardia/src/core/soar/engine.rs | 982 ++++++++++--- net-guardia/src/core/soar/frequency.rs | 87 ++ net-guardia/src/core/soar/mod.rs | 1 + net-guardia/src/core/soar/scheduler.rs | 34 +- net-guardia/src/core/stats_aggregator.rs | 63 +- net-guardia/src/core/system.rs | 218 ++- net-guardia/src/infrastructure/app_config.rs | 165 ++- .../src/infrastructure/app_services.rs | 24 +- .../src/infrastructure/audit_logger.rs | 103 ++ .../infrastructure/communication_manager.rs | 62 +- .../infrastructure/enforce_mode_handler.rs | 79 +- net-guardia/src/infrastructure/geoip.rs | 43 +- net-guardia/src/infrastructure/health.rs | 62 +- net-guardia/src/infrastructure/http_server.rs | 231 +++- net-guardia/src/infrastructure/mod.rs | 2 + .../src/infrastructure/secret_store.rs | 324 +++++ .../src/infrastructure/service_factory.rs | 205 ++- net-guardia/src/infrastructure/statistics.rs | 4 +- .../interface/communication/event_types.rs | 18 +- .../src/interface/communication/mod.rs | 8 +- net-guardia/src/interface/port/api_key.rs | 14 + net-guardia/src/interface/port/audit.rs | 6 + net-guardia/src/interface/port/mod.rs | 5 + .../src/interface/port/notification.rs | 6 + net-guardia/src/interface/port/repository.rs | 26 +- .../src/interface/port/secret_store.rs | 6 + net-guardia/src/interface/port/soar.rs | 119 ++ net-guardia/src/interface/port/stats.rs | 11 + net-guardia/src/main.rs | 84 +- .../src/model/access_control/ip_address.rs | 24 +- net-guardia/src/model/config/constants.rs | 25 + net-guardia/src/model/config/mod.rs | 5 + net-guardia/src/model/detection/drift.rs | 31 + .../src/model/detection/flow_features.rs | 138 ++ .../src/model/detection/ml_detection.rs | 6 + net-guardia/src/model/detection/mod.rs | 2 + net-guardia/src/model/error/crypto.rs | 21 + net-guardia/src/model/error/mod.rs | 10 + net-guardia/src/model/error/soar.rs | 20 +- net-guardia/src/model/error/system.rs | 3 + net-guardia/src/model/event.rs | 94 ++ net-guardia/src/model/log/audit.rs | 27 + net-guardia/src/model/log/crypto.rs | 20 + net-guardia/src/model/log/detection.rs | 48 + net-guardia/src/model/log/ebpf.rs | 21 +- net-guardia/src/model/log/misc.rs | 16 +- net-guardia/src/model/log/ml.rs | 10 +- net-guardia/src/model/log/mod.rs | 7 +- net-guardia/src/model/log/soar.rs | 34 +- net-guardia/src/model/log/system.rs | 26 +- net-guardia/src/model/mod.rs | 4 +- .../src/model/monitoring/geolocation.rs | 9 + net-guardia/src/model/monitoring/mod.rs | 1 + net-guardia/src/model/report/data.rs | 182 +++ net-guardia/src/model/report/mod.rs | 1 + net-guardia/src/model/soar/condition.rs | 60 + net-guardia/src/model/soar/mod.rs | 2 + net-guardia/src/model/soar/playbook.rs | 4 + net-guardia/src/model/soar/playbook_data.rs | 77 ++ net-guardia/src/model/system/config.rs | 17 +- net-guardia/src/model/system/mod.rs | 2 + .../src/model/system/rate_limit_settings.rs | 10 + net-guardia/src/model/system/readiness.rs | 20 + net-guardia/src/utils/ip_address.rs | 7 +- net-guardia/src/utils/logging.rs | 72 +- net-guardia/src/utils/mod.rs | 6 +- net-guardia/src/utils/packet_parser.rs | 1 - 156 files changed, 8554 insertions(+), 2759 deletions(-) delete mode 100644 config.toml.example rename deploy/scripts/{setup.sh => dev.sh} (100%) create mode 100755 deploy/scripts/install.sh delete mode 100755 deploy/setup-wizard.sh create mode 160000 net-guardia-trainer rename net-guardia/src/adapter/http/{mcp_keys.rs => api_keys.rs} (51%) create mode 100644 net-guardia/src/adapter/http/audit.rs create mode 100644 net-guardia/src/adapter/http/logs.rs create mode 100644 net-guardia/src/core/auth/https_redirect.rs create mode 100644 net-guardia/src/core/correlation/botnet.rs create mode 100644 net-guardia/src/core/correlation/engine.rs create mode 100644 net-guardia/src/core/correlation/lateral.rs create mode 100644 net-guardia/src/core/correlation/mod.rs create mode 100644 net-guardia/src/core/correlation/scan.rs create mode 100644 net-guardia/src/core/detection/beaconing.rs create mode 100644 net-guardia/src/core/detection/mod.rs create mode 100644 net-guardia/src/core/detection/orchestrator.rs create mode 100644 net-guardia/src/core/ml/drift_detector.rs create mode 100644 net-guardia/src/core/soar/frequency.rs create mode 100644 net-guardia/src/infrastructure/audit_logger.rs create mode 100644 net-guardia/src/infrastructure/secret_store.rs create mode 100644 net-guardia/src/interface/port/api_key.rs create mode 100644 net-guardia/src/interface/port/audit.rs create mode 100644 net-guardia/src/interface/port/secret_store.rs create mode 100644 net-guardia/src/interface/port/soar.rs create mode 100644 net-guardia/src/interface/port/stats.rs create mode 100644 net-guardia/src/model/config/constants.rs create mode 100644 net-guardia/src/model/config/mod.rs create mode 100644 net-guardia/src/model/detection/drift.rs create mode 100644 net-guardia/src/model/detection/flow_features.rs create mode 100644 net-guardia/src/model/error/crypto.rs create mode 100644 net-guardia/src/model/event.rs create mode 100644 net-guardia/src/model/log/audit.rs create mode 100644 net-guardia/src/model/log/crypto.rs create mode 100644 net-guardia/src/model/log/detection.rs create mode 100644 net-guardia/src/model/monitoring/geolocation.rs create mode 100644 net-guardia/src/model/report/data.rs create mode 100644 net-guardia/src/model/report/mod.rs create mode 100644 net-guardia/src/model/soar/condition.rs create mode 100644 net-guardia/src/model/soar/playbook_data.rs create mode 100644 net-guardia/src/model/system/rate_limit_settings.rs create mode 100644 net-guardia/src/model/system/readiness.rs diff --git a/.gitignore b/.gitignore index f6ba2dd..342a607 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Claude Code +.claude/ + ### https://raw.github.com/github/gitignore/master/Rust.gitignore # Generated by Cargo @@ -44,6 +47,12 @@ TODOS.md VERSION CHANGELOG.md +# Benchmark data/results (local only) +benchmark/ + +# Generated docs +docs/ + # SQLite database files *.db *.db-shm diff --git a/.gitmodules b/.gitmodules index 765f1e2..ac1c8be 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,7 @@ [submodule "net-guardia-frontend"] path = net-guardia-frontend url = https://github.com/DaLaw2/NetGuardia-FrontEnd.git +[submodule "net-guardia-trainer"] + path = net-guardia-trainer + url = https://github.com/ParrotXray/NetGuardia-Trainer.git + branch = dalaw2-dev diff --git a/Cargo.lock b/Cargo.lock index 31da644..ebee79c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -258,6 +258,41 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + [[package]] name = "ahash" version = "0.8.12" @@ -771,6 +806,16 @@ dependencies = [ "stacker", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clang-sys" version = "1.8.1" @@ -969,9 +1014,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "dashmap" version = "6.1.0" @@ -1322,6 +1377,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + [[package]] name = "glob" version = "0.3.3" @@ -1406,6 +1471,24 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "hostname" version = "0.4.2" @@ -1695,6 +1778,15 @@ dependencies = [ "which", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -2188,10 +2280,12 @@ dependencies = [ "actix-cors", "actix-web", "actix-ws", + "aes-gcm", "argon2", "async-trait", "aya", "aya-log", + "base64", "cargo_metadata", "chrono", "common", @@ -2199,6 +2293,7 @@ dependencies = [ "dashmap", "dotenvy", "futures-util", + "hkdf", "ipnetwork", "jsonwebtoken", "lettre", @@ -2248,7 +2343,7 @@ name = "ng-cli" version = "0.1.0" dependencies = [ "clap", - "rand 0.9.2", + "libc", "reqwest", "serde", "serde_json", @@ -2430,6 +2525,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2553,6 +2654,18 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -3996,6 +4109,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index 8bcbdd5..23f61fb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,7 @@ actix-ws = "0.4.0" # Logging / tracing tracing = "0.1.44" tracing-appender = "0.2.4" -tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } +tracing-subscriber = { version = "0.3.23", features = ["env-filter", "registry"] } # ML tract-onnx = "0.22.1" @@ -68,10 +68,10 @@ quote = "1.0.45" syn = { version = "2.0.117", features = ["full"] } [profile.dev] -panic = "abort" +panic = "unwind" [profile.release] -panic = "abort" +panic = "unwind" opt-level = 3 lto = "thin" strip = true diff --git a/cli/Cargo.toml b/cli/Cargo.toml index fd2786c..c50a573 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -9,7 +9,7 @@ serde = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true } clap = { workspace = true } -rand = { workspace = true } +libc = { workspace = true } [[bin]] name = "ng" diff --git a/cli/src/main.rs b/cli/src/main.rs index e93b8b5..f1c6f67 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -19,28 +19,25 @@ struct Cli { #[derive(Subcommand)] enum Commands { - /// System health + enforce mode + uptime + /// System health + enforce mode Status, - /// Recent threat alerts - Alerts { - #[arg(long, default_value = "20")] - limit: u32, - }, - /// Add IP to blacklist + /// ML engine status + Ml, + /// Add IP to source blacklist Block { ip: String, - #[arg(long, default_value = "1800")] - ttl: u64, }, - /// Remove IP from blacklist + /// Remove IP from source blacklist Unblock { ip: String }, - /// List all ACL rules - Rules, - /// Generate security report - Report { - #[arg(long, default_value = "text")] - format: String, + /// List ACL rules (source blacklist by default) + Rules { + #[arg(long, default_value = "source")] + direction: String, + #[arg(long, default_value = "blacklist")] + list_type: String, }, + /// Generate security report (JSON data) + Report, /// Get or set enforce mode Mode { /// Set mode to "monitor" or "enforce" @@ -48,25 +45,31 @@ enum Commands { }, /// Authenticate and save JWT Login, - /// MCP API key management - McpKey { + /// List SOAR active blocks + Blocks, + /// List SOAR playbooks + Playbooks, + /// List SOAR execution history + Executions, + /// API key management + ApiKey { #[command(subcommand)] - action: McpKeyAction, + action: ApiKeyAction, }, } #[derive(Subcommand)] -enum McpKeyAction { - /// Generate a new MCP API key +enum ApiKeyAction { + /// Generate a new API key Generate { #[arg(long, default_value = "default")] name: String, #[arg(long, default_value = "read_only")] level: String, }, - /// List all MCP API keys + /// List all API keys List, - /// Revoke an MCP API key + /// Revoke an API key Revoke { id: i64 }, } @@ -106,36 +109,36 @@ impl ApiClient { req = req.header("Authorization", format!("Bearer {}", token.trim())); } let resp = req.send().await.map_err(|e| format!("Connection error: {}", e))?; - if resp.status().as_u16() == 401 { + let status = resp.status().as_u16(); + if status == 401 { return Err("Session expired. Run `ng login` to re-authenticate.".into()); } - resp.json().await.map_err(|e| format!("Parse error: {}", e)) + 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)])) } - async fn post(&self, path: &str, body: Value) -> Result { + async fn request(&self, method: reqwest::Method, path: &str, body: Option) -> Result { let url = format!("{}{}", self.base_url, path); - let mut req = self.client.post(&url).json(&body); + let mut req = self.client.request(method, &url); if let Some(token) = self.load_token() { req = req.header("Authorization", format!("Bearer {}", token.trim())); } - let resp = req.send().await.map_err(|e| format!("Connection error: {}", e))?; - if resp.status().as_u16() == 401 { - return Err("Session expired. Run `ng login` to re-authenticate.".into()); - } - resp.json().await.map_err(|e| format!("Parse error: {}", e)) - } - - async fn delete(&self, path: &str) -> Result { - let url = format!("{}{}", self.base_url, path); - let mut req = self.client.delete(&url); - if let Some(token) = self.load_token() { - req = req.header("Authorization", format!("Bearer {}", token.trim())); + if let Some(b) = body { + req = req.json(&b); } let resp = req.send().await.map_err(|e| format!("Connection error: {}", e))?; - if resp.status().as_u16() == 401 { + let status = resp.status().as_u16(); + if status == 401 { return Err("Session expired. Run `ng login` to re-authenticate.".into()); } - resp.json().await.map_err(|e| format!("Parse error: {}", e)) + let text = resp.text().await.map_err(|e| format!("Read error: {}", e))?; + if text.is_empty() { + if (200..300).contains(&status) { + return Ok(Value::Null); + } + return Err(format!("Empty response (HTTP {})", status)); + } + 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 { @@ -154,42 +157,35 @@ fn dirs_next() -> PathBuf { PathBuf::from(home).join(".ng") } -fn format_report_text(data: &Value) -> String { - let mut out = String::new(); - out.push_str("=== NetGuardia Security Report ===\n\n"); +fn print_json(data: &Value) { + println!("{}", serde_json::to_string_pretty(data).unwrap_or_default()); +} - if let Some(obj) = data.as_object() { - for (key, value) in obj { - let label = key.replace('_', " "); - match value { - Value::String(s) => { - out.push_str(&format!("{}: {}\n", label, s)); - } - Value::Number(n) => { - out.push_str(&format!("{}: {}\n", label, n)); - } - Value::Bool(b) => { - out.push_str(&format!("{}: {}\n", label, b)); - } - Value::Array(arr) => { - out.push_str(&format!("{}:\n", label)); - for item in arr { - out.push_str(&format!(" - {}\n", item)); - } - } - Value::Object(_) => { - out.push_str(&format!("{}:\n{}\n", label, serde_json::to_string_pretty(value).unwrap_or_default())); - } - Value::Null => { - out.push_str(&format!("{}: N/A\n", label)); - } - } - } - } else { - out.push_str(&serde_json::to_string_pretty(data).unwrap_or_default()); +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::() }; + unsafe { libc::tcgetattr(fd, &mut termios) }; + let old = termios; + termios.c_lflag &= !libc::ECHO; + unsafe { libc::tcsetattr(fd, libc::TCSANOW, &termios) }; + + let mut password = String::new(); + std::io::stdin().read_line(&mut password).unwrap(); + println!(); // newline after hidden input + + unsafe { libc::tcsetattr(fd, libc::TCSANOW, &old) }; + password.trim().to_string() + } + #[cfg(not(unix))] + { + let mut password = String::new(); + std::io::stdin().read_line(&mut password).unwrap(); + password.trim().to_string() } - - out } #[tokio::main] @@ -199,82 +195,54 @@ async fn main() { let result = match cli.command { Commands::Status => { - match api.get("/api/health/status").await { - Ok(data) => { - println!("{}", serde_json::to_string_pretty(&data).unwrap_or_default()); - Ok(()) - } - Err(e) => Err(e), - } + api.get("/api/health/status").await.map(|d| print_json(&d)) } - // Issue 8: Use limit parameter in alerts query - Commands::Alerts { limit } => { - match api.get(&format!("/api/ml/alerts?limit={}", limit)).await { - Ok(data) => { - println!("{}", serde_json::to_string_pretty(&data).unwrap_or_default()); - Ok(()) - } - Err(e) => Err(e), - } + Commands::Ml => { + api.get("/api/ml/status").await.map(|d| print_json(&d)) } - // Issue 9: Use ttl parameter in block request body - Commands::Block { ip, ttl } => { - let ip_ver = if ip.contains(':') { 6 } else { 4 }; - let body = serde_json::json!({ - "ip_version": ip_ver, "direction": "source", - "list_type": "blacklist", "ip_address": ip, "port": 0, - "ttl_secs": ttl - }); - match api.post("/api/acl/add", body).await { - Ok(data) => { println!("Blocked: {}", serde_json::to_string(&data).unwrap_or_default()); Ok(()) } - Err(e) => Err(e), - } + 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)) } Commands::Unblock { ip } => { - let ip_ver = if ip.contains(':') { 6 } else { 4 }; - let body = serde_json::json!({ - "ip_version": ip_ver, "direction": "source", - "list_type": "blacklist", "ip_address": ip, "port": 0 - }); - match api.post("/api/acl/delete", body).await { - Ok(data) => { println!("Unblocked: {}", serde_json::to_string(&data).unwrap_or_default()); Ok(()) } - Err(e) => Err(e), - } + 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)) } - Commands::Rules => { - match api.get("/api/acl/list").await { - Ok(data) => { println!("{}", serde_json::to_string_pretty(&data).unwrap_or_default()); Ok(()) } - Err(e) => Err(e), + Commands::Rules { direction, list_type } => { + // Try both IPv4 and IPv6 + let v4 = api.get(&format!("/api/acl/ipv4/{}/{}", direction, list_type)).await; + let v6 = api.get(&format!("/api/acl/ipv6/{}/{}", direction, list_type)).await; + println!("=== IPv4 {} {} ===", direction, list_type); + match v4 { + Ok(d) => print_json(&d), + Err(e) => eprintln!("{}", e), } + println!("\n=== IPv6 {} {} ===", direction, list_type); + match v6 { + Ok(d) => print_json(&d), + Err(e) => eprintln!("{}", e), + } + Ok(()) } - // Issue 10: Use format parameter for report output - Commands::Report { format } => { - match api.post("/api/report/generate", serde_json::json!({})).await { - Ok(data) => { - if format == "json" { - println!("{}", serde_json::to_string_pretty(&data).unwrap_or_default()); - } else { - print!("{}", format_report_text(&data)); - } - Ok(()) - } - Err(e) => Err(e), - } + Commands::Report => { + // 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}); - match api.post("/api/system/enforce-mode", body).await { - Ok(data) => { println!("{}", serde_json::to_string_pretty(&data).unwrap_or_default()); Ok(()) } - Err(e) => Err(e), - } + api.request(reqwest::Method::PUT, "/api/system/enforce-mode", Some(body)) + .await.map(|d| print_json(&d)) } None => { - match api.get("/api/system/enforce-mode").await { - Ok(data) => { println!("{}", serde_json::to_string_pretty(&data).unwrap_or_default()); Ok(()) } - Err(e) => Err(e), - } + api.get("/api/system/enforce-mode").await.map(|d| print_json(&d)) } } } @@ -285,14 +253,11 @@ async fn main() { std::io::stdin().read_line(&mut username).unwrap(); let username = username.trim(); - // Read password without echo (simple version) print!("Password: "); std::io::Write::flush(&mut std::io::stdout()).unwrap(); - let mut password = String::new(); - std::io::stdin().read_line(&mut password).unwrap(); - let password = password.trim(); + let password = read_password(); - match api.login(username, password).await { + match api.login(username, &password).await { Ok(token) => { api.save_token(&token); println!("Login successful. Token saved to ~/.ng/token"); @@ -301,69 +266,62 @@ async fn main() { Err(e) => Err(e), } } - Commands::McpKey { action } => { + 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 { - // Issue 13: Generate key via API so it persists - McpKeyAction::Generate { name, level } => { - let body = serde_json::json!({ - "name": name, - "level": level, - }); - match api.post("/api/mcp-keys/generate", body).await { - Ok(data) => { + 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 MCP API key: {}", key); + println!("Generated API key: {}", key); println!("Name: {}, Level: {}", name, level); - println!("Set NETGUARDIA_MCP_KEY={} in your MCP client config", key); + println!("Set NETGUARDIA_API_KEY={} in your client config", key); } else { - println!("{}", serde_json::to_string_pretty(&data).unwrap_or_default()); + print_json(&data); } - Ok(()) - } - Err(e) => Err(e), - } + }) } - // Issue 11: List keys via API - McpKeyAction::List => { - match api.get("/api/mcp-keys").await { - Ok(data) => { - if let Some(keys) = data.as_array() { - if keys.is_empty() { - println!("No MCP 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"), - ); - } + 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 { - println!("{}", serde_json::to_string_pretty(&data).unwrap_or_default()); } - Ok(()) + } else { + print_json(&data); } - Err(e) => Err(e), - } + }) } - // Issue 12: Revoke key via API - McpKeyAction::Revoke { id } => { - match api.delete(&format!("/api/mcp-keys/{}", id)).await { - Ok(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 { - println!("{}", serde_json::to_string_pretty(&data).unwrap_or_default()); + print_json(&data); } - Ok(()) - } - Err(e) => Err(e), - } + }) } } } diff --git a/config.toml.example b/config.toml.example deleted file mode 100644 index 2c3c37e..0000000 --- a/config.toml.example +++ /dev/null @@ -1,36 +0,0 @@ -[Http] -http_server_bind_port = 8080 -jwt_expiry_hours = 24 - -[Network] -ingress_ifname = "ng-ext" -egress_ifname = "ng-int" -combined_queue_count = 1 -channel_size = 4096 -fill_queue_size = 4096 -comp_queue_size = 4096 -tx_queue_size = 4096 -rx_queue_size = 4096 -frame_size = 4096 -frame_count = 4096 -refresh_interval = 5 - -[Inference] -deep_autoencoder_name = "deep_autoencoder.onnx" -classifier_name = "classifier.onnx" -models_config_name = "inference_config.json" -max_concurrent_flows = 10000 -min_packets_for_inference = 5 -inference_interval_secs = 5 -aggregator_window_secs = 30 -inference_batch_size = 200 -traffic_logging_mode = true -traffic_log_csv_path = "traffic_log.csv" - -[Misc] -geoip_db_name = "net-guardia/static/geo/GeoLite2-City.mmdb" -database_path = "net-guardia.db" - -[Pipeline] -ingress = ["access_control", "rate_limit", "service"] -egress = [] diff --git a/deploy/compose/Containerfile.netguardia b/deploy/compose/Containerfile.netguardia index e21c33a..fd27ba5 100644 --- a/deploy/compose/Containerfile.netguardia +++ b/deploy/compose/Containerfile.netguardia @@ -23,6 +23,7 @@ RUN dnf install -y epel-release && \ nodejs24-npm \ m4 \ make pkg-config \ + openssl-devel \ && dnf clean all # Rust toolchain diff --git a/deploy/netguardia.service b/deploy/netguardia.service index 1f7cbb7..bda09e3 100644 --- a/deploy/netguardia.service +++ b/deploy/netguardia.service @@ -18,7 +18,7 @@ WatchdogSec=30 NoNewPrivileges=false ProtectSystem=strict ProtectHome=yes -ReadWritePaths=/opt/netguardia /var/log/netguardia +ReadWritePaths=/opt/netguardia /var/log/netguardia /var/lib/netguardia PrivateTmp=yes # Resource limits diff --git a/deploy/packer/cloud-init/user-data b/deploy/packer/cloud-init/user-data index 232dff9..38fc293 100644 --- a/deploy/packer/cloud-init/user-data +++ b/deploy/packer/cloud-init/user-data @@ -27,8 +27,6 @@ autoinstall: dhcp4: true packages: - - whiptail - - jq - curl - net-tools - iproute2 @@ -43,14 +41,6 @@ autoinstall: - >- curtin in-target -- systemctl enable serial-getty@ttyS0.service - user-data: - runcmd: - # Run the setup wizard on first boot if not already configured - - | - if [ ! -f /opt/netguardia/config.toml ]; then - /opt/netguardia/bin/setup-wizard.sh - fi - final_message: | NetGuardia image provisioning complete. - Run /opt/netguardia/bin/setup-wizard.sh to configure. + The HTTP setup wizard starts automatically on port 8080. diff --git a/deploy/packer/netguardia.pkr.hcl b/deploy/packer/netguardia.pkr.hcl index 8eca159..f1447bb 100644 --- a/deploy/packer/netguardia.pkr.hcl +++ b/deploy/packer/netguardia.pkr.hcl @@ -20,14 +20,18 @@ variable "ubuntu_iso_url" { } variable "ubuntu_iso_checksum" { - type = string - default = "sha256:none" - description = "SHA-256 checksum of the Ubuntu 24.04 Server ISO. Update before building." + type = string + description = "SHA-256 checksum of the Ubuntu 24.04 Server ISO (e.g. sha256:abcdef...). Must be provided explicitly." + + validation { + condition = can(regex("^sha256:[0-9a-fA-F]{64}$", var.ubuntu_iso_checksum)) + error_message = "ubuntu_iso_checksum must be a valid SHA-256 checksum in the form 'sha256:<64 hex chars>'. Do not use 'sha256:none'." + } } variable "netguardia_binary" { - type = string - default = "../target/release/net-guardia" + type = string + default = "../target/release/net-guardia" description = "Path to the pre-built NetGuardia binary." } @@ -43,8 +47,8 @@ variable "ssh_password" { } variable "disk_size" { - type = string - default = "20480" + type = string + default = "20480" description = "Virtual disk size in MB." } @@ -58,6 +62,17 @@ variable "cpus" { default = "2" } +variable "accelerator" { + type = string + default = "kvm" + description = "QEMU accelerator: 'kvm' (default) or 'none' for environments without KVM support." + + validation { + condition = contains(["kvm", "none"], var.accelerator) + error_message = "accelerator must be 'kvm' or 'none'." + } +} + # --------------------------------------------------------------------------- # Source: QEMU (produces QCOW2) # --------------------------------------------------------------------------- @@ -90,7 +105,7 @@ source "qemu" "netguardia" { vm_name = "netguardia" net_device = "virtio-net" disk_interface = "virtio" - accelerator = "kvm" + accelerator = var.accelerator } # --------------------------------------------------------------------------- @@ -138,6 +153,17 @@ build { "source.virtualbox-iso.netguardia" ] + # ------ KVM fallback warning ------ + + provisioner "shell" { + inline = [ + "if [ '${var.accelerator}' = 'none' ]; then", + " echo '⚠ WARNING: Building without KVM acceleration. This will be significantly slower.'", + " echo '⚠ Set accelerator=kvm for production builds.'", + "fi" + ] + } + # ------ Upload artifacts ------ provisioner "file" { @@ -145,48 +171,71 @@ build { destination = "/tmp/net-guardia" } + provisioner "file" { + source = "../deploy/scripts/install.sh" + destination = "/tmp/install.sh" + } + provisioner "file" { source = "../deploy/netguardia.service" destination = "/tmp/netguardia.service" } + provisioner "file" { + source = "../deploy/logrotate.conf" + destination = "/tmp/logrotate.conf" + } + provisioner "file" { source = "../deploy/setup-wizard.sh" destination = "/tmp/setup-wizard.sh" } - provisioner "file" { - source = "../deploy/logrotate.conf" - destination = "/tmp/netguardia-logrotate.conf" - } - - # ------ Install everything ------ + # ------ Debug binary gate ------ provisioner "shell" { inline = [ - "set -ex", + "set -e", + "echo 'Checking binary is not a debug build...'", + "if file /tmp/net-guardia | grep -q 'not stripped'; then", + " echo 'FATAL: Binary is a debug build (not stripped). Use a release build for VM images.'", + " exit 1", + "fi", + "echo 'Binary check passed: stripped release build.'" + ] + } - "# Create directories", - "sudo mkdir -p /opt/netguardia/bin", - "sudo mkdir -p /var/log/netguardia", + # ------ Install runtime dependencies (SQLCipher needs OpenSSL) ------ - "# Install binary", - "sudo install -m 0755 /tmp/net-guardia /opt/netguardia/bin/net-guardia", + provisioner "shell" { + inline = [ + "set -e", + "if command -v apt-get &>/dev/null; then", + " sudo DEBIAN_FRONTEND=noninteractive apt-get install -y libssl3", + "elif command -v dnf &>/dev/null; then", + " sudo dnf install -y openssl-libs", + "fi" + ] + } - "# Install systemd unit", - "sudo install -m 0644 /tmp/netguardia.service /etc/systemd/system/netguardia.service", - "sudo systemctl daemon-reload", - "sudo systemctl enable netguardia.service", + # ------ Install via install.sh --local ------ + + provisioner "shell" { + inline = [ + "set -e", + "chmod +x /tmp/install.sh", + + "# Lay out deploy dir structure so install.sh can find service/logrotate files", + "sudo mkdir -p /tmp/deploy/scripts", + "cp /tmp/install.sh /tmp/deploy/scripts/install.sh", + "cp /tmp/netguardia.service /tmp/deploy/netguardia.service", + "cp /tmp/logrotate.conf /tmp/deploy/logrotate.conf", + + "sudo /tmp/deploy/scripts/install.sh --local /tmp/net-guardia", "# Install setup wizard", "sudo install -m 0755 /tmp/setup-wizard.sh /opt/netguardia/bin/setup-wizard.sh", - "# Install logrotate config", - "sudo install -m 0644 /tmp/netguardia-logrotate.conf /etc/logrotate.d/netguardia", - - "# Cleanup temp files", - "rm -f /tmp/net-guardia /tmp/netguardia.service /tmp/setup-wizard.sh /tmp/netguardia-logrotate.conf", - "# Configure first-boot setup wizard via rc.local", "sudo tee /etc/rc.local > /dev/null << 'RCEOF'", "#!/bin/bash", diff --git a/deploy/scripts/setup.sh b/deploy/scripts/dev.sh similarity index 100% rename from deploy/scripts/setup.sh rename to deploy/scripts/dev.sh diff --git a/deploy/scripts/install.sh b/deploy/scripts/install.sh new file mode 100755 index 0000000..3709d72 --- /dev/null +++ b/deploy/scripts/install.sh @@ -0,0 +1,134 @@ +#!/bin/bash +# install.sh — Install NetGuardia on a fresh system. +# +# Usage: +# install.sh # Download from GitHub Release +# install.sh --local /path/to/binary # Use a pre-built local binary +# +set -euo pipefail + +# ── Helpers ────────────────────────────────────────────────────────────────── +info() { printf '\033[1;34m[INFO]\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33m[WARN]\033[0m %s\n' "$*"; } +fatal() { printf '\033[1;31m[FATAL]\033[0m %s\n' "$*" >&2; exit 1; } + +# ── Defaults ───────────────────────────────────────────────────────────────── +LOCAL_BINARY="" +INSTALL_DIR="/opt/netguardia" +BIN_DIR="${INSTALL_DIR}/bin" +DATA_DIR="/var/lib/netguardia" +LOG_DIR="/var/log/netguardia" +SERVICE_USER="netguardia" +SERVICE_GROUP="netguardia" +GITHUB_REPO="dalaw2/NetGuardia" + +# ── Parse arguments ────────────────────────────────────────────────────────── +while [[ $# -gt 0 ]]; do + case "$1" in + --local) + [[ -z "${2:-}" ]] && fatal "--local requires a path to the binary" + LOCAL_BINARY="$2" + shift 2 + ;; + -h|--help) + echo "Usage: $0 [--local /path/to/binary]" + exit 0 + ;; + *) + fatal "Unknown argument: $1" + ;; + esac +done + +# ── Validate local binary (if provided) ───────────────────────────────────── +if [[ -n "${LOCAL_BINARY}" ]]; then + [[ -f "${LOCAL_BINARY}" ]] || fatal "Local binary not found: ${LOCAL_BINARY}" + [[ -x "${LOCAL_BINARY}" ]] || fatal "Local binary is not executable: ${LOCAL_BINARY}" + info "Using local binary: ${LOCAL_BINARY}" +fi + +# ── Must be root ───────────────────────────────────────────────────────────── +[[ "$(id -u)" -eq 0 ]] || fatal "This script must be run as root" + +# ── Install runtime dependencies (SQLCipher needs OpenSSL) ────────────────── +if command -v apt-get &>/dev/null; then + info "Refreshing apt package metadata" + DEBIAN_FRONTEND=noninteractive apt-get update >/dev/null 2>&1 || warn "Could not refresh apt metadata" + info "Installing runtime dependencies (libssl)" + DEBIAN_FRONTEND=noninteractive apt-get install -y libssl3 >/dev/null 2>&1 || warn "Could not install libssl3" +elif command -v dnf &>/dev/null; then + info "Installing runtime dependencies (openssl-libs)" + dnf install -y openssl-libs >/dev/null 2>&1 || warn "Could not install openssl-libs" +fi + +# ── Create system user ─────────────────────────────────────────────────────── +if ! id "${SERVICE_USER}" &>/dev/null; then + info "Creating system user: ${SERVICE_USER}" + useradd --system --no-create-home --shell /usr/sbin/nologin "${SERVICE_USER}" +fi + +# ── Create directories ─────────────────────────────────────────────────────── +info "Creating directories" +mkdir -p "${BIN_DIR}" "${DATA_DIR}" "${LOG_DIR}" +chown "${SERVICE_USER}:${SERVICE_GROUP}" "${DATA_DIR}" "${LOG_DIR}" + +# ── Obtain the binary ──────────────────────────────────────────────────────── +if [[ -n "${LOCAL_BINARY}" ]]; then + # --local mode: skip download and checksum entirely + info "Installing local binary to ${BIN_DIR}/net-guardia" + install -m 0755 "${LOCAL_BINARY}" "${BIN_DIR}/net-guardia" +else + # Download from GitHub Release + info "Fetching latest release from GitHub (${GITHUB_REPO})" + LATEST_TAG=$(curl -fsSL "https://api.github.com/repos/${GITHUB_REPO}/releases/latest" \ + | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/') + [[ -n "${LATEST_TAG}" ]] || fatal "Could not determine latest release tag" + info "Latest release: ${LATEST_TAG}" + + DOWNLOAD_URL="https://github.com/${GITHUB_REPO}/releases/download/${LATEST_TAG}/net-guardia-linux-amd64" + CHECKSUMS_URL="https://github.com/${GITHUB_REPO}/releases/download/${LATEST_TAG}/SHA256SUMS" + + TMPDIR=$(mktemp -d) + trap 'rm -rf "${TMPDIR}"' EXIT + + info "Downloading binary" + curl -fSL -o "${TMPDIR}/net-guardia" "${DOWNLOAD_URL}" + + info "Downloading SHA256SUMS" + if ! curl -fSL -o "${TMPDIR}/SHA256SUMS" "${CHECKSUMS_URL}"; then + fatal "SHA256SUMS file not found in release — aborting" + fi + + info "Verifying checksum" + (cd "${TMPDIR}" && sha256sum -c SHA256SUMS) + + install -m 0755 "${TMPDIR}/net-guardia" "${BIN_DIR}/net-guardia" +fi + +# ── Install systemd unit ───────────────────────────────────────────────────── +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +DEPLOY_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" + +if [[ -f "${DEPLOY_DIR}/netguardia.service" ]]; then + info "Installing systemd unit" + install -m 0644 "${DEPLOY_DIR}/netguardia.service" /etc/systemd/system/netguardia.service + systemctl daemon-reload + systemctl enable netguardia.service +else + warn "netguardia.service not found at ${DEPLOY_DIR}/netguardia.service — skipping" +fi + +# ── Install logrotate config ───────────────────────────────────────────────── +if [[ -f "${DEPLOY_DIR}/logrotate.conf" ]]; then + info "Installing logrotate config" + install -m 0644 "${DEPLOY_DIR}/logrotate.conf" /etc/logrotate.d/netguardia +else + warn "logrotate.conf not found — skipping" +fi + +# ── Done ───────────────────────────────────────────────────────────────────── +info "NetGuardia installed successfully" +info " Binary: ${BIN_DIR}/net-guardia" +info " Data: ${DATA_DIR}" +info " Logs: ${LOG_DIR}" +info " Service: systemctl start netguardia" diff --git a/deploy/setup-wizard.sh b/deploy/setup-wizard.sh deleted file mode 100755 index 07300c6..0000000 --- a/deploy/setup-wizard.sh +++ /dev/null @@ -1,313 +0,0 @@ -#!/usr/bin/env bash -# -# NetGuardia Interactive Setup Wizard -# Uses whiptail (falls back to dialog) for interactive configuration. -# -set -euo pipefail - -# --------------------------------------------------------------------------- -# Globals -# --------------------------------------------------------------------------- -readonly LOG_DIR="/var/log/netguardia" -readonly LOG_FILE="${LOG_DIR}/setup.log" -readonly CONFIG_DIR="/opt/netguardia" -readonly CONFIG_FILE="${CONFIG_DIR}/config.toml" -readonly PASSWORD_FLAG="${CONFIG_DIR}/.admin_password_set" -readonly BACKTITLE="NetGuardia Setup Wizard" - -DIALOG="" -INGRESS_NIC="" -EGRESS_NIC="" -NET_MODE="" -STATIC_IP="" -STATIC_MASK="" -STATIC_GW="" -ADMIN_PASS="" - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- -log() { - local ts - ts="$(date '+%Y-%m-%d %H:%M:%S')" - echo "[${ts}] $*" >> "${LOG_FILE}" -} - -die() { - log "FATAL: $*" - if [[ -n "${DIALOG}" ]]; then - "${DIALOG}" --backtitle "${BACKTITLE}" --title "Error" \ - --msgbox "Setup failed:\n\n$*\n\nSee ${LOG_FILE} for details." 12 60 - else - echo "FATAL: $*" >&2 - fi - exit 1 -} - -ensure_root() { - if [[ "$(id -u)" -ne 0 ]]; then - die "This script must be run as root." - fi -} - -init_logging() { - mkdir -p "${LOG_DIR}" - touch "${LOG_FILE}" - chmod 0640 "${LOG_FILE}" - log "=== NetGuardia setup wizard started ===" -} - -detect_dialog() { - if command -v whiptail &>/dev/null; then - DIALOG="whiptail" - elif command -v dialog &>/dev/null; then - DIALOG="dialog" - else - die "Neither whiptail nor dialog is installed. Install whiptail and retry." - fi - log "Using dialog frontend: ${DIALOG}" -} - -# --------------------------------------------------------------------------- -# Step 1 & 2: Detect and select NICs -# --------------------------------------------------------------------------- -get_interfaces() { - local -a ifaces=() - for iface in /sys/class/net/*; do - local name - name="$(basename "${iface}")" - [[ "${name}" == "lo" ]] && continue - ifaces+=("${name}") - done - - if [[ ${#ifaces[@]} -lt 2 ]]; then - die "At least 2 network interfaces are required (found ${#ifaces[@]}). Connect additional NICs and retry." - fi - - # Build menu items: "name description" - local -a menu_items=() - for name in "${ifaces[@]}"; do - local mac state - mac="$(cat "/sys/class/net/${name}/address" 2>/dev/null || echo "unknown")" - state="$(cat "/sys/class/net/${name}/operstate" 2>/dev/null || echo "unknown")" - menu_items+=("${name}" "MAC=${mac} state=${state}") - done - - # Select ingress NIC - INGRESS_NIC=$("${DIALOG}" --backtitle "${BACKTITLE}" \ - --title "Step 1: Select Ingress (External) NIC" \ - --menu "Choose the network interface facing the untrusted/external network:" \ - 20 70 10 "${menu_items[@]}" 3>&1 1>&2 2>&3) || die "Ingress NIC selection cancelled." - log "Ingress NIC selected: ${INGRESS_NIC}" - - # Build egress menu (exclude the chosen ingress NIC) - local -a egress_items=() - for ((i = 0; i < ${#menu_items[@]}; i += 2)); do - [[ "${menu_items[i]}" == "${INGRESS_NIC}" ]] && continue - egress_items+=("${menu_items[i]}" "${menu_items[i+1]}") - done - - EGRESS_NIC=$("${DIALOG}" --backtitle "${BACKTITLE}" \ - --title "Step 2: Select Egress (Internal) NIC" \ - --menu "Choose the network interface facing the trusted/internal network:" \ - 20 70 10 "${egress_items[@]}" 3>&1 1>&2 2>&3) || die "Egress NIC selection cancelled." - log "Egress NIC selected: ${EGRESS_NIC}" -} - -# --------------------------------------------------------------------------- -# Step 3: Configure network mode -# --------------------------------------------------------------------------- -configure_network() { - NET_MODE=$("${DIALOG}" --backtitle "${BACKTITLE}" \ - --title "Step 3: Network Configuration" \ - --menu "How should the management IP be configured?" \ - 12 60 2 \ - "dhcp" "Automatic (DHCP)" \ - "static" "Manual (Static IP)" \ - 3>&1 1>&2 2>&3) || die "Network configuration cancelled." - - log "Network mode: ${NET_MODE}" - - if [[ "${NET_MODE}" == "static" ]]; then - STATIC_IP=$("${DIALOG}" --backtitle "${BACKTITLE}" \ - --title "Static IP Address" \ - --inputbox "Enter the management IP address (e.g. 192.168.1.10):" \ - 10 60 "" 3>&1 1>&2 2>&3) || die "Static IP entry cancelled." - - STATIC_MASK=$("${DIALOG}" --backtitle "${BACKTITLE}" \ - --title "Subnet Mask" \ - --inputbox "Enter the subnet prefix length (e.g. 24):" \ - 10 60 "24" 3>&1 1>&2 2>&3) || die "Subnet mask entry cancelled." - - STATIC_GW=$("${DIALOG}" --backtitle "${BACKTITLE}" \ - --title "Default Gateway" \ - --inputbox "Enter the default gateway (e.g. 192.168.1.1):" \ - 10 60 "" 3>&1 1>&2 2>&3) || die "Gateway entry cancelled." - - log "Static config: ip=${STATIC_IP}/${STATIC_MASK} gw=${STATIC_GW}" - fi -} - -# --------------------------------------------------------------------------- -# Step 4: Set admin password flag -# --------------------------------------------------------------------------- -set_admin_password() { - while true; do - ADMIN_PASS=$("${DIALOG}" --backtitle "${BACKTITLE}" \ - --title "Step 4: Admin Password" \ - --passwordbox "Set the initial admin password (min 8 characters):" \ - 10 60 "" 3>&1 1>&2 2>&3) || die "Password entry cancelled." - - if [[ ${#ADMIN_PASS} -lt 8 ]]; then - "${DIALOG}" --backtitle "${BACKTITLE}" --title "Invalid Password" \ - --msgbox "Password must be at least 8 characters. Please try again." 8 50 - continue - fi - - local confirm - confirm=$("${DIALOG}" --backtitle "${BACKTITLE}" \ - --title "Confirm Password" \ - --passwordbox "Re-enter the admin password:" \ - 10 60 "" 3>&1 1>&2 2>&3) || die "Password confirmation cancelled." - - if [[ "${ADMIN_PASS}" != "${confirm}" ]]; then - "${DIALOG}" --backtitle "${BACKTITLE}" --title "Mismatch" \ - --msgbox "Passwords do not match. Please try again." 8 50 - continue - fi - - break - done - - # Write flag file; actual password is set on first web login. - echo "password_pending" > "${PASSWORD_FLAG}" - chmod 0600 "${PASSWORD_FLAG}" - log "Admin password flag written to ${PASSWORD_FLAG}" -} - -# --------------------------------------------------------------------------- -# Step 5: Generate config.toml -# --------------------------------------------------------------------------- -generate_config() { - log "Generating ${CONFIG_FILE}" - mkdir -p "${CONFIG_DIR}" - - local bind_port=8080 - - cat > "${CONFIG_FILE}" <> "${CONFIG_FILE}" </dev/null \ - | grep -oP 'inet \K[0-9.]+' | head -1) - if [[ -z "${mgmt_ip}" ]]; then - mgmt_ip="" - fi - fi - - local url="http://${mgmt_ip}:8080" - - "${DIALOG}" --backtitle "${BACKTITLE}" \ - --title "Setup Complete" \ - --msgbox "NetGuardia is running!\n\nDashboard: ${url}\n\nLog in with the admin account.\nYou will set your password on first login.\n\nSetup log: ${LOG_FILE}" \ - 14 60 - - log "Setup complete. Dashboard URL: ${url}" -} - -# --------------------------------------------------------------------------- -# Main -# --------------------------------------------------------------------------- -main() { - ensure_root - init_logging - detect_dialog - - get_interfaces - configure_network - set_admin_password - generate_config - start_service - show_dashboard_url - - log "=== NetGuardia setup wizard finished ===" -} - -main "$@" diff --git a/mcp-server/src/main.rs b/mcp-server/src/main.rs index 097d8ec..6d6fa3e 100644 --- a/mcp-server/src/main.rs +++ b/mcp-server/src/main.rs @@ -15,7 +15,7 @@ struct Args { #[arg(long, default_value = "http://127.0.0.1:8080")] api_url: String, - /// API key for authentication (prefer NETGUARDIA_MCP_KEY env var) + /// API key for authentication (prefer NETGUARDIA_API_KEY env var) #[arg(long)] api_key: Option, } @@ -97,12 +97,12 @@ impl McpServer { { "name": "get_stats", "description": "Traffic statistics summary", "inputSchema": { "type": "object", "properties": {} } }, { "name": "list_alerts", "description": "Recent threat alerts with details", "inputSchema": { "type": "object", "properties": { "limit": { "type": "integer", "default": 20 } } } }, { "name": "list_blocked_ips", "description": "Currently blocked IPs (manual + auto)", "inputSchema": { "type": "object", "properties": {} } }, - { "name": "get_geo_stats", "description": "GeoIP traffic breakdown", "inputSchema": { "type": "object", "properties": {} } }, + { "name": "get_geo_stats", "description": "List GeoIP blocked countries", "inputSchema": { "type": "object", "properties": {} } }, { "name": "get_flow_summary", "description": "Top talkers, protocols, ports", "inputSchema": { "type": "object", "properties": {} } }, { "name": "get_enforce_mode", "description": "Current mode (monitor/enforce)", "inputSchema": { "type": "object", "properties": {} } }, { "name": "list_playbooks", "description": "SOAR playbook configurations", "inputSchema": { "type": "object", "properties": {} } }, { "name": "generate_report", "description": "Generate security summary report", "inputSchema": { "type": "object", "properties": {} } }, - { "name": "block_ip", "description": "Add IP to blacklist", "inputSchema": { "type": "object", "properties": { "ip": { "type": "string" }, "ttl_secs": { "type": "integer", "default": 1800 } }, "required": ["ip"] } }, + { "name": "block_ip", "description": "Add IP to blacklist", "inputSchema": { "type": "object", "properties": { "ip": { "type": "string" } }, "required": ["ip"] } }, { "name": "unblock_ip", "description": "Remove IP from blacklist", "inputSchema": { "type": "object", "properties": { "ip": { "type": "string" } }, "required": ["ip"] } }, { "name": "set_enforce_mode", "description": "Toggle monitor/enforce mode", "inputSchema": { "type": "object", "properties": { "mode": { "type": "string", "enum": ["monitor", "enforce"] } }, "required": ["mode"] } }, { "name": "add_dns_filter", "description": "Add domain to DNS blacklist", "inputSchema": { "type": "object", "properties": { "domain": { "type": "string" } }, "required": ["domain"] } }, @@ -122,52 +122,41 @@ impl McpServer { 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 (method, path, body) = match tool_name { - "get_health" => ("GET", "/api/health/status", None), - "get_stats" => ("GET", "/api/stats/summary", None), - "list_alerts" => ("GET", "/api/ml/alerts", None), - "list_blocked_ips" => ("GET", "/api/soar/blocks", None), - "get_geo_stats" => ("GET", "/api/stats/geo", None), - "get_flow_summary" => ("GET", "/api/stats/flows", None), - "get_enforce_mode" => ("GET", "/api/system/enforce-mode", None), - "list_playbooks" => ("GET", "/api/soar/playbooks", None), - "generate_report" => ("POST", "/api/report/generate", None), + let (method, path, body): (&str, String, Option) = match tool_name { + "get_health" => ("GET", "/api/health/status".into(), None), + "get_stats" => ("GET", "/api/stats/summary".into(), None), + "list_alerts" => ("GET", "/api/soar/executions".into(), None), + "list_blocked_ips" => ("GET", "/api/soar/blocks".into(), None), + "get_geo_stats" => ("GET", "/api/acl/geo/blocked".into(), None), + "get_flow_summary" => ("GET", "/api/stats/flows".into(), None), + "get_enforce_mode" => ("GET", "/api/system/enforce-mode".into(), None), + "list_playbooks" => ("GET", "/api/soar/playbooks".into(), None), + "generate_report" => ("POST", "/api/report/generate".into(), None), "block_ip" => { let ip = arguments.get("ip").and_then(|v| v.as_str()).unwrap_or(""); - let body = serde_json::json!({ - "ip_version": if ip.contains(':') { 6 } else { 4 }, - "direction": "source", - "list_type": "blacklist", - "ip_address": ip, - "port": 0 - }); - ("POST", "/api/acl/add", Some(body)) + 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))) } "unblock_ip" => { let ip = arguments.get("ip").and_then(|v| v.as_str()).unwrap_or(""); - let body = serde_json::json!({ - "ip_version": if ip.contains(':') { 6 } else { 4 }, - "direction": "source", - "list_type": "blacklist", - "ip_address": ip, - "port": 0 - }); - ("POST", "/api/acl/delete", Some(body)) + 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))) } "set_enforce_mode" => { let mode = arguments.get("mode").and_then(|v| v.as_str()).unwrap_or("monitor"); - let body = serde_json::json!({"mode": mode}); - ("POST", "/api/system/enforce-mode", Some(body)) + ("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(""); - let body = serde_json::json!({"domain": domain}); - ("POST", "/api/filter/dns/add", Some(body)) + ("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(""); - let body = serde_json::json!({"codes": [code]}); - ("POST", "/api/acl/geo/block", Some(body)) + ("PUT", "/api/acl/geo/block".into(), Some(serde_json::json!({"country_codes": [code]}))) } _ => { return JsonRpcResponse { @@ -181,6 +170,8 @@ impl McpServer { let url = format!("{}{}", self.api_url, path); let mut req_builder = match method { + "PUT" => self.client.put(&url), + "DELETE" => self.client.delete(&url), "POST" => self.client.post(&url), _ => self.client.get(&url), }; @@ -237,9 +228,9 @@ async fn main() { let args = Args::parse(); let api_key = args.api_key - .or_else(|| std::env::var("NETGUARDIA_MCP_KEY").ok()) + .or_else(|| std::env::var("NETGUARDIA_API_KEY").ok()) .unwrap_or_else(|| { - eprintln!("Error: No API key provided. Set NETGUARDIA_MCP_KEY env var or use --api-key flag."); + eprintln!("Error: No API key provided. Set NETGUARDIA_API_KEY env var or use --api-key flag."); std::process::exit(1); }); diff --git a/net-guardia-frontend b/net-guardia-frontend index c651241..71d2d7f 160000 --- a/net-guardia-frontend +++ b/net-guardia-frontend @@ -1 +1 @@ -Subproject commit c651241916f82fcb5df4f60ddda9c46bd06fc6e0 +Subproject commit 71d2d7f2d53f4afe6510b3018227aa5e28d97476 diff --git a/net-guardia-trainer b/net-guardia-trainer new file mode 160000 index 0000000..1f5cbb8 --- /dev/null +++ b/net-guardia-trainer @@ -0,0 +1 @@ +Subproject commit 1f5cbb8b9ba69a5bd16cc15055c230715d6bb9ae diff --git a/net-guardia/Cargo.toml b/net-guardia/Cargo.toml index 36ada68..992f18a 100644 --- a/net-guardia/Cargo.toml +++ b/net-guardia/Cargo.toml @@ -61,12 +61,15 @@ sysinfo = { workspace = true } maxminddb = { workspace = true } ipnetwork = { workspace = true } lru = { workspace = true } -rusqlite = { workspace = true } +rusqlite = { version = "0.34", features = ["bundled-sqlcipher"] } r2d2 = "0.8" r2d2_sqlite = "0.27" jsonwebtoken = { workspace = true } argon2 = { workspace = true } sha2 = "0.10" +aes-gcm = "0.10" +hkdf = "0.12" +base64 = { workspace = true } sd-notify = "0.4" rand = { workspace = true } diff --git a/net-guardia/build.rs b/net-guardia/build.rs index 85bcd01..c8b5fd7 100644 --- a/net-guardia/build.rs +++ b/net-guardia/build.rs @@ -63,8 +63,7 @@ fn build_ebpf_package(package_name: &str, target_subdir: &str) { // Find bpf-linker once, pass its path to the subprocess explicitly. let bpf_linker = find_bpf_linker(); - let bpf_linker_str = bpf_linker.to_str() - .expect("bpf-linker path is not valid UTF-8"); + let bpf_linker_str = bpf_linker.to_str().expect("bpf-linker path is not valid UTF-8"); let Package { manifest_path, .. } = ebpf_package; let ebpf_dir = manifest_path.parent().unwrap(); @@ -211,8 +210,7 @@ fn build_frontend() { return; } - let npm = which::which("npm") - .unwrap_or_else(|_| panic!("npm not found in PATH. Install Node.js first.")); + let npm = which::which("npm").unwrap_or_else(|_| panic!("npm not found in PATH. Install Node.js first.")); let status = Command::new(&npm) .args(["install", "--include=optional"]) @@ -223,8 +221,7 @@ fn build_frontend() { panic!("npm install failed with exit code: {:?}", status.code()); } - let npx = which::which("npx") - .unwrap_or_else(|_| panic!("npx not found in PATH. Install Node.js first.")); + let npx = which::which("npx").unwrap_or_else(|_| panic!("npx not found in PATH. Install Node.js first.")); let status = Command::new(&npx) .args(["vite", "build"]) @@ -248,7 +245,11 @@ fn build_frontend() { emit_rerun_if_changed_recursive(&static_dir); } -fn needs_frontend_rebuild(frontend_dir: &std::path::Path, out_dir: &std::path::Path, static_dir: &std::path::Path) -> bool { +fn needs_frontend_rebuild( + frontend_dir: &std::path::Path, + out_dir: &std::path::Path, + static_dir: &std::path::Path, +) -> bool { if !out_dir.exists() || !static_dir.exists() { return true; } @@ -264,8 +265,12 @@ fn needs_frontend_rebuild(frontend_dir: &std::path::Path, out_dir: &std::path::P }; let essential_items = [ - "src", "public", "package.json", "vite.config.ts", - "tsconfig.json", "package-lock.json", + "src", + "public", + "package.json", + "vite.config.ts", + "tsconfig.json", + "package-lock.json", ]; for item_name in essential_items { diff --git a/net-guardia/src/adapter/access_control_adapter.rs b/net-guardia/src/adapter/access_control_adapter.rs index 41697f2..4614a67 100644 --- a/net-guardia/src/adapter/access_control_adapter.rs +++ b/net-guardia/src/adapter/access_control_adapter.rs @@ -23,9 +23,9 @@ impl EbpfAccessControlAdapter { #[async_trait] impl AccessControlPort for EbpfAccessControlAdapter { async fn block_ip(&self, ip: &str) -> Result<(), Error> { - let addr: IpAddr = ip.parse().map_err(|_| { - Error::from(crate::model::error::ebpf::EbpfError::InvalidIpAddress { ip: ip.to_string() }) - })?; + let addr: IpAddr = ip + .parse() + .map_err(|_| Error::from(crate::model::error::ebpf::EbpfError::InvalidIpAddress { ip: ip.to_string() }))?; match addr { IpAddr::V4(v4) => { let socket = SocketAddrV4::new(v4, 0); @@ -43,9 +43,9 @@ impl AccessControlPort for EbpfAccessControlAdapter { } async fn unblock_ip(&self, ip: &str) -> Result<(), Error> { - let addr: IpAddr = ip.parse().map_err(|_| { - Error::from(crate::model::error::ebpf::EbpfError::InvalidIpAddress { ip: ip.to_string() }) - })?; + let addr: IpAddr = ip + .parse() + .map_err(|_| Error::from(crate::model::error::ebpf::EbpfError::InvalidIpAddress { ip: ip.to_string() }))?; match addr { IpAddr::V4(v4) => { let socket = SocketAddrV4::new(v4, 0); diff --git a/net-guardia/src/adapter/http/acl.rs b/net-guardia/src/adapter/http/acl.rs index 5820064..f667bbf 100644 --- a/net-guardia/src/adapter/http/acl.rs +++ b/net-guardia/src/adapter/http/acl.rs @@ -1,6 +1,6 @@ use std::net::{SocketAddrV4, SocketAddrV6}; -use actix_web::{web, HttpResponse, Responder, Scope}; +use actix_web::{HttpResponse, Responder, Scope, web}; use serde::Deserialize; use crate::core::acl_service::AclService; @@ -25,19 +25,13 @@ pub fn initialize() -> Scope { .route("/geo/unblock", web::delete().to(unblock_geo_countries)) } -async fn get_ipv4_list( - path: web::Path<(FlowDirection, ListType)>, - acl: web::Data, -) -> impl Responder { +async fn get_ipv4_list(path: web::Path<(FlowDirection, ListType)>, acl: web::Data) -> impl Responder { let (direction, list_type) = path.into_inner(); let list = acl.access_control().get_ipv4_list(direction, list_type).await; HttpResponse::Ok().json(list) } -async fn get_ipv6_list( - path: web::Path<(FlowDirection, ListType)>, - acl: web::Data, -) -> impl Responder { +async fn get_ipv6_list(path: web::Path<(FlowDirection, ListType)>, acl: web::Data) -> impl Responder { let (direction, list_type) = path.into_inner(); let list = acl.access_control().get_ipv6_list(direction, list_type).await; HttpResponse::Ok().json(list) @@ -95,32 +89,24 @@ async fn get_geo_blocked(acl: web::Data) -> impl Responder { HttpResponse::Ok().json(serde_json::json!({"blocked_countries": acl.get_blocked_countries()})) } -async fn block_geo_countries( - body: web::Json, - acl: web::Data, -) -> impl Responder { +async fn block_geo_countries(body: web::Json, acl: web::Data) -> impl Responder { let codes = body.into_inner().country_codes; match acl.block_geo_countries(&codes) { Ok(total_prefixes) => HttpResponse::Ok().json(serde_json::json!({ "blocked_countries": acl.get_blocked_countries(), "total_prefixes": total_prefixes, })), - Err(e) => HttpResponse::InternalServerError() - .json(serde_json::json!({"error": e.to_string()})), + Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), } } -async fn unblock_geo_countries( - body: web::Json, - acl: web::Data, -) -> impl Responder { +async fn unblock_geo_countries(body: web::Json, acl: web::Data) -> impl Responder { let codes = body.into_inner().country_codes; match acl.unblock_geo_countries(&codes) { Ok(total_prefixes) => HttpResponse::Ok().json(serde_json::json!({ "blocked_countries": acl.get_blocked_countries(), "total_prefixes": total_prefixes, })), - Err(e) => HttpResponse::InternalServerError() - .json(serde_json::json!({"error": e.to_string()})), + Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), } } diff --git a/net-guardia/src/adapter/http/mcp_keys.rs b/net-guardia/src/adapter/http/api_keys.rs similarity index 51% rename from net-guardia/src/adapter/http/mcp_keys.rs rename to net-guardia/src/adapter/http/api_keys.rs index 2657162..cc7a4ea 100644 --- a/net-guardia/src/adapter/http/mcp_keys.rs +++ b/net-guardia/src/adapter/http/api_keys.rs @@ -1,31 +1,31 @@ -use actix_web::{web, HttpResponse, Scope}; +use actix_web::{HttpResponse, Scope, web}; use serde::Deserialize; -use crate::adapter::persistence::Database; use crate::core::auth::extractor::AuthClaims; +use crate::interface::port::api_key::ApiKeyPort; pub fn initialize() -> Scope { - web::scope("/mcp-keys") + web::scope("/api-keys") .route("", web::get().to(list_keys)) .route("/generate", web::post().to(generate_key)) .route("/{id}", web::delete().to(delete_key)) } -async fn list_keys( - _auth: AuthClaims, - db: web::Data, -) -> HttpResponse { - match db.list_mcp_keys() { +async fn list_keys(_auth: AuthClaims, db: web::Data) -> HttpResponse { + match db.list_api_keys() { Ok(keys) => { - let responses: Vec = keys.into_iter().map(|(id, name, level, created, last_used)| { - serde_json::json!({ - "id": id, - "name": name, - "permission_level": level, - "created_at": created, - "last_used_at": last_used, + let responses: Vec = keys + .into_iter() + .map(|(id, name, level, created, last_used)| { + serde_json::json!({ + "id": id, + "name": name, + "permission_level": level, + "created_at": created, + "last_used_at": last_used, + }) }) - }).collect(); + .collect(); HttpResponse::Ok().json(responses) } Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), @@ -40,7 +40,7 @@ struct GenerateKeyRequest { async fn generate_key( _auth: AuthClaims, - db: web::Data, + db: web::Data, body: web::Json, ) -> HttpResponse { use rand::Rng; @@ -52,7 +52,7 @@ async fn generate_key( .map(char::from) .collect(); - use sha2::{Sha256, Digest}; + use sha2::{Digest, Sha256}; let key_hash = { let mut hasher = Sha256::new(); hasher.update(raw_key.as_bytes()); @@ -60,27 +60,26 @@ async fn generate_key( }; let level = body.level.as_deref().unwrap_or("read_only"); + if !matches!(level, "read_only" | "read_write" | "full_access") { + return HttpResponse::BadRequest().json(serde_json::json!({ + "error": "Invalid permission level. Must be: read_only, read_write, or full_access" + })); + } - match db.insert_mcp_key(&key_hash, &body.name, level) { - Ok(id) => { - HttpResponse::Created().json(serde_json::json!({ - "id": id, - "key": raw_key, - "name": body.name, - "permission_level": level, - })) - } + match db.insert_api_key(&key_hash, &body.name, level) { + Ok(id) => HttpResponse::Created().json(serde_json::json!({ + "id": id, + "key": raw_key, + "name": body.name, + "permission_level": level, + })), Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), } } -async fn delete_key( - _auth: AuthClaims, - db: web::Data, - path: web::Path, -) -> HttpResponse { +async fn delete_key(_auth: AuthClaims, db: web::Data, path: web::Path) -> HttpResponse { let id = path.into_inner(); - match db.delete_mcp_key(id) { + match db.delete_api_key(id) { 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()})), diff --git a/net-guardia/src/adapter/http/audit.rs b/net-guardia/src/adapter/http/audit.rs new file mode 100644 index 0000000..ee617b1 --- /dev/null +++ b/net-guardia/src/adapter/http/audit.rs @@ -0,0 +1,29 @@ +use actix_web::{HttpResponse, Scope, web}; + +use crate::adapter::persistence::Database; +use crate::core::auth::extractor::AuthClaims; + +pub fn initialize() -> Scope { + web::scope("/audit").route("", web::get().to(list_audit_logs)) +} + +async fn list_audit_logs(_auth: AuthClaims, db: web::Data) -> HttpResponse { + match db.list_audit_logs() { + Ok(entries) => { + let json: Vec = entries + .into_iter() + .map(|e| { + serde_json::json!({ + "id": e.id, + "actor": e.actor, + "action": e.action, + "detail": e.detail, + "created_at": e.created_at, + }) + }) + .collect(); + HttpResponse::Ok().json(json) + } + Err(_) => HttpResponse::Ok().json(serde_json::json!([])), + } +} diff --git a/net-guardia/src/adapter/http/auth.rs b/net-guardia/src/adapter/http/auth.rs index b2c849b..e1a72b8 100644 --- a/net-guardia/src/adapter/http/auth.rs +++ b/net-guardia/src/adapter/http/auth.rs @@ -1,4 +1,4 @@ -use actix_web::{web, HttpResponse, Responder, Scope}; +use actix_web::{HttpResponse, Responder, Scope, web}; use macros::log; use serde::Deserialize; @@ -69,21 +69,16 @@ fn validate_password(password: &str) -> Result<(), &'static str> { /// 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, - db: web::Data, - jwt: web::Data, -) -> impl Responder { +async fn login(body: web::Json, db: web::Data, jwt: web::Data) -> 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, - })); + 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) => {} @@ -97,8 +92,7 @@ async fn login( if let Err(e) = db.record_login_failure(&req.username) { log!(AuthError::LoginFailureTrackingError(e)); } - return HttpResponse::Unauthorized() - .json(serde_json::json!({"error": "Invalid credentials"})); + return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid credentials"})); } }; @@ -110,8 +104,7 @@ async fn login( if let Err(e) = db.record_login_failure(&req.username) { log!(AuthError::LoginFailureTrackingError(e)); } - return HttpResponse::Unauthorized() - .json(serde_json::json!({"error": "Invalid credentials"})); + return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Invalid credentials"})); } } @@ -137,16 +130,11 @@ async fn login( "role": role, "force_password_change": force_password_change, })), - Err(_) => HttpResponse::InternalServerError() - .json(serde_json::json!({"error": "Failed to create token"})), + Err(_) => HttpResponse::InternalServerError().json(serde_json::json!({"error": "Failed to create token"})), } } -async fn register( - auth: AuthClaims, - body: web::Json, - db: web::Data, -) -> impl Responder { +async fn register(auth: AuthClaims, body: web::Json, db: web::Data) -> impl Responder { let reg = body.into_inner(); // Validate input @@ -159,8 +147,7 @@ async fn register( // Validate role if reg.role != "admin" && reg.role != "viewer" { - return HttpResponse::BadRequest() - .json(serde_json::json!({"error": "Role must be 'admin' or 'viewer'"})); + return HttpResponse::BadRequest().json(serde_json::json!({"error": "Role must be 'admin' or 'viewer'"})); } // Only admins can create admin accounts @@ -172,8 +159,7 @@ async fn register( let hash = match password::hash_password(®.password) { Ok(h) => h, Err(_) => { - return HttpResponse::InternalServerError() - .json(serde_json::json!({"error": "Failed to hash password"})); + return HttpResponse::InternalServerError().json(serde_json::json!({"error": "Failed to hash password"})); } }; @@ -182,23 +168,22 @@ async fn register( // 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 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(e) => { - HttpResponse::Conflict().json(serde_json::json!({"error": e.to_string()})) + HttpResponse::Created().json(serde_json::json!({"username": reg.username, "role": reg.role})) } + Err(e) => HttpResponse::Conflict().json(serde_json::json!({"error": e.to_string()})), } } async fn me(auth: AuthClaims, db: web::Data) -> impl Responder { let user_groups = db.get_user_groups(auth.sub).unwrap_or_default(); - let group_names: Vec = user_groups.iter() + let group_names: Vec = user_groups + .iter() .map(|(_id, name, _desc, _perms)| name.clone()) .collect(); let role = if group_names.iter().any(|n| n == "Administrator") { @@ -233,8 +218,7 @@ async fn change_password( let user = match db.find_user(&claims.username) { Ok(Some(u)) => u, _ => { - return HttpResponse::InternalServerError() - .json(serde_json::json!({"error": "User not found"})); + return HttpResponse::InternalServerError().json(serde_json::json!({"error": "User not found"})); } }; @@ -243,8 +227,7 @@ async fn change_password( match password::verify_password(&change_req.current_password, &hash) { Ok(true) => {} _ => { - return HttpResponse::Unauthorized() - .json(serde_json::json!({"error": "Current password is incorrect"})); + return HttpResponse::Unauthorized().json(serde_json::json!({"error": "Current password is incorrect"})); } } @@ -252,64 +235,56 @@ async fn change_password( let new_hash = match password::hash_password(&change_req.new_password) { Ok(h) => h, Err(_) => { - return HttpResponse::InternalServerError() - .json(serde_json::json!({"error": "Failed to hash password"})); + return HttpResponse::InternalServerError().json(serde_json::json!({"error": "Failed to hash password"})); } }; match db.update_user_password(claims.sub, &new_hash) { - Ok(_) => HttpResponse::Ok() - .json(serde_json::json!({"message": "Password changed successfully"})), - Err(e) => HttpResponse::InternalServerError() - .json(serde_json::json!({"error": e.to_string()})), + 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, -) -> impl Responder { +async fn list_users(_auth: AuthClaims, db: web::Data) -> impl Responder { match db.list_users_with_groups() { Ok(users) => { - let result: Vec = users.into_iter().map(|(id, username, _role, force_pw, created_at, user_groups)| { - let groups: Vec = user_groups.iter() - .map(|(gid, name)| serde_json::json!({"id": gid, "name": name})) - .collect(); - // Derive role from groups for backwards compat - let role = if user_groups.iter().any(|(_id, name)| name == "Administrator") { - "admin" - } else { - "viewer" - }; - serde_json::json!({ - "id": id, - "username": username, - "role": role, - "force_password_change": force_pw, - "created_at": created_at, - "groups": groups, + let result: Vec = users + .into_iter() + .map(|(id, username, _role, force_pw, created_at, user_groups)| { + let groups: Vec = user_groups + .iter() + .map(|(gid, name)| serde_json::json!({"id": gid, "name": name})) + .collect(); + // Derive role from groups for backwards compat + let role = if user_groups.iter().any(|(_id, name)| name == "Administrator") { + "admin" + } else { + "viewer" + }; + serde_json::json!({ + "id": id, + "username": username, + "role": role, + "force_password_change": force_pw, + "created_at": created_at, + "groups": groups, + }) }) - }).collect(); + .collect(); HttpResponse::Ok().json(result) } - Err(e) => HttpResponse::InternalServerError() - .json(serde_json::json!({"error": e.to_string()})), + Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), } } -async fn delete_user( - _auth: AuthClaims, - path: web::Path, - db: web::Data, -) -> impl Responder { +async fn delete_user(_auth: AuthClaims, path: web::Path, db: web::Data) -> 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"})); + return HttpResponse::BadRequest().json(serde_json::json!({"error": "Cannot delete your own account"})); } // Protect the built-in admin account @@ -322,12 +297,9 @@ async fn delete_user( } match db.delete_user(user_id) { - 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()})), + 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()})), } } @@ -341,15 +313,13 @@ async fn update_role( // Can't change own role if _auth.sub == user_id { - return HttpResponse::BadRequest() - .json(serde_json::json!({"error": "Cannot change your own role"})); + 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, _ => { - return HttpResponse::BadRequest() - .json(serde_json::json!({"error": "Role must be 'admin' or 'viewer'"})); + return HttpResponse::BadRequest().json(serde_json::json!({"error": "Role must be 'admin' or 'viewer'"})); } }; @@ -357,20 +327,16 @@ async fn update_role( match db.find_user_by_id(user_id) { Ok(Some(_)) => {} Ok(None) => { - return HttpResponse::NotFound() - .json(serde_json::json!({"error": "User not found"})); + return HttpResponse::NotFound().json(serde_json::json!({"error": "User not found"})); } Err(e) => { - return HttpResponse::InternalServerError() - .json(serde_json::json!({"error": e.to_string()})); + return HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})); } } match db.update_user_role(user_id, role) { - 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()})), + 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()})), } } @@ -382,11 +348,14 @@ async fn reset_password( ) -> impl Responder { let user_id = path.into_inner(); - let new_password = match body.get("new_password").or_else(|| body.get("password")).and_then(|v| v.as_str()) { + let new_password = match body + .get("new_password") + .or_else(|| body.get("password")) + .and_then(|v| v.as_str()) + { Some(p) => p, None => { - return HttpResponse::BadRequest() - .json(serde_json::json!({"error": "Password is required"})); + return HttpResponse::BadRequest().json(serde_json::json!({"error": "Password is required"})); } }; @@ -398,72 +367,62 @@ async fn reset_password( match db.find_user_by_id(user_id) { Ok(Some(_)) => {} Ok(None) => { - return HttpResponse::NotFound() - .json(serde_json::json!({"error": "User not found"})); + return HttpResponse::NotFound().json(serde_json::json!({"error": "User not found"})); } Err(e) => { - return HttpResponse::InternalServerError() - .json(serde_json::json!({"error": e.to_string()})); + return HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})); } } let hash = match password::hash_password(new_password) { Ok(h) => h, Err(_) => { - return HttpResponse::InternalServerError() - .json(serde_json::json!({"error": "Failed to hash password"})); + return HttpResponse::InternalServerError().json(serde_json::json!({"error": "Failed to hash 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(_) => HttpResponse::Ok().json(serde_json::json!({"message": "Password reset successfully"})), + Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), } } // --- User Group Management (users:admin required) --- -async fn list_groups( - _auth: AuthClaims, - db: web::Data, -) -> impl Responder { +async fn list_groups(_auth: AuthClaims, db: web::Data) -> impl Responder { match db.list_user_groups() { Ok(groups) => { - let result: Vec = 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 = 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, + let result: Vec = 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 = 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(); + .collect(); HttpResponse::Ok().json(result) } - Err(e) => HttpResponse::InternalServerError() - .json(serde_json::json!({"error": e.to_string()})), + Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), } } -async fn create_group( - _auth: AuthClaims, - body: web::Json, - db: web::Data, -) -> impl Responder { +async fn create_group(_auth: AuthClaims, body: web::Json, db: web::Data) -> impl Responder { let name = match body.get("name").and_then(|v| v.as_str()) { Some(n) if !n.is_empty() => n, _ => { - return HttpResponse::BadRequest() - .json(serde_json::json!({"error": "Group name is required"})); + return HttpResponse::BadRequest().json(serde_json::json!({"error": "Group name is required"})); } }; @@ -480,16 +439,11 @@ async fn create_group( "description": description, "permissions": serde_json::from_str::(&permissions).unwrap_or(serde_json::json!([])), })), - Err(e) => HttpResponse::Conflict() - .json(serde_json::json!({"error": e.to_string()})), + Err(e) => HttpResponse::Conflict().json(serde_json::json!({"error": e.to_string()})), } } -async fn get_group( - _auth: AuthClaims, - path: web::Path, - db: web::Data, -) -> impl Responder { +async fn get_group(_auth: AuthClaims, path: web::Path, db: web::Data) -> impl Responder { let group_id = path.into_inner(); match db.get_user_group(group_id) { @@ -505,10 +459,8 @@ async fn get_group( "members": members, })) } - Ok(None) => HttpResponse::NotFound() - .json(serde_json::json!({"error": "Group not found"})), - Err(e) => HttpResponse::InternalServerError() - .json(serde_json::json!({"error": e.to_string()})), + Ok(None) => HttpResponse::NotFound().json(serde_json::json!({"error": "Group not found"})), + Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), } } @@ -525,18 +477,15 @@ async fn update_group( Ok(Some(g)) => { // Protect built-in groups if g.1 == "Administrator" || g.1 == "Viewer" { - return HttpResponse::Forbidden() - .json(serde_json::json!({"error": "Cannot modify built-in groups"})); + return HttpResponse::Forbidden().json(serde_json::json!({"error": "Cannot modify built-in groups"})); } g } Ok(None) => { - return HttpResponse::NotFound() - .json(serde_json::json!({"error": "Group not found"})); + return HttpResponse::NotFound().json(serde_json::json!({"error": "Group not found"})); } Err(e) => { - return HttpResponse::InternalServerError() - .json(serde_json::json!({"error": e.to_string()})); + return HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})); } }; @@ -554,34 +503,25 @@ async fn update_group( "description": description, "permissions": serde_json::from_str::(&permissions).unwrap_or(serde_json::json!([])), })), - Err(e) => HttpResponse::InternalServerError() - .json(serde_json::json!({"error": e.to_string()})), + Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), } } -async fn delete_group( - _auth: AuthClaims, - path: web::Path, - db: web::Data, -) -> impl Responder { +async fn delete_group(_auth: AuthClaims, path: web::Path, db: web::Data) -> 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" => { - return HttpResponse::Forbidden() - .json(serde_json::json!({"error": "Cannot delete built-in groups"})); + return HttpResponse::Forbidden().json(serde_json::json!({"error": "Cannot delete built-in groups"})); } _ => {} } match db.delete_user_group(group_id) { - 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()})), + 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()})), } } @@ -601,28 +541,24 @@ async fn set_user_groups( } Ok(Some(_)) => {} Ok(None) => { - return HttpResponse::NotFound() - .json(serde_json::json!({"error": "User not found"})); + return HttpResponse::NotFound().json(serde_json::json!({"error": "User not found"})); } Err(e) => { - return HttpResponse::InternalServerError() - .json(serde_json::json!({"error": e.to_string()})); + return HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})); } } let group_ids: Vec = match body.get("group_ids").and_then(|v| v.as_array()) { Some(arr) => arr.iter().filter_map(|v| v.as_i64()).collect(), None => { - return HttpResponse::BadRequest() - .json(serde_json::json!({"error": "group_ids array is required"})); + return HttpResponse::BadRequest().json(serde_json::json!({"error": "group_ids array is required"})); } }; match db.set_user_groups(user_id, &group_ids) { 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()})), + Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), } } @@ -675,6 +611,10 @@ mod tests { // DUMMY_HASH must be parseable as a valid Argon2 hash structure // so that timing-based username enumeration is prevented let parsed = PasswordHash::new(DUMMY_HASH); - assert!(parsed.is_ok(), "DUMMY_HASH should be a valid Argon2 hash format, got error: {:?}", parsed.err()); + assert!( + parsed.is_ok(), + "DUMMY_HASH should be a valid Argon2 hash format, got error: {:?}", + parsed.err() + ); } } diff --git a/net-guardia/src/adapter/http/filter.rs b/net-guardia/src/adapter/http/filter.rs index 9c8994b..f2fb8a2 100644 --- a/net-guardia/src/adapter/http/filter.rs +++ b/net-guardia/src/adapter/http/filter.rs @@ -1,7 +1,7 @@ use std::fmt; use std::net::{Ipv4Addr, Ipv6Addr, SocketAddrV4, SocketAddrV6}; -use actix_web::{web, HttpResponse, Responder, Scope}; +use actix_web::{HttpResponse, Responder, Scope, web}; use common::model::http_method::HttpMethod; use serde::Deserialize; @@ -12,8 +12,7 @@ use crate::core::ebpf::protocol_filter::ProtocolFilter; fn ok_or_error(result: Result) -> HttpResponse { match result { Ok(_) => HttpResponse::Ok().finish(), - Err(e) => HttpResponse::InternalServerError() - .json(serde_json::json!({"error": e.to_string()})), + Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), } } @@ -30,13 +29,12 @@ struct DnsDomainsPayload { } 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)) - ) + 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) -> impl Responder { @@ -50,8 +48,7 @@ async fn add_dns_blacklist( 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()})), + Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), } } @@ -62,8 +59,7 @@ async fn remove_dns_blacklist( 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()})), + Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), } } @@ -122,22 +118,34 @@ async fn get_ipv6_http_service(service: web::Data) -> impl Respo HttpResponse::Ok().json(service.get_ipv6_http_service().await) } -async fn add_ipv4_http_service(payload: web::Json<(SocketAddrV4, Vec)>, service: web::Data) -> impl Responder { +async fn add_ipv4_http_service( + payload: web::Json<(SocketAddrV4, Vec)>, + service: web::Data, +) -> impl Responder { let (addr, methods) = payload.into_inner(); ok_or_error(service.add_ipv4_http_service(addr, methods).await) } -async fn add_ipv6_http_service(payload: web::Json<(SocketAddrV6, Vec)>, service: web::Data) -> impl Responder { +async fn add_ipv6_http_service( + payload: web::Json<(SocketAddrV6, Vec)>, + service: web::Data, +) -> impl Responder { let (addr, methods) = payload.into_inner(); ok_or_error(service.add_ipv6_http_service(addr, methods).await) } -async fn remove_ipv4_http_service(payload: web::Json<(SocketAddrV4, Vec)>, service: web::Data) -> impl Responder { +async fn remove_ipv4_http_service( + payload: web::Json<(SocketAddrV4, Vec)>, + service: web::Data, +) -> impl Responder { let (addr, methods) = payload.into_inner(); ok_or_error(service.remove_ipv4_http_service(addr, methods).await) } -async fn remove_ipv6_http_service(payload: web::Json<(SocketAddrV6, Vec)>, service: web::Data) -> impl Responder { +async fn remove_ipv6_http_service( + payload: web::Json<(SocketAddrV6, Vec)>, + service: web::Data, +) -> impl Responder { let (addr, methods) = payload.into_inner(); ok_or_error(service.remove_ipv6_http_service(addr, methods).await) } @@ -160,11 +168,17 @@ async fn add_ipv6_ssh_service(ip_addr: web::Json, service: web::Da ok_or_error(service.add_ipv6_ssh_service(ip_addr.into_inner()).await) } -async fn remove_ipv4_ssh_service(ip_addr: web::Json, service: web::Data) -> impl Responder { +async fn remove_ipv4_ssh_service( + ip_addr: web::Json, + service: web::Data, +) -> impl Responder { ok_or_error(service.remove_ipv4_ssh_service(ip_addr.into_inner()).await) } -async fn remove_ipv6_ssh_service(ip_addr: web::Json, service: web::Data) -> impl Responder { +async fn remove_ipv6_ssh_service( + ip_addr: web::Json, + service: web::Data, +) -> impl Responder { ok_or_error(service.remove_ipv6_ssh_service(ip_addr.into_inner()).await) } @@ -198,11 +212,17 @@ async fn add_ipv6_ssh_white_list(ip_addr: web::Json, service: web::Dat ok_or_error(service.add_ipv6_ssh_white_list(ip_addr.into_inner()).await) } -async fn remove_ipv4_ssh_white_list(ip_addr: web::Json, service: web::Data) -> impl Responder { +async fn remove_ipv4_ssh_white_list( + ip_addr: web::Json, + service: web::Data, +) -> impl Responder { ok_or_error(service.remove_ipv4_ssh_white_list(ip_addr.into_inner()).await) } -async fn remove_ipv6_ssh_white_list(ip_addr: web::Json, service: web::Data) -> impl Responder { +async fn remove_ipv6_ssh_white_list( + ip_addr: web::Json, + service: web::Data, +) -> impl Responder { ok_or_error(service.remove_ipv6_ssh_white_list(ip_addr.into_inner()).await) } @@ -224,10 +244,16 @@ async fn add_ipv6_ssh_black_list(ip_addr: web::Json, service: web::Dat ok_or_error(service.add_ipv6_ssh_black_list(ip_addr.into_inner()).await) } -async fn remove_ipv4_ssh_black_list(ip_addr: web::Json, service: web::Data) -> impl Responder { +async fn remove_ipv4_ssh_black_list( + ip_addr: web::Json, + service: web::Data, +) -> impl Responder { ok_or_error(service.remove_ipv4_ssh_black_list(ip_addr.into_inner()).await) } -async fn remove_ipv6_ssh_black_list(ip_addr: web::Json, service: web::Data) -> impl Responder { +async fn remove_ipv6_ssh_black_list( + ip_addr: web::Json, + service: web::Data, +) -> impl Responder { ok_or_error(service.remove_ipv6_ssh_black_list(ip_addr.into_inner()).await) } diff --git a/net-guardia/src/adapter/http/health.rs b/net-guardia/src/adapter/http/health.rs index c01d36a..76a438c 100644 --- a/net-guardia/src/adapter/http/health.rs +++ b/net-guardia/src/adapter/http/health.rs @@ -1,4 +1,4 @@ -use actix_web::{web, HttpResponse, Responder, Scope}; +use actix_web::{HttpResponse, Responder, Scope, web}; use crate::infrastructure::health::SystemHealth; diff --git a/net-guardia/src/adapter/http/logs.rs b/net-guardia/src/adapter/http/logs.rs new file mode 100644 index 0000000..1bdcda3 --- /dev/null +++ b/net-guardia/src/adapter/http/logs.rs @@ -0,0 +1,157 @@ +use actix_web::{HttpResponse, Scope, web}; +use serde::Serialize; + +/// 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; + +/// Validate log filename: only alphanumeric, dots, underscores, hyphens. +/// Prevents path traversal. +fn is_valid_log_filename(name: &str) -> bool { + !name.is_empty() + && name.len() <= 128 + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-') +} + +pub fn initialize() -> Scope { + web::scope("/logs") + .route("", web::get().to(list_logs)) + .route("/{filename}", web::get().to(download_log)) +} + +#[derive(Serialize)] +struct LogFileEntry { + name: String, + size: u64, + modified: Option, +} + +async fn list_logs() -> HttpResponse { + let log_dir = LOG_DIR; + let entries = match std::fs::read_dir(log_dir) { + Ok(dir) => dir + .filter_map(|e| e.ok()) + .filter_map(|e| { + let name = e.file_name().to_string_lossy().to_string(); + let meta = e.metadata().ok()?; + if !meta.is_file() { + return None; + } + let modified = meta + .modified() + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_secs()); + Some(LogFileEntry { + name, + size: meta.len(), + modified, + }) + }) + .collect::>(), + Err(_) => Vec::new(), + }; + + HttpResponse::Ok().json(serde_json::json!({ "files": entries })) +} + +async fn download_log(path: web::Path) -> HttpResponse { + let filename = path.into_inner(); + + if !is_valid_log_filename(&filename) { + return HttpResponse::BadRequest().json(serde_json::json!({ + "error": "Invalid filename: only alphanumeric, dots, underscores, hyphens allowed" + })); + } + + let file_path = std::path::Path::new(LOG_DIR).join(&filename); + + // Canonicalize to prevent symlink traversal + let canonical = match std::fs::canonicalize(&file_path) { + Ok(p) => p, + Err(_) => { + return HttpResponse::NotFound().json(serde_json::json!({ + "error": format!("Log file '{}' not found", filename) + })); + } + }; + if let Ok(log_dir_canonical) = std::fs::canonicalize(LOG_DIR) + && !canonical.starts_with(&log_dir_canonical) + { + return HttpResponse::Forbidden().json(serde_json::json!({ + "error": "Access denied: file is outside the log directory" + })); + } + + // Check file size before reading to prevent OOM on large logs + match std::fs::metadata(&canonical) { + Ok(meta) if meta.len() > MAX_DOWNLOAD_SIZE => { + return HttpResponse::PayloadTooLarge().json(serde_json::json!({ + "error": format!("Log file exceeds maximum download size ({}MB)", MAX_DOWNLOAD_SIZE / 1024 / 1024) + })); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return HttpResponse::NotFound().json(serde_json::json!({ + "error": format!("Log file '{}' not found", filename) + })); + } + Err(e) => { + return HttpResponse::InternalServerError().json(serde_json::json!({ + "error": format!("Failed to read log file: {}", e) + })); + } + Ok(_) => {} + } + + let content = match std::fs::read(&canonical) { + Ok(bytes) => bytes, + Err(e) => { + return HttpResponse::InternalServerError().json(serde_json::json!({ + "error": format!("Failed to read log file: {}", e) + })); + } + }; + + HttpResponse::Ok() + .insert_header(("Content-Type", "application/octet-stream")) + .insert_header(("Content-Disposition", format!("attachment; filename=\"{}\"", filename))) + .body(content) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn valid_filenames() { + assert!(is_valid_log_filename("NetGuardia.2026-03-30")); + assert!(is_valid_log_filename("app.log")); + assert!(is_valid_log_filename("debug_2026-03-30.log")); + } + + #[test] + fn path_traversal_blocked() { + assert!(!is_valid_log_filename("../../etc/passwd")); + assert!(!is_valid_log_filename("../secret")); + assert!(!is_valid_log_filename("/etc/shadow")); + } + + #[test] + fn special_chars_blocked() { + assert!(!is_valid_log_filename("file;rm -rf")); + assert!(!is_valid_log_filename("log file.txt")); + assert!(!is_valid_log_filename("")); + } + + #[test] + fn too_long_blocked() { + let long = "a".repeat(129); + assert!(!is_valid_log_filename(&long)); + let exact = "a".repeat(128); + assert!(is_valid_log_filename(&exact)); + } +} diff --git a/net-guardia/src/adapter/http/ml.rs b/net-guardia/src/adapter/http/ml.rs index 2129a1b..be52b8d 100644 --- a/net-guardia/src/adapter/http/ml.rs +++ b/net-guardia/src/adapter/http/ml.rs @@ -1,20 +1,15 @@ -use actix_web::{web, HttpResponse, Responder, Scope}; +use actix_web::{HttpResponse, Responder, Scope, web}; use crate::core::ml::engine::Engine; pub fn initialize() -> Scope { - web::scope("/ml") - .route("/status", web::get().to(get_status)) + web::scope("/ml").route("/status", web::get().to(get_status)) } -async fn get_status( - engine: web::Data, -) -> impl Responder { +async fn get_status(engine: web::Data) -> impl Responder { let trackers = engine.trackers(); let num_trackers = trackers.len(); - let total_flows: usize = trackers.iter() - .map(|t| t.lock().flow_count()) - .sum(); + let total_flows: usize = trackers.iter().map(|t| t.lock().flow_count()).sum(); let has_traffic_logger = engine.has_traffic_logger(); HttpResponse::Ok().json(serde_json::json!({ diff --git a/net-guardia/src/adapter/http/mod.rs b/net-guardia/src/adapter/http/mod.rs index a16c194..4c87808 100644 --- a/net-guardia/src/adapter/http/mod.rs +++ b/net-guardia/src/adapter/http/mod.rs @@ -1,9 +1,11 @@ pub mod acl; +pub mod api_keys; +pub mod audit; pub mod auth; pub mod default; pub mod filter; pub mod health; -pub mod mcp_keys; +pub mod logs; pub mod ml; pub mod notification; pub mod rate_limit; diff --git a/net-guardia/src/adapter/http/notification.rs b/net-guardia/src/adapter/http/notification.rs index 9e18444..04b7f1a 100644 --- a/net-guardia/src/adapter/http/notification.rs +++ b/net-guardia/src/adapter/http/notification.rs @@ -1,4 +1,4 @@ -use actix_web::{web, HttpResponse, Scope}; +use actix_web::{HttpResponse, Scope, web}; use serde::Deserialize; use crate::core::auth::extractor::AuthClaims; @@ -12,10 +12,7 @@ pub fn initialize() -> Scope { .route("/smtp/test", web::post().to(test_smtp)) } -async fn get_telegram_config( - _auth: AuthClaims, - svc: web::Data, -) -> HttpResponse { +async fn get_telegram_config(_auth: AuthClaims, svc: web::Data) -> HttpResponse { match svc.get_telegram_config() { Ok(config) => HttpResponse::Ok().json(config), Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), @@ -39,20 +36,14 @@ async fn set_telegram_config( } } -async fn test_telegram( - _auth: AuthClaims, - svc: web::Data, -) -> HttpResponse { +async fn test_telegram(_auth: AuthClaims, svc: web::Data) -> HttpResponse { match svc.test_telegram().await { Ok(()) => HttpResponse::Ok().json(serde_json::json!({"success": true, "message": "Test message sent"})), Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"success": false, "error": e.to_string()})), } } -async fn test_smtp( - _auth: AuthClaims, - svc: web::Data, -) -> HttpResponse { +async fn test_smtp(_auth: AuthClaims, svc: web::Data) -> HttpResponse { match svc.test_smtp() { 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()})), diff --git a/net-guardia/src/adapter/http/rate_limit.rs b/net-guardia/src/adapter/http/rate_limit.rs index ebd9093..708416f 100644 --- a/net-guardia/src/adapter/http/rate_limit.rs +++ b/net-guardia/src/adapter/http/rate_limit.rs @@ -1,7 +1,8 @@ -use actix_web::{web, HttpResponse, Responder, Scope}; +use actix_web::{HttpResponse, Responder, Scope, web}; use common::define::setting::*; -use crate::core::rate_limit_service::{RateLimitService, RateLimitSettings}; +use crate::core::rate_limit_service::RateLimitService; +use crate::model::system::rate_limit_settings::RateLimitSettings; pub fn initialize() -> Scope { web::scope("/rate-limit") @@ -19,10 +20,7 @@ async fn get_config(service: web::Data) -> impl Responder { }) } -async fn set_config( - settings: web::Json, - service: web::Data, -) -> impl Responder { +async fn set_config(settings: web::Json, service: web::Data) -> 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()})), diff --git a/net-guardia/src/adapter/http/report.rs b/net-guardia/src/adapter/http/report.rs index 3bf0e5b..9ff06e3 100644 --- a/net-guardia/src/adapter/http/report.rs +++ b/net-guardia/src/adapter/http/report.rs @@ -1,53 +1,120 @@ -use actix_web::{web, HttpResponse, Scope}; +use actix_web::{HttpResponse, Scope, web}; use crate::adapter::persistence::Database; use crate::core::auth::extractor::AuthClaims; +use crate::core::email::scheduler::SmtpClient; use crate::core::report::engine; +use crate::infrastructure::secret_store::SecretStore; use crate::interface::port::repository::RepositoryPort; - +use crate::interface::port::secret_store::SecretStorePort; pub fn initialize() -> Scope { web::scope("/report") .route("/generate", web::post().to(generate_report)) .route("/data", web::get().to(report_data)) + .route("/send", web::post().to(send_report)) } -async fn generate_report( - _auth: AuthClaims, - db: web::Data, -) -> HttpResponse { +async fn generate_report(_auth: AuthClaims, db: web::Data) -> HttpResponse { + let report_dir = db + .get_setting("report_dir") + .ok() + .flatten() + .unwrap_or_else(|| "/var/lib/netguardia/reports".to_string()); + if let Err(e) = std::fs::create_dir_all(&report_dir) { + return HttpResponse::InternalServerError().json(serde_json::json!({ + "error": format!("Failed to create report directory: {}", e) + })); + } let db_ref = db.get_ref(); - match engine::generate_html_report(db_ref as &dyn RepositoryPort, "/tmp/netguardia-reports") { - Ok(path) => { - match std::fs::read(&path) { - Ok(content) => { - HttpResponse::Ok() - .content_type("text/html; charset=utf-8") - .insert_header(("Content-Disposition", format!( - "attachment; filename=\"{}\"", - path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "report.html".into()) - ))) - .body(content) - } - Err(_) => { - HttpResponse::Ok().json(serde_json::json!({ - "success": true, - "path": path.to_string_lossy(), - "message": "HTML report generated." - })) - } - } - } + match engine::generate_html_report(db_ref as &dyn RepositoryPort, &report_dir) { + Ok(path) => match std::fs::read(&path) { + Ok(content) => HttpResponse::Ok() + .content_type("text/html; charset=utf-8") + .insert_header(( + "Content-Disposition", + format!( + "attachment; filename=\"{}\"", + path.file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "report.html".into()) + ), + )) + .body(content), + Err(_) => HttpResponse::Ok().json(serde_json::json!({ + "success": true, + "path": path.to_string_lossy(), + "message": "HTML report generated." + })), + }, Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), } } -async fn report_data( - _auth: AuthClaims, - db: web::Data, -) -> HttpResponse { +async fn report_data(_auth: AuthClaims, db: web::Data) -> HttpResponse { let db_ref = db.get_ref(); match engine::generate_report_json(db_ref as &dyn RepositoryPort) { Ok(data) => HttpResponse::Ok().json(data), Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), } } + +/// Manually trigger: generate the weekly report and send it via SMTP now. +async fn send_report(_auth: AuthClaims, db: web::Data, secrets: web::Data) -> HttpResponse { + let db_ref = db.get_ref() as &dyn RepositoryPort; + let secrets_ref = secrets.get_ref() as &dyn SecretStorePort; + + let smtp = match SmtpClient::from_database(db_ref, Some(secrets_ref)) { + Ok(Some(client)) => client, + Ok(None) => { + return HttpResponse::BadRequest().json(serde_json::json!({ + "success": false, + "error": "SMTP not configured. Ensure smtp_host, smtp_port, smtp_username, smtp_password are set, and that the sender address (smtp_sender or smtp_username) contains '@'." + })); + } + Err(e) => { + return HttpResponse::InternalServerError().json(serde_json::json!({ + "success": false, + "error": format!("Failed to read SMTP settings: {e}") + })); + } + }; + + 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 html = match crate::core::email::report::generate_weekly_report(db_ref) { + Ok(h) => h, + Err(e) => { + return HttpResponse::InternalServerError().json(serde_json::json!({ + "success": false, + "error": format!("Failed to generate report: {e}") + })); + } + }; + + let subject = format!("NetGuardia Weekly Report — {}", chrono::Local::now().format("%Y-%m-%d")); + + let send_result = tokio::task::spawn_blocking(move || smtp.send(&recipient, &subject, &html)).await; + + match send_result { + Ok(Ok(())) => HttpResponse::Ok().json(serde_json::json!({ + "success": true, + "message": "Report sent successfully." + })), + Ok(Err(e)) => HttpResponse::InternalServerError().json(serde_json::json!({ + "success": false, + "error": format!("Failed to send report: {e}") + })), + Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({ + "success": false, + "error": format!("Send task panicked: {e}") + })), + } +} diff --git a/net-guardia/src/adapter/http/setup.rs b/net-guardia/src/adapter/http/setup.rs index 40252ca..7959be6 100644 --- a/net-guardia/src/adapter/http/setup.rs +++ b/net-guardia/src/adapter/http/setup.rs @@ -1,12 +1,14 @@ -use std::sync::atomic::Ordering; -use actix_web::{web, HttpResponse, Scope}; +use actix_web::{HttpResponse, Scope, web}; use serde::Deserialize; +use std::sync::atomic::Ordering; use macros::log; use crate::adapter::persistence::Database; use crate::core::auth::password; use crate::core::auth::setup_guard::SetupCompleteFlag; +use crate::infrastructure::secret_store::SecretStore; +use crate::interface::port::secret_store::SecretStorePort; use crate::model::error::system::SystemError; pub fn initialize() -> Scope { @@ -16,9 +18,7 @@ pub fn initialize() -> Scope { .route("/complete", web::post().to(complete_setup)) } -async fn setup_status( - setup_flag: web::Data, -) -> HttpResponse { +async fn setup_status(setup_flag: web::Data) -> HttpResponse { let complete = setup_flag.load(Ordering::SeqCst); HttpResponse::Ok().json(serde_json::json!({ "setup_complete": complete, @@ -28,18 +28,16 @@ async fn setup_status( async fn list_interfaces() -> HttpResponse { // List available network interfaces let interfaces: Vec = match std::fs::read_dir("/sys/class/net") { - Ok(entries) => { - entries - .filter_map(|e| e.ok()) - .map(|e| { - let name = e.file_name().to_string_lossy().to_string(); - serde_json::json!({ - "name": name, - "is_loopback": name == "lo", - }) + Ok(entries) => entries + .filter_map(|e| e.ok()) + .map(|e| { + let name = e.file_name().to_string_lossy().to_string(); + serde_json::json!({ + "name": name, + "is_loopback": name == "lo", }) - .collect() - } + }) + .collect(), Err(_) => Vec::new(), }; @@ -74,11 +72,14 @@ struct SetupRequest { fn is_valid_interface_name(name: &str) -> bool { !name.is_empty() && name.len() <= 16 - && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-') + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-') } async fn complete_setup( db: web::Data, + secret_store: web::Data, setup_flag: web::Data, body: web::Json, ) -> HttpResponse { @@ -128,7 +129,7 @@ async fn complete_setup( } // Save configuration to database - if let Err(e) = save_config(&db, &body) { + if let Err(e) = save_config(&db, secret_store.as_ref(), &body) { return HttpResponse::InternalServerError().json(serde_json::json!({ "error": format!("Failed to save configuration: {}", e) })); @@ -212,7 +213,11 @@ mod tests { } } -fn save_config(db: &Database, req: &SetupRequest) -> Result<(), crate::model::error::Error> { +fn save_config( + db: &Database, + secrets: &dyn SecretStorePort, + req: &SetupRequest, +) -> Result<(), crate::model::error::Error> { // Save network config db.set_setting("ingress_interface", &req.ingress_interface)?; db.set_setting("egress_interface", &req.egress_interface)?; @@ -221,7 +226,7 @@ fn save_config(db: &Database, req: &SetupRequest) -> Result<(), crate::model::er db.set_setting("http_port", &port.to_string())?; } - // Save SMTP config + // Save SMTP config (non-secret fields go to settings) if let Some(host) = &req.smtp_host { db.set_setting("smtp_host", host)?; } @@ -232,18 +237,22 @@ fn save_config(db: &Database, req: &SetupRequest) -> Result<(), crate::model::er db.set_setting("smtp_username", user)?; } if let Some(pass) = &req.smtp_password { - db.set_setting("smtp_password", pass)?; + // Store password through secret store (encrypted) + secrets.set_secret("smtp_password", pass)?; + db.set_setting("smtp_password", "__encrypted__")?; } if let Some(recipient) = &req.smtp_recipient { db.set_setting("smtp_recipient", recipient)?; } - // Save Telegram config + // 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)?; let config_json = serde_json::json!({ - "bot_token": token, + "bot_token": "__encrypted__", "chat_id": chat_id, - }).to_string(); + }) + .to_string(); db.set_notification_config("telegram", &config_json)?; } diff --git a/net-guardia/src/adapter/http/soar.rs b/net-guardia/src/adapter/http/soar.rs index e09ef0f..c98e709 100644 --- a/net-guardia/src/adapter/http/soar.rs +++ b/net-guardia/src/adapter/http/soar.rs @@ -1,8 +1,9 @@ -use actix_web::{web, HttpResponse, Scope}; +use actix_web::{HttpResponse, Scope, web}; use serde::Deserialize; use crate::core::auth::extractor::AuthClaims; -use crate::core::playbook_service::{CreatePlaybookInput, PlaybookService}; +use crate::core::playbook_service::PlaybookService; +use crate::model::soar::playbook_data::{CreateConditionInput, CreatePlaybookInput}; #[derive(Deserialize)] struct CreatePlaybookRequest { @@ -13,6 +14,7 @@ struct CreatePlaybookRequest { condition_window_secs: Option, cooldown_secs: Option, actions: Vec, + conditions: Option>, } #[derive(Deserialize)] @@ -21,11 +23,21 @@ struct CreateActionRequest { params: Option, } +#[derive(Deserialize)] +struct CreateConditionRequest { + condition_type: String, + operator: Option, + value: String, + value2: Option, +} + pub fn initialize() -> Scope { web::scope("/soar") .route("/playbooks", web::get().to(list_playbooks)) .route("/playbooks", web::post().to(create_playbook)) + .route("/playbooks/{id}", web::put().to(update_playbook)) .route("/playbooks/{id}", web::delete().to(delete_playbook)) + .route("/playbooks/{id}/toggle", web::post().to(toggle_playbook)) .route("/blocks", web::get().to(list_active_blocks)) .route("/blocks/{id}/unblock", web::post().to(manual_unblock)) .route("/executions", web::get().to(list_executions)) @@ -34,33 +46,51 @@ pub fn initialize() -> Scope { .route("/whitelist/{ip}", web::delete().to(remove_whitelist)) } -async fn list_playbooks( - _auth: AuthClaims, - svc: web::Data, -) -> HttpResponse { +async fn list_playbooks(_auth: AuthClaims, svc: web::Data) -> HttpResponse { match svc.list_playbooks() { Ok(playbooks) => { - let responses: Vec = playbooks.into_iter().map(|pb| { - let actions: Vec = pb.actions.into_iter().map(|a| { + let responses: Vec = playbooks + .into_iter() + .map(|pb| { + let actions: Vec = 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 = 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": a.id, - "action_order": a.action_order, - "action_type": a.action_type, - "params": a.params, + "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(); - 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, }) - }).collect(); + .collect(); HttpResponse::Ok().json(responses) } Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), @@ -72,12 +102,39 @@ async fn create_playbook( svc: web::Data, body: web::Json, ) -> 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 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 = 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(), @@ -87,6 +144,7 @@ async fn create_playbook( condition_window_secs: body.condition_window_secs, cooldown_secs: body.cooldown_secs.unwrap_or(300), actions, + conditions, }; match svc.create_playbook(&input) { @@ -95,11 +153,85 @@ async fn create_playbook( } } -async fn delete_playbook( +async fn update_playbook( _auth: AuthClaims, svc: web::Data, path: web::Path, + body: web::Json, ) -> 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 = 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) { + 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()})), + } +} + +#[derive(Deserialize)] +struct TogglePlaybookRequest { + enabled: bool, +} + +async fn toggle_playbook( + _auth: AuthClaims, + svc: web::Data, + path: web::Path, + body: web::Json, +) -> HttpResponse { + match svc.toggle_playbook(path.into_inner(), body.enabled) { + 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()})), + } +} + +async fn delete_playbook(_auth: AuthClaims, svc: web::Data, path: web::Path) -> HttpResponse { match svc.delete_playbook(path.into_inner()) { Ok(true) => HttpResponse::Ok().json(serde_json::json!({"deleted": true})), Ok(false) => HttpResponse::NotFound().json(serde_json::json!({"error": "Playbook not found"})), @@ -107,63 +239,56 @@ async fn delete_playbook( } } -async fn list_active_blocks( - _auth: AuthClaims, - svc: web::Data, -) -> HttpResponse { +async fn list_active_blocks(_auth: AuthClaims, svc: web::Data) -> HttpResponse { match svc.list_active_blocks() { Ok(blocks) => { - let responses: Vec = 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, + let responses: Vec = 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(); + .collect(); HttpResponse::Ok().json(responses) } Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), } } -async fn manual_unblock( - _auth: AuthClaims, - svc: web::Data, - path: web::Path, -) -> HttpResponse { +async fn manual_unblock(_auth: AuthClaims, svc: web::Data, path: web::Path) -> 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()})), } } -async fn list_executions( - _auth: AuthClaims, - svc: web::Data, -) -> HttpResponse { +async fn list_executions(_auth: AuthClaims, svc: web::Data) -> HttpResponse { match svc.list_executions(100) { Ok(executions) => { - let responses: Vec = 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, + let responses: Vec = 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(); + .collect(); HttpResponse::Ok().json(responses) } Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), } } -async fn list_whitelist( - _auth: AuthClaims, - svc: web::Data, -) -> HttpResponse { +async fn list_whitelist(_auth: AuthClaims, svc: web::Data) -> HttpResponse { match svc.list_whitelist() { Ok(ips) => HttpResponse::Ok().json(ips), Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), @@ -186,11 +311,7 @@ async fn add_whitelist( } } -async fn remove_whitelist( - _auth: AuthClaims, - svc: web::Data, - path: web::Path, -) -> HttpResponse { +async fn remove_whitelist(_auth: AuthClaims, svc: web::Data, path: web::Path) -> 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()})), diff --git a/net-guardia/src/adapter/http/stats.rs b/net-guardia/src/adapter/http/stats.rs index a9a1171..e7f3892 100644 --- a/net-guardia/src/adapter/http/stats.rs +++ b/net-guardia/src/adapter/http/stats.rs @@ -1,4 +1,4 @@ -use actix_web::{web, HttpResponse, Responder, Scope}; +use actix_web::{HttpResponse, Responder, Scope, web}; use crate::core::ebpf::drop_monitor::DropMonitor; use crate::infrastructure::statistics::FlowStatistics; @@ -15,10 +15,7 @@ async fn get_all_flows(stats: web::Data) -> impl Responder { HttpResponse::Ok().json(stats.get_all_flows()) } -async fn get_top_flows( - stats: web::Data, - path: web::Path, -) -> impl Responder { +async fn get_top_flows(stats: web::Data, path: web::Path) -> impl Responder { let n = path.into_inner(); HttpResponse::Ok().json(stats.get_top_flows(n)) } diff --git a/net-guardia/src/adapter/http/system.rs b/net-guardia/src/adapter/http/system.rs index e6eddbc..d649809 100644 --- a/net-guardia/src/adapter/http/system.rs +++ b/net-guardia/src/adapter/http/system.rs @@ -1,7 +1,8 @@ -use actix_web::{web, HttpResponse, Responder, Scope}; +use actix_web::{HttpResponse, Responder, Scope, web}; use serde::Deserialize; use crate::core::config_service::ConfigService; +use crate::core::system::{ShutdownHandle, ShutdownMode}; use crate::infrastructure::communication_manager::CommunicationManager; use crate::interface::communication::command_types::ChangeEnforceModeCommand; use crate::interface::communication::query_types::GetEnforceModeQuery; @@ -22,6 +23,10 @@ pub fn initialize() -> Scope { .route("/xdp-mode", web::get().to(get_xdp_mode)) .route("/config", web::get().to(get_config)) .route("/config", web::put().to(update_config)) + .route("/log-level", web::get().to(get_log_level)) + .route("/log-level", web::put().to(set_log_level)) + .route("/shutdown", web::post().to(shutdown)) + .route("/restart", web::post().to(restart)) } async fn get_boot_time() -> impl Responder { @@ -31,8 +36,7 @@ async fn get_boot_time() -> impl Responder { async fn get_enforce_mode(comm: web::Data) -> 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()})), + Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), } } @@ -41,25 +45,28 @@ async fn set_enforce_mode( comm: web::Data, ) -> impl Responder { let mode = &body.mode; - if mode != "monitor" && mode != "enforce" { + if mode != "monitor" && mode != "ml_only" && mode != "enforce" { return HttpResponse::BadRequest() - .json(serde_json::json!({"error": "Mode must be 'monitor' or 'enforce'"})); + .json(serde_json::json!({"error": "Mode must be 'monitor', 'ml_only', or 'enforce'"})); } match comm.send_command(ChangeEnforceModeCommand { 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()})), + 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) -> 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()); + 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()); HttpResponse::Ok().json(serde_json::json!({ "ingress_mode": ingress, @@ -71,17 +78,73 @@ async fn get_config(svc: web::Data) -> impl Responder { HttpResponse::Ok().json(svc.get_config()) } +async fn get_log_level() -> impl Responder { + HttpResponse::Ok().json(serde_json::json!({ + "level": crate::utils::logging::Logging::current_level(), + })) +} + +#[derive(Deserialize)] +struct LogLevelRequest { + level: String, +} + +async fn set_log_level(body: web::Json) -> impl Responder { + match crate::utils::logging::Logging::set_level(&body.level) { + Ok(new_level) => HttpResponse::Ok().json(serde_json::json!({ + "level": new_level, + "message": "Log level updated", + })), + Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"error": e})), + } +} + +/// HTTP config keys that require a server restart to take effect. +const HTTP_RELOAD_KEYS: &[&str] = &["http_port", "cors_allowed_origins", "force_https"]; + async fn update_config( body: web::Json, svc: web::Data, + handle: web::Data, ) -> impl Responder { match svc.update_config(&body) { Ok(updated) => { - HttpResponse::Ok().json(serde_json::json!({ - "updated": updated, - "message": if updated.is_empty() { "No changes" } else { "Settings updated. Restart required for changes to take effect." } - })) + let needs_restart = updated.iter().any(|k| HTTP_RELOAD_KEYS.contains(&k.as_str())); + if needs_restart { + // Auto-trigger restart for HTTP config changes + let triggered = handle.trigger(ShutdownMode::Restart); + HttpResponse::Ok().json(serde_json::json!({ + "updated": updated, + "message": if triggered { + "Settings updated. Server restarting to apply HTTP config changes." + } else { + "Settings updated. Restart already in progress." + }, + "restarting": triggered, + })) + } else { + HttpResponse::Ok().json(serde_json::json!({ + "updated": updated, + "message": if updated.is_empty() { "No changes" } else { "Settings updated" }, + })) + } } Err(e) => HttpResponse::BadRequest().json(serde_json::json!({"error": e.to_string()})), } } + +async fn shutdown(handle: web::Data) -> impl Responder { + if handle.trigger(ShutdownMode::Shutdown) { + HttpResponse::Ok().json(serde_json::json!({"message": "Shutdown initiated"})) + } else { + HttpResponse::Conflict().json(serde_json::json!({"error": "Shutdown already in progress"})) + } +} + +async fn restart(handle: web::Data) -> impl Responder { + if handle.trigger(ShutdownMode::Restart) { + HttpResponse::Ok().json(serde_json::json!({"message": "Restart initiated"})) + } else { + HttpResponse::Conflict().json(serde_json::json!({"error": "Shutdown already in progress"})) + } +} diff --git a/net-guardia/src/adapter/persistence/repository.rs b/net-guardia/src/adapter/persistence/repository.rs index 0995ace..c06d702 100644 --- a/net-guardia/src/adapter/persistence/repository.rs +++ b/net-guardia/src/adapter/persistence/repository.rs @@ -1,54 +1,244 @@ -use std::collections::HashMap; use r2d2::Pool; use r2d2_sqlite::SqliteConnectionManager; use rusqlite::params; +use std::collections::HashMap; + +use macros::log; -use crate::model::error::database::DatabaseError; use crate::model::error::Error; +use crate::model::error::database::DatabaseError; +use crate::model::log::misc::MiscLog; -/// Applies SQLite PRAGMAs to each new connection in the pool. -#[derive(Debug)] -struct SqlitePragmaCustomizer; +/// Reads the SQLCipher encryption key from the environment variable `NETGUARDIA_DB_KEY`. +/// Returns `Some(key)` if set and non-empty, `None` otherwise (dev / unencrypted mode). +fn db_encryption_key() -> Option { + match std::env::var("NETGUARDIA_DB_KEY") { + Ok(k) if !k.is_empty() => Some(k), + _ => None, + } +} + +/// Applies the SQLCipher PRAGMA key (if configured) and standard PRAGMAs +/// to every new connection obtained from the pool. +#[derive(Debug, Clone)] +struct SqlitePragmaCustomizer { + /// `None` means no encryption (dev mode). + encryption_key: Option, +} impl r2d2::CustomizeConnection for SqlitePragmaCustomizer { fn on_acquire(&self, conn: &mut rusqlite::Connection) -> Result<(), rusqlite::Error> { + // SQLCipher: the very first statement on a connection MUST be PRAGMA key. + if let Some(ref key) = self.encryption_key { + // Use a parameterised query to avoid SQL-injection via the key value. + conn.pragma_update(None, "key", key)?; + } conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?; Ok(()) } } +pub struct AuditLogEntry { + pub id: i64, + pub actor: String, + pub action: String, + pub detail: String, + pub created_at: String, +} + pub struct Database { pool: Pool, } impl Database { pub fn new(path: &str) -> Result { + let encryption_key = db_encryption_key(); + + // For on-disk databases with an encryption key, attempt transparent migration + // from a plaintext SQLite database to an encrypted SQLCipher database. + if path != ":memory:" { + if let Some(ref key) = encryption_key { + Self::migrate_plaintext_to_encrypted(path, key)?; + } else { + log!(MiscLog::DbEncryptionDisabled); + } + } + let manager = if path == ":memory:" { SqliteConnectionManager::memory() } else { SqliteConnectionManager::file(path) }; + let customizer = SqlitePragmaCustomizer { + encryption_key: encryption_key.clone(), + }; + let pool = Pool::builder() .max_size(if path == ":memory:" { 1 } else { 6 }) - .connection_customizer(Box::new(SqlitePragmaCustomizer)) + .connection_customizer(Box::new(customizer)) .build(manager) .map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?; + // Verify the pool is actually usable (catches wrong key / corrupt DB early). + { + let test_conn = pool + .get() + .map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?; + test_conn + .query_row("SELECT count(*) FROM sqlite_master", [], |_| Ok(())) + .map_err(|_| DatabaseError::QueryFailed { + reason: "Database encryption key is incorrect or database is corrupted".to_string(), + })?; + } + let db = Self { pool }; db.create_tables()?; Ok(db) } + /// One-time migration: if the DB file exists and is a *plaintext* SQLite database + /// (i.e. opening it with the encryption key fails, but opening without a key + /// succeeds), export it to a new encrypted file and atomically replace the original. + fn migrate_plaintext_to_encrypted(path: &str, key: &str) -> Result<(), Error> { + use std::path::Path; + + let db_path = Path::new(path); + if !db_path.exists() { + return Ok(()); // brand-new DB — nothing to migrate + } + + // Try opening with the key — if it works, the DB is already encrypted. + { + let conn = + rusqlite::Connection::open(path).map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?; + conn.pragma_update(None, "key", key) + .map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?; + if conn + .query_row("SELECT count(*) FROM sqlite_master", [], |_| Ok(())) + .is_ok() + { + return Ok(()); // already encrypted — nothing to do + } + } + + // Try opening *without* a key — if this also fails the file is corrupted. + { + let conn = + rusqlite::Connection::open(path).map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?; + if conn + .query_row("SELECT count(*) FROM sqlite_master", [], |_| Ok(())) + .is_err() + { + log!(MiscLog::DbMigrationSkipped); + return Err(DatabaseError::QueryFailed { + reason: "Database encryption key is incorrect or database is corrupted".to_string(), + } + .into()); + } + } + + // The DB is plaintext and we have a key → migrate via temp file. + let tmp_path = format!("{path}.migrating"); + log!(MiscLog::DbMigrationStarted); + + let result = (|| -> Result<(), Error> { + let conn = + rusqlite::Connection::open(path).map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?; + + // Attach a new encrypted database. + conn.execute_batch(&format!( + "ATTACH DATABASE '{}' AS encrypted KEY '{}';", + tmp_path.replace('\'', "''"), + key.replace('\'', "''"), + )) + .map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?; + + // Export everything from the plaintext DB into the encrypted one. + conn.query_row("SELECT sqlcipher_export('encrypted')", [], |_| Ok(())) + .map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?; + + conn.execute_batch("DETACH DATABASE encrypted;") + .map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?; + Ok(()) + })(); + + match result { + Ok(()) => { + // Atomic replace. + std::fs::rename(&tmp_path, path).map_err(|e| DatabaseError::QueryFailed { + reason: format!("Failed to replace DB file after migration: {e}"), + })?; + log!(MiscLog::DbMigrationCompleted); + Ok(()) + } + Err(e) => { + // Clean up temp file; leave original untouched. + let _ = std::fs::remove_file(&tmp_path); + log!(MiscLog::DbMigrationFailed { error: e.to_string() }); + Err(e) + } + } + } + + /// Export an encrypted database to a plaintext copy. + /// The original file is NOT modified. + pub fn decrypt_to_file(src_path: &str, key: &str, dest_path: &str) -> Result<(), Error> { + let conn = + rusqlite::Connection::open(src_path).map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?; + conn.pragma_update(None, "key", key) + .map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?; + // Verify we can read the encrypted DB + conn.query_row("SELECT count(*) FROM sqlite_master", [], |_| Ok(())) + .map_err(|_| DatabaseError::QueryFailed { + reason: "Cannot read database with provided key — wrong key or not encrypted".to_string(), + })?; + // Attach a plaintext destination (empty key = no encryption) + conn.execute_batch(&format!( + "ATTACH DATABASE '{}' AS plaintext KEY '';", + dest_path.replace('\'', "''"), + )) + .map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?; + conn.query_row("SELECT sqlcipher_export('plaintext')", [], |_| Ok(())) + .map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?; + conn.execute_batch("DETACH DATABASE plaintext;") + .map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?; + Ok(()) + } + + /// Encrypt a plaintext database to a new encrypted copy. + /// The original file is NOT modified. + pub fn encrypt_to_file(src_path: &str, key: &str, dest_path: &str) -> Result<(), Error> { + let conn = + rusqlite::Connection::open(src_path).map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?; + // Verify it's readable as plaintext + conn.query_row("SELECT count(*) FROM sqlite_master", [], |_| Ok(())) + .map_err(|_| DatabaseError::QueryFailed { + reason: "Cannot read source database — may already be encrypted".to_string(), + })?; + conn.execute_batch(&format!( + "ATTACH DATABASE '{}' AS encrypted KEY '{}';", + dest_path.replace('\'', "''"), + key.replace('\'', "''"), + )) + .map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?; + conn.query_row("SELECT sqlcipher_export('encrypted')", [], |_| Ok(())) + .map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?; + conn.execute_batch("DETACH DATABASE encrypted;") + .map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?; + Ok(()) + } + fn conn(&self) -> Result, Error> { - self.pool.get().map_err(|e| -> Error { - DatabaseError::QueryFailed { reason: e.to_string() }.into() - }) + self.pool + .get() + .map_err(|e| -> Error { DatabaseError::QueryFailed { reason: e.to_string() }.into() }) } fn create_tables(&self) -> Result<(), Error> { let conn = self.conn()?; - conn.execute_batch(" + conn.execute_batch( + " CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, @@ -116,6 +306,15 @@ impl Database { 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, @@ -139,7 +338,7 @@ impl Database { ); -- MCP API keys - CREATE TABLE IF NOT EXISTS mcp_keys ( + CREATE TABLE IF NOT EXISTS api_keys ( id INTEGER PRIMARY KEY AUTOINCREMENT, key_hash TEXT NOT NULL, name TEXT NOT NULL, @@ -164,7 +363,25 @@ impl Database { 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 + CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts TEXT NOT NULL DEFAULT (datetime('now')), + actor TEXT NOT NULL, + action TEXT NOT NULL, + detail TEXT NOT NULL DEFAULT '{}' + ); + ", + )?; // Migration: add force_password_change column if missing (for existing DBs) let conn_ref = &*conn; @@ -172,37 +389,58 @@ impl Database { .prepare("SELECT force_password_change FROM users LIMIT 0") .is_ok(); if !has_column { - conn_ref.execute_batch( - "ALTER TABLE users ADD COLUMN force_password_change INTEGER NOT NULL DEFAULT 0;" - )?; + conn_ref.execute_batch("ALTER TABLE users ADD COLUMN force_password_change INTEGER NOT NULL DEFAULT 0;")?; } // Migration: seed default user groups if table is empty - let group_count: i64 = conn_ref.query_row( - "SELECT COUNT(*) FROM user_groups", [], |row| row.get(0), - )?; + let group_count: i64 = conn_ref.query_row("SELECT COUNT(*) FROM user_groups", [], |row| row.get(0))?; if group_count == 0 { let all_permissions = serde_json::json!([ - "dashboard:read", "statistics:read", "traffic_map:read", "drops:read", - "ai_detection:read", "ai_detection:write", - "access_control:read", "access_control:write", - "geo_block:read", "geo_block:write", - "dns_filter:read", "dns_filter:write", - "rate_limit:read", "rate_limit:write", - "protocol_filter:read", "protocol_filter:write", - "system:read", "system:write", - "users:read", "users:write", "users:admin" - ]).to_string(); + "dashboard:read", + "statistics:read", + "traffic_map:read", + "drops:read", + "ai_detection:read", + "ai_detection:write", + "access_control:read", + "access_control:write", + "geo_block:read", + "geo_block:write", + "dns_filter:read", + "dns_filter:write", + "rate_limit:read", + "rate_limit:write", + "protocol_filter:read", + "protocol_filter:write", + "system:read", + "system:write", + "users:read", + "users:write", + "users:admin" + ]) + .to_string(); let viewer_permissions = serde_json::json!([ - "dashboard:read", "statistics:read", "traffic_map:read", "drops:read", - "ai_detection:read", "access_control:read", "geo_block:read", - "dns_filter:read", "rate_limit:read", "protocol_filter:read", + "dashboard:read", + "statistics:read", + "traffic_map:read", + "drops:read", + "ai_detection:read", + "access_control:read", + "geo_block:read", + "dns_filter:read", + "rate_limit:read", + "protocol_filter:read", "system:read" - ]).to_string(); + ]) + .to_string(); conn_ref.execute( "INSERT INTO user_groups (name, description, permissions) VALUES (?1, ?2, ?3)", - params!["Administrator", "Full system access with all permissions", &all_permissions], + params![ + "Administrator", + "Full system access with all permissions", + &all_permissions + ], )?; conn_ref.execute( "INSERT INTO user_groups (name, description, permissions) VALUES (?1, ?2, ?3)", @@ -211,24 +449,21 @@ impl Database { } // Migration: assign existing users to default groups if user_group_members is empty - let member_count: i64 = conn_ref.query_row( - "SELECT COUNT(*) FROM user_group_members", [], |row| row.get(0), - )?; + let member_count: i64 = conn_ref.query_row("SELECT COUNT(*) FROM user_group_members", [], |row| row.get(0))?; if member_count == 0 { // Get admin group id and viewer group id - let admin_group_id: Option = conn_ref.query_row( - "SELECT id FROM user_groups WHERE name = 'Administrator'", [], - |row| row.get(0), - ).ok(); - let viewer_group_id: Option = conn_ref.query_row( - "SELECT id FROM user_groups WHERE name = 'Viewer'", [], - |row| row.get(0), - ).ok(); + let admin_group_id: Option = conn_ref + .query_row("SELECT id FROM user_groups WHERE name = 'Administrator'", [], |row| { + row.get(0) + }) + .ok(); + let viewer_group_id: Option = conn_ref + .query_row("SELECT id FROM user_groups WHERE name = 'Viewer'", [], |row| row.get(0)) + .ok(); if let Some(ag_id) = admin_group_id { let mut stmt = conn_ref.prepare("SELECT id FROM users WHERE role = 'admin'")?; - let admin_ids: Vec = stmt.query_map([], |row| row.get(0))? - .filter_map(|r| r.ok()).collect(); + let admin_ids: Vec = stmt.query_map([], |row| row.get(0))?.filter_map(|r| r.ok()).collect(); for uid in admin_ids { conn_ref.execute( "INSERT OR IGNORE INTO user_group_members (user_id, group_id) VALUES (?1, ?2)", @@ -238,8 +473,7 @@ impl Database { } if let Some(vg_id) = viewer_group_id { let mut stmt = conn_ref.prepare("SELECT id FROM users WHERE role = 'viewer'")?; - let viewer_ids: Vec = stmt.query_map([], |row| row.get(0))? - .filter_map(|r| r.ok()).collect(); + let viewer_ids: Vec = stmt.query_map([], |row| row.get(0))?.filter_map(|r| r.ok()).collect(); for uid in viewer_ids { conn_ref.execute( "INSERT OR IGNORE INTO user_group_members (user_id, group_id) VALUES (?1, ?2)", @@ -253,7 +487,14 @@ impl Database { } // --- ACL --- - pub fn insert_acl_rule(&self, ip_version: u8, direction: &str, list_type: &str, ip_address: &str, port: u16) -> Result<(), Error> { + pub fn insert_acl_rule( + &self, + ip_version: u8, + direction: &str, + list_type: &str, + ip_address: &str, + port: u16, + ) -> Result<(), Error> { let conn = self.conn()?; conn.execute( "INSERT OR IGNORE INTO acl_rules (ip_version, direction, list_type, ip_address, port) VALUES (?1, ?2, ?3, ?4, ?5)", @@ -262,7 +503,14 @@ impl Database { Ok(()) } - pub fn delete_acl_rule(&self, ip_version: u8, direction: &str, list_type: &str, ip_address: &str, port: u16) -> Result<(), Error> { + pub fn delete_acl_rule( + &self, + ip_version: u8, + direction: &str, + list_type: &str, + ip_address: &str, + port: u16, + ) -> Result<(), Error> { let conn = self.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", @@ -303,9 +551,7 @@ impl Database { pub fn load_rate_limit_config(&self) -> Result, Error> { let conn = self.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 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?); @@ -316,7 +562,10 @@ impl Database { // --- DNS --- pub fn insert_dns_domain(&self, domain: &str) -> Result<(), Error> { let conn = self.conn()?; - conn.execute("INSERT OR IGNORE INTO dns_blacklist (domain) VALUES (?1)", params![domain])?; + conn.execute( + "INSERT OR IGNORE INTO dns_blacklist (domain) VALUES (?1)", + params![domain], + )?; Ok(()) } @@ -340,13 +589,19 @@ impl Database { // --- Geo --- pub fn insert_geo_country(&self, code: &str) -> Result<(), Error> { let conn = self.conn()?; - conn.execute("INSERT OR IGNORE INTO geo_blocked_countries (country_code) VALUES (?1)", params![code])?; + conn.execute( + "INSERT OR IGNORE INTO geo_blocked_countries (country_code) VALUES (?1)", + params![code], + )?; Ok(()) } pub fn delete_geo_country(&self, code: &str) -> Result<(), Error> { let conn = self.conn()?; - conn.execute("DELETE FROM geo_blocked_countries WHERE country_code = ?1", params![code])?; + conn.execute( + "DELETE FROM geo_blocked_countries WHERE country_code = ?1", + params![code], + )?; Ok(()) } @@ -364,11 +619,9 @@ impl Database { // --- Settings --- pub fn get_setting(&self, key: &str) -> Result, Error> { let conn = self.conn()?; - let result = conn.query_row( - "SELECT value FROM settings WHERE key = ?1", - params![key], - |row| row.get(0), - ); + 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(rusqlite::Error::QueryReturnedNoRows) => Ok(None), @@ -385,13 +638,44 @@ impl Database { Ok(()) } + // --- App Secrets --- + + pub fn get_app_secret(&self, key: &str) -> Result, Error> { + let conn = self.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(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } + } + + pub fn set_app_secret(&self, key: &str, value: &str) -> Result<(), Error> { + let conn = self.conn()?; + conn.execute( + "INSERT OR REPLACE INTO app_secrets (key, value) VALUES (?1, ?2)", + params![key, value], + )?; + Ok(()) + } + // --- Users --- pub fn find_user(&self, username: &str) -> Result, Error> { let conn = self.conn()?; let result = conn.query_row( "SELECT id, username, password_hash, role, force_password_change FROM users WHERE username = ?1", params![username], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get::<_, i64>(4)? != 0)), + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get::<_, i64>(4)? != 0, + )) + }, ); match result { Ok(user) => Ok(Some(user)), @@ -400,14 +684,24 @@ impl Database { } } - pub fn insert_user(&self, username: &str, password_hash: &str, role: &str, force_password_change: bool) -> Result { + pub fn insert_user( + &self, + username: &str, + password_hash: &str, + role: &str, + force_password_change: bool, + ) -> Result { let conn = self.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 { + ) + .map_err(|e| -> Error { if e.to_string().contains("UNIQUE constraint") { - DatabaseError::UserAlreadyExists { username: username.to_string() }.into() + DatabaseError::UserAlreadyExists { + username: username.to_string(), + } + .into() } else { e.into() } @@ -431,7 +725,8 @@ impl Database { pub fn list_users(&self) -> Result, Error> { let conn = self.conn()?; - let mut stmt = conn.prepare("SELECT id, username, role, force_password_change, created_at FROM users ORDER BY id")?; + let mut stmt = + conn.prepare("SELECT id, username, role, force_password_change, created_at FROM users ORDER BY id")?; let rows = stmt.query_map([], |row| { Ok(( row.get::<_, i64>(0)?, @@ -456,7 +751,7 @@ impl Database { 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" + ORDER BY u.id, g.id", )?; let rows = stmt.query_map([], |row| { Ok(( @@ -509,12 +804,23 @@ impl Database { Ok(()) } - pub fn find_user_by_id(&self, user_id: i64) -> Result, Error> { + pub fn find_user_by_id( + &self, + user_id: i64, + ) -> Result, Error> { let conn = self.conn()?; let result = conn.query_row( "SELECT id, username, password_hash, role, force_password_change FROM users WHERE id = ?1", params![user_id], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get::<_, i64>(4)? != 0)), + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get::<_, i64>(4)? != 0, + )) + }, ); match result { Ok(user) => Ok(Some(user)), @@ -526,7 +832,8 @@ impl Database { // --- User Groups --- pub fn list_user_groups(&self) -> Result, Error> { let conn = self.conn()?; - let mut stmt = conn.prepare("SELECT id, name, description, permissions, created_at FROM user_groups ORDER BY id")?; + let mut stmt = + conn.prepare("SELECT id, name, description, permissions, created_at FROM user_groups ORDER BY id")?; let rows = stmt.query_map([], |row| { Ok(( row.get::<_, i64>(0)?, @@ -548,9 +855,13 @@ impl Database { conn.execute( "INSERT INTO user_groups (name, description, permissions) VALUES (?1, ?2, ?3)", params![name, description, permissions], - ).map_err(|e| -> Error { + ) + .map_err(|e| -> Error { if e.to_string().contains("UNIQUE constraint") { - DatabaseError::QueryFailed { reason: format!("Group '{}' already exists", name) }.into() + DatabaseError::QueryFailed { + reason: format!("Group '{}' already exists", name), + } + .into() } else { e.into() } @@ -579,13 +890,15 @@ impl Database { let result = conn.query_row( "SELECT id, name, description, permissions, created_at FROM user_groups WHERE id = ?1", params![id], - |row| Ok(( - row.get::<_, i64>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, - row.get::<_, String>(3)?, - row.get::<_, String>(4)?, - )), + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + )) + }, ); match result { Ok(group) => Ok(Some(group)), @@ -600,7 +913,7 @@ impl Database { let mut stmt = conn.prepare( "SELECT g.id, g.name, g.description, g.permissions 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" + WHERE m.user_id = ?1 ORDER BY g.id", )?; let rows = stmt.query_map(params![user_id], |row| { Ok(( @@ -666,7 +979,7 @@ impl Database { 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" + WHERE m.group_id = ?1 ORDER BY u.username", )?; let rows = stmt.query_map(params![group_id], |row| { Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) @@ -683,16 +996,14 @@ impl Database { let key_count = format!("login_failures:{}", username); let key_locked = format!("login_locked_until:{}", username); - let count: u32 = self.get_setting(&key_count)? - .and_then(|v| v.parse().ok()) - .unwrap_or(0) + 1; + let count: u32 = self.get_setting(&key_count)?.and_then(|v| v.parse().ok()).unwrap_or(0) + 1; self.set_setting(&key_count, &count.to_string())?; if count >= 5 { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) - .unwrap() + .unwrap_or(std::time::Duration::ZERO) .as_secs(); let locked_until = now + 900; // 15 minutes self.set_setting(&key_locked, &locked_until.to_string())?; @@ -705,37 +1016,44 @@ impl Database { pub fn check_login_locked(&self, username: &str) -> Result, Error> { let key_locked = format!("login_locked_until:{}", username); if let Some(locked_str) = self.get_setting(&key_locked)? - && let Ok(locked_until) = locked_str.parse::() { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - if now < locked_until { - return Ok(Some(locked_until - now)); - } - // Lock expired, clear it - self.clear_login_failures(username)?; + && let Ok(locked_until) = locked_str.parse::() + { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or(std::time::Duration::ZERO) + .as_secs(); + if now < locked_until { + return Ok(Some(locked_until - now)); + } + // Lock expired, clear it + self.clear_login_failures(username)?; } Ok(None) } pub fn clear_login_failures(&self, username: &str) -> Result<(), Error> { let conn = self.conn()?; - conn.execute("DELETE FROM settings WHERE key = ?1", params![format!("login_failures:{}", username)])?; - conn.execute("DELETE FROM settings WHERE key = ?1", params![format!("login_locked_until:{}", username)])?; + conn.execute( + "DELETE FROM settings WHERE key = ?1", + params![format!("login_failures:{}", username)], + )?; + conn.execute( + "DELETE FROM settings WHERE key = ?1", + params![format!("login_locked_until:{}", username)], + )?; Ok(()) } // --- MCP API Keys --- /// Validate an API key and return Claims if valid. - /// Computes SHA-256 hash of the key and looks it up in mcp_keys table. + /// Computes SHA-256 hash of the key and looks it up in api_keys table. pub fn validate_api_key(&self, api_key: &str) -> Result, Error> { use std::fmt::Write; // SHA-256 hash the key let digest = { - use sha2::{Sha256, Digest}; + use sha2::{Digest, Sha256}; let mut hasher = Sha256::new(); hasher.update(api_key.as_bytes()); let result = hasher.finalize(); @@ -748,7 +1066,7 @@ impl Database { let conn = self.conn()?; let result = conn.query_row( - "SELECT id, name, permission_level FROM mcp_keys WHERE key_hash = ?1", + "SELECT id, name, permission_level FROM api_keys WHERE key_hash = ?1", params![digest], |row| { Ok(( @@ -763,32 +1081,43 @@ impl Database { Ok((id, name, level)) => { // Update last_used_at let _ = conn.execute( - "UPDATE mcp_keys SET last_used_at = datetime('now') WHERE id = ?1", + "UPDATE api_keys SET last_used_at = datetime('now') WHERE id = ?1", params![id], ); // Build permissions based on permission level let permissions = match level.as_str() { - "read_write" => vec![ - "dashboard:read".into(), "statistics:read".into(), - "ai_detection:read".into(), "ai_detection:write".into(), - "access_control:read".into(), "access_control:write".into(), - "geo_block:read".into(), "geo_block:write".into(), - "dns_filter:read".into(), "dns_filter:write".into(), - "rate_limit:read".into(), "rate_limit:write".into(), - "system:read".into(), "system:write".into(), + "read_write" | "full_access" => vec![ + "dashboard:read".into(), + "statistics:read".into(), + "ai_detection:read".into(), + "ai_detection:write".into(), + "access_control:read".into(), + "access_control:write".into(), + "geo_block:read".into(), + "geo_block:write".into(), + "dns_filter:read".into(), + "dns_filter:write".into(), + "rate_limit:read".into(), + "rate_limit:write".into(), + "system:read".into(), + "system:write".into(), ], _ => vec![ - "dashboard:read".into(), "statistics:read".into(), - "ai_detection:read".into(), "access_control:read".into(), - "geo_block:read".into(), "dns_filter:read".into(), - "rate_limit:read".into(), "system:read".into(), + "dashboard:read".into(), + "statistics:read".into(), + "ai_detection:read".into(), + "access_control:read".into(), + "geo_block:read".into(), + "dns_filter:read".into(), + "rate_limit:read".into(), + "system:read".into(), ], }; Ok(Some(crate::model::auth::Claims { sub: -id, // negative ID to distinguish from user IDs - username: format!("mcp:{}", name), + username: format!("api:{}", name), role: level, permissions, exp: usize::MAX, // API keys don't expire (revocation via DB deletion) @@ -801,7 +1130,15 @@ impl Database { // --- SOAR --- - pub fn insert_playbook(&self, name: &str, trigger_event: &str, threshold: Option, count: Option, window: Option, cooldown: i64) -> Result { + pub fn insert_playbook( + &self, + name: &str, + trigger_event: &str, + threshold: Option, + count: Option, + window: Option, + cooldown: i64, + ) -> Result { let conn = self.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)", @@ -810,7 +1147,13 @@ impl Database { Ok(conn.last_insert_rowid()) } - pub fn insert_playbook_action(&self, playbook_id: i64, action_order: i64, action_type: &str, params_json: &str) -> Result { + pub fn insert_playbook_action( + &self, + playbook_id: i64, + action_order: i64, + action_type: &str, + params_json: &str, + ) -> Result { let conn = self.conn()?; conn.execute( "INSERT INTO playbook_actions (playbook_id, action_order, action_type, params) VALUES (?1, ?2, ?3, ?4)", @@ -847,18 +1190,24 @@ impl Database { Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, i64>(2)?)) })?; let mut result = Vec::new(); - for row in rows { result.push(row?); } + for row in rows { + result.push(row?); + } Ok(result) } /// Get a single SOAR block rule by ID, returning (id, source_ip, playbook_id, expires_at). pub fn get_soar_block_by_id(&self, id: i64) -> Result, Error> { let conn = self.conn()?; - let mut stmt = conn.prepare( - "SELECT id, source_ip, playbook_id, expires_at FROM soar_block_rules WHERE id = ?1" - )?; + let mut stmt = + conn.prepare("SELECT id, source_ip, playbook_id, expires_at FROM soar_block_rules WHERE id = ?1")?; let mut rows = stmt.query_map(params![id], |row| { - Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, i64>(2)?, row.get::<_, String>(3)?)) + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, String>(3)?, + )) })?; match rows.next() { Some(row) => Ok(Some(row?)), @@ -869,7 +1218,25 @@ impl Database { /// Load all playbooks with their actions in a single JOIN query (avoids N+1). /// Returns Vec of (playbook fields..., action fields...). #[allow(clippy::type_complexity)] - pub fn load_playbooks_with_actions(&self) -> Result, Option, Option, i64, Option, Option, Option, Option)>, Error> { + pub fn load_playbooks_with_actions( + &self, + ) -> Result< + Vec<( + i64, + String, + bool, + String, + Option, + Option, + Option, + i64, + Option, + Option, + Option, + Option, + )>, + Error, + > { let conn = self.conn()?; let mut stmt = conn.prepare( "SELECT p.id, p.name, p.enabled, p.trigger_event, p.condition_threshold, \ @@ -877,7 +1244,7 @@ impl Database { 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" + ORDER BY p.id, a.action_order", )?; let rows = stmt.query_map([], |row| { Ok(( @@ -896,7 +1263,9 @@ impl Database { )) })?; let mut result = Vec::new(); - for row in rows { result.push(row?); } + for row in rows { + result.push(row?); + } Ok(result) } @@ -915,14 +1284,65 @@ impl Database { "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([], |row| { - Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, i64>(2)?, row.get::<_, String>(3)?)) + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, String>(3)?, + )) })?; let mut result = Vec::new(); - for row in rows { result.push(row?); } + for row in rows { + result.push(row?); + } Ok(result) } - pub fn insert_soar_execution(&self, playbook_id: i64, source_ip: Option<&str>, trigger_event: &str, actions_json: &str) -> Result { + // --- Pending Unblock --- + pub fn insert_pending_unblock(&self, source_ip: &str) -> Result { + let conn = self.conn()?; + conn.execute( + "INSERT INTO pending_unblock (source_ip) VALUES (?1)", + params![source_ip], + )?; + Ok(conn.last_insert_rowid()) + } + + pub fn load_pending_unblocks(&self) -> Result, Error> { + let conn = self.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((row.get::<_, i64>(0)?, row.get::<_, String>(1)?, row.get::<_, i64>(2)?)) + })?; + let mut result = Vec::new(); + for row in rows { + result.push(row?); + } + Ok(result) + } + + pub fn delete_pending_unblock(&self, id: i64) -> Result<(), Error> { + let conn = self.conn()?; + conn.execute("DELETE FROM pending_unblock WHERE id = ?1", params![id])?; + Ok(()) + } + + pub fn increment_pending_unblock_retry(&self, id: i64) -> Result<(), Error> { + let conn = self.conn()?; + conn.execute( + "UPDATE pending_unblock SET retry_count = retry_count + 1 WHERE id = ?1", + params![id], + )?; + Ok(()) + } + + pub fn insert_soar_execution( + &self, + playbook_id: i64, + source_ip: Option<&str>, + trigger_event: &str, + actions_json: &str, + ) -> Result { let conn = self.conn()?; conn.execute( "INSERT INTO soar_executions (playbook_id, source_ip, trigger_event, actions_executed) VALUES (?1, ?2, ?3, ?4)", @@ -931,17 +1351,110 @@ impl Database { Ok(conn.last_insert_rowid()) } - pub fn delete_playbook(&self, id: i64) -> Result { + pub fn update_playbook( + &self, + id: i64, + row: &crate::model::soar::playbook_data::UpdatePlaybookRow, + ) -> Result { let conn = self.conn()?; let rows = conn.execute( - "DELETE FROM playbooks WHERE id = ?1", - params![id], + "UPDATE playbooks SET name = ?2, trigger_event = ?3, condition_threshold = ?4, \ + condition_count = ?5, condition_window_secs = ?6, cooldown_secs = ?7, \ + updated_at = datetime('now') WHERE id = ?1", + params![ + id, + row.name, + row.trigger_event, + row.condition_threshold, + row.condition_count, + row.condition_window_secs, + row.cooldown_secs + ], )?; Ok(rows > 0) } + pub fn update_playbook_enabled(&self, id: i64, enabled: bool) -> Result { + let conn = self.conn()?; + let rows = conn.execute( + "UPDATE playbooks SET enabled = ?2, updated_at = datetime('now') WHERE id = ?1", + params![id, enabled as i32], + )?; + Ok(rows > 0) + } + + pub fn delete_playbook(&self, id: i64) -> Result { + let conn = self.conn()?; + let rows = conn.execute("DELETE FROM playbooks WHERE id = ?1", params![id])?; + Ok(rows > 0) + } + + pub fn delete_playbook_actions(&self, playbook_id: i64) -> Result<(), Error> { + let conn = self.conn()?; + conn.execute( + "DELETE FROM playbook_actions WHERE playbook_id = ?1", + params![playbook_id], + )?; + Ok(()) + } + + pub fn delete_playbook_conditions(&self, playbook_id: i64) -> Result<(), Error> { + let conn = self.conn()?; + conn.execute( + "DELETE FROM playbook_conditions WHERE playbook_id = ?1", + params![playbook_id], + )?; + Ok(()) + } + + pub fn insert_playbook_condition( + &self, + playbook_id: i64, + condition_type: &str, + operator: &str, + value: &str, + value2: Option<&str>, + ) -> Result { + let conn = self.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()) + } + #[allow(clippy::type_complexity)] - pub fn list_soar_executions(&self, limit: i64) -> Result, String, String, String)>, Error> { + pub fn load_all_playbook_conditions( + &self, + ) -> Result)>, Error> { + let conn = self.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>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, Option>(5)?, + )) + })?; + let mut result = Vec::new(); + for row in rows { + result.push(row?); + } + Ok(result) + } + + #[allow(clippy::type_complexity)] + pub fn list_soar_executions( + &self, + limit: i64, + ) -> Result, String, String, String)>, Error> { let conn = self.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" @@ -957,7 +1470,9 @@ impl Database { )) })?; let mut result = Vec::new(); - for row in rows { result.push(row?); } + for row in rows { + result.push(row?); + } Ok(result) } @@ -978,7 +1493,9 @@ impl Database { 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?); } + for row in rows { + result.push(row?); + } Ok(result) } @@ -1019,21 +1536,21 @@ impl Database { Ok(()) } - // --- MCP Key Management --- + // --- API Key Management --- - pub fn insert_mcp_key(&self, key_hash: &str, name: &str, permission_level: &str) -> Result { + pub fn insert_api_key(&self, key_hash: &str, name: &str, permission_level: &str) -> Result { let conn = self.conn()?; conn.execute( - "INSERT INTO mcp_keys (key_hash, name, permission_level) VALUES (?1, ?2, ?3)", + "INSERT INTO api_keys (key_hash, name, permission_level) VALUES (?1, ?2, ?3)", params![key_hash, name, permission_level], )?; Ok(conn.last_insert_rowid()) } #[allow(clippy::type_complexity)] - pub fn list_mcp_keys(&self) -> Result)>, Error> { + pub fn list_api_keys(&self) -> Result)>, Error> { let conn = self.conn()?; - let mut stmt = conn.prepare("SELECT id, name, permission_level, created_at, last_used_at FROM mcp_keys")?; + 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(( row.get::<_, i64>(0)?, @@ -1044,13 +1561,15 @@ impl Database { )) })?; let mut result = Vec::new(); - for row in rows { result.push(row?); } + for row in rows { + result.push(row?); + } Ok(result) } - pub fn delete_mcp_key(&self, id: i64) -> Result { + pub fn delete_api_key(&self, id: i64) -> Result { let conn = self.conn()?; - let affected = conn.execute("DELETE FROM mcp_keys WHERE id = ?1", params![id])?; + let affected = conn.execute("DELETE FROM api_keys WHERE id = ?1", params![id])?; Ok(affected > 0) } @@ -1099,7 +1618,9 @@ impl Database { Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as u64)) })?; let mut result = Vec::new(); - for row in rows { result.push(row?); } + for row in rows { + result.push(row?); + } Ok(result) } @@ -1113,7 +1634,9 @@ impl Database { Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as u64)) })?; let mut result = Vec::new(); - for row in rows { result.push(row?); } + for row in rows { + result.push(row?); + } Ok(result) } @@ -1138,62 +1661,383 @@ impl Database { let pb1 = self.insert_playbook("default_block", "threat_detected", Some(0.85), None, None, 300)?; self.insert_playbook_action(pb1, 1, "block_ip", r#"{"ttl_secs": 1800}"#)?; self.insert_playbook_action(pb1, 2, "log", r#"{"level": "warn"}"#)?; + self.insert_playbook_condition(pb1, "threshold", ">=", "0.85", None)?; // 2. brute_force_block: brute_force, count 5 in 60s → block_ip(3600s) + send_telegram + log let pb2 = self.insert_playbook("brute_force_block", "brute_force", None, Some(5), Some(60), 600)?; self.insert_playbook_action(pb2, 1, "block_ip", r#"{"ttl_secs": 3600}"#)?; self.insert_playbook_action(pb2, 2, "send_telegram", "{}")?; self.insert_playbook_action(pb2, 3, "log", r#"{"level": "warn"}"#)?; + self.insert_playbook_condition(pb2, "frequency", ">=", "5", Some("60"))?; // 3. port_scan_alert: port_scan, threshold 0.7 → send_telegram + log (no block) let pb3 = self.insert_playbook("port_scan_alert", "port_scan", Some(0.7), None, None, 300)?; self.insert_playbook_action(pb3, 1, "send_telegram", "{}")?; self.insert_playbook_action(pb3, 2, "log", r#"{"level": "warn"}"#)?; + self.insert_playbook_condition(pb3, "threshold", ">=", "0.7", None)?; Ok(()) } + + // --- Audit Log --- + + /// Insert an audit trail entry. + pub fn insert_audit_log(&self, actor: &str, action: &str, detail: &str) -> Result<(), Error> { + let conn = self.conn()?; + conn.execute( + "INSERT INTO audit_log (actor, action, detail) VALUES (?1, ?2, ?3)", + params![actor, action, detail], + )?; + Ok(()) + } + + /// List recent audit log entries (most recent first, max 200). + pub fn list_audit_logs(&self) -> Result, Error> { + let conn = self.conn()?; + let mut stmt = + conn.prepare("SELECT id, actor, action, detail, created_at FROM audit_log ORDER BY id DESC LIMIT 200")?; + 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) + } } /// Implement the RepositoryPort trait, proving Database satisfies the port contract. /// This enables adapter-level testing with mock implementations. impl crate::interface::port::repository::RepositoryPort for Database { - fn insert_acl_rule(&self, ip_version: u8, direction: &str, list_type: &str, ip_address: &str, port: u16) -> Result<(), Error> { self.insert_acl_rule(ip_version, direction, list_type, ip_address, port) } - fn delete_acl_rule(&self, ip_version: u8, direction: &str, list_type: &str, ip_address: &str, port: u16) -> Result<(), Error> { self.delete_acl_rule(ip_version, direction, list_type, ip_address, port) } - fn load_acl_rules(&self) -> Result, Error> { self.load_acl_rules() } - fn set_rate_limit(&self, key: &str, value: u64) -> Result<(), Error> { self.set_rate_limit(key, value) } - fn load_rate_limit_config(&self) -> Result, Error> { self.load_rate_limit_config() } - fn insert_dns_domain(&self, domain: &str) -> Result<(), Error> { self.insert_dns_domain(domain) } - fn delete_dns_domain(&self, domain: &str) -> Result<(), Error> { self.delete_dns_domain(domain) } - fn load_dns_domains(&self) -> Result, Error> { self.load_dns_domains() } - fn insert_geo_country(&self, code: &str) -> Result<(), Error> { self.insert_geo_country(code) } - fn delete_geo_country(&self, code: &str) -> Result<(), Error> { self.delete_geo_country(code) } - fn load_geo_countries(&self) -> Result, Error> { self.load_geo_countries() } - fn get_setting(&self, key: &str) -> Result, Error> { self.get_setting(key) } - fn set_setting(&self, key: &str, value: &str) -> Result<(), Error> { self.set_setting(key, value) } - fn find_user(&self, username: &str) -> Result, Error> { self.find_user(username) } - fn insert_user(&self, username: &str, password_hash: &str, role: &str, force_password_change: bool) -> Result { self.insert_user(username, password_hash, role, force_password_change) } - fn update_user_password(&self, user_id: i64, password_hash: &str) -> Result<(), Error> { self.update_user_password(user_id, password_hash) } - fn user_count(&self) -> Result { self.user_count() } - fn list_users(&self) -> Result, Error> { self.list_users() } - fn list_users_with_groups(&self) -> Result, Error> { self.list_users_with_groups() } - fn delete_user(&self, user_id: i64) -> Result { self.delete_user(user_id) } - fn update_user_role(&self, user_id: i64, role: &str) -> Result<(), Error> { self.update_user_role(user_id, role) } - fn reset_user_password(&self, user_id: i64, password_hash: &str) -> Result<(), Error> { self.reset_user_password(user_id, password_hash) } - fn find_user_by_id(&self, user_id: i64) -> Result, Error> { self.find_user_by_id(user_id) } - fn list_user_groups(&self) -> Result, Error> { self.list_user_groups() } - fn create_user_group(&self, name: &str, description: &str, permissions: &str) -> Result { self.create_user_group(name, description, permissions) } - fn update_user_group(&self, id: i64, name: &str, description: &str, permissions: &str) -> Result<(), Error> { self.update_user_group(id, name, description, permissions) } - fn delete_user_group(&self, id: i64) -> Result { self.delete_user_group(id) } - fn get_user_group(&self, id: i64) -> Result, Error> { self.get_user_group(id) } - fn get_user_groups(&self, user_id: i64) -> Result, Error> { self.get_user_groups(user_id) } - fn set_user_groups(&self, user_id: i64, group_ids: &[i64]) -> Result<(), Error> { self.set_user_groups(user_id, group_ids) } - fn get_user_permissions(&self, user_id: i64) -> Result, Error> { self.get_user_permissions(user_id) } - fn cleanup_user_memberships(&self, user_id: i64) -> Result<(), Error> { self.cleanup_user_memberships(user_id) } - fn get_group_member_ids(&self, group_id: i64) -> Result, Error> { self.get_group_member_ids(group_id) } - fn get_group_members(&self, group_id: i64) -> Result, Error> { self.get_group_members(group_id) } - fn record_login_failure(&self, username: &str) -> Result<(u32, Option), Error> { self.record_login_failure(username) } - fn check_login_locked(&self, username: &str) -> Result, Error> { self.check_login_locked(username) } - fn clear_login_failures(&self, username: &str) -> Result<(), Error> { self.clear_login_failures(username) } + fn insert_acl_rule( + &self, + ip_version: u8, + direction: &str, + list_type: &str, + ip_address: &str, + port: u16, + ) -> Result<(), Error> { + self.insert_acl_rule(ip_version, direction, list_type, ip_address, port) + } + fn delete_acl_rule( + &self, + ip_version: u8, + direction: &str, + list_type: &str, + ip_address: &str, + port: u16, + ) -> Result<(), Error> { + self.delete_acl_rule(ip_version, direction, list_type, ip_address, port) + } + fn load_acl_rules(&self) -> Result, Error> { + self.load_acl_rules() + } + fn set_rate_limit(&self, key: &str, value: u64) -> Result<(), Error> { + self.set_rate_limit(key, value) + } + fn load_rate_limit_config(&self) -> Result, Error> { + self.load_rate_limit_config() + } + fn insert_dns_domain(&self, domain: &str) -> Result<(), Error> { + self.insert_dns_domain(domain) + } + fn delete_dns_domain(&self, domain: &str) -> Result<(), Error> { + self.delete_dns_domain(domain) + } + fn load_dns_domains(&self) -> Result, Error> { + self.load_dns_domains() + } + fn insert_geo_country(&self, code: &str) -> Result<(), Error> { + self.insert_geo_country(code) + } + fn delete_geo_country(&self, code: &str) -> Result<(), Error> { + self.delete_geo_country(code) + } + fn load_geo_countries(&self) -> Result, Error> { + self.load_geo_countries() + } + fn get_setting(&self, key: &str) -> Result, Error> { + self.get_setting(key) + } + fn set_setting(&self, key: &str, value: &str) -> Result<(), Error> { + self.set_setting(key, value) + } + fn find_user(&self, username: &str) -> Result, Error> { + self.find_user(username) + } + fn insert_user( + &self, + username: &str, + password_hash: &str, + role: &str, + force_password_change: bool, + ) -> Result { + self.insert_user(username, password_hash, role, force_password_change) + } + fn update_user_password(&self, user_id: i64, password_hash: &str) -> Result<(), Error> { + self.update_user_password(user_id, password_hash) + } + fn user_count(&self) -> Result { + self.user_count() + } + fn list_users(&self) -> Result, Error> { + self.list_users() + } + fn list_users_with_groups(&self) -> Result, Error> { + self.list_users_with_groups() + } + fn delete_user(&self, user_id: i64) -> Result { + self.delete_user(user_id) + } + fn update_user_role(&self, user_id: i64, role: &str) -> Result<(), Error> { + self.update_user_role(user_id, role) + } + fn reset_user_password(&self, user_id: i64, password_hash: &str) -> Result<(), Error> { + self.reset_user_password(user_id, password_hash) + } + fn find_user_by_id(&self, user_id: i64) -> Result, Error> { + self.find_user_by_id(user_id) + } + fn list_user_groups(&self) -> Result, Error> { + self.list_user_groups() + } + fn create_user_group(&self, name: &str, description: &str, permissions: &str) -> Result { + self.create_user_group(name, description, permissions) + } + fn update_user_group(&self, id: i64, name: &str, description: &str, permissions: &str) -> Result<(), Error> { + self.update_user_group(id, name, description, permissions) + } + fn delete_user_group(&self, id: i64) -> Result { + self.delete_user_group(id) + } + fn get_user_group(&self, id: i64) -> Result, Error> { + self.get_user_group(id) + } + fn get_user_groups(&self, user_id: i64) -> Result, Error> { + self.get_user_groups(user_id) + } + fn set_user_groups(&self, user_id: i64, group_ids: &[i64]) -> Result<(), Error> { + self.set_user_groups(user_id, group_ids) + } + fn get_user_permissions(&self, user_id: i64) -> Result, Error> { + self.get_user_permissions(user_id) + } + fn cleanup_user_memberships(&self, user_id: i64) -> Result<(), Error> { + self.cleanup_user_memberships(user_id) + } + fn get_group_member_ids(&self, group_id: i64) -> Result, Error> { + self.get_group_member_ids(group_id) + } + fn get_group_members(&self, group_id: i64) -> Result, Error> { + self.get_group_members(group_id) + } + fn record_login_failure(&self, username: &str) -> Result<(u32, Option), Error> { + self.record_login_failure(username) + } + fn check_login_locked(&self, username: &str) -> Result, Error> { + self.check_login_locked(username) + } + fn clear_login_failures(&self, username: &str) -> Result<(), Error> { + self.clear_login_failures(username) + } +} + +impl crate::interface::port::soar::SoarPort for Database { + fn get_setting(&self, key: &str) -> Result, Error> { + self.get_setting(key) + } + fn set_setting(&self, key: &str, value: &str) -> Result<(), Error> { + self.set_setting(key, value) + } + fn insert_acl_rule( + &self, + ip_version: u8, + direction: &str, + list_type: &str, + ip_address: &str, + port: u16, + ) -> Result<(), Error> { + self.insert_acl_rule(ip_version, direction, list_type, ip_address, port) + } + fn delete_acl_rule( + &self, + ip_version: u8, + direction: &str, + list_type: &str, + ip_address: &str, + port: u16, + ) -> Result<(), Error> { + self.delete_acl_rule(ip_version, direction, list_type, ip_address, port) + } + fn insert_playbook( + &self, + name: &str, + trigger_event: &str, + threshold: Option, + count: Option, + window: Option, + cooldown: i64, + ) -> Result { + self.insert_playbook(name, trigger_event, threshold, count, window, cooldown) + } + fn insert_playbook_action( + &self, + playbook_id: i64, + action_order: i64, + action_type: &str, + params_json: &str, + ) -> Result { + self.insert_playbook_action(playbook_id, action_order, action_type, params_json) + } + fn load_playbooks_with_actions(&self) -> Result, Error> { + self.load_playbooks_with_actions() + } + fn update_playbook( + &self, + id: i64, + row: &crate::model::soar::playbook_data::UpdatePlaybookRow, + ) -> Result { + self.update_playbook(id, row) + } + fn update_playbook_enabled(&self, id: i64, enabled: bool) -> Result { + self.update_playbook_enabled(id, enabled) + } + fn delete_playbook(&self, id: i64) -> Result { + self.delete_playbook(id) + } + fn delete_playbook_actions(&self, playbook_id: i64) -> Result<(), Error> { + self.delete_playbook_actions(playbook_id) + } + fn delete_playbook_conditions(&self, playbook_id: i64) -> Result<(), Error> { + self.delete_playbook_conditions(playbook_id) + } + fn seed_default_playbooks(&self) -> Result<(), Error> { + self.seed_default_playbooks() + } + fn insert_playbook_condition( + &self, + playbook_id: i64, + condition_type: &str, + operator: &str, + value: &str, + value2: Option<&str>, + ) -> Result { + self.insert_playbook_condition(playbook_id, condition_type, operator, value, value2) + } + fn load_all_playbook_conditions(&self) -> Result)>, Error> { + self.load_all_playbook_conditions() + } + fn insert_soar_block_rule(&self, source_ip: &str, playbook_id: i64, expires_at: &str) -> Result { + self.insert_soar_block_rule(source_ip, playbook_id, expires_at) + } + fn count_active_soar_blocks(&self) -> Result { + self.count_active_soar_blocks() + } + fn get_active_soar_blocks(&self) -> Result, Error> { + self.get_active_soar_blocks() + } + fn get_soar_block_by_id(&self, id: i64) -> Result, Error> { + self.get_soar_block_by_id(id) + } + fn get_expired_soar_blocks(&self) -> Result, Error> { + self.get_expired_soar_blocks() + } + fn mark_soar_block_unblocked(&self, id: i64) -> Result<(), Error> { + self.mark_soar_block_unblocked(id) + } + fn has_manual_acl_rule(&self, ip_address: &str) -> Result { + self.has_manual_acl_rule(ip_address) + } + fn insert_pending_unblock(&self, source_ip: &str) -> Result { + self.insert_pending_unblock(source_ip) + } + fn load_pending_unblocks(&self) -> Result, Error> { + self.load_pending_unblocks() + } + fn delete_pending_unblock(&self, id: i64) -> Result<(), Error> { + self.delete_pending_unblock(id) + } + fn increment_pending_unblock_retry(&self, id: i64) -> Result<(), Error> { + self.increment_pending_unblock_retry(id) + } + fn insert_soar_execution( + &self, + playbook_id: i64, + source_ip: Option<&str>, + trigger_event: &str, + actions_json: &str, + ) -> Result { + self.insert_soar_execution(playbook_id, source_ip, trigger_event, actions_json) + } + fn list_soar_executions(&self, limit: i64) -> Result, Error> { + self.list_soar_executions(limit) + } + fn load_admin_whitelist(&self) -> Result, Error> { + self.load_admin_whitelist() + } + fn insert_admin_whitelist(&self, ip: &str) -> Result<(), Error> { + self.insert_admin_whitelist(ip) + } + fn delete_admin_whitelist(&self, ip: &str) -> Result<(), Error> { + self.delete_admin_whitelist(ip) + } +} + +impl crate::interface::port::stats::StatsPort for Database { + fn count_weekly_executions(&self, days: i64) -> Result { + self.count_weekly_executions(days) + } + fn count_weekly_blocks(&self, days: i64) -> Result { + self.count_weekly_blocks(days) + } + fn count_weekly_unblocks(&self, days: i64) -> Result { + self.count_weekly_unblocks(days) + } + fn weekly_threat_breakdown(&self, days: i64) -> Result, Error> { + self.weekly_threat_breakdown(days) + } + fn weekly_top_ips(&self, days: i64, limit: i64) -> Result, Error> { + self.weekly_top_ips(days, limit) + } + fn count_acl_rules(&self) -> Result { + self.count_acl_rules() + } +} + +impl crate::interface::port::notification::NotificationConfigPort for Database { + fn get_notification_config(&self, channel: &str) -> Result, Error> { + self.get_notification_config(channel) + } + fn set_notification_config(&self, channel: &str, config_json: &str) -> Result<(), Error> { + self.set_notification_config(channel, config_json) + } +} + +impl crate::interface::port::audit::AuditPort for Database { + fn insert_audit_log(&self, actor: &str, action: &str, detail: &str) -> Result<(), Error> { + self.insert_audit_log(actor, action, detail) + } +} + +impl crate::interface::port::api_key::ApiKeyPort for Database { + fn validate_api_key(&self, api_key: &str) -> Result, Error> { + self.validate_api_key(api_key) + } + fn insert_api_key(&self, key_hash: &str, name: &str, permission_level: &str) -> Result { + self.insert_api_key(key_hash, name, permission_level) + } + fn list_api_keys(&self) -> Result, Error> { + self.list_api_keys() + } + fn delete_api_key(&self, id: i64) -> Result { + self.delete_api_key(id) + } } #[cfg(test)] @@ -1266,7 +2110,16 @@ mod tests { db.insert_acl_rule(4, "source", "blacklist", "192.168.1.1", 80).unwrap(); let rules = db.load_acl_rules().unwrap(); assert_eq!(rules.len(), 1); - assert_eq!(rules[0], (4, "source".to_string(), "blacklist".to_string(), "192.168.1.1".to_string(), 80)); + assert_eq!( + rules[0], + ( + 4, + "source".to_string(), + "blacklist".to_string(), + "192.168.1.1".to_string(), + 80 + ) + ); db.delete_acl_rule(4, "source", "blacklist", "192.168.1.1", 80).unwrap(); let rules = db.load_acl_rules().unwrap(); diff --git a/net-guardia/src/adapter/telegram/mod.rs b/net-guardia/src/adapter/telegram/mod.rs index 2bc30a6..b46365f 100644 --- a/net-guardia/src/adapter/telegram/mod.rs +++ b/net-guardia/src/adapter/telegram/mod.rs @@ -6,26 +6,29 @@ use parking_lot::Mutex; use reqwest::Client; use tracing::{debug, warn}; -use crate::adapter::persistence::Database; -use crate::interface::port::notification::{AlertNotifier, AlertPayload}; -use crate::model::error::notification::NotificationError; +use crate::interface::port::notification::{AlertNotifier, AlertPayload, NotificationConfigPort}; +use crate::interface::port::repository::RepositoryPort; +use crate::interface::port::secret_store::SecretStorePort; +use crate::model::config::constants::TELEGRAM_MAX_RETRIES; use crate::model::error::Error; - -/// Rate limit: max 20 messages per minute. -const MAX_MESSAGES_PER_MINUTE: u32 = 20; -/// Max retries on 429 (rate limited). -const MAX_RETRIES: u32 = 2; +use crate::model::error::notification::NotificationError; /// Telegram Bot API adapter implementing AlertNotifier. pub struct TelegramAdapter { client: Client, - db: Arc, + notif: Arc, + repo: Arc, + secrets: Option>, /// Rate limiter: (count, window_start) rate_state: Mutex<(u32, Instant)>, } impl TelegramAdapter { - pub fn new(db: Arc) -> Result { + pub fn new( + notif: Arc, + repo: Arc, + secrets: Option>, + ) -> Result { let client = Client::builder() .timeout(Duration::from_secs(10)) .build() @@ -35,25 +38,32 @@ impl TelegramAdapter { Ok(Self { client, - db, + notif, + repo, + secrets, rate_state: Mutex::new((0, Instant::now())), }) } /// 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, Error> { - match self.db.get_notification_config("telegram")? { + match self.notif.get_notification_config("telegram")? { Some(json_str) => { - let config: serde_json::Value = serde_json::from_str(&json_str) - .map_err(|e| NotificationError::TelegramApiError { + let config: serde_json::Value = + serde_json::from_str(&json_str).map_err(|e| NotificationError::TelegramApiError { reason: format!("Invalid telegram config JSON: {}", e), })?; - let token = config.get("bot_token") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - let chat_id = config.get("chat_id") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); + let mut token = config.get("bot_token").and_then(|v| v.as_str()).map(|s| s.to_string()); + let chat_id = config.get("chat_id").and_then(|v| v.as_str()).map(|s| s.to_string()); + + // 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()); + } match (token, chat_id) { (Some(t), Some(c)) if !t.is_empty() && !c.is_empty() => Ok(Some((t, c))), @@ -75,7 +85,14 @@ impl TelegramAdapter { *window_start = Instant::now(); } - if *count >= MAX_MESSAGES_PER_MINUTE { + let max_per_min: u32 = self + .repo + .get_setting("telegram_max_messages_per_minute") + .ok() + .flatten() + .and_then(|v| v.parse().ok()) + .unwrap_or(20); + if *count >= max_per_min { return false; } @@ -87,8 +104,9 @@ impl TelegramAdapter { 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); - for attempt in 0..=MAX_RETRIES { - let resp = self.client + for attempt in 0..=TELEGRAM_MAX_RETRIES { + let resp = self + .client .post(&url) .json(&serde_json::json!({ "chat_id": chat_id, @@ -116,7 +134,8 @@ impl TelegramAdapter { if body.contains("chat not found") || body.contains("CHAT_NOT_FOUND") { return Err(NotificationError::TelegramChatNotFound { chat_id: chat_id.to_string(), - }.into()); + } + .into()); } return Err(NotificationError::TelegramAuthError.into()); } @@ -124,20 +143,26 @@ impl TelegramAdapter { if status.as_u16() == 429 { // Rate limited by Telegram let body: serde_json::Value = resp.json().await.unwrap_or_default(); - let retry_after = body.get("parameters") + let retry_after = body + .get("parameters") .and_then(|p| p.get("retry_after")) .and_then(|r| r.as_u64()) .unwrap_or(5); - if attempt < MAX_RETRIES { - warn!("Telegram rate limited, retrying after {}s (attempt {}/{})", - retry_after, attempt + 1, MAX_RETRIES); + if attempt < TELEGRAM_MAX_RETRIES { + warn!( + "Telegram rate limited, retrying after {}s (attempt {}/{})", + retry_after, + attempt + 1, + TELEGRAM_MAX_RETRIES + ); tokio::time::sleep(Duration::from_secs(retry_after)).await; continue; } else { return Err(NotificationError::TelegramRateLimited { retry_after_secs: retry_after, - }.into()); + } + .into()); } } @@ -145,7 +170,8 @@ impl TelegramAdapter { let body = resp.text().await.unwrap_or_default(); return Err(NotificationError::TelegramApiError { reason: format!("HTTP {}: {}", status, body), - }.into()); + } + .into()); } unreachable!() @@ -185,8 +211,17 @@ impl AlertNotifier for TelegramAdapter { }; if !self.check_rate_limit() { - warn!("Telegram rate limit reached ({}/min), dropping alert for IP {}", - MAX_MESSAGES_PER_MINUTE, payload.source_ip); + let max_per_min: u32 = self + .repo + .get_setting("telegram_max_messages_per_minute") + .ok() + .flatten() + .and_then(|v| v.parse().ok()) + .unwrap_or(20); + warn!( + "Telegram rate limit reached ({}/min), dropping alert for IP {}", + max_per_min, payload.source_ip + ); return Ok(()); } @@ -200,7 +235,8 @@ impl AlertNotifier for TelegramAdapter { None => { return Err(NotificationError::NotConfigured { channel: "telegram".to_string(), - }.into()); + } + .into()); } }; @@ -208,6 +244,7 @@ impl AlertNotifier for TelegramAdapter { &bot_token, &chat_id, "✅ NetGuardia connected successfully\n\nTelegram notifications are working.", - ).await + ) + .await } } diff --git a/net-guardia/src/adapter/websocket/alert_websocket.rs b/net-guardia/src/adapter/websocket/alert_websocket.rs index cd4f312..3c328ff 100644 --- a/net-guardia/src/adapter/websocket/alert_websocket.rs +++ b/net-guardia/src/adapter/websocket/alert_websocket.rs @@ -1,20 +1,16 @@ -use actix_web::{web, HttpRequest, HttpResponse, Result}; -use actix_ws::{handle, Message, MessageStream, Session}; +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 crate::core::ml::alert::MLAlert; -use crate::model::ml_detection::AlertMessage; use crate::model::error::http::HttpError; use crate::model::error::misc::MiscError; use crate::model::log::http::HttpLog; +use crate::model::ml_detection::AlertMessage; -pub async fn websocket_alert( - req: HttpRequest, - body: web::Payload, - ai: web::Data, -) -> Result { +pub async fn websocket_alert(req: HttpRequest, body: web::Payload, ai: web::Data) -> Result { let (response, session, msg_stream) = handle(&req, body)?; let broadcast_rx = ai.subscribe_to_alerts(); @@ -88,4 +84,4 @@ async fn send_alert(session: &mut Session, alert: &AlertMessage) -> bool { false } } -} \ No newline at end of file +} diff --git a/net-guardia/src/adapter/websocket/drop_websocket.rs b/net-guardia/src/adapter/websocket/drop_websocket.rs index 32a681c..e212408 100644 --- a/net-guardia/src/adapter/websocket/drop_websocket.rs +++ b/net-guardia/src/adapter/websocket/drop_websocket.rs @@ -1,5 +1,5 @@ -use actix_web::{web, HttpRequest, HttpResponse, Result}; -use actix_ws::{handle, Message, MessageStream, Session}; +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; diff --git a/net-guardia/src/adapter/websocket/flow_websocket.rs b/net-guardia/src/adapter/websocket/flow_websocket.rs index 9bfe505..a81e984 100644 --- a/net-guardia/src/adapter/websocket/flow_websocket.rs +++ b/net-guardia/src/adapter/websocket/flow_websocket.rs @@ -1,6 +1,6 @@ use std::time::Duration; -use actix_web::{web, HttpRequest, HttpResponse}; +use actix_web::{HttpRequest, HttpResponse, web}; use actix_ws::Message; use futures_util::StreamExt; use tokio::time::interval; @@ -33,9 +33,7 @@ pub async fn flow_stats_ws( actix_web::rt::spawn(async move { let mut subscription = default_subscription(); - let mut ticker = interval(Duration::from_secs( - subscription.interval_secs.unwrap_or(5), - )); + let mut ticker = interval(Duration::from_secs(subscription.interval_secs.unwrap_or(5))); loop { tokio::select! { diff --git a/net-guardia/src/adapter/websocket/health_websocket.rs b/net-guardia/src/adapter/websocket/health_websocket.rs index df2a86c..c8a6c3a 100644 --- a/net-guardia/src/adapter/websocket/health_websocket.rs +++ b/net-guardia/src/adapter/websocket/health_websocket.rs @@ -1,5 +1,5 @@ -use actix_web::{web, HttpRequest, HttpResponse, Result}; -use actix_ws::{handle, Message, MessageStream, Session}; +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; @@ -7,8 +7,8 @@ use tokio::sync::broadcast; 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::health::SystemHealthMetrics; +use crate::model::log::http::HttpLog; pub async fn websocket_system_health( req: HttpRequest, @@ -88,4 +88,4 @@ async fn send_metrics(session: &mut Session, metrics: &SystemHealthMetrics) -> b false } } -} \ No newline at end of file +} diff --git a/net-guardia/src/adapter/websocket/routes.rs b/net-guardia/src/adapter/websocket/routes.rs index ee84334..51732e5 100644 --- a/net-guardia/src/adapter/websocket/routes.rs +++ b/net-guardia/src/adapter/websocket/routes.rs @@ -1,12 +1,12 @@ -use actix_web::{web, HttpRequest, HttpResponse, Responder, Scope}; +use actix_web::{HttpRequest, HttpResponse, Responder, Scope, web}; use serde::Deserialize; +use super::{alert_websocket, drop_websocket, flow_websocket, health_websocket}; use crate::core::auth::jwt::JwtService; use crate::core::ebpf::drop_monitor::DropMonitor; +use crate::core::ml::alert::MLAlert; use crate::infrastructure::health::SystemHealth; use crate::infrastructure::statistics::FlowStatistics; -use crate::core::ml::alert::MLAlert; -use super::{alert_websocket, drop_websocket, flow_websocket, health_websocket}; #[derive(Deserialize)] struct WsQuery { @@ -60,7 +60,9 @@ async fn health_ws( } match health_websocket::websocket_system_health(req, stream, health).await { Ok(response) => response, - Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": format!("WebSocket error: {}", err)})), + Err(err) => { + HttpResponse::InternalServerError().json(serde_json::json!({"error": format!("WebSocket error: {}", err)})) + } } } @@ -76,7 +78,9 @@ async fn alerts_ws( } match alert_websocket::websocket_alert(req, stream, ai).await { Ok(response) => response, - Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": format!("WebSocket error: {}", err)})), + Err(err) => { + HttpResponse::InternalServerError().json(serde_json::json!({"error": format!("WebSocket error: {}", err)})) + } } } @@ -92,7 +96,9 @@ async fn flows_ws( } match flow_websocket::flow_stats_ws(req, stream, stats).await { Ok(response) => response, - Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": format!("WebSocket error: {}", err)})), + Err(err) => { + HttpResponse::InternalServerError().json(serde_json::json!({"error": format!("WebSocket error: {}", err)})) + } } } @@ -108,6 +114,8 @@ async fn drops_ws( } match drop_websocket::websocket_drops(req, stream, monitor).await { Ok(response) => response, - Err(err) => HttpResponse::InternalServerError().json(serde_json::json!({"error": format!("WebSocket error: {}", err)})), + Err(err) => { + HttpResponse::InternalServerError().json(serde_json::json!({"error": format!("WebSocket error: {}", err)})) + } } } diff --git a/net-guardia/src/core/acl_service.rs b/net-guardia/src/core/acl_service.rs index c74f2a5..74e4d82 100644 --- a/net-guardia/src/core/acl_service.rs +++ b/net-guardia/src/core/acl_service.rs @@ -20,12 +20,12 @@ pub struct AclService { } impl AclService { - pub fn new( - db: Arc, - access_control: Arc, - geo_block: Arc, - ) -> Self { - Self { db, access_control, geo_block } + pub fn new(db: Arc, access_control: Arc, geo_block: Arc) -> Self { + Self { + db, + access_control, + geo_block, + } } pub async fn add_ipv4( @@ -42,7 +42,11 @@ impl AclService { &address.ip().to_string(), address.port(), ) { - if let Err(rollback_err) = self.access_control.remove_ipv4_list(direction, list_type, address).await { + if let Err(rollback_err) = self + .access_control + .remove_ipv4_list(direction, list_type, address) + .await + { log!(EbpfError::RollbackFailed(rollback_err)); } return Err(e); @@ -64,7 +68,11 @@ impl AclService { &address.ip().to_string(), address.port(), ) { - if let Err(rollback_err) = self.access_control.remove_ipv6_list(direction, list_type, address).await { + if let Err(rollback_err) = self + .access_control + .remove_ipv6_list(direction, list_type, address) + .await + { log!(EbpfError::RollbackFailed(rollback_err)); } return Err(e); @@ -78,7 +86,9 @@ impl AclService { list_type: ListType, address: SocketAddrV4, ) -> Result<(), Error> { - self.access_control.remove_ipv4_list(direction, list_type, address).await?; + self.access_control + .remove_ipv4_list(direction, list_type, address) + .await?; if let Err(e) = self.db.delete_acl_rule( 4, direction_str(direction), @@ -100,7 +110,9 @@ impl AclService { list_type: ListType, address: SocketAddrV6, ) -> Result<(), Error> { - self.access_control.remove_ipv6_list(direction, list_type, address).await?; + self.access_control + .remove_ipv6_list(direction, list_type, address) + .await?; if let Err(e) = self.db.delete_acl_rule( 6, direction_str(direction), diff --git a/net-guardia/src/core/auth/extractor.rs b/net-guardia/src/core/auth/extractor.rs index c422f2b..e2f7ce0 100644 --- a/net-guardia/src/core/auth/extractor.rs +++ b/net-guardia/src/core/auth/extractor.rs @@ -1,4 +1,4 @@ -use std::future::{ready, Ready}; +use std::future::{Ready, ready}; use actix_web::dev::Payload; use actix_web::{FromRequest, HttpMessage, HttpRequest}; diff --git a/net-guardia/src/core/auth/https_redirect.rs b/net-guardia/src/core/auth/https_redirect.rs new file mode 100644 index 0000000..20a05bc --- /dev/null +++ b/net-guardia/src/core/auth/https_redirect.rs @@ -0,0 +1,147 @@ +use std::future::{Future, Ready, ready}; +use std::pin::Pin; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use actix_web::body::EitherBody; +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; + +/// 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 +/// without dots (e.g., "netguardia"). Rejects public IPs and arbitrary domains +/// to prevent host-header injection / open redirect attacks. +fn is_safe_redirect_host(host: &str) -> bool { + // Strip port if present (e.g., "192.168.1.1:8443" → "192.168.1.1") + let hostname = if host.starts_with('[') { + // IPv6 bracket: [::1]:8443 + host.find(']').map(|i| &host[1..i]).unwrap_or(host) + } else { + host.split(':').next().unwrap_or(host) + }; + + // Localhost + if hostname == "localhost" || hostname == "127.0.0.1" || hostname == "::1" { + return true; + } + + // .local mDNS hostnames (e.g., "netguardia.local") + if hostname.ends_with(".local") { + return true; + } + + // Bare hostname without dots (e.g., "netguardia", not a public domain) + if !hostname.contains('.') && !hostname.contains(':') { + return true; + } + + // Try parsing as IP — allow private ranges only + if let Ok(ip) = hostname.parse::() { + return match ip { + std::net::IpAddr::V4(v4) => { + let o = v4.octets(); + o[0] == 10 || (o[0] == 172 && (16..=31).contains(&o[1])) || (o[0] == 192 && o[1] == 168) || o[0] == 127 + } + std::net::IpAddr::V6(v6) => v6.is_loopback() || (v6.segments()[0] & 0xfe00) == 0xfc00, + }; + } + + false +} + +pub struct HttpsRedirect; + +impl Transform for HttpsRedirect +where + S: Service, Error = ActixError> + 'static, + B: 'static, +{ + type Response = ServiceResponse>; + type Error = ActixError; + type Transform = HttpsRedirectService; + type InitError = (); + type Future = Ready>; + + fn new_transform(&self, service: S) -> Self::Future { + ready(Ok(HttpsRedirectService { + service: std::rc::Rc::new(service), + })) + } +} + +pub struct HttpsRedirectService { + service: std::rc::Rc, +} + +impl Service for HttpsRedirectService +where + S: Service, Error = ActixError> + 'static, + B: 'static, +{ + type Response = ServiceResponse>; + type Error = ActixError; + type Future = Pin>>>; + + fn poll_ready(&self, ctx: &mut core::task::Context<'_>) -> std::task::Poll> { + self.service.poll_ready(ctx) + } + + fn call(&self, req: ServiceRequest) -> Self::Future { + let service = std::rc::Rc::clone(&self.service); + + Box::pin(async move { + // Check if force_https is enabled + let force = req + .app_data::>() + .map(|flag| flag.load(Ordering::Relaxed)) + .unwrap_or(false); + + if !force { + let res = service.call(req).await?.map_into_left_body(); + return Ok(res); + } + + // Allow health check endpoints without redirect (for load balancer probes) + let path = req.path(); + if path.starts_with("/health/") { + let res = service.call(req).await?.map_into_left_body(); + return Ok(res); + } + + // Check X-Forwarded-Proto (set by reverse proxy / load balancer) + let proto = req + .headers() + .get("X-Forwarded-Proto") + .and_then(|v| v.to_str().ok()) + .unwrap_or("http"); + + if proto == "https" { + let res = service.call(req).await?.map_into_left_body(); + return Ok(res); + } + + // Build HTTPS redirect URL. + // Validate host to prevent host-header injection / open redirect: + // only allow private IPs, localhost, and .local hostnames. + let host = req.connection_info().host().to_string(); + let uri = req.uri().clone(); + + if !is_safe_redirect_host(&host) { + let resp = HttpResponse::BadRequest().finish(); + return Ok(req.into_response(resp).map_into_right_body()); + } + + let redirect_url = format!("https://{}{}", host, uri); + let resp = HttpResponse::MovedPermanently() + .insert_header((header::LOCATION, redirect_url)) + // HSTS: 1 year, include subdomains + .insert_header(("Strict-Transport-Security", "max-age=31536000; includeSubDomains")) + .finish(); + Ok(req.into_response(resp).map_into_right_body()) + }) + } +} diff --git a/net-guardia/src/core/auth/jwt.rs b/net-guardia/src/core/auth/jwt.rs index 176befe..7b62106 100644 --- a/net-guardia/src/core/auth/jwt.rs +++ b/net-guardia/src/core/auth/jwt.rs @@ -1,9 +1,11 @@ -use jsonwebtoken::{decode, encode, errors::ErrorKind, Algorithm, DecodingKey, EncodingKey, Header, Validation}; +use std::sync::Arc; -use crate::interface::port::repository::RepositoryPort; +use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode, errors::ErrorKind}; + +use crate::interface::port::secret_store::SecretStorePort; use crate::model::auth::Claims; -use crate::model::error::auth::AuthError; use crate::model::error::Error; +use crate::model::error::auth::AuthError; pub struct JwtService { encoding_key: EncodingKey, @@ -12,13 +14,13 @@ pub struct JwtService { } impl JwtService { - pub fn new(db: &dyn RepositoryPort, expiry_hours: u64) -> Result { - let raw_bytes = match db.get_setting("jwt_secret")? { + pub fn new(secrets: &Arc, expiry_hours: u64) -> Result { + let raw_bytes = match secrets.get_secret("jwt_secret")? { Some(hex_str) => hex_decode(&hex_str).map_err(|_| AuthError::InvalidToken)?, None => { use rand::Rng; let secret: [u8; 32] = rand::rng().random(); - db.set_setting("jwt_secret", &hex_encode(&secret))?; + secrets.set_secret("jwt_secret", &hex_encode(&secret))?; secret.to_vec() } }; @@ -30,10 +32,16 @@ impl JwtService { }) } - pub fn create_token(&self, user_id: i64, username: &str, role: &str, permissions: Vec) -> Result { + pub fn create_token( + &self, + user_id: i64, + username: &str, + role: &str, + permissions: Vec, + ) -> Result { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) - .unwrap() + .unwrap_or(std::time::Duration::ZERO) .as_secs(); let claims = Claims { @@ -44,13 +52,12 @@ impl JwtService { exp: (now + self.expiry_hours * 3600) as usize, }; - encode(&Header::default(), &claims, &self.encoding_key) - .map_err(|_| AuthError::InvalidToken.into()) + encode(&Header::default(), &claims, &self.encoding_key).map_err(|_| AuthError::InvalidToken.into()) } pub fn validate_token(&self, token: &str) -> Result { - let token_data = decode::(token, &self.decoding_key, &Validation::new(Algorithm::HS256)) - .map_err(|e| { + let token_data = + decode::(token, &self.decoding_key, &Validation::new(Algorithm::HS256)).map_err(|e| { match e.kind() { ErrorKind::ExpiredSignature => Error::from(AuthError::TokenExpired), _ => Error::from(AuthError::InvalidToken), @@ -83,10 +90,12 @@ fn hex_decode(hex: &str) -> Result, &'static str> { mod tests { use super::*; use crate::adapter::persistence::Database; + use crate::infrastructure::secret_store::SecretStore; fn test_jwt_service() -> JwtService { - let db = Database::new(":memory:").unwrap(); - JwtService::new(&db, 24).unwrap() + let db = Arc::new(Database::new(":memory:").unwrap()); + let secrets: Arc = Arc::new(SecretStore::new(db)); + JwtService::new(&secrets, 24).unwrap() } #[test] @@ -110,8 +119,9 @@ mod tests { #[test] fn test_expired_token() { - let db = Database::new(":memory:").unwrap(); - let jwt = JwtService::new(&db, 0).unwrap(); // 0 hours = immediate expiry + let db = Arc::new(Database::new(":memory:").unwrap()); + let secrets: Arc = Arc::new(SecretStore::new(db)); + let jwt = JwtService::new(&secrets, 0).unwrap(); // 0 hours = immediate expiry // Create token with 0 hour expiry — it expires in the past let claims = Claims { @@ -128,14 +138,15 @@ mod tests { #[test] fn test_jwt_secret_persistence() { - let db = Database::new(":memory:").unwrap(); + let db = Arc::new(Database::new(":memory:").unwrap()); + let secrets: Arc = Arc::new(SecretStore::new(db)); // First creation generates and stores secret - let jwt1 = JwtService::new(&db, 24).unwrap(); + let jwt1 = JwtService::new(&secrets, 24).unwrap(); let token = jwt1.create_token(1, "admin", "admin", vec![]).unwrap(); // Second creation reuses stored secret - let jwt2 = JwtService::new(&db, 24).unwrap(); + let jwt2 = JwtService::new(&secrets, 24).unwrap(); let claims = jwt2.validate_token(&token).unwrap(); assert_eq!(claims.username, "admin"); } diff --git a/net-guardia/src/core/auth/middleware.rs b/net-guardia/src/core/auth/middleware.rs index c84127b..6cb53e6 100644 --- a/net-guardia/src/core/auth/middleware.rs +++ b/net-guardia/src/core/auth/middleware.rs @@ -1,15 +1,16 @@ -use std::future::{ready, Future, Ready}; +use std::future::{Future, Ready, ready}; use std::pin::Pin; use std::rc::Rc; use actix_web::body::EitherBody; use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform}; -use actix_web::{web, Error as ActixError, HttpMessage, HttpResponse}; +use actix_web::{Error as ActixError, HttpMessage, HttpResponse, web}; use macros::log; -use crate::adapter::persistence::Database; use crate::core::auth::jwt::JwtService; +use crate::interface::port::api_key::ApiKeyPort; +use crate::interface::port::repository::RepositoryPort; use crate::model::error::auth::AuthError; pub struct AuthMiddleware; @@ -64,7 +65,9 @@ fn required_permission(path: &str, method: &actix_web::http::Method) -> Option>>>; - fn poll_ready( - &self, - ctx: &mut core::task::Context<'_>, - ) -> std::task::Poll> { + fn poll_ready(&self, ctx: &mut core::task::Context<'_>) -> std::task::Poll> { self.service.poll_ready(ctx) } @@ -102,10 +102,7 @@ where 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/") - { + 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); } @@ -114,8 +111,8 @@ where let jwt_service = match req.app_data::>() { Some(s) => s.clone(), None => { - let resp = HttpResponse::InternalServerError() - .json(serde_json::json!({"error": "Auth not configured"})); + let resp = + HttpResponse::InternalServerError().json(serde_json::json!({"error": "Auth not configured"})); return Ok(req.into_response(resp).map_into_right_body()); } }; @@ -135,43 +132,53 @@ where match jwt_service.validate_token(token) { Ok(c) => c, Err(_) => { - let resp = HttpResponse::Unauthorized() - .json(serde_json::json!({"error": "Invalid or expired token"})); + 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") { - // MCP API key auth with rate limiting + // API key auth with rate limiting let api_key = api_key_header.to_str().unwrap_or(""); - let db = match req.app_data::>() { + let api_key_port = match req.app_data::>() { Some(d) => d.clone(), None => { let resp = HttpResponse::InternalServerError() - .json(serde_json::json!({"error": "Database not configured"})); + .json(serde_json::json!({"error": "ApiKeyPort not configured"})); + return Ok(req.into_response(resp).map_into_right_body()); + } + }; + let repo = match req.app_data::>() { + Some(d) => d.clone(), + None => { + let resp = HttpResponse::InternalServerError() + .json(serde_json::json!({"error": "RepositoryPort 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)) = db.check_login_locked(&rate_key) { - let resp = HttpResponse::TooManyRequests() - .json(serde_json::json!({ - "error": "Too many failed API key attempts", - "retry_after_secs": remaining, - })); + 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) { + 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 db.validate_api_key(api_key) { + match api_key_port.validate_api_key(api_key) { Ok(Some(key_claims)) => { - if let Err(e) = db.clear_login_failures(&rate_key) { + if let Err(e) = repo.clear_login_failures(&rate_key) { log!(AuthError::LoginClearError(e)); } key_claims } Ok(None) => { - if let Err(e) = db.record_login_failure(&rate_key) { + if let Err(e) = repo.record_login_failure(&rate_key) { log!(AuthError::LoginFailureTrackingError(e)); } let resp = HttpResponse::Unauthorized() @@ -185,8 +192,8 @@ where } } } else { - let resp = HttpResponse::Unauthorized() - .json(serde_json::json!({"error": "Missing authorization header"})); + let resp = + HttpResponse::Unauthorized().json(serde_json::json!({"error": "Missing authorization header"})); return Ok(req.into_response(resp).map_into_right_body()); }; @@ -194,8 +201,7 @@ where if let Some(required) = required_permission(&path, req.method()) && !claims.permissions.contains(&required) { - let resp = HttpResponse::Forbidden() - .json(serde_json::json!({"error": "Insufficient permissions"})); + let resp = HttpResponse::Forbidden().json(serde_json::json!({"error": "Insufficient permissions"})); return Ok(req.into_response(resp).map_into_right_body()); } diff --git a/net-guardia/src/core/auth/mod.rs b/net-guardia/src/core/auth/mod.rs index 8802259..e3f4c74 100644 --- a/net-guardia/src/core/auth/mod.rs +++ b/net-guardia/src/core/auth/mod.rs @@ -1,4 +1,5 @@ pub mod extractor; +pub mod https_redirect; pub mod jwt; pub mod middleware; pub mod password; diff --git a/net-guardia/src/core/auth/password.rs b/net-guardia/src/core/auth/password.rs index 53a6dd3..0530302 100644 --- a/net-guardia/src/core/auth/password.rs +++ b/net-guardia/src/core/auth/password.rs @@ -1,9 +1,9 @@ -use argon2::password_hash::rand_core::OsRng; use argon2::password_hash::SaltString; +use argon2::password_hash::rand_core::OsRng; use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier}; -use crate::model::error::auth::AuthError; use crate::model::error::Error; +use crate::model::error::auth::AuthError; pub fn hash_password(password: &str) -> Result { let salt = SaltString::generate(&mut OsRng); @@ -16,9 +16,7 @@ pub fn hash_password(password: &str) -> Result { pub fn verify_password(password: &str, hash: &str) -> Result { let parsed = PasswordHash::new(hash).map_err(|_| AuthError::InvalidCredentials)?; - Ok(Argon2::default() - .verify_password(password.as_bytes(), &parsed) - .is_ok()) + Ok(Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok()) } #[cfg(test)] diff --git a/net-guardia/src/core/auth/setup_guard.rs b/net-guardia/src/core/auth/setup_guard.rs index 8e48e6c..1767cb0 100644 --- a/net-guardia/src/core/auth/setup_guard.rs +++ b/net-guardia/src/core/auth/setup_guard.rs @@ -1,11 +1,11 @@ -use std::future::{ready, Future, Ready}; +use std::future::{Future, Ready, ready}; use std::pin::Pin; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use actix_web::body::EitherBody; use actix_web::dev::{Service, ServiceRequest, ServiceResponse, Transform}; -use actix_web::{web, Error as ActixError, HttpResponse}; +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. @@ -44,10 +44,7 @@ where type Error = ActixError; type Future = Pin>>>; - fn poll_ready( - &self, - ctx: &mut core::task::Context<'_>, - ) -> std::task::Poll> { + fn poll_ready(&self, ctx: &mut core::task::Context<'_>) -> std::task::Poll> { self.service.poll_ready(ctx) } @@ -67,8 +64,7 @@ where // Normal mode: pass through, but block setup mutation endpoints. // Allow /api/setup/status (read-only) so frontend can check setup state. if path.starts_with("/api/setup/") && path != "/api/setup/status" { - let resp = HttpResponse::Gone() - .json(serde_json::json!({"error": "Setup already completed"})); + let resp = HttpResponse::Gone().json(serde_json::json!({"error": "Setup already completed"})); return Ok(req.into_response(resp).map_into_right_body()); } let res = service.call(req).await?.map_into_left_body(); @@ -86,12 +82,11 @@ where } // Block all other API routes with 503 - let resp = HttpResponse::ServiceUnavailable() - .json(serde_json::json!({ - "error": "System setup in progress", - "setup_required": true, - "message": "Please complete the setup wizard at /setup" - })); + let resp = HttpResponse::ServiceUnavailable().json(serde_json::json!({ + "error": "System setup in progress", + "setup_required": true, + "message": "Please complete the setup wizard at /setup" + })); Ok(req.into_response(resp).map_into_right_body()) }) } diff --git a/net-guardia/src/core/config_service.rs b/net-guardia/src/core/config_service.rs index ca07490..599b0da 100644 --- a/net-guardia/src/core/config_service.rs +++ b/net-guardia/src/core/config_service.rs @@ -1,43 +1,83 @@ use std::sync::Arc; use crate::interface::port::repository::RepositoryPort; +use crate::interface::port::secret_store::SecretStorePort; use crate::model::error::Error; use crate::model::error::misc::MiscError; +/// Keys that must be routed through SecretStore instead of plaintext settings. +const SECRET_KEYS: &[&str] = &["smtp_password"]; + /// Valid eBPF pipeline stage names. const VALID_PIPELINE_STAGES: &[&str] = &["access_control", "rate_limit", "service"]; /// All configurable settings grouped by section. const SETTINGS_MAP: &[(&str, &[&str])] = &[ - ("network", &["ingress_interface", "egress_interface", "refresh_interval"]), - ("http", &["http_port", "jwt_expiry_hours"]), - ("inference", &["max_concurrent_flows", "min_packets_for_inference", - "inference_interval_secs", "aggregator_window_secs", - "inference_batch_size", "traffic_logging_mode", - "traffic_log_csv_path"]), - ("xdp", &["combined_queue_count", "channel_size", "fill_queue_size", "comp_queue_size", - "tx_queue_size", "rx_queue_size", "frame_size", "frame_count", - "packet_buffer_size", "buffer_pool_capacity"]), - ("models", &["deep_autoencoder_name", "classifier_name", "models_config_name"]), + ( + "network", + &["ingress_interface", "egress_interface", "refresh_interval"], + ), + ("http", &["http_port", "jwt_expiry_hours", "force_https"]), + ( + "inference", + &[ + "max_concurrent_flows", + "min_packets_for_inference", + "inference_interval_secs", + "aggregator_window_secs", + "inference_batch_size", + "traffic_logging_mode", + "traffic_log_csv_path", + ], + ), + ( + "xdp", + &[ + "combined_queue_count", + "channel_size", + "fill_queue_size", + "comp_queue_size", + "tx_queue_size", + "rx_queue_size", + "frame_size", + "frame_count", + "packet_buffer_size", + "buffer_pool_capacity", + ], + ), + ( + "models", + &["deep_autoencoder_name", "classifier_name", "models_config_name"], + ), + // report_dir and log_dir intentionally NOT configurable via API to prevent + // arbitrary directory write/read. They use hardcoded safe defaults. ("misc", &["geoip_db_name"]), - ("smtp", &["smtp_host", "smtp_port", "smtp_username", "smtp_password", "smtp_recipient"]), + ("soar", &["soar_max_auto_block_cap", "soar_max_ttl_secs"]), + ("ml", &["ml_drift_window_secs"]), + ("telegram", &["telegram_max_messages_per_minute"]), + ("dns", &["dns_max_domains_per_request"]), + ("smtp", &["smtp_host", "smtp_port", "smtp_username", "smtp_recipient"]), ]; /// Domain service for system configuration read/write. pub struct ConfigService { db: Arc, + secrets: Option>, } impl ConfigService { pub fn new(db: Arc) -> Self { - Self { db } + Self { db, secrets: None } + } + + pub fn with_secret_store(mut self, secrets: Arc) -> Self { + self.secrets = Some(secrets); + self } /// Read all user-configurable settings from DB as structured JSON. pub fn get_config(&self) -> serde_json::Value { - let get = |key: &str| -> String { - self.db.get_setting(key).ok().flatten().unwrap_or_default() - }; + let get = |key: &str| -> String { self.db.get_setting(key).ok().flatten().unwrap_or_default() }; serde_json::json!({ "network": { @@ -48,6 +88,7 @@ impl ConfigService { "http": { "http_port": get("http_port"), "jwt_expiry_hours": get("jwt_expiry_hours"), + "force_https": get("force_https"), }, "inference": { "max_concurrent_flows": get("max_concurrent_flows"), @@ -78,6 +119,19 @@ impl ConfigService { "misc": { "geoip_db_name": get("geoip_db_name"), }, + "soar": { + "soar_max_auto_block_cap": get("soar_max_auto_block_cap"), + "soar_max_ttl_secs": get("soar_max_ttl_secs"), + }, + "ml": { + "ml_drift_window_secs": get("ml_drift_window_secs"), + }, + "telegram": { + "telegram_max_messages_per_minute": get("telegram_max_messages_per_minute"), + }, + "dns": { + "dns_max_domains_per_request": get("dns_max_domains_per_request"), + }, "pipeline": { "ingress": get("pipeline_ingress"), "egress": get("pipeline_egress"), @@ -108,6 +162,26 @@ impl ConfigService { } } + // Route secret keys through SecretStore (encrypted storage) + if let Some(ref secrets) = self.secrets { + for key in SECRET_KEYS { + // Secret keys live under their parent section (e.g., smtp_password under smtp) + 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) + { + secrets.set_secret(key, &val)?; + // Clear plaintext residue from settings table to prevent + // pre-migration plaintext passwords from persisting. + let _ = self.db.set_setting(key, ""); + updated.push(key.to_string()); + } + } + } + // Pipeline settings — validate stage names 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")] { @@ -122,7 +196,8 @@ impl ConfigService { stage, VALID_PIPELINE_STAGES.join(", ") ), - }.into()); + } + .into()); } } } diff --git a/net-guardia/src/core/correlation/botnet.rs b/net-guardia/src/core/correlation/botnet.rs new file mode 100644 index 0000000..8607380 --- /dev/null +++ b/net-guardia/src/core/correlation/botnet.rs @@ -0,0 +1,190 @@ +use std::collections::HashSet; +use std::time::{Duration, Instant}; + +use dashmap::DashMap; +use macros::log; +use tokio::sync::mpsc; + +use crate::model::detection::ml_detection::AlertMessage; +use crate::model::event::{DetectionEvent, DetectionSource}; +use crate::model::log::detection::DetectionLog; + +/// Window within which unique sources are counted toward a single destination. +const BOTNET_WINDOW_SECS: u64 = 300; // 5 minutes + +/// Minimum unique source IPs targeting the same destination to trigger a botnet alert. +const BOTNET_THRESHOLD: usize = 10; + +/// Maximum tracked destination IPs to bound memory. +const MAX_TRACKED_DSTS: usize = 10_000; + +struct TimedSourceSet { + sources: HashSet, + window_start: Instant, + /// Most recent alert to this destination (used for protocol/confidence in DetectionEvent). + last_alert: AlertMessage, +} + +/// Detects coordinated attacks: multiple source IPs targeting the same destination IP:port. +/// Uses a DashMap for lock-free concurrent access. +pub struct BotnetDetector { + /// dst_ip → set of unique src_ips within the time window + state: DashMap, + window: Duration, + threshold: usize, +} + +impl BotnetDetector { + pub fn new() -> Self { + Self { + state: DashMap::new(), + window: Duration::from_secs(BOTNET_WINDOW_SECS), + threshold: BOTNET_THRESHOLD, + } + } + + /// Process an alert and return a DetectionEvent if the botnet threshold is crossed. + pub fn process(&self, alert: &AlertMessage, detection_tx: &mpsc::Sender) { + let key = alert.dst_ip.clone(); + let now = Instant::now(); + + let should_alert = { + let mut entry = self.state.entry(key.clone()).or_insert_with(|| TimedSourceSet { + sources: HashSet::new(), + window_start: now, + last_alert: alert.clone(), + }); + + let set = entry.value_mut(); + + // Reset window if expired + if now.duration_since(set.window_start) >= self.window { + set.sources.clear(); + set.window_start = now; + } + + set.sources.insert(alert.src_ip.clone()); + set.last_alert = alert.clone(); + + if set.sources.len() >= self.threshold { + Some(set.sources.len()) + } else { + None + } + }; + + if let Some(unique_sources) = should_alert { + log!(DetectionLog::BotnetDetected { + dst_ip: key.clone(), + unique_sources, + window_secs: BOTNET_WINDOW_SECS, + }); + + // source_ip = the latest attacker; dest_ip = the victim being targeted. + // SOAR blocks source_ip, so we must NOT put the victim here. + let event = DetectionEvent { + source: DetectionSource::Correlation, + attack_type: "threat_detected".to_string(), + confidence: 0.85, + source_ip: alert.src_ip.clone(), + dest_ip: key.clone(), + protocol: alert.protocol, + packet_count: 0, + flow_duration_us: 0, + }; + + let _ = detection_tx.try_send(event); + + // Reset after alerting to avoid repeated alerts within same window + if let Some(mut entry) = self.state.get_mut(&key) { + entry.sources.clear(); + entry.window_start = now; + } + } + } + + /// Remove expired entries. Returns number of entries removed. + pub fn cleanup(&self) -> usize { + let now = Instant::now(); + let window = self.window; + let before = self.state.len(); + + self.state + .retain(|_, set| now.duration_since(set.window_start) < window); + + // Enforce max capacity by removing oldest entries if over limit + if self.state.len() > MAX_TRACKED_DSTS { + let excess = self.state.len() - MAX_TRACKED_DSTS; + let keys_to_remove: Vec = self.state.iter().take(excess).map(|e| e.key().clone()).collect(); + for key in keys_to_remove { + self.state.remove(&key); + } + } + + before.saturating_sub(self.state.len()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_alert(src_ip: &str, dst_ip: &str) -> AlertMessage { + AlertMessage { + timestamp: 0, + flow_key: String::new(), + src_ip: src_ip.to_string(), + dst_ip: dst_ip.to_string(), + src_port: 12345, + dst_port: 80, + protocol: 6, + is_attack: true, + attack_type: Some("DDoS".to_string()), + confidence: 0.9, + ae_score: 0.5, + packet_count: 100, + flow_duration_us: 1_000_000, + } + } + + #[tokio::test] + async fn botnet_threshold_triggers_alert() { + let detector = BotnetDetector::new(); + let (tx, mut rx) = mpsc::channel(64); + + // Send alerts from 9 different sources (below threshold) + for i in 0..9 { + let alert = make_alert(&format!("10.0.0.{i}"), "192.168.1.1"); + detector.process(&alert, &tx); + } + assert!(rx.try_recv().is_err(), "Should not alert below threshold"); + + // 10th source should trigger + let alert = make_alert("10.0.0.9", "192.168.1.1"); + detector.process(&alert, &tx); + let event = rx.try_recv().expect("Should alert at threshold"); + assert_eq!(event.source, DetectionSource::Correlation); + // source_ip must be the attacker, NOT the victim + assert_eq!(event.source_ip, "10.0.0.9"); + assert_eq!(event.dest_ip, "192.168.1.1"); + } + + #[tokio::test] + async fn cleanup_removes_expired() { + let detector = BotnetDetector { + state: DashMap::new(), + window: Duration::from_millis(10), + threshold: BOTNET_THRESHOLD, + }; + let (tx, _rx) = mpsc::channel(64); + + let alert = make_alert("10.0.0.1", "192.168.1.1"); + detector.process(&alert, &tx); + assert_eq!(detector.state.len(), 1); + + std::thread::sleep(Duration::from_millis(20)); + let removed = detector.cleanup(); + assert_eq!(removed, 1); + assert_eq!(detector.state.len(), 0); + } +} diff --git a/net-guardia/src/core/correlation/engine.rs b/net-guardia/src/core/correlation/engine.rs new file mode 100644 index 0000000..fc6a34a --- /dev/null +++ b/net-guardia/src/core/correlation/engine.rs @@ -0,0 +1,76 @@ +use std::time::Duration; + +use macros::log; +use tokio::sync::{broadcast, mpsc}; + +use crate::core::correlation::botnet::BotnetDetector; +use crate::core::correlation::lateral::LateralMovementDetector; +use crate::core::correlation::scan::ScanDetector; +use crate::model::detection::ml_detection::AlertMessage; +use crate::model::event::DetectionEvent; +use crate::model::log::detection::DetectionLog; + +/// How often to sweep expired correlation state. +const CLEANUP_INTERVAL_SECS: u64 = 60; + +/// Coordinates cross-flow correlation detectors (botnet, scan, lateral movement). +/// Subscribes to ML AlertMessage broadcast and feeds enriched DetectionEvents +/// to the DetectionOrchestrator for dedup and SOAR routing. +pub struct CorrelationEngine { + botnet: BotnetDetector, + scan: ScanDetector, + lateral: LateralMovementDetector, + alert_rx: broadcast::Receiver, + detection_tx: mpsc::Sender, +} + +impl CorrelationEngine { + pub fn new(alert_rx: broadcast::Receiver, detection_tx: mpsc::Sender) -> Self { + Self { + botnet: BotnetDetector::new(), + scan: ScanDetector::new(), + lateral: LateralMovementDetector::new(), + alert_rx, + detection_tx, + } + } + + /// Spawn the correlation engine as a background task. + pub fn start(self) { + tokio::spawn(async move { self.run().await }); + } + + async fn run(mut self) { + log!(DetectionLog::CorrelationEngineStarted); + + let mut cleanup_interval = tokio::time::interval(Duration::from_secs(CLEANUP_INTERVAL_SECS)); + + loop { + tokio::select! { + result = self.alert_rx.recv() => { + match result { + Ok(alert) => self.process_alert(&alert), + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => break, + } + } + _ = cleanup_interval.tick() => { + self.cleanup(); + } + } + } + } + + fn process_alert(&self, alert: &AlertMessage) { + self.botnet.process(alert, &self.detection_tx); + self.scan.process(alert, &self.detection_tx); + self.lateral.process(alert, &self.detection_tx); + } + + fn cleanup(&self) { + let removed = self.botnet.cleanup() + self.scan.cleanup() + self.lateral.cleanup(); + if removed > 0 { + log!(DetectionLog::CorrelationCleanup { removed }); + } + } +} diff --git a/net-guardia/src/core/correlation/lateral.rs b/net-guardia/src/core/correlation/lateral.rs new file mode 100644 index 0000000..168de0b --- /dev/null +++ b/net-guardia/src/core/correlation/lateral.rs @@ -0,0 +1,229 @@ +use std::collections::HashSet; +use std::net::IpAddr; +use std::time::{Duration, Instant}; + +use dashmap::DashMap; +use macros::log; +use tokio::sync::mpsc; + +use crate::model::detection::ml_detection::AlertMessage; +use crate::model::event::{DetectionEvent, DetectionSource}; +use crate::model::log::detection::DetectionLog; + +/// Window within which unique internal destinations are counted per source. +const LATERAL_WINDOW_SECS: u64 = 300; // 5 minutes + +/// Minimum unique internal destination IPs to trigger a lateral movement alert. +const LATERAL_THRESHOLD: usize = 5; + +/// Maximum tracked source IPs to bound memory. +const MAX_TRACKED_SRCS: usize = 10_000; + +struct TimedDestSet { + dests: HashSet, + window_start: Instant, +} + +/// Detects lateral movement: an internal IP reaching many other internal IPs. +pub struct LateralMovementDetector { + /// src_ip → set of unique internal dst_ips within the time window + state: DashMap, + window: Duration, + threshold: usize, +} + +impl LateralMovementDetector { + pub fn new() -> Self { + Self { + state: DashMap::new(), + window: Duration::from_secs(LATERAL_WINDOW_SECS), + threshold: LATERAL_THRESHOLD, + } + } + + /// Process an alert. Only tracks internal-to-internal flows. + pub fn process(&self, alert: &AlertMessage, detection_tx: &mpsc::Sender) { + // Only track internal-to-internal flows + if !is_internal_ip(&alert.src_ip) || !is_internal_ip(&alert.dst_ip) { + return; + } + + let key = alert.src_ip.clone(); + let now = Instant::now(); + + let should_alert = { + let mut entry = self.state.entry(key.clone()).or_insert_with(|| TimedDestSet { + dests: HashSet::new(), + window_start: now, + }); + + let set = entry.value_mut(); + + if now.duration_since(set.window_start) >= self.window { + set.dests.clear(); + set.window_start = now; + } + + set.dests.insert(alert.dst_ip.clone()); + + if set.dests.len() >= self.threshold { + Some(set.dests.len()) + } else { + None + } + }; + + if let Some(unique_dests) = should_alert { + log!(DetectionLog::LateralMovementDetected { + src_ip: key.clone(), + unique_dests, + window_secs: LATERAL_WINDOW_SECS, + }); + + let event = DetectionEvent { + source: DetectionSource::Correlation, + attack_type: "threat_detected".to_string(), + confidence: 0.75, + source_ip: key.clone(), + dest_ip: alert.dst_ip.clone(), + protocol: alert.protocol, + packet_count: 0, + flow_duration_us: 0, + }; + + let _ = detection_tx.try_send(event); + + // Reset after alerting + if let Some(mut entry) = self.state.get_mut(&key) { + entry.dests.clear(); + entry.window_start = now; + } + } + } + + /// Remove expired entries. Returns number of entries removed. + pub fn cleanup(&self) -> usize { + let now = Instant::now(); + let window = self.window; + let before = self.state.len(); + + self.state + .retain(|_, set| now.duration_since(set.window_start) < window); + + if self.state.len() > MAX_TRACKED_SRCS { + let excess = self.state.len() - MAX_TRACKED_SRCS; + let keys_to_remove: Vec = self.state.iter().take(excess).map(|e| e.key().clone()).collect(); + for key in keys_to_remove { + self.state.remove(&key); + } + } + + before.saturating_sub(self.state.len()) + } +} + +/// Check if an IP address string represents a private/internal address. +/// RFC 1918: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 +/// RFC 4193: fc00::/7 (IPv6 unique local) +pub fn is_internal_ip(ip_str: &str) -> bool { + let Ok(ip) = ip_str.parse::() else { + return false; + }; + + match ip { + IpAddr::V4(v4) => { + let octets = v4.octets(); + // 10.0.0.0/8 + octets[0] == 10 + // 172.16.0.0/12 + || (octets[0] == 172 && (16..=31).contains(&octets[1])) + // 192.168.0.0/16 + || (octets[0] == 192 && octets[1] == 168) + // 127.0.0.0/8 (loopback) + || octets[0] == 127 + } + IpAddr::V6(v6) => { + let segments = v6.segments(); + // fc00::/7 + (segments[0] & 0xfe00) == 0xfc00 + // ::1 (loopback) + || v6.is_loopback() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_internal_ip_detection() { + assert!(is_internal_ip("10.0.0.1")); + assert!(is_internal_ip("10.255.255.255")); + assert!(is_internal_ip("172.16.0.1")); + assert!(is_internal_ip("172.31.255.255")); + assert!(is_internal_ip("192.168.0.1")); + assert!(is_internal_ip("192.168.255.255")); + assert!(is_internal_ip("127.0.0.1")); + + assert!(!is_internal_ip("8.8.8.8")); + assert!(!is_internal_ip("172.32.0.1")); + assert!(!is_internal_ip("192.169.0.1")); + assert!(!is_internal_ip("1.1.1.1")); + } + + #[test] + fn test_internal_ipv6() { + assert!(is_internal_ip("fc00::1")); + assert!(is_internal_ip("fd12:3456:789a::1")); + assert!(is_internal_ip("::1")); + + assert!(!is_internal_ip("2001:db8::1")); + assert!(!is_internal_ip("2607:f8b0::1")); + } + + #[test] + fn test_invalid_ip() { + assert!(!is_internal_ip("not-an-ip")); + assert!(!is_internal_ip("")); + } + + fn make_alert(src_ip: &str, dst_ip: &str) -> AlertMessage { + AlertMessage { + timestamp: 0, + flow_key: String::new(), + src_ip: src_ip.to_string(), + dst_ip: dst_ip.to_string(), + src_port: 12345, + dst_port: 445, + protocol: 6, + is_attack: true, + attack_type: Some("Exploitation".to_string()), + confidence: 0.8, + ae_score: 0.4, + packet_count: 50, + flow_duration_us: 500_000, + } + } + + #[tokio::test] + async fn lateral_threshold_triggers_for_internal_only() { + let detector = LateralMovementDetector::new(); + let (tx, mut rx) = mpsc::channel(64); + + // Internal → external should be ignored + detector.process(&make_alert("10.0.0.1", "8.8.8.8"), &tx); + assert!(rx.try_recv().is_err()); + + // Internal → internal, below threshold + for i in 1..5 { + detector.process(&make_alert("10.0.0.1", &format!("10.0.1.{i}")), &tx); + } + assert!(rx.try_recv().is_err(), "Should not alert below threshold"); + + // 5th unique internal dest should trigger + detector.process(&make_alert("10.0.0.1", "10.0.1.5"), &tx); + let event = rx.try_recv().expect("Should alert at threshold"); + assert_eq!(event.source, DetectionSource::Correlation); + } +} diff --git a/net-guardia/src/core/correlation/mod.rs b/net-guardia/src/core/correlation/mod.rs new file mode 100644 index 0000000..94d8574 --- /dev/null +++ b/net-guardia/src/core/correlation/mod.rs @@ -0,0 +1,4 @@ +pub mod botnet; +pub mod engine; +pub mod lateral; +pub mod scan; diff --git a/net-guardia/src/core/correlation/scan.rs b/net-guardia/src/core/correlation/scan.rs new file mode 100644 index 0000000..8fcdf84 --- /dev/null +++ b/net-guardia/src/core/correlation/scan.rs @@ -0,0 +1,173 @@ +use std::collections::HashSet; +use std::time::{Duration, Instant}; + +use dashmap::DashMap; +use macros::log; +use tokio::sync::mpsc; + +use crate::model::detection::ml_detection::AlertMessage; +use crate::model::event::{DetectionEvent, DetectionSource}; +use crate::model::log::detection::DetectionLog; + +/// Window within which unique destination ports are counted per source. +const SCAN_WINDOW_SECS: u64 = 120; // 2 minutes + +/// Minimum unique destination ports to trigger a scan alert. +const SCAN_THRESHOLD: usize = 20; + +/// Maximum tracked source IPs to bound memory. +const MAX_TRACKED_SRCS: usize = 10_000; + +struct TimedPortSet { + ports: HashSet, + window_start: Instant, + last_dst_ip: String, +} + +/// Detects port scanning: a single source IP probing many destination ports. +pub struct ScanDetector { + /// src_ip → set of unique dst_ports within the time window + state: DashMap, + window: Duration, + threshold: usize, +} + +impl ScanDetector { + pub fn new() -> Self { + Self { + state: DashMap::new(), + window: Duration::from_secs(SCAN_WINDOW_SECS), + threshold: SCAN_THRESHOLD, + } + } + + /// Process an alert and emit a DetectionEvent if the scan threshold is crossed. + pub fn process(&self, alert: &AlertMessage, detection_tx: &mpsc::Sender) { + let key = alert.src_ip.clone(); + let now = Instant::now(); + + let should_alert = { + let mut entry = self.state.entry(key.clone()).or_insert_with(|| TimedPortSet { + ports: HashSet::new(), + window_start: now, + last_dst_ip: alert.dst_ip.clone(), + }); + + let set = entry.value_mut(); + + // Reset window if expired + if now.duration_since(set.window_start) >= self.window { + set.ports.clear(); + set.window_start = now; + } + + set.ports.insert(alert.dst_port); + set.last_dst_ip = alert.dst_ip.clone(); + + if set.ports.len() >= self.threshold { + Some((set.ports.len(), set.last_dst_ip.clone())) + } else { + None + } + }; + + if let Some((unique_ports, last_dst_ip)) = should_alert { + log!(DetectionLog::ScanDetected { + src_ip: key.clone(), + unique_ports, + window_secs: SCAN_WINDOW_SECS, + }); + + let event = DetectionEvent { + source: DetectionSource::Correlation, + attack_type: "port_scan".to_string(), + confidence: 0.80, + source_ip: key.clone(), + dest_ip: last_dst_ip, + protocol: alert.protocol, + packet_count: 0, + flow_duration_us: 0, + }; + + let _ = detection_tx.try_send(event); + + // Reset after alerting + if let Some(mut entry) = self.state.get_mut(&key) { + entry.ports.clear(); + entry.window_start = now; + } + } + } + + /// Remove expired entries. Returns number of entries removed. + pub fn cleanup(&self) -> usize { + let now = Instant::now(); + let window = self.window; + let before = self.state.len(); + + self.state + .retain(|_, set| now.duration_since(set.window_start) < window); + + if self.state.len() > MAX_TRACKED_SRCS { + let excess = self.state.len() - MAX_TRACKED_SRCS; + let keys_to_remove: Vec = self.state.iter().take(excess).map(|e| e.key().clone()).collect(); + for key in keys_to_remove { + self.state.remove(&key); + } + } + + before.saturating_sub(self.state.len()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_alert(src_ip: &str, dst_port: u16) -> AlertMessage { + AlertMessage { + timestamp: 0, + flow_key: String::new(), + src_ip: src_ip.to_string(), + dst_ip: "192.168.1.1".to_string(), + src_port: 12345, + dst_port, + protocol: 6, + is_attack: true, + attack_type: Some("Reconnaissance".to_string()), + confidence: 0.7, + ae_score: 0.3, + packet_count: 5, + flow_duration_us: 100_000, + } + } + + #[tokio::test] + async fn scan_threshold_triggers_alert() { + let detector = ScanDetector::new(); + let (tx, mut rx) = mpsc::channel(64); + + for port in 0..19 { + let alert = make_alert("10.0.0.1", port); + detector.process(&alert, &tx); + } + assert!(rx.try_recv().is_err(), "Should not alert below threshold"); + + let alert = make_alert("10.0.0.1", 19); + detector.process(&alert, &tx); + let event = rx.try_recv().expect("Should alert at threshold"); + assert_eq!(event.attack_type, "port_scan"); + } + + #[tokio::test] + async fn different_sources_tracked_independently() { + let detector = ScanDetector::new(); + let (tx, mut rx) = mpsc::channel(64); + + for port in 0..15 { + detector.process(&make_alert("10.0.0.1", port), &tx); + detector.process(&make_alert("10.0.0.2", port), &tx); + } + assert!(rx.try_recv().is_err(), "Neither should alert at 15 ports"); + } +} diff --git a/net-guardia/src/core/detection/beaconing.rs b/net-guardia/src/core/detection/beaconing.rs new file mode 100644 index 0000000..380a9ce --- /dev/null +++ b/net-guardia/src/core/detection/beaconing.rs @@ -0,0 +1,277 @@ +use std::time::{Duration, Instant}; + +use dashmap::DashMap; +use macros::log; +use tokio::sync::{broadcast, mpsc}; + +use crate::model::detection::ml_detection::AlertMessage; +use crate::model::event::{DetectionEvent, DetectionSource}; +use crate::model::log::detection::DetectionLog; + +/// How often to analyze cached flows for beaconing patterns. +const ANALYSIS_INTERVAL_SECS: u64 = 30; + +/// Minimum number of flow observations before computing CV. +const MIN_OBSERVATIONS: usize = 5; + +/// CV threshold: values below this indicate periodic (beaconing) behavior. +/// 0 = perfectly periodic, 1 = random. C2 beacons typically have CV < 0.3. +const CV_THRESHOLD: f64 = 0.3; + +/// Maximum entries in the flow cache to bound memory. +const MAX_CACHE_ENTRIES: usize = 50_000; + +/// Expire entries not seen within this window. +const EXPIRY_SECS: u64 = 600; // 10 minutes + +/// Cooldown between re-alerting on the same (src, dst, port) tuple. +const ALERT_COOLDOWN_SECS: u64 = 300; // 5 minutes + +/// Key for tracking flow timing: (src_ip, dst_ip, dst_port). +type FlowTuple = (String, String, u16); + +struct CachedFlow { + timestamps: Vec, + last_alerted: Option, +} + +/// Detects C2 beaconing by analyzing the periodicity of flows between +/// (src_ip, dst_ip, dst_port) tuples. Uses coefficient of variation (CV) +/// of inter-arrival times: CV < 0.3 with sufficient observations = beaconing. +pub struct BeaconingDetector { + flow_cache: DashMap, + detection_tx: mpsc::Sender, + alert_rx: broadcast::Receiver, +} + +impl BeaconingDetector { + pub fn new(alert_rx: broadcast::Receiver, detection_tx: mpsc::Sender) -> Self { + Self { + flow_cache: DashMap::new(), + detection_tx, + alert_rx, + } + } + + /// Spawn the beaconing detector as a background task. + pub fn start(self) { + tokio::spawn(async move { self.run().await }); + } + + async fn run(mut self) { + log!(DetectionLog::BeaconingDetectorStarted); + + let mut analysis_interval = tokio::time::interval(Duration::from_secs(ANALYSIS_INTERVAL_SECS)); + + loop { + tokio::select! { + result = self.alert_rx.recv() => { + match result { + Ok(alert) => self.record_flow(&alert), + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => break, + } + } + _ = analysis_interval.tick() => { + self.analyze_and_alert(); + self.cleanup(); + } + } + } + } + + fn record_flow(&self, alert: &AlertMessage) { + let key = (alert.src_ip.clone(), alert.dst_ip.clone(), alert.dst_port); + let now = Instant::now(); + + let mut entry = self.flow_cache.entry(key).or_insert_with(|| CachedFlow { + timestamps: Vec::new(), + last_alerted: None, + }); + + entry.timestamps.push(now); + + // Cap stored timestamps to avoid unbounded growth per entry + if entry.timestamps.len() > 100 { + let excess = entry.timestamps.len() - 100; + entry.timestamps.drain(..excess); + } + } + + fn analyze_and_alert(&self) { + let now = Instant::now(); + let cooldown = Duration::from_secs(ALERT_COOLDOWN_SECS); + + // Phase 1: read-lock scan to find beaconing candidates (avoids holding write locks + // across the entire 50K-entry iteration, reducing contention with record_flow). + let mut alerts: Vec<(FlowTuple, f64, usize)> = Vec::new(); + for entry in self.flow_cache.iter() { + let flow = entry.value(); + if flow.timestamps.len() < MIN_OBSERVATIONS { + continue; + } + if let Some(last) = flow.last_alerted + && now.duration_since(last) < cooldown + { + continue; + } + let cv = compute_cv(&flow.timestamps); + if cv < CV_THRESHOLD { + alerts.push((entry.key().clone(), cv, flow.timestamps.len())); + } + } + + // Phase 2: selective write-lock only for entries that need last_alerted update. + for (key, cv, count) in alerts { + let (src_ip, dst_ip, dst_port) = &key; + log!(DetectionLog::BeaconingDetected { + src_ip: src_ip.clone(), + dst_ip: dst_ip.clone(), + dst_port: *dst_port, + cv, + count, + }); + + let event = DetectionEvent { + source: DetectionSource::Beaconing, + attack_type: "c2_communication".to_string(), + confidence: (1.0 - cv / CV_THRESHOLD) as f32 * 0.5 + 0.5, + source_ip: src_ip.clone(), + dest_ip: dst_ip.clone(), + protocol: 6, + packet_count: count as u64, + flow_duration_us: 0, + }; + + let _ = self.detection_tx.try_send(event); + if let Some(mut entry) = self.flow_cache.get_mut(&key) { + entry.last_alerted = Some(now); + } + } + } + + fn cleanup(&self) { + let now = Instant::now(); + let expiry = Duration::from_secs(EXPIRY_SECS); + + self.flow_cache.retain(|_, flow| { + flow.timestamps + .last() + .is_some_and(|last| now.duration_since(*last) < expiry) + }); + + // Enforce max capacity + if self.flow_cache.len() > MAX_CACHE_ENTRIES { + let excess = self.flow_cache.len() - MAX_CACHE_ENTRIES; + let keys_to_remove: Vec = self.flow_cache.iter().take(excess).map(|e| e.key().clone()).collect(); + for key in keys_to_remove { + self.flow_cache.remove(&key); + } + } + } +} + +/// Compute the coefficient of variation (std / mean) of inter-arrival times. +/// Returns f64::MAX if fewer than 2 timestamps (no intervals to compute). +fn compute_cv(timestamps: &[Instant]) -> f64 { + if timestamps.len() < 2 { + return f64::MAX; + } + + let intervals: Vec = timestamps + .windows(2) + .map(|w| w[1].duration_since(w[0]).as_secs_f64()) + .collect(); + + let n = intervals.len() as f64; + let mean = intervals.iter().sum::() / n; + + if mean <= 0.0 { + return f64::MAX; + } + + let variance = intervals.iter().map(|x| (x - mean).powi(2)).sum::() / n; + let std = variance.sqrt(); + + std / mean +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cv_perfectly_periodic() { + // Perfectly periodic: CV should be ~0 + let base = Instant::now(); + let timestamps: Vec = (0..10).map(|i| base + Duration::from_secs(i * 60)).collect(); + let cv = compute_cv(×tamps); + assert!(cv < 0.01, "Perfectly periodic CV should be ~0, got {cv}"); + } + + #[test] + fn cv_random_high() { + // Irregular intervals: CV should be high + let base = Instant::now(); + let timestamps = vec![ + base, + base + Duration::from_secs(1), + base + Duration::from_secs(100), + base + Duration::from_secs(101), + base + Duration::from_secs(500), + base + Duration::from_secs(501), + ]; + let cv = compute_cv(×tamps); + assert!(cv > 0.5, "Random intervals CV should be high, got {cv}"); + } + + #[test] + fn cv_with_slight_jitter() { + // Periodic with small jitter: CV should be low but > 0 + let base = Instant::now(); + let timestamps = vec![ + base, + base + Duration::from_millis(60_000), + base + Duration::from_millis(121_000), // 61s interval + base + Duration::from_millis(179_000), // 58s interval + base + Duration::from_millis(240_000), // 61s interval + base + Duration::from_millis(299_000), // 59s interval + ]; + let cv = compute_cv(×tamps); + assert!(cv < 0.3, "Slight jitter CV should be < 0.3, got {cv}"); + } + + #[test] + fn cv_insufficient_data() { + let base = Instant::now(); + assert_eq!(compute_cv(&[base]), f64::MAX); + assert_eq!(compute_cv(&[]), f64::MAX); + } + + #[tokio::test] + async fn beaconing_detector_records_and_detects() { + let (alert_tx, alert_rx) = broadcast::channel(64); + let (detection_tx, mut detection_rx) = mpsc::channel(64); + + let detector = BeaconingDetector::new(alert_rx, detection_tx); + + // Manually record periodic flows + let base = Instant::now(); + let key = ("10.0.0.1".to_string(), "1.2.3.4".to_string(), 443_u16); + detector.flow_cache.insert( + key, + CachedFlow { + timestamps: (0..10).map(|i| base + Duration::from_secs(i * 60)).collect(), + last_alerted: None, + }, + ); + + detector.analyze_and_alert(); + + let event = detection_rx.try_recv().expect("Should detect beaconing"); + assert_eq!(event.source, DetectionSource::Beaconing); + assert_eq!(event.attack_type, "c2_communication"); + + drop(alert_tx); + } +} diff --git a/net-guardia/src/core/detection/mod.rs b/net-guardia/src/core/detection/mod.rs new file mode 100644 index 0000000..0faa443 --- /dev/null +++ b/net-guardia/src/core/detection/mod.rs @@ -0,0 +1,2 @@ +pub mod beaconing; +pub mod orchestrator; diff --git a/net-guardia/src/core/detection/orchestrator.rs b/net-guardia/src/core/detection/orchestrator.rs new file mode 100644 index 0000000..5f95bec --- /dev/null +++ b/net-guardia/src/core/detection/orchestrator.rs @@ -0,0 +1,205 @@ +use std::num::NonZero; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use macros::log; +use tokio::sync::mpsc; + +use crate::infrastructure::communication_manager::CommunicationManager; +use crate::infrastructure::geoip::GeoIpService; +use crate::model::error::system::SystemError; +use crate::model::event::{DetectionEvent, DetectionSource, ThreatDetectedEvent}; +use crate::model::log::detection::DetectionLog; + +/// Dedup window: detections for the same (source_ip, attack_type) within this window +/// are suppressed after the first emission. +const DEDUP_WINDOW_SECS: u64 = 30; + +/// How often to sweep expired dedup entries. +const CLEANUP_INTERVAL_SECS: u64 = 60; + +/// Repeat offender detection: same IP within this duration counts as repeat. +const REPEAT_OFFENDER_WINDOW_SECS: u64 = 2 * 60 * 60; // 2 hours + +/// Maximum dedup entries to prevent unbounded memory growth under sustained attack. +const MAX_DEDUP_ENTRIES: usize = 50_000; + +struct DedupEntry { + sources: Vec, + emitted_at: Instant, +} + +/// Coordinates detections from multiple sources (ML, future: rules, correlation, threat feeds). +/// Deduplicates, enriches with GeoIP/hit count/repeat offender, and emits ThreatDetectedEvent. +pub struct DetectionOrchestrator { + rx: mpsc::Receiver, + comm: Arc, + geoip: Option>, + // Enrichment state + // SAFETY: NonZero::new on a literal is infallible. + src_ip_counts: lru::LruCache, + repeat_tracker: lru::LruCache, + // Dedup state — LRU-bounded to prevent unbounded growth under sustained attack + dedup: lru::LruCache<(String, String), DedupEntry>, + dedup_window: Duration, +} + +impl DetectionOrchestrator { + pub fn new( + rx: mpsc::Receiver, + comm: Arc, + geoip: Option>, + ) -> Self { + Self { + rx, + comm, + geoip, + // SAFETY: NonZero::new on a non-zero literal is infallible. + src_ip_counts: lru::LruCache::new(NonZero::new(10_000).unwrap()), + repeat_tracker: lru::LruCache::new(NonZero::new(5_000).unwrap()), + dedup: lru::LruCache::new(NonZero::new(MAX_DEDUP_ENTRIES).unwrap()), + dedup_window: Duration::from_secs(DEDUP_WINDOW_SECS), + } + } + + /// Spawn the orchestrator as a background task. + pub fn start(self) { + tokio::spawn(async move { self.run().await }); + } + + async fn run(mut self) { + log!(DetectionLog::OrchestratorStarted); + + let mut cleanup_interval = tokio::time::interval(Duration::from_secs(CLEANUP_INTERVAL_SECS)); + + loop { + tokio::select! { + event = self.rx.recv() => { + match event { + Some(detection) => self.handle_detection(detection).await, + None => break, // All senders dropped + } + } + _ = cleanup_interval.tick() => { + self.cleanup_expired(); + } + } + } + } + + async fn handle_detection(&mut self, event: DetectionEvent) { + let key = (event.source_ip.clone(), event.attack_type.clone()); + let now = Instant::now(); + + // Dedup check + if let Some(entry) = self.dedup.get(&key) + && now.checked_duration_since(entry.emitted_at).unwrap_or(Duration::ZERO) < self.dedup_window + { + // Within window: add source attribution but don't re-emit + if !entry.sources.contains(&event.source) { + // Re-get as mutable to update sources + if let Some(entry) = self.dedup.get_mut(&key) { + entry.sources.push(event.source.clone()); + } + } + log!(DetectionLog::DetectionDeduplicated { + source_ip: event.source_ip, + attack_type: event.attack_type, + }); + return; + } + + // Enrich and emit + let threat_event = self.enrich(&event).await; + let sources = vec![event.source.clone()]; + + log!(DetectionLog::DetectionEmitted { + source_ip: event.source_ip.clone(), + attack_type: event.attack_type.clone(), + confidence: event.confidence, + sources_count: sources.len(), + }); + + // Record dedup entry (LRU-bounded) + self.dedup.put( + key, + DedupEntry { + sources, + emitted_at: now, + }, + ); + + if let Err(e) = self.comm.publish_event(threat_event).await { + log!(SystemError::MlSoarBridgeFailed(e)); + } + } + + async fn enrich(&mut self, event: &DetectionEvent) -> ThreatDetectedEvent { + let src_ip = &event.source_ip; + + // Compute packet rate + let packet_rate = if event.flow_duration_us > 0 { + event.packet_count as f64 / (event.flow_duration_us as f64 / 1_000_000.0) + } else { + 0.0 + }; + + // Update hit count (LRU bounded) + let hit_count = match self.src_ip_counts.get_mut(src_ip) { + Some(c) => { + *c = c.saturating_add(1); + *c + } + None => { + self.src_ip_counts.put(src_ip.clone(), 1); + 1 + } + }; + + // Check repeat offender (same IP within window) + let repeat_window = Duration::from_secs(REPEAT_OFFENDER_WINDOW_SECS); + let now = Instant::now(); + let is_repeat = self + .repeat_tracker + .get(src_ip) + .is_some_and(|last| now.checked_duration_since(*last).unwrap_or(Duration::ZERO) < repeat_window); + self.repeat_tracker.put(src_ip.clone(), now); + + // GeoIP lookup + let geoip_country = if let Some(ref svc) = self.geoip { + if let Ok(ip) = src_ip.parse() { + svc.lookup(ip).await.ok().flatten().and_then(|loc| loc.country_code) + } else { + None + } + } else { + None + }; + + ThreatDetectedEvent { + attack_type: event.attack_type.clone(), + confidence: event.confidence, + source_ip: event.source_ip.clone(), + dest_ip: event.dest_ip.clone(), + flow_count: hit_count, + packet_rate, + protocol: event.protocol, + geoip_country, + is_repeat_offender: is_repeat, + sources: vec![event.source.clone()], + } + } + + fn cleanup_expired(&mut self) { + let now = Instant::now(); + let window = self.dedup_window; + // Pop expired entries from the LRU (oldest entries are least recently used) + while let Some((_, entry)) = self.dedup.peek_lru() { + if now.checked_duration_since(entry.emitted_at).unwrap_or(Duration::ZERO) >= window { + self.dedup.pop_lru(); + } else { + break; + } + } + } +} diff --git a/net-guardia/src/core/dns_filter_service.rs b/net-guardia/src/core/dns_filter_service.rs index 9b5dd9f..0d24a20 100644 --- a/net-guardia/src/core/dns_filter_service.rs +++ b/net-guardia/src/core/dns_filter_service.rs @@ -12,8 +12,6 @@ pub struct DnsFilterService { dns_filter: Arc, } -const MAX_DNS_DOMAINS_PER_REQUEST: usize = 1000; - impl DnsFilterService { pub fn new(db: Arc, dns_filter: Arc) -> Self { Self { db, dns_filter } @@ -24,10 +22,18 @@ impl DnsFilterService { } pub fn add_domains(&self, domains: &[String]) -> Result { - if domains.len() > MAX_DNS_DOMAINS_PER_REQUEST { + let max_domains: usize = self + .db + .get_setting("dns_max_domains_per_request") + .ok() + .flatten() + .and_then(|v| v.parse().ok()) + .unwrap_or(1000); + if domains.len() > max_domains { return Err(MiscError::ValidationError { - message: format!("too many domains (max {})", MAX_DNS_DOMAINS_PER_REQUEST), - }.into()); + message: format!("too many domains (max {})", max_domains), + } + .into()); } // eBPF first for domain in domains { diff --git a/net-guardia/src/core/ebpf/access_control.rs b/net-guardia/src/core/ebpf/access_control.rs index 158624c..f9f9844 100644 --- a/net-guardia/src/core/ebpf/access_control.rs +++ b/net-guardia/src/core/ebpf/access_control.rs @@ -8,8 +8,8 @@ use common::model::port_rule::PortRule; use tokio::sync::RwLock; use crate::model::direction::FlowDirection; -use crate::model::error::ebpf::EbpfError; use crate::model::error::Error; +use crate::model::error::ebpf::EbpfError; use crate::model::ip_address::NativeConvert; use crate::model::list_type::ListType; @@ -165,9 +165,7 @@ impl MapWrapper { Err(EbpfError::RuleReachLimit)?; } - self.map - .insert(ip, rule, 0) - .map_err(EbpfError::MapOperationError)?; + self.map.insert(ip, rule, 0).map_err(EbpfError::MapOperationError)?; Ok(()) } @@ -189,9 +187,7 @@ impl MapWrapper { if rule.is_empty() { self.map.remove(&ip).map_err(EbpfError::MapOperationError)?; } else { - self.map - .insert(ip, rule, 0) - .map_err(EbpfError::MapOperationError)?; + self.map.insert(ip, rule, 0).map_err(EbpfError::MapOperationError)?; } Ok(()) } diff --git a/net-guardia/src/core/ebpf/dns_filter.rs b/net-guardia/src/core/ebpf/dns_filter.rs index d132833..0567aaa 100644 --- a/net-guardia/src/core/ebpf/dns_filter.rs +++ b/net-guardia/src/core/ebpf/dns_filter.rs @@ -3,8 +3,8 @@ use std::collections::HashSet; use common::model::dns_name::DnsName; use parking_lot::RwLock; -use crate::model::error::misc::MiscError; use crate::model::error::Error; +use crate::model::error::misc::MiscError; pub struct DnsFilter { blacklist: RwLock>, @@ -30,11 +30,7 @@ impl DnsFilter { } pub fn list_domains(&self) -> Vec { - self.blacklist - .read() - .iter() - .filter_map(wire_format_to_domain) - .collect() + self.blacklist.read().iter().filter_map(wire_format_to_domain).collect() } /// Check if a DNS query name (in wire format) or any of its parent domains is blacklisted. @@ -67,8 +63,7 @@ impl DnsFilter { let mut parent = DnsName::zeroed(); let remaining = name_len - offset; - parent.data[..remaining.min(128)] - .copy_from_slice(&name.data[offset..offset + remaining.min(128)]); + parent.data[..remaining.min(128)].copy_from_slice(&name.data[offset..offset + remaining.min(128)]); if bl.contains(&parent) { return true; } diff --git a/net-guardia/src/core/ebpf/drop_monitor.rs b/net-guardia/src/core/ebpf/drop_monitor.rs index 8c743f1..f6644ef 100644 --- a/net-guardia/src/core/ebpf/drop_monitor.rs +++ b/net-guardia/src/core/ebpf/drop_monitor.rs @@ -1,5 +1,5 @@ -use std::sync::Arc; use std::mem; +use std::sync::Arc; use std::time::Duration; use aya::maps::{MapData, RingBuf}; @@ -9,10 +9,9 @@ use common::define::drop_reason::*; use common::model::drop_event::DropEvent as RawDropEvent; use parking_lot::Mutex; +use crate::model::config::constants::DROP_CHANNEL_CAPACITY; use crate::model::drop_event::{DropCounters, DropEventMessage}; -const DROP_CHANNEL_CAPACITY: usize = 100; - pub struct DropMonitor { broadcast_tx: broadcast::Sender, counters: Mutex, @@ -82,8 +81,14 @@ impl Default for DropMonitor { fn format_ips(raw: &RawDropEvent) -> (String, String) { match raw.ip_version { 4 => { - let src = format!("{}.{}.{}.{}", raw.src_ip[0], raw.src_ip[1], raw.src_ip[2], raw.src_ip[3]); - let dst = format!("{}.{}.{}.{}", raw.dst_ip[0], raw.dst_ip[1], raw.dst_ip[2], raw.dst_ip[3]); + let src = format!( + "{}.{}.{}.{}", + raw.src_ip[0], raw.src_ip[1], raw.src_ip[2], raw.src_ip[3] + ); + let dst = format!( + "{}.{}.{}.{}", + raw.dst_ip[0], raw.dst_ip[1], raw.dst_ip[2], raw.dst_ip[3] + ); (src, dst) } _ => { @@ -114,10 +119,7 @@ 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, - monitor: Arc, -) -> oneshot::Sender<()> { +pub async fn start_consumer(ring_buf: RingBuf, monitor: Arc) -> oneshot::Sender<()> { let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); tokio::spawn(async move { diff --git a/net-guardia/src/core/ebpf/geo_block.rs b/net-guardia/src/core/ebpf/geo_block.rs index 87ab12f..973d186 100644 --- a/net-guardia/src/core/ebpf/geo_block.rs +++ b/net-guardia/src/core/ebpf/geo_block.rs @@ -1,21 +1,21 @@ use std::collections::{HashMap as StdHashMap, HashSet}; use std::sync::Arc; -use aya::maps::lpm_trie::{Key, LpmTrie}; -use aya::maps::MapData; use aya::Ebpf; +use aya::maps::MapData; +use aya::maps::lpm_trie::{Key, LpmTrie}; use ipnetwork::IpNetwork; -use maxminddb::{geoip2, Reader}; +use maxminddb::{Reader, geoip2}; use parking_lot::RwLock; use crate::infrastructure::app_config::AppConfig; +use crate::model::error::Error; use crate::model::error::ebpf::EbpfError; use crate::model::error::misc::MiscError; -use crate::model::error::Error; /// Pre-indexed GeoIP prefix table, built once at startup. struct GeoIndex { - v4: StdHashMap>, // country -> [(ip_be, prefix_len)] + v4: StdHashMap>, // country -> [(ip_be, prefix_len)] v6: StdHashMap>, } @@ -35,11 +35,10 @@ impl GeoBlock { let v6_trie = LpmTrie::try_from(v6_map).map_err(EbpfError::MapOperationError)?; let db_path = &app_config.misc.geoip_db_name; - let reader = Reader::open_readfile(db_path) - .map_err(|e| MiscError::GeoIPDatabaseError { - path: db_path.clone(), - reason: e.to_string(), - })?; + let reader = Reader::open_readfile(db_path).map_err(|e| MiscError::GeoIPDatabaseError { + path: db_path.clone(), + reason: e.to_string(), + })?; let index = Self::build_index(&reader)?; @@ -61,7 +60,9 @@ impl GeoBlock { for result in iter { let Ok(lookup) = result else { continue }; let Ok(network) = lookup.network() else { continue }; - let Ok(Some(city)) = lookup.decode::() else { continue }; + let Ok(Some(city)) = lookup.decode::() else { + continue; + }; let Some(code) = city.country.iso_code else { continue }; let code = code.to_uppercase(); @@ -77,7 +78,9 @@ impl GeoBlock { for result in iter { let Ok(lookup) = result else { continue }; let Ok(network) = lookup.network() else { continue }; - let Ok(Some(city)) = lookup.decode::() else { continue }; + let Ok(Some(city)) = lookup.decode::() else { + continue; + }; let Some(code) = city.country.iso_code else { continue }; let code = code.to_uppercase(); @@ -163,20 +166,14 @@ impl GeoBlock { } fn clear_trie_v4(trie: &mut LpmTrie) { - let keys: Vec> = trie.iter() - .filter_map(|r| r.ok()) - .map(|(k, _)| k) - .collect(); + let keys: Vec> = trie.iter().filter_map(|r| r.ok()).map(|(k, _)| k).collect(); for key in keys { let _ = trie.remove(&key); } } fn clear_trie_v6(trie: &mut LpmTrie) { - let keys: Vec> = trie.iter() - .filter_map(|r| r.ok()) - .map(|(k, _)| k) - .collect(); + let keys: Vec> = trie.iter().filter_map(|r| r.ok()).map(|(k, _)| k).collect(); for key in keys { let _ = trie.remove(&key); } diff --git a/net-guardia/src/core/ebpf/mod.rs b/net-guardia/src/core/ebpf/mod.rs index 57bdbce..98b51d9 100644 --- a/net-guardia/src/core/ebpf/mod.rs +++ b/net-guardia/src/core/ebpf/mod.rs @@ -2,8 +2,8 @@ pub mod access_control; pub mod dns_filter; pub mod drop_monitor; pub mod geo_block; -pub mod rate_limit; pub mod protocol_filter; +pub mod rate_limit; pub mod xsk_manager; use std::sync::Arc; @@ -19,14 +19,14 @@ use crate::core::ebpf::access_control::AccessControl; use crate::core::ebpf::dns_filter::DnsFilter; use crate::core::ebpf::drop_monitor::DropMonitor; use crate::core::ebpf::geo_block::GeoBlock; -use crate::core::ebpf::rate_limit::RateLimitConfig; use crate::core::ebpf::protocol_filter::ProtocolFilter; +use crate::core::ebpf::rate_limit::RateLimitConfig; use crate::core::ebpf::xsk_manager::XskManager; -use crate::infrastructure::app_config::AppConfig; use crate::core::ml::engine::Engine; +use crate::infrastructure::app_config::AppConfig; +use crate::model::error::Error; use crate::model::error::ebpf::EbpfError; use crate::model::error::system::SystemError; -use crate::model::error::Error; pub struct EbpfServices { pub xsk_manager: Arc, diff --git a/net-guardia/src/core/ebpf/protocol_filter.rs b/net-guardia/src/core/ebpf/protocol_filter.rs index 134ba61..565cd7d 100644 --- a/net-guardia/src/core/ebpf/protocol_filter.rs +++ b/net-guardia/src/core/ebpf/protocol_filter.rs @@ -8,8 +8,8 @@ use common::model::ip_address::{AddrPortV4, AddrPortV6, IPv4, IPv6}; use common::model::placeholder::PlaceHolder; use tokio::sync::RwLock; -use crate::model::error::ebpf::EbpfError; use crate::model::error::Error; +use crate::model::error::ebpf::EbpfError; use crate::model::ip_address::NativeConvert; pub struct ProtocolFilter { @@ -190,9 +190,7 @@ impl WhiteListControl { fn is_white_list_enable(&self) -> bool { match self.map.get(&0, 0) { - Ok(status) => { - status != 0 - } + Ok(status) => status != 0, Err(_) => false, } } @@ -280,9 +278,7 @@ impl EntryMap { fn add(&mut self, key: T::Native) -> Result<(), Error> { let key = T::from_native(key); - self.map - .insert(key, 0_u8, 0) - .map_err(EbpfError::MapOperationError)?; + self.map.insert(key, 0_u8, 0).map_err(EbpfError::MapOperationError)?; Ok(()) } diff --git a/net-guardia/src/core/ebpf/rate_limit.rs b/net-guardia/src/core/ebpf/rate_limit.rs index 8084353..fb50528 100644 --- a/net-guardia/src/core/ebpf/rate_limit.rs +++ b/net-guardia/src/core/ebpf/rate_limit.rs @@ -1,9 +1,9 @@ -use aya::maps::{Array, MapData}; use aya::Ebpf; +use aya::maps::{Array, MapData}; use parking_lot::Mutex; -use crate::model::error::ebpf::EbpfError; use crate::model::error::Error; +use crate::model::error::ebpf::EbpfError; pub struct RateLimitConfig { config_map: Mutex>, @@ -13,51 +13,83 @@ impl RateLimitConfig { pub fn new(ebpf: &mut Ebpf) -> Result { let map = ebpf.take_map("RATE_LIMIT_CONFIG").ok_or(EbpfError::MapNotFound)?; let config_map = Array::try_from(map).map_err(EbpfError::MapOperationError)?; - Ok(Self { config_map: Mutex::new(config_map) }) + Ok(Self { + config_map: Mutex::new(config_map), + }) } pub fn set_packet_rate(&self, rate: u64) -> Result<(), Error> { - self.config_map.lock().set(0, rate, 0).map_err(EbpfError::MapOperationError)?; + self.config_map + .lock() + .set(0, rate, 0) + .map_err(EbpfError::MapOperationError)?; Ok(()) } pub fn set_syn_rate(&self, rate: u64) -> Result<(), Error> { - self.config_map.lock().set(1, rate, 0).map_err(EbpfError::MapOperationError)?; + self.config_map + .lock() + .set(1, rate, 0) + .map_err(EbpfError::MapOperationError)?; Ok(()) } pub fn set_udp_rate(&self, rate: u64) -> Result<(), Error> { - self.config_map.lock().set(2, rate, 0).map_err(EbpfError::MapOperationError)?; + self.config_map + .lock() + .set(2, rate, 0) + .map_err(EbpfError::MapOperationError)?; Ok(()) } pub fn set_dns_rate(&self, rate: u64) -> Result<(), Error> { - self.config_map.lock().set(3, rate, 0).map_err(EbpfError::MapOperationError)?; + self.config_map + .lock() + .set(3, rate, 0) + .map_err(EbpfError::MapOperationError)?; Ok(()) } pub fn set_window_ns(&self, ns: u64) -> Result<(), Error> { - self.config_map.lock().set(4, ns, 0).map_err(EbpfError::MapOperationError)?; + self.config_map + .lock() + .set(4, ns, 0) + .map_err(EbpfError::MapOperationError)?; Ok(()) } pub fn get_packet_rate(&self) -> Result { - self.config_map.lock().get(&0, 0).map_err(|e| EbpfError::MapOperationError(e).into()) + self.config_map + .lock() + .get(&0, 0) + .map_err(|e| EbpfError::MapOperationError(e).into()) } pub fn get_syn_rate(&self) -> Result { - self.config_map.lock().get(&1, 0).map_err(|e| EbpfError::MapOperationError(e).into()) + self.config_map + .lock() + .get(&1, 0) + .map_err(|e| EbpfError::MapOperationError(e).into()) } pub fn get_udp_rate(&self) -> Result { - self.config_map.lock().get(&2, 0).map_err(|e| EbpfError::MapOperationError(e).into()) + self.config_map + .lock() + .get(&2, 0) + .map_err(|e| EbpfError::MapOperationError(e).into()) } pub fn get_dns_rate(&self) -> Result { - self.config_map.lock().get(&3, 0).map_err(|e| EbpfError::MapOperationError(e).into()) + self.config_map + .lock() + .get(&3, 0) + .map_err(|e| EbpfError::MapOperationError(e).into()) } pub fn get_window_ns(&self) -> Result { - self.config_map.lock().get(&4, 0).map_err(|e| EbpfError::MapOperationError(e).into()) + self.config_map + .lock() + .get(&4, 0) + .map_err(|e| EbpfError::MapOperationError(e).into()) } } diff --git a/net-guardia/src/core/ebpf/xsk_manager.rs b/net-guardia/src/core/ebpf/xsk_manager.rs index 7fd7f96..a62ba96 100644 --- a/net-guardia/src/core/ebpf/xsk_manager.rs +++ b/net-guardia/src/core/ebpf/xsk_manager.rs @@ -6,9 +6,9 @@ use std::sync::Arc; use std::thread; use std::time::Duration; -use aya::maps::{MapData, XskMap}; use aya::Ebpf; -use crossbeam::channel::{bounded, Receiver, Sender}; +use aya::maps::{MapData, XskMap}; +use crossbeam::channel::{Receiver, Sender, bounded}; use crossbeam::queue::SegQueue; use macros::log; use parking_lot::Mutex; @@ -17,14 +17,14 @@ use xsk_rs::config::{BindFlags, FrameSize, Interface, LibxdpFlags, QueueSize, So use xsk_rs::{CompQueue, FillQueue, FrameDesc, RxQueue, Socket, TxQueue, Umem}; use crate::core::ebpf::dns_filter::DnsFilter; -use crate::infrastructure::app_config::AppConfig; use crate::core::ml::engine::Engine; use crate::core::ml::flow_tracker::FlowTracker; +use crate::infrastructure::app_config::AppConfig; use crate::model::config::NetworkConfig; use crate::model::direction::Direction; +use crate::model::error::Error; use crate::model::error::ebpf::EbpfError; use crate::model::error::system::SystemError; -use crate::model::error::Error; use crate::model::log::ebpf::EbpfLog; use crate::utils::packet_parser::parse_packet; @@ -37,10 +37,12 @@ struct BufferPool { impl BufferPool { fn new(capacity: usize, buffer_size: usize) -> Self { - let buffers = (0..capacity) - .map(|_| Vec::with_capacity(buffer_size)) - .collect(); - Self { buffers, buffer_size, max_capacity: capacity * 2 } + let buffers = (0..capacity).map(|_| Vec::with_capacity(buffer_size)).collect(); + Self { + buffers, + buffer_size, + max_capacity: capacity * 2, + } } fn get(&mut self) -> Vec { @@ -80,7 +82,12 @@ impl XskManager { }) } - pub fn run(&self, ml_engine: Option>, dns_filter: Option>, shutdowns: &SegQueue>) -> Result<(), Error> { + pub fn run( + &self, + ml_engine: Option>, + dns_filter: Option>, + shutdowns: &SegQueue>, + ) -> Result<(), Error> { let network = self.app_config.network.clone(); let combined_queue_count = network.combined_queue_count; @@ -306,7 +313,12 @@ impl XskPair { Ok(nb_completed) } - fn process_rx_queue(&mut self, forward_tx: &Sender>, buffer_pool: &mut BufferPool, rx_descs: &mut [FrameDesc]) -> Result { + fn process_rx_queue( + &mut self, + forward_tx: &Sender>, + buffer_pool: &mut BufferPool, + rx_descs: &mut [FrameDesc], + ) -> Result { let rx_count = unsafe { self.rx.consume(rx_descs) }; if rx_count > 0 { @@ -328,15 +340,17 @@ impl XskPair { // DNS blacklist check — drop blacklisted DNS queries before forwarding if let Some(ref dns) = self.dns_filter && let Some((dns_name, name_len)) = DnsFilter::parse_query_name(raw) - && dns.is_blacklisted(&dns_name, name_len) { - continue; + && dns.is_blacklisted(&dns_name, name_len) + { + continue; } // Parse directly from UMEM (zero-copy for ML path). // Only clone for the forwarding path afterwards. if let Some(ref tracker) = self.tracker - && let Some((packet_info, _)) = parse_packet(raw) { - tracker.lock().process_packet(packet_info, is_ingress); + && let Some((packet_info, _)) = parse_packet(raw) + { + tracker.lock().process_packet(packet_info, is_ingress); } // Clone into pooled buffer for forwarding @@ -367,7 +381,12 @@ impl XskPair { Ok(rx_count) } - fn process_tx_queue(&mut self, forward_rx: &Receiver>, buffer_pool: &mut BufferPool, comp_descs: &mut [FrameDesc]) -> Result { + fn process_tx_queue( + &mut self, + forward_rx: &Receiver>, + buffer_pool: &mut BufferPool, + comp_descs: &mut [FrameDesc], + ) -> Result { let mut packets_to_send = Vec::with_capacity(64); while let Ok(packet) = forward_rx.try_recv() { packets_to_send.push(packet); @@ -426,8 +445,9 @@ impl XskPair { } if let Err(e) = self.tx.wakeup() - && e.kind() != std::io::ErrorKind::WouldBlock { - log!(EbpfLog::TXWakeupFailed(e.to_string())); + && e.kind() != std::io::ErrorKind::WouldBlock + { + log!(EbpfLog::TXWakeupFailed(e.to_string())); } // Log dropped packets when frames < packets diff --git a/net-guardia/src/core/email/report.rs b/net-guardia/src/core/email/report.rs index a39ec74..8f2e1b1 100644 --- a/net-guardia/src/core/email/report.rs +++ b/net-guardia/src/core/email/report.rs @@ -14,54 +14,40 @@ use crate::model::error::Error; /// If a key is missing the report uses empty/zero defaults. pub fn generate_weekly_report(db: &dyn RepositoryPort) -> Result { let threats_count = db - .get_setting("weekly_threats_count") -? + .get_setting("weekly_threats_count")? .unwrap_or_else(|| "0".to_string()); - let top_ips_json = db - .get_setting("weekly_top_ips") -? - .unwrap_or_else(|| "[]".to_string()); + let top_ips_json = db.get_setting("weekly_top_ips")?.unwrap_or_else(|| "[]".to_string()); let threat_breakdown_json = db - .get_setting("weekly_threat_breakdown") -? + .get_setting("weekly_threat_breakdown")? .unwrap_or_else(|| "{}".to_string()); let bandwidth = db - .get_setting("weekly_bandwidth_bytes") -? + .get_setting("weekly_bandwidth_bytes")? .unwrap_or_else(|| "0".to_string()); - let health_json = db - .get_setting("weekly_system_health") -? - .unwrap_or_else(|| { - serde_json::json!({ - "cpu_percent": 0.0, - "memory_percent": 0.0, - "disk_percent": 0.0 - }) - .to_string() - }); + let health_json = db.get_setting("weekly_system_health")?.unwrap_or_else(|| { + serde_json::json!({ + "cpu_percent": 0.0, + "memory_percent": 0.0, + "disk_percent": 0.0 + }) + .to_string() + }); // ── Parse JSON blobs ─────────────────────────────────────────────── - let top_ips: Vec = - serde_json::from_str(&top_ips_json).unwrap_or_default(); + let top_ips: Vec = serde_json::from_str(&top_ips_json).unwrap_or_default(); let threat_breakdown: serde_json::Map = serde_json::from_str(&threat_breakdown_json).unwrap_or_default(); - let health: serde_json::Value = - serde_json::from_str(&health_json).unwrap_or_default(); + let health: serde_json::Value = serde_json::from_str(&health_json).unwrap_or_default(); // ── Build HTML ───────────────────────────────────────────────────── - let bandwidth_mb = bandwidth - .parse::() - .unwrap_or(0.0) - / 1_048_576.0; + let bandwidth_mb = bandwidth.parse::().unwrap_or(0.0) / 1_048_576.0; let mut top_ips_rows = String::new(); for (i, entry) in top_ips.iter().enumerate().take(5) { diff --git a/net-guardia/src/core/email/scheduler.rs b/net-guardia/src/core/email/scheduler.rs index d92259f..9754d0a 100644 --- a/net-guardia/src/core/email/scheduler.rs +++ b/net-guardia/src/core/email/scheduler.rs @@ -1,6 +1,7 @@ use crate::interface::port::repository::RepositoryPort; -use crate::model::error::notification::NotificationError; +use crate::interface::port::secret_store::SecretStorePort; use crate::model::error::Error; +use crate::model::error::notification::NotificationError; use lettre::message::header::ContentType; use lettre::transport::smtp::authentication::Credentials; use lettre::{Message, SmtpTransport, Transport}; @@ -15,6 +16,8 @@ pub struct SmtpClient { port: u16, username: String, password: String, + /// The sender email address. Falls back to `username` if not set. + sender: String, } impl SmtpClient { @@ -22,7 +25,12 @@ impl SmtpClient { /// /// Returns `None` if any required setting (`smtp_host`, `smtp_port`, /// `smtp_username`, `smtp_password`) is missing. - pub fn from_database(db: &dyn RepositoryPort) -> Result, Error> { + /// If a `SecretStorePort` is provided, reads the password from the secret store + /// (falling back to the settings table for backward compat before migration). + pub fn from_database( + db: &dyn RepositoryPort, + secrets: Option<&dyn SecretStorePort>, + ) -> Result, Error> { let host = match db.get_setting("smtp_host")? { Some(v) if !v.is_empty() => v, _ => return Ok(None), @@ -35,32 +43,111 @@ impl SmtpClient { Some(v) if !v.is_empty() => v, _ => return Ok(None), }; - let password = match db.get_setting("smtp_password")? { + + // Try secret store first, fall back to settings + let password = Self::resolve_smtp_password(db, secrets)?; + let password = match password { Some(v) if !v.is_empty() => v, _ => return Ok(None), }; let port: u16 = port_str.parse().unwrap_or(587); + // smtp_sender overrides username as the From address. + // Fall back to username if smtp_sender is not configured. + let sender = match db.get_setting("smtp_sender")? { + Some(v) if !v.is_empty() => v, + _ => username.clone(), + }; + + // Validate that the sender looks like an email address + if !sender.contains('@') { + return Ok(None); + } + Ok(Some(Self { host, port, username, password, + sender, })) } + /// Try to construct an `SmtpClient` from a SOAR port (which also provides `get_setting`). + /// Same logic as `from_database`, but accepts `&dyn SoarPort` instead of `&dyn RepositoryPort`. + pub fn from_soar_port( + db: &dyn crate::interface::port::soar::SoarPort, + secrets: Option<&dyn SecretStorePort>, + ) -> Result, Error> { + let host = match db.get_setting("smtp_host")? { + Some(v) if !v.is_empty() => v, + _ => return Ok(None), + }; + let port_str = match db.get_setting("smtp_port")? { + Some(v) if !v.is_empty() => v, + _ => return Ok(None), + }; + let username = match db.get_setting("smtp_username")? { + Some(v) if !v.is_empty() => v, + _ => return Ok(None), + }; + + // Try secret store first, fall back to settings via SoarPort + let password = match secrets.and_then(|ss| ss.get_secret("smtp_password").ok().flatten()) { + Some(pw) if !pw.is_empty() => pw, + _ => match db.get_setting("smtp_password")? { + Some(v) if !v.is_empty() && v != "__encrypted__" => v, + _ => return Ok(None), + }, + }; + + let port: u16 = port_str.parse().unwrap_or(587); + + let sender = match db.get_setting("smtp_sender")? { + Some(v) if !v.is_empty() => v, + _ => username.clone(), + }; + + if !sender.contains('@') { + return Ok(None); + } + + Ok(Some(Self { + host, + port, + username, + password, + sender, + })) + } + + /// Resolve SMTP password: try secret store first, fall back to settings. + fn resolve_smtp_password( + db: &dyn RepositoryPort, + secrets: Option<&dyn SecretStorePort>, + ) -> Result, Error> { + if let Some(ss) = secrets + && let Some(pw) = ss.get_secret("smtp_password")? + && !pw.is_empty() + { + return Ok(Some(pw)); + } + // Fallback: read from settings (pre-migration or no secret store) + let val = db.get_setting("smtp_password")?; + match val { + Some(ref v) if v == "__encrypted__" => Ok(None), + other => Ok(other), + } + } + /// Send an HTML email using the configured SMTP transport. pub fn send(&self, to: &str, subject: &str, html_body: &str) -> Result<(), Error> { - let from_addr = self.username.parse().map_err(|e| { - NotificationError::InvalidAddress { - reason: format!("invalid from address: {e}"), - } + let from_addr = self.sender.parse().map_err(|e| NotificationError::InvalidAddress { + reason: format!("invalid from address: {e}"), })?; - let to_addr = to.parse().map_err(|e| { - NotificationError::InvalidAddress { - reason: format!("invalid to address: {e}"), - } + let to_addr = to.parse().map_err(|e| NotificationError::InvalidAddress { + reason: format!("invalid to address: {e}"), })?; let email = Message::builder() @@ -69,29 +156,39 @@ impl SmtpClient { .subject(subject) .header(ContentType::TEXT_HTML) .body(html_body.to_string()) - .map_err(|e| { - NotificationError::MessageBuildFailed { - reason: e.to_string(), - } - })?; + .map_err(|e| NotificationError::MessageBuildFailed { reason: e.to_string() })?; let creds = Credentials::new(self.username.clone(), self.password.clone()); - let mailer = SmtpTransport::starttls_relay(&self.host) - .map_err(|e| { - NotificationError::SmtpConnectionFailed { - reason: e.to_string(), - } - })? - .port(self.port) - .credentials(creds) - .build(); - - mailer.send(&email).map_err(|e| { - NotificationError::SmtpSendFailed { - reason: e.to_string(), + let mailer = match self.port { + 465 => { + // Implicit TLS (SMTPS) + SmtpTransport::relay(&self.host) + .map_err(|e| NotificationError::SmtpConnectionFailed { reason: e.to_string() })? + .port(self.port) + .credentials(creds) + .build() } - })?; + 25 | 587 => { + // STARTTLS (standard submission ports) + SmtpTransport::starttls_relay(&self.host) + .map_err(|e| NotificationError::SmtpConnectionFailed { reason: e.to_string() })? + .port(self.port) + .credentials(creds) + .build() + } + _ => { + // Non-standard port — use unencrypted transport with credentials + SmtpTransport::builder_dangerous(&self.host) + .port(self.port) + .credentials(creds) + .build() + } + }; + + mailer + .send(&email) + .map_err(|e| NotificationError::SmtpSendFailed { reason: e.to_string() })?; Ok(()) } @@ -101,16 +198,18 @@ impl SmtpClient { /// report (Monday 08:00 local time) and dispatches it via SMTP. pub struct ReportScheduler { db: Arc, + secrets: Option>, } impl ReportScheduler { - pub fn new(db: Arc) -> Self { - Self { db } + pub fn new(db: Arc, secrets: Option>) -> Self { + Self { db, secrets } } /// Spawn a background tokio task that runs the weekly check loop. pub fn run(&self) -> tokio::task::JoinHandle<()> { let db = Arc::clone(&self.db); + let secrets = self.secrets.clone(); tokio::spawn(async move { info!("Weekly report scheduler started"); let mut interval = time::interval(Duration::from_secs(3600)); @@ -123,7 +222,7 @@ impl ReportScheduler { info!("Weekly report window reached — preparing report"); - let smtp = match SmtpClient::from_database(&*db) { + let smtp = match SmtpClient::from_database(&*db, secrets.as_deref()) { Ok(Some(client)) => client, Ok(None) => { warn!( @@ -154,13 +253,8 @@ impl ReportScheduler { } }; - let subject = format!( - "NetGuardia Weekly Report — {}", - chrono::Local::now().format("%Y-%m-%d") - ); - let send_result = - tokio::task::spawn_blocking(move || smtp.send(&recipient, &subject, &html)) - .await; + let subject = format!("NetGuardia Weekly Report — {}", chrono::Local::now().format("%Y-%m-%d")); + let send_result = tokio::task::spawn_blocking(move || smtp.send(&recipient, &subject, &html)).await; match send_result { Ok(Ok(())) => info!("Weekly report sent successfully"), diff --git a/net-guardia/src/core/ml/aggregator.rs b/net-guardia/src/core/ml/aggregator.rs index e4f87f6..d4527a9 100644 --- a/net-guardia/src/core/ml/aggregator.rs +++ b/net-guardia/src/core/ml/aggregator.rs @@ -20,16 +20,24 @@ impl AttackAggregator { } } - pub fn should_alert(&mut self, flow_key: &FlowKey, score: f32, threshold: f32) -> bool { + pub fn should_alert(&mut self, flow_key: &FlowKey, score: f32, threshold: f32, attack_type: Option<&str>) -> bool { let now = Instant::now(); let detections = self.detections.entry(flow_key.clone()).or_default(); detections.retain(|(time, _)| now.duration_since(*time) < self.window_duration); detections.push((now, score)); - if detections.len() >= self.min_detections { - let avg_score: f32 = - detections.iter().map(|(_, s)| s).sum::() / detections.len() as f32; + // Per-attack-type adaptive min_detections: + // DDoS/DoS: high frequency, need more confirmations to avoid alert storms + // C2/Cryptomining: low frequency, alert on first detection + let effective_min = match attack_type { + Some("DDoS") | Some("DoS") => self.min_detections.saturating_mul(2).max(1), + Some("C2 Communication") | Some("Cryptomining") => 1, + _ => self.min_detections, + }; + + if detections.len() >= effective_min { + let avg_score: f32 = detections.iter().map(|(_, s)| s).sum::() / detections.len() as f32; return avg_score > threshold * self.alert_threshold_multiplier; } @@ -44,5 +52,65 @@ impl AttackAggregator { !detections.is_empty() }); } +} -} \ No newline at end of file +#[cfg(test)] +mod tests { + use super::*; + + fn test_key() -> FlowKey { + FlowKey { + src_ip: [192, 168, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + dst_ip: [10, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + src_port: 12345, + dst_port: 80, + protocol: 6, + ip_version: 4, + } + } + + #[test] + fn default_attack_type_uses_base_min_detections() { + let mut agg = AttackAggregator::new(60, 3); + let key = test_key(); + // Need 3 detections for default type + assert!(!agg.should_alert(&key, 5.0, 1.0, Some("Brute Force"))); + assert!(!agg.should_alert(&key, 5.0, 1.0, Some("Brute Force"))); + assert!(agg.should_alert(&key, 5.0, 1.0, Some("Brute Force"))); + } + + #[test] + fn ddos_requires_double_min_detections() { + let mut agg = AttackAggregator::new(60, 3); + let key = test_key(); + // DDoS needs 6 detections (3 * 2) + for _ in 0..5 { + assert!(!agg.should_alert(&key, 5.0, 1.0, Some("DDoS"))); + } + assert!(agg.should_alert(&key, 5.0, 1.0, Some("DDoS"))); + } + + #[test] + fn c2_alerts_on_first_detection() { + let mut agg = AttackAggregator::new(60, 3); + let key = test_key(); + // C2 Communication alerts immediately (min=1) + assert!(agg.should_alert(&key, 5.0, 1.0, Some("C2 Communication"))); + } + + #[test] + fn cryptomining_alerts_on_first_detection() { + let mut agg = AttackAggregator::new(60, 3); + let key = test_key(); + assert!(agg.should_alert(&key, 5.0, 1.0, Some("Cryptomining"))); + } + + #[test] + fn none_attack_type_uses_default() { + let mut agg = AttackAggregator::new(60, 3); + let key = test_key(); + assert!(!agg.should_alert(&key, 5.0, 1.0, None)); + assert!(!agg.should_alert(&key, 5.0, 1.0, None)); + assert!(agg.should_alert(&key, 5.0, 1.0, None)); + } +} diff --git a/net-guardia/src/core/ml/alert.rs b/net-guardia/src/core/ml/alert.rs index 85152fa..3e025c4 100644 --- a/net-guardia/src/core/ml/alert.rs +++ b/net-guardia/src/core/ml/alert.rs @@ -1,22 +1,19 @@ use macros::log; use tokio::sync::broadcast; +use crate::model::config::constants::ML_ALERT_CHANNEL_CAPACITY; use crate::model::log::ml::MLLog; use crate::model::ml_detection::{AlertMessage, DetectionResult}; -const ALERT_CHANNEL_CAPACITY: usize = 100; - -pub struct MLAlert { +pub struct MLAlert { broadcast_tx: broadcast::Sender, } impl MLAlert { pub fn new() -> Self { - let (broadcast_tx, _) = broadcast::channel(ALERT_CHANNEL_CAPACITY); + let (broadcast_tx, _) = broadcast::channel(ML_ALERT_CHANNEL_CAPACITY); - MLAlert { - broadcast_tx, - } + MLAlert { broadcast_tx } } pub fn subscribe_to_alerts(&self) -> broadcast::Receiver { diff --git a/net-guardia/src/core/ml/config_loader.rs b/net-guardia/src/core/ml/config_loader.rs index a66cdc1..040ca3a 100644 --- a/net-guardia/src/core/ml/config_loader.rs +++ b/net-guardia/src/core/ml/config_loader.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; use crate::model::error::ml::MLError; -pub use crate::model::config::MLInferenceConfig; +use crate::model::config::MLInferenceConfig; /// Backward-compatible alias so existing `use config_loader::InferenceConfig` paths still compile. pub type InferenceConfig = MLInferenceConfig; @@ -11,10 +11,9 @@ pub type InferenceConfig = MLInferenceConfig; impl MLInferenceConfig { pub fn load_file(file: &str) -> Result { let path = PathBuf::from("models").join(file); - let content = fs::read_to_string(&path) - .map_err(|_| MLError::ConfigLoadFailed(path.to_path_buf()))?; - let config: MLInferenceConfig = serde_json::from_str(&content) - .map_err(|e| MLError::ConfigParseFailed(e.to_string()))?; + let content = fs::read_to_string(&path).map_err(|_| MLError::ConfigLoadFailed(path.to_path_buf()))?; + let config: MLInferenceConfig = + serde_json::from_str(&content).map_err(|e| MLError::ConfigParseFailed(e.to_string()))?; if config.ae_feature_names.is_empty() { return Err(MLError::ConfigParseFailed("ae_feature_names is empty")); } diff --git a/net-guardia/src/core/ml/drift_detector.rs b/net-guardia/src/core/ml/drift_detector.rs new file mode 100644 index 0000000..99438e5 --- /dev/null +++ b/net-guardia/src/core/ml/drift_detector.rs @@ -0,0 +1,156 @@ +use std::collections::VecDeque; +use std::time::{Duration, Instant}; + +use crate::model::detection::drift::{DriftReport, FeatureBaselines}; + +/// Maximum number of snapshots to retain, preventing unbounded memory growth. +const MAX_SNAPSHOTS: usize = 10_000; + +/// Tracks rolling mean/stddev of normalized input features over a configurable window. +/// Compares against training-time baselines to detect data drift. +pub struct DriftDetector { + /// Recent feature snapshots within the rolling window, capped at MAX_SNAPSHOTS. + snapshots: VecDeque<(Instant, Vec)>, + /// Number of features expected per snapshot. + num_features: usize, + /// Feature baselines (if available). + baselines: Option, + /// Rolling window duration (runtime-configurable via DB `ml_drift_window_secs`). + drift_window: Duration, +} + +impl DriftDetector { + /// Create a new detector with a configurable drift window duration. + /// Default window is 3600s (1 hour) when not specified via DB setting `ml_drift_window_secs`. + pub fn new(baselines: Option, drift_window: Duration) -> Self { + let num_features = baselines.as_ref().map_or(0, |b| b.names.len()); + Self { + snapshots: VecDeque::new(), + num_features, + baselines, + drift_window, + } + } + + /// Add a new feature snapshot and evict stale entries. + pub fn update(&mut self, features: &[f64]) { + let now = Instant::now(); + self.snapshots.push_back((now, features.to_vec())); + self.evict_stale(now); + // Cap total snapshots to prevent unbounded memory growth + while self.snapshots.len() > MAX_SNAPSHOTS { + self.snapshots.pop_front(); + } + } + + /// Check whether the current rolling mean has drifted > 3σ from baseline. + pub fn check_drift(&self) -> Option { + let baselines = self.baselines.as_ref()?; + if self.snapshots.is_empty() || self.num_features == 0 { + return None; + } + + let n = self.snapshots.len() as f64; + let mut sums = vec![0.0_f64; self.num_features]; + + for (_, features) in &self.snapshots { + for (i, &val) in features.iter().enumerate().take(self.num_features) { + sums[i] += val; + } + } + + let mut drifted_features = Vec::new(); + let mut max_deviation = 0.0_f64; + + for (i, (sum, (bl_mean, bl_std))) in sums + .iter() + .zip(baselines.means.iter().zip(baselines.stds.iter())) + .enumerate() + .take(self.num_features) + { + let current_mean = sum / n; + + // Skip features with zero or near-zero stddev (constant features) + if *bl_std < 1e-12 { + continue; + } + + let deviation = ((current_mean - bl_mean) / bl_std).abs(); + if deviation > 3.0 { + drifted_features.push(baselines.names[i].clone()); + if deviation > max_deviation { + max_deviation = deviation; + } + } + } + + if drifted_features.is_empty() { + None + } else { + Some(DriftReport { + drifted_features, + max_deviation, + }) + } + } + + /// Remove snapshots older than the configured drift window. + fn evict_stale(&mut self, now: Instant) { + while let Some((ts, _)) = self.snapshots.front() { + if now.duration_since(*ts) > self.drift_window { + self.snapshots.pop_front(); + } else { + break; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_baselines(n: usize) -> FeatureBaselines { + FeatureBaselines { + names: (0..n).map(|i| format!("feature_{i}")).collect(), + means: vec![0.0; n], + stds: vec![1.0; n], + } + } + + #[test] + fn no_drift_when_within_threshold() { + let baselines = make_baselines(3); + let mut detector = DriftDetector::new(Some(baselines), Duration::from_secs(3600)); + // Values within 3σ of baseline mean 0.0 with std 1.0 + detector.update(&[1.0, -1.0, 2.0]); + detector.update(&[0.5, -0.5, 1.5]); + assert!(detector.check_drift().is_none()); + } + + #[test] + fn drift_detected_when_exceeds_threshold() { + let baselines = make_baselines(3); + let mut detector = DriftDetector::new(Some(baselines), Duration::from_secs(3600)); + // Mean of 5.0 exceeds 3σ from baseline mean 0.0 + detector.update(&[5.0, 0.0, 0.0]); + detector.update(&[5.0, 0.0, 0.0]); + let report = detector.check_drift().unwrap(); + assert!(report.drifted_features.contains(&"feature_0".to_string())); + assert!(report.max_deviation > 3.0); + } + + #[test] + fn no_baselines_means_no_drift() { + let mut detector = DriftDetector::new(None, Duration::from_secs(3600)); + detector.update(&[100.0, 200.0]); + assert!(detector.check_drift().is_none()); + } + + #[test] + fn empty_snapshots_no_drift() { + let baselines = make_baselines(3); + let detector = DriftDetector::new(Some(baselines), Duration::from_secs(3600)); + assert!(detector.check_drift().is_none()); + } +} diff --git a/net-guardia/src/core/ml/engine.rs b/net-guardia/src/core/ml/engine.rs index a3f9978..6c971b8 100644 --- a/net-guardia/src/core/ml/engine.rs +++ b/net-guardia/src/core/ml/engine.rs @@ -1,18 +1,19 @@ use std::sync::Arc; use std::time::{Duration, Instant}; -use parking_lot::Mutex; use macros::log; +use parking_lot::Mutex; use tokio::sync::oneshot; use tokio::time::interval; use super::aggregator::AttackAggregator; use super::config_loader::InferenceConfig; -use super::feature_extractor::FlowFeatures; +use super::drift_detector::DriftDetector; use super::flow_tracker::{FlowData, FlowTracker}; use super::inference::Inference; use super::model_loader::MLModels; use super::traffic_logger::TrafficLogger; +use crate::model::detection::flow_features::FlowFeatures; use super::alert::MLAlert; use crate::model::log::ml::MLLog; @@ -26,6 +27,7 @@ pub struct Engine { trackers: Vec, inference_pipeline: Arc, aggregator: Mutex, + drift_detector: Arc>, ml_alert: Arc, min_packets: usize, batch_size: usize, @@ -38,14 +40,19 @@ impl Engine { models: Arc, config: Arc, ml_alert: Arc, + drift_detector: Arc>, engine_config: EngineConfig, traffic_logger: Option>, num_threads: u32, ) -> Self { let inference_pipeline = Arc::new(Inference::new(models, config)); - let min_detections = ((engine_config.aggregator_window_secs / engine_config.inference_interval_secs) / 2).max(1) as usize; - let aggregator = Mutex::new(AttackAggregator::new(engine_config.aggregator_window_secs, min_detections)); + let min_detections = + ((engine_config.aggregator_window_secs / engine_config.inference_interval_secs) / 2).max(1) as usize; + let aggregator = Mutex::new(AttackAggregator::new( + engine_config.aggregator_window_secs, + min_detections, + )); let max_flows_per_thread = engine_config.max_flows / (num_threads as usize).max(1); let trackers: Vec = (0..num_threads) @@ -56,6 +63,7 @@ impl Engine { trackers, inference_pipeline, aggregator, + drift_detector, ml_alert, min_packets: engine_config.min_packets, batch_size: engine_config.batch_size, @@ -89,7 +97,7 @@ impl Engine { shutdown_tx } - async fn run_inference_loop(&self, mut shutdown_rx: oneshot::Receiver<()>) { + async fn run_inference_loop(self: Arc, mut shutdown_rx: oneshot::Receiver<()>) { let mut ticker = interval(Duration::from_secs(self.inference_interval_secs)); loop { @@ -98,21 +106,37 @@ impl Engine { _ = ticker.tick() => {} } - self.run_inference_tick(); + // Move CPU-bound ML inference off the tokio executor + let engine = Arc::clone(&self); + let _ = tokio::task::spawn_blocking(move || { + engine.run_inference_tick(); + }) + .await; } } fn run_inference_tick(&self) { let mut all_flows = Vec::new(); let mut total_count = 0; + let now_us = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_micros() as u64) + .unwrap_or(0); + + // Phase 0: clean up stale / terminated flows + for tracker in &self.trackers { + let mut t = tracker.lock(); + t.cleanup_stale_flows(now_us); + } // Phase 1: short lock per tracker — clone uninferred flows, mark as inferred for tracker in &self.trackers { let mut t = tracker.lock(); total_count += t.flow_count(); all_flows.extend( - t.get_uninferred_flows().into_iter() - .filter(|flow| flow.packet_count() >= self.min_packets) + t.get_uninferred_flows() + .into_iter() + .filter(|flow| flow.packet_count() >= self.min_packets), ); // lock released here } @@ -148,6 +172,22 @@ impl Engine { log!(MLLog::RunningInference(batch.len())); + // Feed normalized features into drift detector for each flow in the batch + { + let config = &self.inference_pipeline.config; + let mut dd = self.drift_detector.lock(); + for flow in batch.iter() { + let features = FlowFeatures::extract(flow, &config.ae_feature_names); + let normalized: Vec = features + .features + .iter() + .zip(config.ae_scaler_mean.iter().zip(config.ae_scaler_std.iter())) + .map(|(&val, (&mean, &std))| if std.abs() > 1e-12 { (val - mean) / std } else { 0.0 }) + .collect(); + dd.update(&normalized); + } + } + let start = Instant::now(); let results = self.inference_pipeline.infer_batch(batch); let elapsed_us = start.elapsed().as_micros() as u64; @@ -170,8 +210,12 @@ impl Engine { let mut aggregator = self.aggregator.lock(); for result in &results { if result.is_attack { - let should_alert = - aggregator.should_alert(&result.flow_key_raw, result.ae_score, result.threshold); + let should_alert = aggregator.should_alert( + &result.flow_key_raw, + result.ae_score, + result.threshold, + result.attack_type.as_deref(), + ); if should_alert { log!(MLLog::ThreatDetected( @@ -190,5 +234,4 @@ impl Engine { aggregator.cleanup(); } } - } diff --git a/net-guardia/src/core/ml/feature_extractor.rs b/net-guardia/src/core/ml/feature_extractor.rs index 3cce761..5063a4d 100644 --- a/net-guardia/src/core/ml/feature_extractor.rs +++ b/net-guardia/src/core/ml/feature_extractor.rs @@ -1,15 +1,9 @@ -use std::collections::HashMap; - use common::define::tcp_flags::*; use super::flow_tracker::FlowData; -use crate::model::ml_detection::{ClipParams, PacketData}; +use crate::model::ml_detection::PacketData; -#[derive(Debug, Clone)] -pub struct FlowFeatures { - pub features: Vec, - pub feature_num: usize, -} +use crate::model::detection::flow_features::FlowFeatures; impl FlowFeatures { pub fn extract(flow: &FlowData, feature_names: &[String]) -> Self { @@ -24,125 +18,6 @@ impl FlowFeatures { Self { features, feature_num } } - - pub fn normalize(&mut self, means: &[f64], stds: &[f64]) { - for i in 0..self.feature_num { - if stds[i] > 0.0 { - self.features[i] = (self.features[i] - means[i]) / stds[i]; - } else { - self.features[i] = 0.0; - } - } - } - - pub fn clip(&mut self, clip_min: f64, clip_max: f64) { - for i in 0..self.feature_num { - self.features[i] = self.features[i].max(clip_min).min(clip_max); - } - } - - pub fn winsorize(&mut self, clip_params: &HashMap, feature_names: &[String]) { - for (i, feature_name) in feature_names.iter().enumerate() { - if i < self.feature_num - && let Some(params) = clip_params.get(feature_name) { - self.features[i] = self.features[i].clamp(params.lower, params.upper); - } - } - } - - pub fn all_feature_names() -> Vec<&'static str> { - vec![ - "Destination Port", - "Protocol", - "Flow Duration", - "Total Fwd Packets", - "Total Backward Packets", - "Total Length of Fwd Packets", - "Total Length of Bwd Packets", - "Fwd Packet Length Max", - "Fwd Packet Length Min", - "Fwd Packet Length Mean", - "Fwd Packet Length Std", - "Bwd Packet Length Max", - "Bwd Packet Length Min", - "Bwd Packet Length Mean", - "Bwd Packet Length Std", - "Flow Bytes/s", - "Flow Packets/s", - "Flow IAT Mean", - "Flow IAT Std", - "Flow IAT Max", - "Flow IAT Min", - "Fwd IAT Total", - "Fwd IAT Mean", - "Fwd IAT Std", - "Fwd IAT Max", - "Fwd IAT Min", - "Bwd IAT Total", - "Bwd IAT Mean", - "Bwd IAT Std", - "Bwd IAT Max", - "Bwd IAT Min", - "Fwd PSH Flags", - "Bwd PSH Flags", - "Fwd URG Flags", - "Bwd URG Flags", - "Fwd Header Length", - "Bwd Header Length", - "Fwd Packets/s", - "Bwd Packets/s", - "Min Packet Length", - "Max Packet Length", - "Packet Length Mean", - "Packet Length Std", - "Packet Length Variance", - "FIN Flag Count", - "SYN Flag Count", - "RST Flag Count", - "PSH Flag Count", - "ACK Flag Count", - "URG Flag Count", - "CWE Flag Count", - "ECE Flag Count", - "Down/Up Ratio", - "Average Packet Size", - "Avg Fwd Segment Size", - "Avg Bwd Segment Size", - "Fwd Header Length.1", - "Fwd Avg Bytes/Bulk", - "Fwd Avg Packets/Bulk", - "Fwd Avg Bulk Rate", - "Bwd Avg Bytes/Bulk", - "Bwd Avg Packets/Bulk", - "Bwd Avg Bulk Rate", - "Subflow Fwd Packets", - "Subflow Fwd Bytes", - "Subflow Bwd Packets", - "Subflow Bwd Bytes", - "Init_Win_bytes_forward", - "Init_Win_bytes_backward", - "act_data_pkt_fwd", - "min_seg_size_forward", - "Active Mean", - "Active Std", - "Active Max", - "Active Min", - "Idle Mean", - "Idle Std", - "Idle Max", - "Idle Min", - ] - } - - pub fn all_feature_names_owned() -> Vec { - Self::all_feature_names().iter().map(|s| s.to_string()).collect() - } - - pub fn to_csv_record(&self) -> Vec { - let mut record: Vec = self.features.iter().map(|f| f.to_string()).collect(); - record.push("BENIGN".to_string()); - record - } } /// All statistics pre-computed once from a FlowData, then looked up by feature name. @@ -244,6 +119,10 @@ struct PrecomputedStats { idle_min: f64, idle_mean: f64, idle_std: f64, + + // Phase 2: new features for C2/Cryptomining detection + fwd_bwd_bytes_ratio: f64, + fwd_iat_skewness: f64, } impl PrecomputedStats { @@ -328,6 +207,10 @@ impl PrecomputedStats { let (idle_max, idle_min, idle_mean, idle_std) = compute_stats(&flow.idle_periods.iter().map(|&x| x as f64).collect::>()); + // Phase 2: new features for C2/Cryptomining detection + let fwd_bwd_bytes_ratio = safe_div(fwd_total_bytes, fwd_total_bytes + bwd_total_bytes); + let fwd_iat_skewness = compute_bowley_skewness(&fwd_iats); + Self { dst_port: flow.flow_key.dst_port as f64, protocol: flow.flow_key.protocol as f64, @@ -397,6 +280,8 @@ impl PrecomputedStats { idle_min, idle_mean, idle_std, + fwd_bwd_bytes_ratio, + fwd_iat_skewness, } } @@ -484,6 +369,16 @@ impl PrecomputedStats { "Idle Max" => self.idle_max, "Idle Min" => self.idle_min, + // Phase 2: unified names for IAT std (already computed, add aliases) + "fwd_iat_std" => self.fwd_iat_std, + "bwd_iat_std" => self.bwd_iat_std, + "flow_iat_std" => self.flow_iat_std, + + // Phase 2: new features for C2/Cryptomining detection + "fwd_bwd_bytes_ratio" => self.fwd_bwd_bytes_ratio, + "pkt_len_variance" => self.all_len_std * self.all_len_std, + "fwd_iat_skewness" => self.fwd_iat_skewness, + _ => 0.0, } } @@ -522,6 +417,23 @@ fn compute_iats(packets: &[PacketData]) -> Vec { .collect() } +/// Bowley (quartile) skewness: (Q3 + Q1 - 2*Q2) / (Q3 - Q1) +/// Returns 0.0 for insufficient data or zero IQR. +/// Used for C2 beacon detection — regular beacons have skewness near 0. +fn compute_bowley_skewness(values: &[f64]) -> f64 { + if values.len() < 4 { + return 0.0; + } + let mut sorted = values.to_vec(); + sorted.sort_by(|a, b| a.total_cmp(b)); + let n = sorted.len(); + let q1 = sorted[n / 4]; + let q2 = sorted[n / 2]; + let q3 = sorted[3 * n / 4]; + let iqr = q3 - q1; + if iqr <= 0.0 { 0.0 } else { (q3 + q1 - 2.0 * q2) / iqr } +} + fn compute_flow_iats(fwd_packets: &[PacketData], bwd_packets: &[PacketData]) -> Vec { let mut all_packets: Vec<&PacketData> = fwd_packets.iter().chain(bwd_packets.iter()).collect(); all_packets.sort_by_key(|p| p.timestamp_us); @@ -535,3 +447,38 @@ fn compute_flow_iats(fwd_packets: &[PacketData], bwd_packets: &[PacketData]) -> .map(|w| (w[1].timestamp_us - w[0].timestamp_us) as f64) .collect() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bowley_skewness_insufficient_data() { + assert_eq!(compute_bowley_skewness(&[]), 0.0); + assert_eq!(compute_bowley_skewness(&[1.0]), 0.0); + assert_eq!(compute_bowley_skewness(&[1.0, 2.0, 3.0]), 0.0); + } + + #[test] + fn bowley_skewness_zero_iqr() { + // All identical values → Q1 == Q3 → IQR = 0 + assert_eq!(compute_bowley_skewness(&[5.0, 5.0, 5.0, 5.0]), 0.0); + assert_eq!(compute_bowley_skewness(&[1.0, 1.0, 1.0, 1.0, 1.0, 1.0]), 0.0); + } + + #[test] + fn bowley_skewness_known_output() { + // Symmetric distribution: [1, 2, 3, 4, 5, 6, 7, 8] (n=8) + // Q1 = sorted[2] = 3, Q2 = sorted[4] = 5, Q3 = sorted[6] = 7 + // Bowley = (7 + 3 - 2*5) / (7 - 3) = 0 / 4 = 0.0 + let symmetric = vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]; + assert!((compute_bowley_skewness(&symmetric)).abs() < 1e-10); + + // Right-skewed: [1, 1, 1, 1, 2, 5, 10, 20] (n=8) + // Q1 = sorted[2] = 1, Q2 = sorted[4] = 2, Q3 = sorted[6] = 10 + // Bowley = (10 + 1 - 2*2) / (10 - 1) = 7 / 9 ≈ 0.778 + let right_skewed = vec![1.0, 1.0, 1.0, 1.0, 2.0, 5.0, 10.0, 20.0]; + let skew = compute_bowley_skewness(&right_skewed); + assert!((skew - 7.0 / 9.0).abs() < 1e-10); + } +} diff --git a/net-guardia/src/core/ml/flow_tracker.rs b/net-guardia/src/core/ml/flow_tracker.rs index 0fff6a5..df3b009 100644 --- a/net-guardia/src/core/ml/flow_tracker.rs +++ b/net-guardia/src/core/ml/flow_tracker.rs @@ -1,6 +1,12 @@ -use std::collections::HashMap; -use common::define::tcp_flags::*; +use std::num::NonZero; +use common::define::tcp_flags::*; +use lru::LruCache; + +use crate::model::config::constants::{ + FLOW_BULK_MIN_BYTES, FLOW_BULK_MIN_PACKETS, FLOW_IDLE_THRESHOLD_US, FLOW_IDLE_TIMEOUT_US, + FLOW_MAX_PACKETS_PER_DIRECTION, FLOW_MAX_PERIODS, FLOW_TERMINATED_TIMEOUT_US, +}; use crate::model::direction::Direction; use crate::model::ml_detection::{BulkState, FlowKey, PacketData}; use crate::model::user_packet::UserPacket; @@ -60,8 +66,16 @@ impl FlowData { urg_count: 0, cwe_count: 0, ece_count: 0, - init_win_bytes_fwd: if first_packet.is_forward { first_packet.tcp_window_size } else { 0 }, - init_win_bytes_bwd: if !first_packet.is_forward { first_packet.tcp_window_size } else { 0 }, + init_win_bytes_fwd: if first_packet.is_forward { + first_packet.tcp_window_size + } else { + 0 + }, + init_win_bytes_bwd: if !first_packet.is_forward { + first_packet.tcp_window_size + } else { + 0 + }, active_periods: Vec::new(), idle_periods: Vec::new(), last_packet_time: first_packet.timestamp_us, @@ -74,9 +88,6 @@ impl FlowData { } pub fn add_packet(&mut self, packet: &UserPacket) { - const MAX_PACKETS_PER_DIRECTION: usize = 1000; - const MAX_PERIODS: usize = 10000; - let packet_data = PacketData { timestamp_us: packet.timestamp_us, length: packet.packet_length, @@ -85,22 +96,40 @@ impl FlowData { flags: packet.tcp_flags, }; - if packet.tcp_flags & TCP_FIN != 0 { self.fin_count += 1; } - if packet.tcp_flags & TCP_SYN != 0 { self.syn_count += 1; } - if packet.tcp_flags & TCP_RST != 0 { self.rst_count += 1; } - if packet.tcp_flags & TCP_PSH != 0 { self.psh_count += 1; } - if packet.tcp_flags & TCP_ACK != 0 { self.ack_count += 1; } - if packet.tcp_flags & TCP_URG != 0 { self.urg_count += 1; } - if packet.tcp_flags & TCP_CWR != 0 { self.cwe_count += 1; } - if packet.tcp_flags & TCP_ECE != 0 { self.ece_count += 1; } + if packet.tcp_flags & TCP_FIN != 0 { + self.fin_count += 1; + } + if packet.tcp_flags & TCP_SYN != 0 { + self.syn_count += 1; + } + if packet.tcp_flags & TCP_RST != 0 { + self.rst_count += 1; + } + if packet.tcp_flags & TCP_PSH != 0 { + self.psh_count += 1; + } + if packet.tcp_flags & TCP_ACK != 0 { + self.ack_count += 1; + } + if packet.tcp_flags & TCP_URG != 0 { + self.urg_count += 1; + } + if packet.tcp_flags & TCP_CWR != 0 { + self.cwe_count += 1; + } + if packet.tcp_flags & TCP_ECE != 0 { + self.ece_count += 1; + } let iat = packet.timestamp_us.saturating_sub(self.last_packet_time); - const IDLE_THRESHOLD_US: u64 = 1_000_000; - if iat > IDLE_THRESHOLD_US { - if self.idle_periods.len() < MAX_PERIODS { self.idle_periods.push(iat); } - } else if iat > 0 - && self.active_periods.len() < MAX_PERIODS { self.active_periods.push(iat); } + if iat > FLOW_IDLE_THRESHOLD_US { + if self.idle_periods.len() < FLOW_MAX_PERIODS { + self.idle_periods.push(iat); + } + } else if iat > 0 && self.active_periods.len() < FLOW_MAX_PERIODS { + self.active_periods.push(iat); + } self.last_packet_time = packet.timestamp_us; self.last_time_us = packet.timestamp_us; @@ -112,28 +141,29 @@ impl FlowData { } if packet.is_forward { - if self.fwd_packets.len() < MAX_PACKETS_PER_DIRECTION { + if self.fwd_packets.len() < FLOW_MAX_PACKETS_PER_DIRECTION { self.fwd_packets.push(packet_data.clone()); } self.fwd_total_bytes += packet.payload_length as u64; self.fwd_header_bytes += packet.header_length as u64; - if self.init_win_bytes_fwd == 0 { self.init_win_bytes_fwd = packet.tcp_window_size; } + if self.init_win_bytes_fwd == 0 { + self.init_win_bytes_fwd = packet.tcp_window_size; + } Self::update_bulk_state(&mut self.fwd_bulk_state, &packet_data); } else { - if self.bwd_packets.len() < MAX_PACKETS_PER_DIRECTION { + if self.bwd_packets.len() < FLOW_MAX_PACKETS_PER_DIRECTION { self.bwd_packets.push(packet_data.clone()); } self.bwd_total_bytes += packet.payload_length as u64; self.bwd_header_bytes += packet.header_length as u64; - if self.init_win_bytes_bwd == 0 { self.init_win_bytes_bwd = packet.tcp_window_size; } + if self.init_win_bytes_bwd == 0 { + self.init_win_bytes_bwd = packet.tcp_window_size; + } Self::update_bulk_state(&mut self.bwd_bulk_state, &packet_data); } } fn update_bulk_state(bulk_state: &mut BulkState, packet: &PacketData) { - const BULK_MIN_PACKETS: u64 = 4; - const BULK_MIN_BYTES: u64 = 1000; - if packet.payload_length > 0 { if !bulk_state.in_bulk { bulk_state.in_bulk = true; @@ -148,8 +178,8 @@ impl FlowData { } } else { if bulk_state.in_bulk - && bulk_state.last_bulk_packets >= BULK_MIN_PACKETS - && bulk_state.last_bulk_bytes >= BULK_MIN_BYTES + && bulk_state.last_bulk_packets >= FLOW_BULK_MIN_PACKETS + && bulk_state.last_bulk_bytes >= FLOW_BULK_MIN_BYTES { bulk_state.bulk_count += 1; bulk_state.total_bytes += bulk_state.last_bulk_bytes; @@ -177,27 +207,29 @@ impl FlowData { /// Per-thread flow tracker. No locks — each XSK thread owns one. /// RSS guarantees the same flow always goes to the same thread. +/// Uses LruCache for O(1) eviction instead of O(n) min_by_key scan. pub struct FlowTracker { - active: HashMap, - max_flows: usize, + active: LruCache, } impl FlowTracker { pub fn new(max_flows: usize) -> Self { + // SAFETY: max(1, max_flows) ensures NonZero is never zero. + let cap = NonZero::new(max_flows.max(1)).unwrap_or_else(|| unreachable!()); Self { - active: HashMap::new(), - max_flows, + active: LruCache::new(cap), } } pub fn process_packet(&mut self, mut packet: UserPacket, is_ingress: bool) { let packet_key = FlowKey::from_packet(&packet); - let reversed_key = packet_key.clone().reverse(); + let reversed_key = packet_key.reverse(); // Try to match an existing flow first (canonical key already established). - let (actual_key, is_forward) = if self.active.contains_key(&packet_key) { + // Use peek() to avoid promoting — we'll promote via get_mut() below. + let (actual_key, is_forward) = if self.active.peek(&packet_key).is_some() { (packet_key, true) - } else if self.active.contains_key(&reversed_key) { + } else if self.active.peek(&reversed_key).is_some() { (reversed_key, false) } else { // New flow: determine initiator using TCP flags, fall back to is_ingress. @@ -208,14 +240,22 @@ impl FlowTracker { // Ingress: external server responding to internal client → reverse so // canonical key has internal client as src. // Egress: internal server responding to external client → keep as-is. - if is_ingress { (reversed_key, false) } else { (packet_key, true) } + if is_ingress { + (reversed_key, false) + } else { + (packet_key, true) + } } else if syn { // SYN: sender is always the initiator. (packet_key, true) } else { // Mid-stream / UDP / ICMP: use is_ingress as best-effort heuristic. // Egress = we are the initiator (forward); ingress = remote initiated (backward). - if is_ingress { (reversed_key, false) } else { (packet_key, true) } + if is_ingress { + (reversed_key, false) + } else { + (packet_key, true) + } } }; @@ -223,36 +263,39 @@ impl FlowTracker { // Record which interface the initiator is on for this flow. let initiator_direction = if is_forward { - if is_ingress { Direction::Ingress } else { Direction::Egress } + if is_ingress { + Direction::Ingress + } else { + Direction::Egress + } + } else if is_ingress { + Direction::Egress } else { - if is_ingress { Direction::Egress } else { Direction::Ingress } + Direction::Ingress }; - let flow = self.active - .entry(actual_key.clone()) - .or_insert_with(|| FlowData::new(actual_key, &packet, initiator_direction)); - - flow.add_packet(&packet); - - if self.active.len() > self.max_flows - && let Some(oldest_key) = self.active.iter() - .min_by_key(|(_, flow)| flow.last_time_us) - .map(|(k, _)| k.clone()) - { - self.active.remove(&oldest_key); + // LruCache::push handles eviction automatically when capacity is exceeded (O(1)). + // If the flow already exists, get_mut promotes it to MRU; otherwise push creates it. + if let Some(flow) = self.active.get_mut(&actual_key) { + flow.add_packet(&packet); + } else { + let mut flow = FlowData::new(actual_key.clone(), &packet, initiator_direction); + flow.add_packet(&packet); + self.active.push(actual_key, flow); } } /// Get all active flows (clone, no drain). Used by WebSocket. pub fn get_flows(&self) -> Vec { - self.active.values().cloned().collect() + self.active.iter().map(|(_, flow)| flow.clone()).collect() } /// Get flows that received new packets since their last inference, /// and mark them as inferred. Used by ML engine. pub fn get_uninferred_flows(&mut self) -> Vec { let mut result = Vec::new(); - for flow in self.active.values_mut() { + // iter_mut does NOT promote entries (preserves LRU order) + for (_, flow) in self.active.iter_mut() { if flow.last_time_us > flow.last_inferred_us { result.push(flow.clone()); flow.last_inferred_us = flow.last_time_us; @@ -264,5 +307,124 @@ impl FlowTracker { pub fn flow_count(&self) -> usize { self.active.len() } + + /// Remove flows that have been idle too long or are terminated (FIN/RST seen). + /// `now_us`: current timestamp in microseconds (same scale as packet timestamps). + /// Returns the number of flows removed. + pub fn cleanup_stale_flows(&mut self, now_us: u64) -> usize { + // LruCache doesn't have retain(), so collect keys to remove then pop them. + let keys_to_remove: Vec = self + .active + .iter() + .filter(|(_, flow)| { + let idle = now_us.saturating_sub(flow.last_time_us); + let is_terminated = flow.fin_count > 0 || flow.rst_count > 0; + if is_terminated { + idle >= FLOW_TERMINATED_TIMEOUT_US + } else { + idle >= FLOW_IDLE_TIMEOUT_US + } + }) + .map(|(k, _)| k.clone()) + .collect(); + let removed = keys_to_remove.len(); + for key in keys_to_remove { + self.active.pop(&key); + } + removed + } } +#[cfg(test)] +mod tests { + use super::*; + + fn make_packet(timestamp_us: u64, tcp_flags: u8) -> UserPacket { + UserPacket { + ip_version: 4, + protocol: 6, // TCP + tcp_flags, + src_ip: [10, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + dst_ip: [10, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + src_port: 12345, + dst_port: 80, + packet_length: 100, + payload_length: 60, + header_length: 40, + tcp_window_size: 65535, + timestamp_us, + is_forward: true, + } + } + + #[test] + fn cleanup_removes_idle_flows() { + let mut tracker = FlowTracker::new(10000); + let base_ts = 1_000_000_000u64; // 1000 seconds + + // Insert a flow with old timestamp + let pkt = make_packet(base_ts, 0x02); // SYN + tracker.process_packet(pkt, false); + assert_eq!(tracker.flow_count(), 1); + + // 130 seconds later — should be cleaned up (idle > 120s) + let now = base_ts + 130_000_000; + let removed = tracker.cleanup_stale_flows(now); + assert_eq!(removed, 1); + assert_eq!(tracker.flow_count(), 0); + } + + #[test] + fn cleanup_keeps_active_flows() { + let mut tracker = FlowTracker::new(10000); + let base_ts = 1_000_000_000u64; + + let pkt = make_packet(base_ts, 0x02); + tracker.process_packet(pkt, false); + + // Only 10 seconds later — should NOT be cleaned up + let now = base_ts + 10_000_000; + let removed = tracker.cleanup_stale_flows(now); + assert_eq!(removed, 0); + assert_eq!(tracker.flow_count(), 1); + } + + #[test] + fn cleanup_removes_terminated_flows_after_short_idle() { + let mut tracker = FlowTracker::new(10000); + let base_ts = 1_000_000_000u64; + + // SYN packet + let pkt1 = make_packet(base_ts, 0x02); + tracker.process_packet(pkt1, false); + + // FIN packet 1 second later + let pkt2 = make_packet(base_ts + 1_000_000, 0x01); // FIN + tracker.process_packet(pkt2, false); + + // 6 seconds after FIN — terminated flow should be removed (idle > 5s) + let now = base_ts + 7_000_000; + let removed = tracker.cleanup_stale_flows(now); + assert_eq!(removed, 1); + assert_eq!(tracker.flow_count(), 0); + } + + #[test] + fn cleanup_keeps_recently_terminated_flows() { + let mut tracker = FlowTracker::new(10000); + let base_ts = 1_000_000_000u64; + + let pkt1 = make_packet(base_ts, 0x02); + tracker.process_packet(pkt1, false); + + // FIN packet + let pkt2 = make_packet(base_ts + 1_000_000, 0x01); + tracker.process_packet(pkt2, false); + + // Only 2 seconds after FIN — should still be around + let now = base_ts + 3_000_000; + let removed = tracker.cleanup_stale_flows(now); + assert_eq!(removed, 0); + assert_eq!(tracker.flow_count(), 1); + } +} diff --git a/net-guardia/src/core/ml/inference.rs b/net-guardia/src/core/ml/inference.rs index e7e304b..3c90702 100644 --- a/net-guardia/src/core/ml/inference.rs +++ b/net-guardia/src/core/ml/inference.rs @@ -4,9 +4,9 @@ use macros::log; use tract_onnx::prelude::*; use super::config_loader::InferenceConfig; -use super::feature_extractor::FlowFeatures; use super::flow_tracker::FlowData; use super::model_loader::MLModels; +use crate::model::detection::flow_features::FlowFeatures; use crate::model::log::ml::MLLog; use crate::model::ml_detection::DetectionResult; @@ -25,6 +25,21 @@ impl Inference { } pub fn infer_single(&self, flow: &FlowData) -> Option { + // catch_unwind protects against tract-onnx internal panics on edge-case inputs. + // Without this, panic=abort config would kill the entire process. + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.infer_single_inner(flow))) { + Ok(result) => result, + Err(_) => { + log!(MLLog::InferenceFailed( + "ONNX".to_string(), + "inference panicked (caught)".to_string(), + )); + None + } + } + } + + fn infer_single_inner(&self, flow: &FlowData) -> Option { let ae_features = self.preprocess_ae_features(flow); let ae_input = Self::vec_to_array2(&ae_features); @@ -67,6 +82,8 @@ impl Inference { confidence, ae_score, threshold: self.config.ae_threshold, + packet_count: flow.packet_count() as u64, + flow_duration_us: flow.duration_us(), }) } @@ -96,10 +113,7 @@ impl Inference { fn run_autoencoder(&self, input: &tract_ndarray::Array2) -> TractResult { let input_tensor = input.clone().into_tensor(); - let result = self - .models - .deep_autoencoder - .run(tvec![input_tensor.into()])?; + let result = self.models.deep_autoencoder.run(tvec![input_tensor.into()])?; let output = result[0] .to_array_view::()? diff --git a/net-guardia/src/core/ml/mod.rs b/net-guardia/src/core/ml/mod.rs index 5b40011..d71c694 100644 --- a/net-guardia/src/core/ml/mod.rs +++ b/net-guardia/src/core/ml/mod.rs @@ -1,9 +1,10 @@ -pub mod alert; -pub mod model_loader; -pub mod config_loader; -pub mod flow_tracker; -pub mod feature_extractor; -pub mod inference; -pub mod engine; pub mod aggregator; -pub mod traffic_logger; \ No newline at end of file +pub mod alert; +pub mod config_loader; +pub mod drift_detector; +pub mod engine; +pub mod feature_extractor; +pub mod flow_tracker; +pub mod inference; +pub mod model_loader; +pub mod traffic_logger; diff --git a/net-guardia/src/core/ml/model_loader.rs b/net-guardia/src/core/ml/model_loader.rs index a525d75..9bc84d1 100644 --- a/net-guardia/src/core/ml/model_loader.rs +++ b/net-guardia/src/core/ml/model_loader.rs @@ -1,5 +1,5 @@ -use tract_onnx::prelude::*; use std::path::PathBuf; +use tract_onnx::prelude::*; use crate::infrastructure::app_config::AppConfig; use crate::model::error::ml::MLError; @@ -14,8 +14,14 @@ pub struct MLModels { impl MLModels { pub fn load_models(app_config: &Arc, inference_config: &Arc) -> Result { Ok(Self { - deep_autoencoder: Self::loader(&app_config.inference.deep_autoencoder_name, inference_config.num_ae_features())?, - classifier: Self::loader(&app_config.inference.classifier_name, inference_config.num_classifier_features())? + deep_autoencoder: Self::loader( + &app_config.inference.deep_autoencoder_name, + inference_config.num_ae_features(), + )?, + classifier: Self::loader( + &app_config.inference.classifier_name, + inference_config.num_classifier_features(), + )?, }) } @@ -42,4 +48,4 @@ impl MLModels { let outputs = model.model().outputs.len(); format!("{}: inputs: {}, outputs: {}", name, inputs, outputs) } -} \ No newline at end of file +} diff --git a/net-guardia/src/core/ml/traffic_logger.rs b/net-guardia/src/core/ml/traffic_logger.rs index 2952fda..d509737 100644 --- a/net-guardia/src/core/ml/traffic_logger.rs +++ b/net-guardia/src/core/ml/traffic_logger.rs @@ -2,7 +2,7 @@ use std::fs::OpenOptions; use std::io::{BufWriter, Write}; use std::thread; -use crossbeam::channel::{bounded, Sender, TrySendError}; +use crossbeam::channel::{Sender, TrySendError, bounded}; use macros::log; use crate::model::error::ml::MLError; diff --git a/net-guardia/src/core/mod.rs b/net-guardia/src/core/mod.rs index c33054c..746b348 100644 --- a/net-guardia/src/core/mod.rs +++ b/net-guardia/src/core/mod.rs @@ -1,14 +1,16 @@ -pub mod acl_service; -pub mod auth; -pub mod config_service; -pub mod dns_filter_service; -pub mod email; -pub mod ebpf; -pub mod ml; -pub mod notification_service; -pub mod playbook_service; -pub mod rate_limit_service; -pub mod report; -pub mod soar; -pub mod stats_aggregator; -pub mod system; +pub mod acl_service; +pub mod auth; +pub mod config_service; +pub mod correlation; +pub mod detection; +pub mod dns_filter_service; +pub mod ebpf; +pub mod email; +pub mod ml; +pub mod notification_service; +pub mod playbook_service; +pub mod rate_limit_service; +pub mod report; +pub mod soar; +pub mod stats_aggregator; +pub mod system; diff --git a/net-guardia/src/core/notification_service.rs b/net-guardia/src/core/notification_service.rs index bf4680f..cf47b44 100644 --- a/net-guardia/src/core/notification_service.rs +++ b/net-guardia/src/core/notification_service.rs @@ -1,76 +1,95 @@ use std::sync::Arc; -use crate::adapter::persistence::Database; -use crate::interface::port::notification::AlertNotifier; +use crate::interface::port::notification::{AlertNotifier, NotificationConfigPort}; use crate::interface::port::repository::RepositoryPort; +use crate::interface::port::secret_store::SecretStorePort; use crate::model::error::Error; use crate::model::error::misc::MiscError; /// Domain service for notification config (Telegram, SMTP). /// Coordinates DB persistence and external service testing. pub struct NotificationService { - db: Arc, + notif: Arc, + repo: Arc, + secrets: Arc, } impl NotificationService { - pub fn new(db: Arc) -> Self { - Self { db } + pub fn new( + notif: Arc, + repo: Arc, + secrets: Arc, + ) -> Self { + Self { notif, repo, secrets } } /// Get Telegram config with redacted bot_token. pub fn get_telegram_config(&self) -> Result { - match self.db.get_notification_config("telegram")? { - Some(json_str) => { - match serde_json::from_str::(&json_str) { - Ok(mut config) => { - if let Some(token) = config.get("bot_token").and_then(|t| t.as_str()) - && token.len() > 8 { - let redacted = format!("{}...{}", &token[..4], &token[token.len()-4..]); - config["bot_token_redacted"] = serde_json::Value::String(redacted); - config.as_object_mut().map(|obj| obj.remove("bot_token")); - } - config["configured"] = serde_json::Value::Bool(true); - Ok(config) + match self.notif.get_notification_config("telegram")? { + Some(json_str) => match serde_json::from_str::(&json_str) { + Ok(mut config) => { + // Resolve the actual token for redaction display + let token = match config.get("bot_token").and_then(|t| t.as_str()) { + Some("__encrypted__") => self.secrets.get_secret("telegram_bot_token")?, + Some(t) => Some(t.to_string()), + None => None, + }; + + if let Some(ref t) = token + && t.len() > 8 + { + let redacted = format!("{}...{}", &t[..4], &t[t.len() - 4..]); + config["bot_token_redacted"] = serde_json::Value::String(redacted); } - Err(_) => Ok(serde_json::json!({"configured": false})), + config.as_object_mut().map(|obj| obj.remove("bot_token")); + config["configured"] = serde_json::Value::Bool(true); + Ok(config) } - } + Err(_) => Ok(serde_json::json!({"configured": false})), + }, None => Ok(serde_json::json!({"configured": false})), } } /// Save Telegram bot_token + chat_id to DB. + /// The bot_token is stored encrypted in the secret store; the config JSON + /// holds the `"__encrypted__"` sentinel. pub fn set_telegram_config(&self, bot_token: &str, chat_id: &str) -> Result<(), Error> { + self.secrets.set_secret("telegram_bot_token", bot_token)?; let config_json = serde_json::json!({ - "bot_token": bot_token, + "bot_token": "__encrypted__", "chat_id": chat_id, - }).to_string(); - self.db.set_notification_config("telegram", &config_json) + }) + .to_string(); + self.notif.set_notification_config("telegram", &config_json) } /// Send a test Telegram message using current config. pub async fn test_telegram(&self) -> Result<(), Error> { - let adapter = crate::adapter::telegram::TelegramAdapter::new(self.db.clone())?; + let adapter = crate::adapter::telegram::TelegramAdapter::new( + self.notif.clone(), + self.repo.clone(), + Some(self.secrets.clone()), + )?; adapter.send_test_message().await } /// Send a test email using current SMTP config. pub fn test_smtp(&self) -> Result { - let smtp_client = crate::core::email::scheduler::SmtpClient::from_database( - self.db.as_ref() as &dyn RepositoryPort, - )?; - let smtp = smtp_client.ok_or_else(|| { - MiscError::ValidationError { message: - "SMTP not configured. Set smtp_host, smtp_port, smtp_username, smtp_password first.".into() - } + let smtp_client = + crate::core::email::scheduler::SmtpClient::from_database(self.repo.as_ref(), Some(self.secrets.as_ref()))?; + let smtp = smtp_client.ok_or_else(|| MiscError::ValidationError { + message: "SMTP not configured. Set smtp_host, smtp_port, smtp_username, smtp_password first. \ + If smtp_username is not an email address, also set smtp_sender." + .into(), })?; - let recipient = self.db.get_setting("smtp_recipient")? + let recipient = self + .repo + .get_setting("smtp_recipient")? .filter(|r| !r.is_empty()) - .ok_or_else(|| { - MiscError::ValidationError { message: - "No smtp_recipient configured.".into() - } + .ok_or_else(|| MiscError::ValidationError { + message: "No smtp_recipient configured.".into(), })?; smtp.send( diff --git a/net-guardia/src/core/playbook_service.rs b/net-guardia/src/core/playbook_service.rs index cec89ba..acfe682 100644 --- a/net-guardia/src/core/playbook_service.rs +++ b/net-guardia/src/core/playbook_service.rs @@ -1,85 +1,57 @@ use std::sync::Arc; -use crate::adapter::persistence::Database; use crate::core::soar::engine::SoarEngine; use crate::interface::port::access_control::AccessControlPort; +use crate::interface::port::soar::SoarPort; use macros::log; use crate::model::error::Error; use crate::model::error::soar::SoarError; +use crate::model::soar::playbook_data::{ + ActionData, ActiveBlockData, ConditionData, CreatePlaybookInput, ExecutionData, PlaybookData, UpdatePlaybookRow, +}; + +use std::collections::HashMap; /// Domain service for SOAR playbook CRUD operations. /// Coordinates DB reads/writes, SOAR engine cache refresh, and eBPF unblock. pub struct PlaybookService { - db: Arc, + db: Arc, soar_engine: Arc, access_control: Arc, } -/// Input for creating a new playbook. -pub struct CreatePlaybookInput { - pub name: String, - pub trigger_event: String, - pub condition_threshold: Option, - pub condition_count: Option, - pub condition_window_secs: Option, - pub cooldown_secs: i64, - pub actions: Vec<(String, String)>, // (action_type, params_json) -} - -/// Flattened playbook representation for API responses. -pub struct PlaybookData { - pub id: i64, - pub name: String, - pub enabled: bool, - pub trigger_event: String, - pub condition_threshold: Option, - pub condition_count: Option, - pub condition_window_secs: Option, - pub cooldown_secs: i64, - pub actions: Vec, -} - -pub struct ActionData { - pub id: i64, - pub action_order: i64, - pub action_type: String, - pub params: serde_json::Value, -} - -/// Execution record from soar_executions table. -pub struct ExecutionData { - pub id: i64, - pub playbook_id: i64, - pub source_ip: Option, - pub trigger_event: String, - pub actions_executed: serde_json::Value, - pub created_at: String, -} - -/// Active block record from soar_block_rules table. -pub struct ActiveBlockData { - pub id: i64, - pub source_ip: String, - pub playbook_id: i64, - pub expires_at: String, -} - impl PlaybookService { pub fn new( - db: Arc, + db: Arc, soar_engine: Arc, access_control: Arc, ) -> Self { - Self { db, soar_engine, access_control } + Self { + db, + soar_engine, + access_control, + } } pub fn list_playbooks(&self) -> Result, Error> { let rows = self.db.load_playbooks_with_actions()?; let mut result: Vec = Vec::new(); - for (pb_id, name, enabled, trigger_event, threshold, count, window, cooldown, - action_id, action_order, action_type, action_params) in rows + for ( + pb_id, + name, + enabled, + trigger_event, + threshold, + count, + window, + cooldown, + action_id, + action_order, + action_type, + action_params, + ) in rows { // Find or create the playbook entry let pb = if let Some(last) = result.last_mut() { @@ -87,25 +59,35 @@ impl PlaybookService { last } else { result.push(PlaybookData { - id: pb_id, name, enabled, trigger_event, + id: pb_id, + name, + enabled, + trigger_event, condition_threshold: threshold, condition_count: count, condition_window_secs: window, cooldown_secs: cooldown, actions: Vec::new(), + conditions: Vec::new(), }); - result.last_mut().unwrap() + // SAFETY: just pushed above, Vec cannot be empty + result.last_mut().unwrap_or_else(|| unreachable!()) } } else { result.push(PlaybookData { - id: pb_id, name, enabled, trigger_event, + id: pb_id, + name, + enabled, + trigger_event, condition_threshold: threshold, condition_count: count, condition_window_secs: window, cooldown_secs: cooldown, actions: Vec::new(), + conditions: Vec::new(), }); - result.last_mut().unwrap() + // SAFETY: just pushed above, Vec cannot be empty + result.last_mut().unwrap_or_else(|| unreachable!()) }; // Append action if present (LEFT JOIN may yield NULLs) @@ -116,26 +98,101 @@ impl PlaybookService { id: aid, action_order: order, action_type: atype, - params: serde_json::from_str(¶ms_str) - .unwrap_or(serde_json::Value::Null), + params: serde_json::from_str(¶ms_str).unwrap_or(serde_json::Value::Null), }); } } + + // Load conditions and attach to playbooks + let cond_rows = self.db.load_all_playbook_conditions()?; + let mut cond_map: HashMap> = HashMap::new(); + for (cid, pb_id, ctype, operator, value, value2) in cond_rows { + cond_map.entry(pb_id).or_default().push(ConditionData { + id: cid, + condition_type: ctype, + operator, + value, + value2, + }); + } + for pb in &mut result { + if let Some(conds) = cond_map.remove(&pb.id) { + pb.conditions = conds; + } + } + Ok(result) } pub fn create_playbook(&self, input: &CreatePlaybookInput) -> Result { let playbook_id = self.db.insert_playbook( - &input.name, &input.trigger_event, input.condition_threshold, - input.condition_count, input.condition_window_secs, input.cooldown_secs, + &input.name, + &input.trigger_event, + input.condition_threshold, + input.condition_count, + input.condition_window_secs, + input.cooldown_secs, )?; for (i, (action_type, params_str)) in input.actions.iter().enumerate() { - self.db.insert_playbook_action(playbook_id, (i + 1) as i64, action_type, params_str)?; + self.db + .insert_playbook_action(playbook_id, (i + 1) as i64, action_type, params_str)?; + } + for cond in &input.conditions { + self.db.insert_playbook_condition( + playbook_id, + &cond.condition_type, + &cond.operator, + &cond.value, + cond.value2.as_deref(), + )?; } self.soar_engine.reload_cache()?; Ok(playbook_id) } + pub fn update_playbook(&self, id: i64, input: &CreatePlaybookInput) -> Result { + let row = UpdatePlaybookRow { + name: input.name.clone(), + trigger_event: input.trigger_event.clone(), + condition_threshold: input.condition_threshold, + condition_count: input.condition_count, + condition_window_secs: input.condition_window_secs, + cooldown_secs: input.cooldown_secs, + }; + let updated = self.db.update_playbook(id, &row)?; + if !updated { + return Ok(false); + } + + // Delete old actions and conditions, then re-insert + self.db.delete_playbook_actions(id)?; + self.db.delete_playbook_conditions(id)?; + + for (i, (action_type, params_str)) in input.actions.iter().enumerate() { + self.db + .insert_playbook_action(id, (i + 1) as i64, action_type, params_str)?; + } + for cond in &input.conditions { + self.db.insert_playbook_condition( + id, + &cond.condition_type, + &cond.operator, + &cond.value, + cond.value2.as_deref(), + )?; + } + self.soar_engine.reload_cache()?; + Ok(true) + } + + pub fn toggle_playbook(&self, id: i64, enabled: bool) -> Result { + let updated = self.db.update_playbook_enabled(id, enabled)?; + if updated { + self.soar_engine.reload_cache()?; + } + Ok(updated) + } + pub fn delete_playbook(&self, id: i64) -> Result { let deleted = self.db.delete_playbook(id)?; if deleted { @@ -146,15 +203,23 @@ impl PlaybookService { pub fn list_active_blocks(&self) -> Result, Error> { let blocks = self.db.get_active_soar_blocks()?; - Ok(blocks.into_iter().map(|(id, ip, pb_id, expires)| { - ActiveBlockData { id, source_ip: ip, playbook_id: pb_id, expires_at: expires } - }).collect()) + Ok(blocks + .into_iter() + .map(|(id, ip, pb_id, expires)| ActiveBlockData { + id, + source_ip: ip, + playbook_id: pb_id, + expires_at: expires, + }) + .collect()) } /// Manually unblock an IP: remove from eBPF, mark DB, decrement counter. pub async fn manual_unblock(&self, id: i64) -> Result<(), Error> { // Look up the block to get source_ip - let block = self.db.get_soar_block_by_id(id)? + let block = self + .db + .get_soar_block_by_id(id)? .ok_or_else(|| SoarError::ActionFailed { action_type: "manual_unblock".to_string(), reason: format!("Block rule {} not found", id), @@ -181,16 +246,19 @@ impl PlaybookService { pub fn list_executions(&self, limit: i64) -> Result, Error> { let rows = self.db.list_soar_executions(limit)?; - Ok(rows.into_iter().map(|(id, pb_id, source_ip, trigger_event, actions, created_at)| { - ExecutionData { - id, - playbook_id: pb_id, - source_ip, - trigger_event, - actions_executed: serde_json::from_str(&actions).unwrap_or(serde_json::Value::Null), - created_at, - } - }).collect()) + Ok(rows + .into_iter() + .map( + |(id, pb_id, source_ip, trigger_event, actions, created_at)| ExecutionData { + id, + playbook_id: pb_id, + source_ip, + trigger_event, + actions_executed: serde_json::from_str(&actions).unwrap_or(serde_json::Value::Null), + created_at, + }, + ) + .collect()) } pub fn list_whitelist(&self) -> Result, Error> { @@ -215,7 +283,13 @@ pub fn ip_version_from_str(ip: &str) -> u8 { match ip.parse::() { Ok(std::net::IpAddr::V4(_)) => 4, Ok(std::net::IpAddr::V6(_)) => 6, - Err(_) => if ip.contains(':') { 6 } else { 4 }, // fallback + Err(_) => { + if ip.contains(':') { + 6 + } else { + 4 + } + } // fallback } } diff --git a/net-guardia/src/core/rate_limit_service.rs b/net-guardia/src/core/rate_limit_service.rs index efc1955..31927b0 100644 --- a/net-guardia/src/core/rate_limit_service.rs +++ b/net-guardia/src/core/rate_limit_service.rs @@ -44,13 +44,4 @@ impl RateLimitService { } } -use serde::{Deserialize, Serialize}; - -#[derive(Serialize, Deserialize)] -pub struct RateLimitSettings { - pub packet_rate: Option, - pub syn_rate: Option, - pub udp_rate: Option, - pub dns_rate: Option, - pub window_ns: Option, -} +use crate::model::system::rate_limit_settings::RateLimitSettings; diff --git a/net-guardia/src/core/report/data.rs b/net-guardia/src/core/report/data.rs index fe890fb..a01d2e3 100644 --- a/net-guardia/src/core/report/data.rs +++ b/net-guardia/src/core/report/data.rs @@ -1,161 +1 @@ -use serde::{Deserialize, Serialize}; - -use crate::interface::port::repository::RepositoryPort; -use crate::model::error::Error; - -/// Shared report data structure used by both HTML email and PDF report. -#[derive(Debug, Clone, Serialize)] -pub struct ReportData { - pub period: String, - pub generated_at: String, - pub executive_summary: ExecutiveSummary, - pub threat_breakdown: Vec, - pub top_blocked_ips: Vec, - pub geo_distribution: Vec, - pub soar_activity: SoarActivity, - pub system_health: SystemHealthSummary, - pub recommendations: Vec, -} - -#[derive(Debug, Clone, Serialize)] -pub struct ExecutiveSummary { - pub total_threats: u64, - pub total_blocked: u64, - pub uptime_percent: f64, - pub active_rules: u64, -} - -#[derive(Debug, Clone, Serialize)] -pub struct ThreatBreakdownItem { - pub threat_type: String, - pub count: u64, - pub trend: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BlockedIpItem { - pub ip: String, - pub count: u64, - pub country: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct GeoItem { - pub country: String, - pub threat_count: u64, -} - -#[derive(Debug, Clone, Serialize)] -pub struct SoarActivity { - pub auto_blocks_executed: u64, - pub playbooks_triggered: u64, - pub auto_unblocks: u64, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SystemHealthSummary { - pub avg_cpu_percent: f64, - pub avg_memory_percent: f64, - pub disk_usage_percent: f64, - pub ebpf_status: String, -} - -impl ReportData { - /// Build report data from database settings (aggregated by the ML pipeline). - pub fn from_database(db: &dyn RepositoryPort) -> Result { - let now = chrono::Local::now(); - let period = format!("{} — {}", (now - chrono::Duration::days(7)).format("%Y-%m-%d"), now.format("%Y-%m-%d")); - - let threats_count: u64 = db.get_setting("weekly_threats_count")? - .and_then(|v| v.parse().ok()) - .unwrap_or(0); - - let top_ips: Vec = db.get_setting("weekly_top_ips")? - .and_then(|v| serde_json::from_str(&v).ok()) - .unwrap_or_else(|| vec![ - BlockedIpItem { ip: "—".into(), count: 0, country: "N/A".into() }, - ]); - - let breakdown: Vec = db.get_setting("weekly_threat_breakdown")? - .and_then(|v| { - let obj: serde_json::Value = serde_json::from_str(&v).ok()?; - let items = obj.as_object()?.iter().map(|(k, v)| { - ThreatBreakdownItem { - threat_type: k.clone(), - count: v.as_u64().unwrap_or(0), - trend: "—".into(), - } - }).collect(); - Some(items) - }) - .unwrap_or_default(); - - let health: SystemHealthSummary = db.get_setting("weekly_system_health")? - .and_then(|v| serde_json::from_str(&v).ok()) - .unwrap_or(SystemHealthSummary { - avg_cpu_percent: 0.0, - avg_memory_percent: 0.0, - disk_usage_percent: 0.0, - ebpf_status: "running".into(), - }); - - // Generate recommendations based on data - let mut recommendations = Vec::new(); - if threats_count > 10 { - recommendations.push("Consider enabling geo-blocking for high-risk regions".into()); - } - if breakdown.iter().any(|b| b.threat_type == "port_scan" && b.count > 50) { - recommendations.push("Review exposed ports and consider tightening protocol filter rules".into()); - } - if recommendations.is_empty() { - recommendations.push("No action needed — your network security posture is healthy".into()); - } - - let uptime_percent: f64 = db.get_setting("system_uptime_percent")? - .and_then(|v| v.parse().ok()) - .unwrap_or(0.0); - - let active_rules: u64 = db.get_setting("active_rules_count")? - .and_then(|v| v.parse().ok()) - .unwrap_or(0); - - let geo_distribution: Vec = db.get_setting("weekly_geo_distribution")? - .and_then(|v| serde_json::from_str(&v).ok()) - .unwrap_or_default(); - - let auto_blocks: u64 = db.get_setting("weekly_soar_blocks")? - .and_then(|v| v.parse().ok()) - .unwrap_or(0); - let playbooks_triggered: u64 = db.get_setting("weekly_soar_triggers")? - .and_then(|v| v.parse().ok()) - .unwrap_or(0); - let auto_unblocks: u64 = db.get_setting("weekly_soar_unblocks")? - .and_then(|v| v.parse().ok()) - .unwrap_or(0); - - let blocked_count: u64 = db.get_setting("weekly_blocked_count")? - .and_then(|v| v.parse().ok()) - .unwrap_or(auto_blocks); - - Ok(ReportData { - period, - generated_at: now.format("%Y-%m-%d %H:%M:%S").to_string(), - executive_summary: ExecutiveSummary { - total_threats: threats_count, - total_blocked: blocked_count, - uptime_percent, - active_rules, - }, - threat_breakdown: breakdown, - top_blocked_ips: top_ips, - geo_distribution, - soar_activity: SoarActivity { - auto_blocks_executed: auto_blocks, - playbooks_triggered, - auto_unblocks, - }, - system_health: health, - recommendations, - }) - } -} +// Types are available via crate::model::report::data diff --git a/net-guardia/src/core/report/engine.rs b/net-guardia/src/core/report/engine.rs index fd82dc9..7a5d771 100644 --- a/net-guardia/src/core/report/engine.rs +++ b/net-guardia/src/core/report/engine.rs @@ -1,10 +1,10 @@ use std::path::PathBuf; use tracing::info; -use crate::core::report::data::ReportData; use crate::interface::port::repository::RepositoryPort; -use crate::model::error::notification::NotificationError; use crate::model::error::Error; +use crate::model::error::notification::NotificationError; +use crate::model::report::data::ReportData; /// Generate a self-contained HTML security report and write to disk. /// Returns the path to the generated HTML file. @@ -17,16 +17,12 @@ pub fn generate_html_report(db: &dyn RepositoryPort, output_dir: &str) -> Result chrono::Local::now().format("%Y%m%d-%H%M%S") )); - std::fs::create_dir_all(output_dir).map_err(|e| { - NotificationError::TelegramApiError { - reason: format!("Failed to create report directory: {}", e), - } + std::fs::create_dir_all(output_dir).map_err(|e| NotificationError::TelegramApiError { + reason: format!("Failed to create report directory: {}", e), })?; - std::fs::write(&html_path, &html).map_err(|e| { - NotificationError::TelegramApiError { - reason: format!("Failed to write HTML report: {}", e), - } + std::fs::write(&html_path, &html).map_err(|e| NotificationError::TelegramApiError { + reason: format!("Failed to write HTML report: {}", e), })?; info!("HTML report generated at {:?}", html_path); @@ -41,11 +37,14 @@ fn render_html_report(data: &ReportData) -> String { for item in &data.threat_breakdown { breakdown_rows.push_str(&format!( "{}{}{}", - html_escape(&item.threat_type), item.count, html_escape(&item.trend) + html_escape(&item.threat_type), + item.count, + html_escape(&item.trend) )); } if data.threat_breakdown.is_empty() { - breakdown_rows.push_str("No threat data available for this period"); + breakdown_rows + .push_str("No threat data available for this period"); } // Top blocked IPs rows @@ -53,7 +52,9 @@ fn render_html_report(data: &ReportData) -> String { for ip in &data.top_blocked_ips { ip_rows.push_str(&format!( "{}{}{}", - html_escape(&ip.ip), ip.count, html_escape(&ip.country) + html_escape(&ip.ip), + ip.count, + html_escape(&ip.country) )); } if data.top_blocked_ips.is_empty() { @@ -65,7 +66,8 @@ fn render_html_report(data: &ReportData) -> String { for geo in &data.geo_distribution { geo_rows.push_str(&format!( "{}{}", - html_escape(&geo.country), geo.threat_count + html_escape(&geo.country), + geo.threat_count )); } if data.geo_distribution.is_empty() { @@ -204,6 +206,7 @@ pub fn generate_report_json(db: &dyn RepositoryPort) -> Result, + db: Arc, access_control: Arc, /// In-memory cache of playbooks (loaded at startup, refreshed on change). playbooks: parking_lot::RwLock>, @@ -37,6 +36,8 @@ pub struct SoarEngine { admin_whitelist: parking_lot::RwLock>, /// Cooldown tracker: maps (playbook_id, source_ip) → last execution time. cooldowns: DashMap, + /// Frequency tracker for frequency-based conditions. + frequency_tracker: FrequencyTracker, /// AtomicU32 counter for active auto-blocks (avoids DB query per event). active_block_count: AtomicU32, /// Optional alert notifier (Telegram, etc.). @@ -45,15 +46,23 @@ pub struct SoarEngine { geoip: Option>, /// Optional rate limit config for adjust_rate_limit action. rate_limit: Option>, + /// Lock to serialize rate limit read-save-write sequences (Item 6: atomicity). + rate_limit_lock: tokio::sync::Mutex<()>, + /// Cached enforce level: Monitor=0, MlOnly=1, Enforce=2. + enforce_level_cache: Arc, + /// Secret store for decrypting SMTP passwords etc. + secrets: Option>, } impl SoarEngine { pub fn new( - db: Arc, + db: Arc, access_control: Arc, alert_notifier: Option>, geoip: Option>, rate_limit: Option>, + enforce_level_cache: Arc, + secrets: Option>, ) -> Result { let engine = Self { db, @@ -61,10 +70,14 @@ impl SoarEngine { playbooks: parking_lot::RwLock::new(Vec::new()), admin_whitelist: parking_lot::RwLock::new(HashSet::new()), cooldowns: DashMap::new(), + frequency_tracker: FrequencyTracker::new(), active_block_count: AtomicU32::new(0), alert_notifier, geoip, rate_limit, + rate_limit_lock: tokio::sync::Mutex::new(()), + enforce_level_cache, + secrets, }; engine.reload_cache()?; Ok(engine) @@ -76,17 +89,33 @@ impl SoarEngine { let rows = self.db.load_playbooks_with_actions()?; let mut playbooks: Vec = Vec::new(); - for (pb_id, name, enabled, trigger_event, threshold, _count, _window, cooldown, - _action_id, action_order, action_type, action_params) in rows + for ( + pb_id, + name, + enabled, + trigger_event, + threshold, + _count, + _window, + cooldown, + _action_id, + action_order, + action_type, + action_params, + ) in rows { // Check if this row belongs to the same playbook as the last one let needs_new = playbooks.last().is_none_or(|last| last.id != pb_id); if needs_new { playbooks.push(Playbook { - id: pb_id, name, enabled, trigger_event, + id: pb_id, + name, + enabled, + trigger_event, condition_threshold: threshold, cooldown_secs: cooldown, actions: Vec::new(), + conditions: Vec::new(), }); } // Safe: we just pushed if empty, and last() was Some otherwise @@ -94,14 +123,49 @@ impl SoarEngine { continue; }; - if let (Some(order), Some(atype), Some(params_str)) = - (action_order, action_type, action_params) - { + if let (Some(order), Some(atype), Some(params_str)) = (action_order, action_type, action_params) { pb.actions.push(PlaybookAction { action_order: order, action_type: atype, - params: serde_json::from_str(¶ms_str) - .unwrap_or(serde_json::Value::Object(Default::default())), + params: serde_json::from_str(¶ms_str).unwrap_or_else(|e| { + log!(SoarLog::PlaybookError { + name: pb.name.clone(), + error: format!("Malformed action params JSON: {}", e), + }); + serde_json::Value::Object(Default::default()) + }), + }); + } + } + + // Load conditions and attach to playbooks + let condition_rows = self.db.load_all_playbook_conditions()?; + for (_cid, pb_id, ctype_str, operator, value, value2) in condition_rows { + if let Ok(ctype) = ctype_str.parse::() + && let Some(pb) = playbooks.iter_mut().find(|p| p.id == pb_id) + { + // Validate operator at load time to prevent silent fallback to defaults + let valid = match ctype { + ConditionType::Threshold => matches!(operator.as_str(), ">=" | "<="), + ConditionType::SourceCountry | ConditionType::IpPattern => { + matches!(operator.as_str(), "in" | "not_in") + } + ConditionType::RepeatOffender => operator == "==", + ConditionType::Frequency => operator == ">=", + }; + if !valid { + log!(SoarLog::InvalidConditionOperator( + pb.name.clone(), + ctype_str.clone(), + operator.clone(), + )); + continue; + } + pb.conditions.push(PlaybookCondition { + condition_type: ctype, + operator, + value, + value2, }); } } @@ -129,7 +193,10 @@ impl SoarEngine { /// Returns an error if subscription fails — caller must handle this as a critical failure. pub fn start(self: Arc, comm: Arc) -> Result<(), Error> { let rx = comm.subscribe_event::().map_err(|e| { - log!(SoarLog::EventHandlingFailed(format!("CRITICAL: SOAR engine failed to subscribe — automated threat response is DISABLED: {}", e))); + log!(SoarLog::EventHandlingFailed(format!( + "CRITICAL: SOAR engine failed to subscribe — automated threat response is DISABLED: {}", + e + ))); SoarError::ActionFailed { action_type: "subscribe".to_string(), reason: e.to_string(), @@ -141,10 +208,7 @@ impl SoarEngine { Ok(()) } - async fn event_loop( - self: Arc, - mut rx: broadcast::Receiver, - ) { + async fn event_loop(self: Arc, mut rx: broadcast::Receiver) { log!(SoarLog::EngineStarted); loop { match rx.recv().await { @@ -168,10 +232,7 @@ impl SoarEngine { } /// Handle a single threat event: find matching playbooks and execute them. - async fn handle_threat_event( - &self, - event: &ThreatDetectedEvent, - ) -> Result<(), Error> { + async fn handle_threat_event(&self, event: &ThreatDetectedEvent) -> Result<(), Error> { let matching = self.find_matching_playbooks(event); if matching.is_empty() { @@ -192,26 +253,142 @@ impl SoarEngine { Ok(()) } - /// Pure function: find playbooks matching the event. + /// Find playbooks matching the event via trigger_event + multi-condition AND logic. fn find_matching_playbooks(&self, event: &ThreatDetectedEvent) -> Vec { let playbooks = self.playbooks.read(); playbooks .iter() - .filter(|pb| { - pb.enabled && pb.trigger_event == event.attack_type - }) - .filter(|pb| { - // Check threshold condition - if let Some(threshold) = pb.condition_threshold - && (event.confidence as f64) < threshold { - return false; - } - true - }) + .filter(|pb| pb.enabled && pb.trigger_event == event.attack_type) + .filter(|pb| self.evaluate_conditions(pb, event)) .cloned() .collect() } + /// Evaluate all conditions on a playbook (AND logic). + /// If conditions vec is empty, falls back to legacy `condition_threshold` check. + fn evaluate_conditions(&self, pb: &Playbook, event: &ThreatDetectedEvent) -> bool { + if pb.conditions.is_empty() { + // Legacy: use inline threshold if present + if let Some(threshold) = pb.condition_threshold { + return (event.confidence as f64) >= threshold; + } + return true; + } + + // Evaluate non-frequency conditions first (avoid recording non-matching events) + for cond in &pb.conditions { + if cond.condition_type == ConditionType::Frequency { + continue; + } + if !self.evaluate_single_condition(cond, pb, event) { + return false; + } + } + + // Evaluate frequency conditions last + for cond in &pb.conditions { + if cond.condition_type == ConditionType::Frequency && !self.evaluate_single_condition(cond, pb, event) { + return false; + } + } + + true + } + + /// Evaluate a single condition against the event. + /// The `operator` field controls comparison direction: + /// - Threshold: ">=" (default) or "<=" + /// - SourceCountry/IpPattern: "in" (default) or "not_in" + /// - RepeatOffender: "==" only + /// - Frequency: ">=" only + fn evaluate_single_condition(&self, cond: &PlaybookCondition, pb: &Playbook, event: &ThreatDetectedEvent) -> bool { + match cond.condition_type { + ConditionType::Threshold => { + let threshold = match cond.value.parse::() { + Ok(v) => v, + Err(_) => return false, + }; + let confidence = event.confidence as f64; + let met = if cond.operator == "<=" { + confidence <= threshold + } else { + confidence >= threshold + }; + if !met { + log!(SoarLog::ConditionNotMet( + "threshold".to_string(), + pb.name.clone(), + format!("{:.2}", event.confidence), + )); + } + met + } + ConditionType::SourceCountry => { + let countries: Vec<&str> = cond.value.split(',').map(|s| s.trim()).collect(); + let matches = event + .geoip_country + .as_ref() + .is_some_and(|c| countries.iter().any(|&cc| cc.eq_ignore_ascii_case(c))); + let met = if cond.operator == "not_in" { !matches } else { matches }; + if !met { + log!(SoarLog::ConditionNotMet( + "source_country".to_string(), + pb.name.clone(), + event.geoip_country.clone().unwrap_or_else(|| "none".to_string()), + )); + } + met + } + ConditionType::IpPattern => { + let net = match cond.value.parse::() { + Ok(n) => n, + Err(_) => return false, + }; + let ip = match event.source_ip.parse::() { + Ok(a) => a, + Err(_) => return false, + }; + let matches = net.contains(ip); + let met = if cond.operator == "not_in" { !matches } else { matches }; + if !met { + log!(SoarLog::ConditionNotMet( + "ip_pattern".to_string(), + pb.name.clone(), + event.source_ip.clone(), + )); + } + met + } + ConditionType::RepeatOffender => { + let expected = cond.value.eq_ignore_ascii_case("true"); + let met = event.is_repeat_offender == expected; + if !met { + log!(SoarLog::ConditionNotMet( + "repeat_offender".to_string(), + pb.name.clone(), + format!("{}", event.is_repeat_offender), + )); + } + met + } + ConditionType::Frequency => { + let required = match cond.value.parse::() { + Ok(v) => v, + Err(_) => return false, + }; + let window_secs = cond.value2.as_ref().and_then(|s| s.parse::().ok()).unwrap_or(60); + let count = self + .frequency_tracker + .record_and_count(pb.id, &event.source_ip, window_secs); + let met = count >= required; + if !met { + log!(SoarLog::FrequencyNotMet(pb.name.clone(), count, required, window_secs)); + } + met + } + } + } + /// Check if cooldown is active for this playbook + source IP combination. fn is_cooldown_active(&self, playbook_id: i64, source_ip: &str, cooldown_secs: i64) -> bool { let key = (playbook_id, source_ip.to_string()); @@ -231,11 +408,7 @@ impl SoarEngine { } /// Execute a single playbook against an event. - async fn execute_playbook( - &self, - playbook: &Playbook, - event: &ThreatDetectedEvent, - ) -> Result<(), Error> { + async fn execute_playbook(&self, playbook: &Playbook, event: &ThreatDetectedEvent) -> Result<(), Error> { // Check cooldown if self.is_cooldown_active(playbook.id, &event.source_ip, playbook.cooldown_secs) { log!(SoarLog::CooldownActive(playbook.name.clone(), event.source_ip.clone())); @@ -244,7 +417,10 @@ impl SoarEngine { // Check admin whitelist if self.admin_whitelist.read().contains(&event.source_ip) { - log!(SoarLog::WhitelistSkipped(event.source_ip.clone(), playbook.name.clone())); + log!(SoarLog::WhitelistSkipped( + event.source_ip.clone(), + playbook.name.clone() + )); return Ok(()); } @@ -254,11 +430,16 @@ impl SoarEngine { let result = self.execute_action(action, event, playbook.id).await; let result_json = match &result { Ok(msg) => serde_json::json!({"action": &action.action_type, "status": "ok", "message": msg}), - Err(e) => serde_json::json!({"action": &action.action_type, "status": "error", "message": e.to_string()}), + Err(e) => { + serde_json::json!({"action": &action.action_type, "status": "error", "message": e.to_string()}) + } }; action_results.push(result_json); if let Err(e) = result { - log!(SoarLog::PlaybookError(playbook.name.clone(), format!("Action '{}': {}", action.action_type, e))); + log!(SoarLog::PlaybookError( + playbook.name.clone(), + format!("Action '{}': {}", action.action_type, e) + )); } } @@ -267,25 +448,22 @@ impl SoarEngine { // Write audit trail let actions_json = serde_json::to_string(&action_results).unwrap_or_default(); - self.db.insert_soar_execution( - playbook.id, - Some(&event.source_ip), - &event.attack_type, - &actions_json, - )?; + self.db + .insert_soar_execution(playbook.id, Some(&event.source_ip), &event.attack_type, &actions_json)?; - log!(SoarLog::PlaybookExecuted(playbook.name.clone(), event.source_ip.clone(), event.attack_type.clone())); + log!(SoarLog::PlaybookExecuted( + playbook.name.clone(), + event.source_ip.clone(), + event.attack_type.clone() + )); Ok(()) } /// Check if the system is in enforce mode (as opposed to monitor mode). + /// Reads from the in-memory AtomicU8 cache: Monitor=0, MlOnly=1, Enforce=2. fn is_enforce_mode(&self) -> bool { - self.db.get_setting("enforce_mode") - .ok() - .flatten() - .map(|m| m == "enforce") - .unwrap_or(false) + self.enforce_level_cache.load(Ordering::Relaxed) == 2 } /// Execute a single action. @@ -298,27 +476,33 @@ impl SoarEngine { match action.action_type.as_str() { "block_ip" => { if !self.is_enforce_mode() { - log!(SoarLog::MonitorModeSkipped(action.action_type.clone(), event.source_ip.clone())); + log!(SoarLog::MonitorModeSkipped( + action.action_type.clone(), + event.source_ip.clone() + )); return Ok(format!("[monitor] Would block IP {} — skipped", event.source_ip)); } self.action_block_ip(action, event, playbook_id).await } "adjust_rate_limit" => { if !self.is_enforce_mode() { - log!(SoarLog::MonitorModeSkipped(action.action_type.clone(), event.source_ip.clone())); + log!(SoarLog::MonitorModeSkipped( + action.action_type.clone(), + event.source_ip.clone() + )); return Ok("[monitor] Would adjust rate limit — skipped".to_string()); } self.action_adjust_rate_limit(action, event).await } "send_telegram" => self.action_send_telegram(event).await, "send_email" => self.action_send_email(event).await, + "webhook" => self.action_webhook(action, event).await, "log" => self.action_log(action, event), - other => { - Err(SoarError::ActionFailed { - action_type: other.to_string(), - reason: "Unknown action type".to_string(), - }.into()) + other => Err(SoarError::ActionFailed { + action_type: other.to_string(), + reason: "Unknown action type".to_string(), } + .into()), } } @@ -329,31 +513,43 @@ impl SoarEngine { event: &ThreatDetectedEvent, playbook_id: i64, ) -> Result { - let ttl_secs = action.params.get("ttl_secs") - .and_then(|v| v.as_u64()) - .unwrap_or(1800); + let ttl_secs = action.params.get("ttl_secs").and_then(|v| v.as_u64()).unwrap_or(1800); - // Validate TTL - if ttl_secs > MAX_TTL_SECS { + // Validate TTL (runtime-configurable via DB) + let max_ttl: u64 = self + .db + .get_setting("soar_max_ttl_secs") + .ok() + .flatten() + .and_then(|v| v.parse().ok()) + .unwrap_or(86400); + if ttl_secs > max_ttl { return Err(SoarError::InvalidTtl { ttl_secs, - max_secs: MAX_TTL_SECS, - }.into()); + max_secs: max_ttl, + } + .into()); } - // Atomically check cap and reserve a slot using CAS loop + // Atomically check cap and reserve a slot using CAS loop (runtime-configurable via DB) + let max_cap: u32 = self + .db + .get_setting("soar_max_auto_block_cap") + .ok() + .flatten() + .and_then(|v| v.parse().ok()) + .unwrap_or(100); loop { let current_count = self.active_block_count.load(Ordering::SeqCst); - if current_count >= MAX_AUTO_BLOCK_CAP { - log!(SoarLog::CapReached(current_count, MAX_AUTO_BLOCK_CAP, event.source_ip.clone())); - return Err(SoarError::CapReached { max_cap: MAX_AUTO_BLOCK_CAP }.into()); + if current_count >= max_cap { + log!(SoarLog::CapReached(current_count, max_cap, event.source_ip.clone())); + return Err(SoarError::CapReached { max_cap }.into()); } - if self.active_block_count.compare_exchange( - current_count, - current_count + 1, - Ordering::SeqCst, - Ordering::SeqCst, - ).is_ok() { + if self + .active_block_count + .compare_exchange(current_count, current_count + 1, Ordering::SeqCst, Ordering::SeqCst) + .is_ok() + { break; } } @@ -365,22 +561,27 @@ impl SoarEngine { } // Calculate expiry time - let expires_at = chrono::Utc::now() - + chrono::Duration::seconds(ttl_secs as i64); + let expires_at = chrono::Utc::now() + chrono::Duration::seconds(ttl_secs as i64); let expires_str = expires_at.format("%Y-%m-%d %H:%M:%S").to_string(); // Record in soar_block_rules - if let Err(e) = self.db.insert_soar_block_rule( - &event.source_ip, - playbook_id, - &expires_str, - ) { - // Attempt to roll back the eBPF block — log failure to prevent silent orphan blocks + if let Err(e) = self + .db + .insert_soar_block_rule(&event.source_ip, playbook_id, &expires_str) + { + // Attempt to roll back the eBPF block — on failure, queue for retry if let Err(unblock_err) = self.access_control.unblock_ip(&event.source_ip).await { log!(SoarLog::EventHandlingFailed(format!( - "CRITICAL: Failed to unblock IP {} after DB error — orphan eBPF block may exist: {}", + "CRITICAL: Failed to unblock IP {} after DB error — queueing for retry: {}", event.source_ip, unblock_err ))); + // Write to pending_unblock table so recovery can retry later + if let Err(pend_err) = self.db.insert_pending_unblock(&event.source_ip) { + log!(SoarLog::EventHandlingFailed(format!( + "CRITICAL: Failed to queue pending unblock for IP {}: {}", + event.source_ip, pend_err + ))); + } } self.decrement_block_count(); return Err(e); @@ -388,7 +589,8 @@ impl SoarEngine { // Also persist to acl_rules for consistency let ip_version = crate::core::playbook_service::ip_version_from_str(&event.source_ip); - self.db.insert_acl_rule(ip_version, "source", "blacklist", &event.source_ip, 0)?; + self.db + .insert_acl_rule(ip_version, "source", "blacklist", &event.source_ip, 0)?; Ok(format!("Blocked IP {} for {}s", event.source_ip, ttl_secs)) } @@ -401,31 +603,40 @@ impl SoarEngine { action: &PlaybookAction, event: &ThreatDetectedEvent, ) -> Result { - let rate_limit = self.rate_limit.as_ref().ok_or_else(|| { - SoarError::ActionFailed { - action_type: "adjust_rate_limit".to_string(), - reason: "Rate limit config not available".to_string(), - } + let rate_limit = self.rate_limit.as_ref().ok_or_else(|| SoarError::ActionFailed { + action_type: "adjust_rate_limit".to_string(), + reason: "Rate limit config not available".to_string(), })?; - let factor = action.params.get("factor") - .and_then(|v| v.as_f64()) - .unwrap_or(0.5); - let ttl_secs = action.params.get("ttl_secs") - .and_then(|v| v.as_u64()) - .unwrap_or(600); + let factor = action.params.get("factor").and_then(|v| v.as_f64()).unwrap_or(0.5); + let ttl_secs = action.params.get("ttl_secs").and_then(|v| v.as_u64()).unwrap_or(600); if !(0.01..=1.0).contains(&factor) { return Err(SoarError::ActionFailed { action_type: "adjust_rate_limit".to_string(), reason: format!("factor must be 0.01..1.0, got {}", factor), - }.into()); + } + .into()); } - if ttl_secs > MAX_TTL_SECS { - return Err(SoarError::InvalidTtl { ttl_secs, max_secs: MAX_TTL_SECS }.into()); + let max_ttl: u64 = self + .db + .get_setting("soar_max_ttl_secs") + .ok() + .flatten() + .and_then(|v| v.parse().ok()) + .unwrap_or(86400); + if ttl_secs > max_ttl { + return Err(SoarError::InvalidTtl { + ttl_secs, + max_secs: max_ttl, + } + .into()); } + // Acquire lock to serialize rate limit read-save-write (Item 6: atomicity) + let _guard = self.rate_limit_lock.lock().await; + // Read current rates, save originals, apply reduced rates let current_packet = rate_limit.get_packet_rate().unwrap_or(10000); let current_syn = rate_limit.get_syn_rate().unwrap_or(1000); @@ -469,10 +680,14 @@ impl SoarEngine { event.attack_type.clone(), format!( "packet {}→{}, syn {}→{}, udp {}→{}, dns {}→{}", - current_packet, new_packet.max(1), - current_syn, new_syn.max(1), - current_udp, new_udp.max(1), - current_dns, new_dns.max(1), + current_packet, + new_packet.max(1), + current_syn, + new_syn.max(1), + current_udp, + new_udp.max(1), + current_dns, + new_dns.max(1), ), )); @@ -483,10 +698,7 @@ impl SoarEngine { } /// Send Telegram notification. - async fn action_send_telegram( - &self, - event: &ThreatDetectedEvent, - ) -> Result { + async fn action_send_telegram(&self, event: &ThreatDetectedEvent) -> Result { if let Some(notifier) = &self.alert_notifier { let country = if let Some(geoip) = &self.geoip { if let Ok(ip_addr) = event.source_ip.parse::() { @@ -500,13 +712,17 @@ impl SoarEngine { } else { None }; + let repeat_tag = if event.is_repeat_offender { " [REPEAT]" } else { "" }; let payload = AlertPayload { source_ip: event.source_ip.clone(), dest_ip: event.dest_ip.clone(), country, threat_type: event.attack_type.clone(), confidence: event.confidence, - action_description: "SOAR auto-response triggered".to_string(), + action_description: format!( + "SOAR auto-response triggered (hits: {}{})", + event.flow_count, repeat_tag, + ), timestamp: chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(), }; notifier.send_alert(&payload).await?; @@ -518,15 +734,15 @@ impl SoarEngine { } /// Send email alert. - async fn action_send_email( - &self, - event: &ThreatDetectedEvent, - ) -> Result { - // Use existing SMTP infrastructure + async fn action_send_email(&self, event: &ThreatDetectedEvent) -> Result { + // Build SmtpClient from settings stored via SoarPort::get_setting use crate::core::email::scheduler::SmtpClient; - match SmtpClient::from_database(&*self.db)? { + match SmtpClient::from_soar_port(&*self.db, self.secrets.as_deref())? { Some(smtp) => { - let subject = format!("[NetGuardia] Threat Alert: {} from {}", event.attack_type, event.source_ip); + let subject = format!( + "[NetGuardia] Threat Alert: {} from {}", + event.attack_type, event.source_ip + ); let body = format!( "

Threat Detected

\

Source IP: {}

\ @@ -539,7 +755,8 @@ impl SoarEngine { chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"), ); if let Some(recipient) = self.db.get_setting("smtp_recipient")? { - tokio::task::spawn_blocking(move || smtp.send(&recipient, &subject, &body)).await + tokio::task::spawn_blocking(move || smtp.send(&recipient, &subject, &body)) + .await .map_err(|e| SoarError::ActionFailed { action_type: "send_email".to_string(), reason: e.to_string(), @@ -553,15 +770,139 @@ impl SoarEngine { } } - /// Log action. - fn action_log( - &self, - action: &PlaybookAction, - event: &ThreatDetectedEvent, - ) -> Result { - let level = action.params.get("level") + /// Send a webhook HTTP POST with SSRF DNS rebinding protection. + /// Params: { "url": "https://example.com/hook", "timeout_secs": 10 } + async fn action_webhook(&self, action: &PlaybookAction, event: &ThreatDetectedEvent) -> Result { + let url_str = action + .params + .get("url") .and_then(|v| v.as_str()) - .unwrap_or("warn"); + .ok_or_else(|| SoarError::ActionFailed { + action_type: "webhook".to_string(), + reason: "Missing 'url' parameter".to_string(), + })?; + + let timeout_secs = action.params.get("timeout_secs").and_then(|v| v.as_u64()).unwrap_or(10); + + // Parse URL and extract host + let parsed_url = url::Url::parse(url_str).map_err(|e| SoarError::ActionFailed { + action_type: "webhook".to_string(), + reason: format!("Invalid URL: {}", e), + })?; + + let host = parsed_url.host_str().ok_or_else(|| SoarError::ActionFailed { + action_type: "webhook".to_string(), + reason: "URL has no host".to_string(), + })?; + + // DNS resolve all IPs and verify none are private/loopback/link-local + let port = parsed_url.port_or_known_default().unwrap_or(443); + let resolve_target = format!("{}:{}", host, port); + let addrs: Vec = tokio::net::lookup_host(&resolve_target) + .await + .map_err(|e| SoarError::ActionFailed { + action_type: "webhook".to_string(), + reason: format!("DNS resolution failed for '{}': {}", host, e), + })? + .collect(); + + if addrs.is_empty() { + return Err(SoarError::ActionFailed { + action_type: "webhook".to_string(), + reason: format!("DNS resolution returned no addresses for '{}'", host), + } + .into()); + } + + for addr in &addrs { + if Self::is_private_ip(&addr.ip()) { + log!(SoarLog::EventHandlingFailed(format!( + "SSRF blocked: webhook URL '{}' resolved to private IP {}", + url_str, + addr.ip() + ))); + return Err(SoarError::ActionFailed { + action_type: "webhook".to_string(), + reason: format!("SSRF blocked: host '{}' resolves to private IP {}", host, addr.ip()), + } + .into()); + } + } + + // Build and send the webhook payload (includes all enriched fields) + let sources_str: Vec = event.sources.iter().map(|s| s.to_string()).collect(); + let payload = serde_json::json!({ + "source_ip": event.source_ip, + "dest_ip": event.dest_ip, + "attack_type": event.attack_type, + "confidence": event.confidence, + "flow_count": event.flow_count, + "packet_rate": event.packet_rate, + "protocol": event.protocol, + "geoip_country": event.geoip_country, + "is_repeat_offender": event.is_repeat_offender, + "detection_sources": sources_str, + "timestamp": chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(), + }); + + // Pin resolved IPs to prevent DNS rebinding: the DNS check above verified + // all resolved addresses are public, so we force reqwest to use those same + // addresses instead of re-resolving (which could return a private IP on TTL expiry). + let mut client_builder = reqwest::Client::builder().timeout(std::time::Duration::from_secs(timeout_secs)); + for addr in &addrs { + client_builder = client_builder.resolve(host, *addr); + } + let client = client_builder.build().map_err(|e| SoarError::ActionFailed { + action_type: "webhook".to_string(), + reason: format!("HTTP client error: {}", e), + })?; + + let resp = client + .post(url_str) + .json(&payload) + .send() + .await + .map_err(|e| SoarError::ActionFailed { + action_type: "webhook".to_string(), + reason: format!("HTTP request failed: {}", e), + })?; + + let status = resp.status(); + if status.is_success() { + Ok(format!("Webhook sent to {} (status {})", url_str, status)) + } else { + Err(SoarError::ActionFailed { + action_type: "webhook".to_string(), + reason: format!("Webhook returned HTTP {}", status), + } + .into()) + } + } + + /// Check if an IP address is private/loopback/link-local (SSRF protection). + fn is_private_ip(ip: &IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => { + v4.is_loopback() // 127.0.0.0/8 + || v4.is_private() // 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 + || v4.is_link_local() // 169.254.0.0/16 + || v4.is_unspecified() // 0.0.0.0 + || v4.is_broadcast() // 255.255.255.255 + } + IpAddr::V6(v6) => { + v6.is_loopback() // ::1 + || v6.is_unspecified() // :: + // fe80::/10 (link-local) + || (v6.segments()[0] & 0xffc0) == 0xfe80 + // fc00::/7 (unique local: fc00::/8 + fd00::/8) + || (v6.segments()[0] & 0xfe00) == 0xfc00 + } + } + } + + /// Log action. + fn action_log(&self, action: &PlaybookAction, event: &ThreatDetectedEvent) -> Result { + let level = action.params.get("level").and_then(|v| v.as_str()).unwrap_or("warn"); log!(SoarLog::ActionLog( level.to_string(), @@ -575,10 +916,22 @@ impl SoarEngine { /// Fallback execution when no playbook matches. /// Only fires when source_ip is present. - async fn execute_fallback( - &self, - event: &ThreatDetectedEvent, - ) -> Result<(), Error> { + async fn execute_fallback(&self, event: &ThreatDetectedEvent) -> Result<(), Error> { + // Check admin whitelist — never block admin IPs even in fallback + if self.admin_whitelist.read().contains(&event.source_ip) { + log!(SoarLog::WhitelistSkipped( + event.source_ip.clone(), + "fallback".to_string() + )); + return Ok(()); + } + + // Check cooldown — use playbook_id=-1 for fallback actions + if self.is_cooldown_active(-1, &event.source_ip, 300) { + log!(SoarLog::CooldownActive("fallback".to_string(), event.source_ip.clone())); + return Ok(()); + } + // Default fallback: block IP for 30 minutes + log let fake_action = PlaybookAction { action_order: 1, @@ -592,6 +945,9 @@ impl SoarEngine { Err(e) => serde_json::json!({"action": "block_ip", "status": "error", "message": e.to_string()}), }; + // Record cooldown for fallback + self.record_cooldown(-1, &event.source_ip); + // Audit trail with playbook_id = -1 self.db.insert_soar_execution( -1, @@ -606,6 +962,9 @@ impl SoarEngine { /// Recover active block rules on startup by re-applying to eBPF. pub async fn recover_active_blocks(&self) -> Result<(), Error> { + // First, retry any pending unblocks from previous orphan failures + self.retry_pending_unblocks().await; + let active_blocks = self.db.get_active_soar_blocks()?; let count = active_blocks.len(); @@ -623,10 +982,63 @@ impl SoarEngine { Ok(()) } + /// Retry pending unblocks that failed during previous runs. + async fn retry_pending_unblocks(&self) { + let pending = match self.db.load_pending_unblocks() { + Ok(p) => p, + Err(e) => { + log!(SoarLog::EventHandlingFailed(format!( + "Failed to load pending unblocks: {}", + e + ))); + return; + } + }; + + for (id, source_ip, retry_count) in pending { + if retry_count >= MAX_PENDING_UNBLOCK_RETRIES { + log!(SoarLog::EventHandlingFailed(format!( + "Giving up on pending unblock for IP {} after {} retries", + source_ip, retry_count + ))); + // Remove from queue to avoid infinite retries + let _ = self.db.delete_pending_unblock(id); + continue; + } + + match self.access_control.unblock_ip(&source_ip).await { + Ok(()) => { + let _ = self.db.delete_pending_unblock(id); + log!(SoarLog::EventHandlingFailed(format!( + "Successfully unblocked orphan IP {} on retry #{}", + source_ip, + retry_count + 1 + ))); + } + Err(e) => { + let _ = self.db.increment_pending_unblock_retry(id); + log!(SoarLog::EventHandlingFailed(format!( + "Retry #{} failed to unblock orphan IP {}: {}", + retry_count + 1, + source_ip, + e + ))); + } + } + } + } + /// Restore original rate limits if the TTL has expired. /// Called by TTL scheduler on each sweep. - pub fn check_rate_limit_restoration(&self) -> Result<(), Error> { - let expires_str = match self.db.get_setting("soar_rate_limit_expires")?.filter(|s| !s.is_empty()) { + pub async fn check_rate_limit_restoration(&self) -> Result<(), Error> { + // Acquire lock to serialize rate limit read-save-write (Item 6: atomicity) + let _guard = self.rate_limit_lock.lock().await; + + let expires_str = match self + .db + .get_setting("soar_rate_limit_expires")? + .filter(|s| !s.is_empty()) + { Some(s) => s, None => return Ok(()), // No active adjustment }; @@ -640,7 +1052,11 @@ impl SoarEngine { } // Restore original rates - let original_str = match self.db.get_setting("soar_rate_limit_original")?.filter(|s| !s.is_empty()) { + let original_str = match self + .db + .get_setting("soar_rate_limit_original")? + .filter(|s| !s.is_empty()) + { Some(s) => s, None => { // No originals saved, just clean up @@ -655,19 +1071,23 @@ impl SoarEngine { ) { let mut restore_errors = Vec::new(); if let Some(v) = original.get("packet_rate").and_then(|v| v.as_u64()) - && let Err(e) = rate_limit.set_packet_rate(v) { + && let Err(e) = rate_limit.set_packet_rate(v) + { restore_errors.push(format!("packet_rate: {}", e)); } if let Some(v) = original.get("syn_rate").and_then(|v| v.as_u64()) - && let Err(e) = rate_limit.set_syn_rate(v) { + && let Err(e) = rate_limit.set_syn_rate(v) + { restore_errors.push(format!("syn_rate: {}", e)); } if let Some(v) = original.get("udp_rate").and_then(|v| v.as_u64()) - && let Err(e) = rate_limit.set_udp_rate(v) { + && let Err(e) = rate_limit.set_udp_rate(v) + { restore_errors.push(format!("udp_rate: {}", e)); } if let Some(v) = original.get("dns_rate").and_then(|v| v.as_u64()) - && let Err(e) = rate_limit.set_dns_rate(v) { + && let Err(e) = rate_limit.set_dns_rate(v) + { restore_errors.push(format!("dns_rate: {}", e)); } if restore_errors.is_empty() { @@ -684,6 +1104,28 @@ impl SoarEngine { Ok(()) } + /// Remove expired cooldown entries to prevent unbounded growth. + /// Called by TTL scheduler every 60 seconds. + pub fn cleanup_expired_cooldowns(&self) { + let max_cooldown_secs = { + let playbooks = self.playbooks.read(); + playbooks.iter().map(|p| p.cooldown_secs as u64).max().unwrap_or(3600) + }; + let expiry = std::time::Duration::from_secs(max_cooldown_secs.saturating_mul(2).max(3600)); + let before = self.cooldowns.len(); + self.cooldowns.retain(|_, instant| instant.elapsed() < expiry); + let removed = before.saturating_sub(self.cooldowns.len()); + if removed > 0 { + log!(SoarLog::CooldownCleanup(removed as u32)); + } + + // Also clean up empty frequency tracker entries + let freq_removed = self.frequency_tracker.cleanup(); + if freq_removed > 0 { + log!(SoarLog::FrequencyCleanup(freq_removed)); + } + } + /// Decrement the active block counter (called by TTL scheduler on unblock). /// Uses CAS loop to avoid underflow race condition. pub fn decrement_block_count(&self) { @@ -692,12 +1134,10 @@ impl SoarEngine { if current == 0 { return; // Nothing to decrement } - match self.active_block_count.compare_exchange( - current, - current - 1, - Ordering::SeqCst, - Ordering::SeqCst, - ) { + match self + .active_block_count + .compare_exchange(current, current - 1, Ordering::SeqCst, Ordering::SeqCst) + { Ok(_) => return, Err(_) => continue, // Retry on contention } @@ -708,9 +1148,9 @@ impl SoarEngine { #[cfg(test)] mod tests { use super::*; - use std::sync::atomic::AtomicBool; - use parking_lot::Mutex; use crate::model::error::ebpf::EbpfError; + use parking_lot::Mutex; + use std::sync::atomic::AtomicBool; /// Mock AccessControlPort that records calls. struct MockAccessControl { @@ -748,8 +1188,9 @@ mod tests { } } - fn test_db() -> Arc { - Arc::new(Database::new(":memory:").expect("Failed to create test database")) + fn test_db() -> Arc { + use crate::adapter::persistence::Database; + Arc::new(Database::new(":memory:").expect("Failed to create test database")) as Arc } fn test_engine(ac: Arc) -> SoarEngine { @@ -757,7 +1198,9 @@ mod tests { db.seed_default_playbooks().ok(); // Tests expect enforce mode to be active so block_ip actions execute db.set_setting("enforce_mode", "enforce").ok(); - SoarEngine::new(db, ac, None, None, None).expect("Failed to create SOAR engine") + // enforce=2 + let cache = Arc::new(AtomicU8::new(2)); + SoarEngine::new(db, ac, None, None, None, cache, None).expect("Failed to create SOAR engine") } #[tokio::test] @@ -770,6 +1213,12 @@ mod tests { dest_ip: "10.0.0.1".to_string(), attack_type: "threat_detected".to_string(), confidence: 0.95, + flow_count: 1, + packet_rate: 0.0, + protocol: 6, + geoip_country: None, + is_repeat_offender: false, + sources: vec![crate::model::event::DetectionSource::ML], }; let action = PlaybookAction { @@ -796,6 +1245,12 @@ mod tests { dest_ip: "::2".to_string(), attack_type: "threat_detected".to_string(), confidence: 0.9, + flow_count: 1, + packet_rate: 0.0, + protocol: 6, + geoip_country: None, + is_repeat_offender: false, + sources: vec![crate::model::event::DetectionSource::ML], }; let action = PlaybookAction { @@ -824,8 +1279,8 @@ mod tests { .to_string(); db.insert_soar_block_rule("192.168.1.100", 1, &expires).ok(); - let engine = SoarEngine::new(db, mock.clone(), None, None, None) - .expect("Failed to create engine"); + let cache = Arc::new(AtomicU8::new(2)); + let engine = SoarEngine::new(db, mock.clone(), None, None, None, cache, None).expect("Failed to create engine"); engine.recover_active_blocks().await.expect("Recovery should succeed"); let blocked = mock.blocked_ips.lock(); @@ -845,8 +1300,8 @@ mod tests { .to_string(); db.insert_soar_block_rule("10.0.0.1", 1, &expires).ok(); - let engine = SoarEngine::new(db, mock.clone(), None, None, None) - .expect("Failed to create engine"); + let cache = Arc::new(AtomicU8::new(2)); + let engine = SoarEngine::new(db, mock.clone(), None, None, None, cache, None).expect("Failed to create engine"); // Should not panic — errors are logged, not propagated let result = engine.recover_active_blocks().await; @@ -861,14 +1316,20 @@ mod tests { let mock = Arc::new(MockAccessControl::new()); let engine = test_engine(mock.clone()); - // Set counter to max - engine.active_block_count.store(MAX_AUTO_BLOCK_CAP, Ordering::SeqCst); + // Set counter to max (default cap is 100) + engine.active_block_count.store(100, Ordering::SeqCst); let event = ThreatDetectedEvent { source_ip: "1.2.3.4".to_string(), dest_ip: "10.0.0.1".to_string(), attack_type: "threat_detected".to_string(), confidence: 0.95, + flow_count: 1, + packet_rate: 0.0, + protocol: 6, + geoip_country: None, + is_repeat_offender: false, + sources: vec![crate::model::event::DetectionSource::ML], }; let action = PlaybookAction { @@ -879,7 +1340,10 @@ mod tests { let result = engine.execute_action(&action, &event, 1).await; assert!(result.is_err(), "Should fail when cap is reached"); - assert!(mock.blocked_ips.lock().is_empty(), "Should not call block_ip when cap reached"); + assert!( + mock.blocked_ips.lock().is_empty(), + "Should not call block_ip when cap reached" + ); } #[tokio::test] @@ -892,6 +1356,12 @@ mod tests { dest_ip: "10.0.0.1".to_string(), attack_type: "threat_detected".to_string(), confidence: 0.95, + flow_count: 1, + packet_rate: 0.0, + protocol: 6, + geoip_country: None, + is_repeat_offender: false, + sources: vec![crate::model::event::DetectionSource::ML], }; // Find a matching playbook — default "threat_detected" playbook should exist @@ -920,14 +1390,20 @@ mod tests { db.seed_default_playbooks().ok(); db.insert_admin_whitelist("1.2.3.4").ok(); - let engine = SoarEngine::new(db, mock.clone(), None, None, None) - .expect("Failed to create engine"); + let cache = Arc::new(AtomicU8::new(2)); + let engine = SoarEngine::new(db, mock.clone(), None, None, None, cache, None).expect("Failed to create engine"); let event = ThreatDetectedEvent { source_ip: "1.2.3.4".to_string(), dest_ip: "10.0.0.1".to_string(), attack_type: "threat_detected".to_string(), confidence: 0.95, + flow_count: 1, + packet_rate: 0.0, + protocol: 6, + geoip_country: None, + is_repeat_offender: false, + sources: vec![crate::model::event::DetectionSource::ML], }; let playbooks = engine.find_matching_playbooks(&event); @@ -935,7 +1411,10 @@ mod tests { let result = engine.execute_playbook(&playbooks[0], &event).await; assert!(result.is_ok()); - assert!(mock.blocked_ips.lock().is_empty(), "Whitelisted IP should not be blocked"); + assert!( + mock.blocked_ips.lock().is_empty(), + "Whitelisted IP should not be blocked" + ); } #[test] @@ -968,8 +1447,8 @@ mod tests { let db = test_db(); db.seed_default_playbooks().ok(); - let engine = SoarEngine::new(db.clone(), mock, None, None, None) - .expect("Failed to create engine"); + let cache = Arc::new(AtomicU8::new(0)); + let engine = SoarEngine::new(db.clone(), mock, None, None, None, cache, None).expect("Failed to create engine"); // Should have loaded default playbooks let count = engine.playbooks.read().len(); @@ -985,4 +1464,173 @@ mod tests { engine.reload_cache().expect("reload should succeed"); assert_eq!(engine.playbooks.read().len(), count + 1); } + + fn test_event(confidence: f32, country: Option<&str>, ip: &str, repeat: bool) -> ThreatDetectedEvent { + ThreatDetectedEvent { + source_ip: ip.to_string(), + dest_ip: "10.0.0.1".to_string(), + attack_type: "threat_detected".to_string(), + confidence, + flow_count: 1, + packet_rate: 100.0, + protocol: 6, + geoip_country: country.map(|s| s.to_string()), + is_repeat_offender: repeat, + sources: vec![crate::model::event::DetectionSource::ML], + } + } + + fn make_playbook(conditions: Vec) -> Playbook { + Playbook { + id: 999, + name: "test-playbook".to_string(), + trigger_event: "threat_detected".to_string(), + condition_threshold: None, + cooldown_secs: 60, + enabled: true, + actions: vec![], + conditions, + } + } + + #[test] + fn condition_threshold_gte_passes() { + let mock = Arc::new(MockAccessControl::new()); + let engine = test_engine(mock); + let pb = make_playbook(vec![PlaybookCondition { + condition_type: ConditionType::Threshold, + operator: ">=".to_string(), + value: "0.9".to_string(), + value2: None, + }]); + let event = test_event(0.95, None, "1.2.3.4", false); + assert!(engine.evaluate_conditions(&pb, &event)); + } + + #[test] + fn condition_threshold_gte_fails() { + let mock = Arc::new(MockAccessControl::new()); + let engine = test_engine(mock); + let pb = make_playbook(vec![PlaybookCondition { + condition_type: ConditionType::Threshold, + operator: ">=".to_string(), + value: "0.9".to_string(), + value2: None, + }]); + let event = test_event(0.85, None, "1.2.3.4", false); + assert!(!engine.evaluate_conditions(&pb, &event)); + } + + #[test] + fn condition_threshold_lte() { + let mock = Arc::new(MockAccessControl::new()); + let engine = test_engine(mock); + let pb = make_playbook(vec![PlaybookCondition { + condition_type: ConditionType::Threshold, + operator: "<=".to_string(), + value: "0.5".to_string(), + value2: None, + }]); + assert!(engine.evaluate_conditions(&pb, &test_event(0.3, None, "1.2.3.4", false))); + assert!(!engine.evaluate_conditions(&pb, &test_event(0.7, None, "1.2.3.4", false))); + } + + #[test] + fn condition_source_country_in() { + let mock = Arc::new(MockAccessControl::new()); + let engine = test_engine(mock); + let pb = make_playbook(vec![PlaybookCondition { + condition_type: ConditionType::SourceCountry, + operator: "in".to_string(), + value: "CN, RU, KP".to_string(), + value2: None, + }]); + assert!(engine.evaluate_conditions(&pb, &test_event(0.9, Some("CN"), "1.2.3.4", false))); + assert!(!engine.evaluate_conditions(&pb, &test_event(0.9, Some("US"), "1.2.3.4", false))); + assert!(!engine.evaluate_conditions(&pb, &test_event(0.9, None, "1.2.3.4", false))); + } + + #[test] + fn condition_source_country_not_in() { + let mock = Arc::new(MockAccessControl::new()); + let engine = test_engine(mock); + let pb = make_playbook(vec![PlaybookCondition { + condition_type: ConditionType::SourceCountry, + operator: "not_in".to_string(), + value: "US, TW".to_string(), + value2: None, + }]); + assert!(engine.evaluate_conditions(&pb, &test_event(0.9, Some("CN"), "1.2.3.4", false))); + assert!(!engine.evaluate_conditions(&pb, &test_event(0.9, Some("TW"), "1.2.3.4", false))); + } + + #[test] + fn condition_ip_pattern_in() { + let mock = Arc::new(MockAccessControl::new()); + let engine = test_engine(mock); + let pb = make_playbook(vec![PlaybookCondition { + condition_type: ConditionType::IpPattern, + operator: "in".to_string(), + value: "10.0.0.0/8".to_string(), + value2: None, + }]); + assert!(engine.evaluate_conditions(&pb, &test_event(0.9, None, "10.1.2.3", false))); + assert!(!engine.evaluate_conditions(&pb, &test_event(0.9, None, "192.168.1.1", false))); + } + + #[test] + fn condition_repeat_offender() { + let mock = Arc::new(MockAccessControl::new()); + let engine = test_engine(mock); + let pb = make_playbook(vec![PlaybookCondition { + condition_type: ConditionType::RepeatOffender, + operator: "==".to_string(), + value: "true".to_string(), + value2: None, + }]); + assert!(engine.evaluate_conditions(&pb, &test_event(0.9, None, "1.2.3.4", true))); + assert!(!engine.evaluate_conditions(&pb, &test_event(0.9, None, "1.2.3.4", false))); + } + + #[test] + fn condition_and_logic_all_must_pass() { + let mock = Arc::new(MockAccessControl::new()); + let engine = test_engine(mock); + let pb = make_playbook(vec![ + PlaybookCondition { + condition_type: ConditionType::Threshold, + operator: ">=".to_string(), + value: "0.9".to_string(), + value2: None, + }, + PlaybookCondition { + condition_type: ConditionType::SourceCountry, + operator: "in".to_string(), + value: "CN".to_string(), + value2: None, + }, + ]); + // Both conditions met + assert!(engine.evaluate_conditions(&pb, &test_event(0.95, Some("CN"), "1.2.3.4", false))); + // Threshold met but country not + assert!(!engine.evaluate_conditions(&pb, &test_event(0.95, Some("US"), "1.2.3.4", false))); + // Country met but threshold not + assert!(!engine.evaluate_conditions(&pb, &test_event(0.5, Some("CN"), "1.2.3.4", false))); + } + + #[test] + fn frequency_condition_counts_events() { + let mock = Arc::new(MockAccessControl::new()); + let engine = test_engine(mock); + let pb = make_playbook(vec![PlaybookCondition { + condition_type: ConditionType::Frequency, + operator: ">=".to_string(), + value: "3".to_string(), + value2: Some("60".to_string()), + }]); + let event = test_event(0.9, None, "1.2.3.4", false); + assert!(!engine.evaluate_conditions(&pb, &event)); // count=1 + assert!(!engine.evaluate_conditions(&pb, &event)); // count=2 + assert!(engine.evaluate_conditions(&pb, &event)); // count=3 ✓ + } } diff --git a/net-guardia/src/core/soar/frequency.rs b/net-guardia/src/core/soar/frequency.rs new file mode 100644 index 0000000..4b3a5ea --- /dev/null +++ b/net-guardia/src/core/soar/frequency.rs @@ -0,0 +1,87 @@ +use std::collections::VecDeque; +use std::time::{Duration, Instant}; + +use dashmap::DashMap; + +/// Key for frequency tracking: (playbook_id, source_ip). +type FreqKey = (i64, String); + +/// Maximum tracked keys to bound memory under DDoS. +const MAX_TRACKED_KEYS: usize = 50_000; + +/// Lock-free frequency tracker using DashMap for concurrent per-IP event counting. +pub struct FrequencyTracker { + events: DashMap>, + max_deque_size: usize, +} + +impl FrequencyTracker { + pub fn new() -> Self { + Self { + events: DashMap::new(), + max_deque_size: 200, + } + } + + /// Record an event and return the count of events within the given window. + pub fn record_and_count(&self, playbook_id: i64, source_ip: &str, window_secs: u64) -> u64 { + let key = (playbook_id, source_ip.to_string()); + let now = Instant::now(); + let window = Duration::from_secs(window_secs); + + let mut entry = self.events.entry(key).or_default(); + let deque = entry.value_mut(); + + // Prune expired entries from the front + while let Some(front) = deque.front() { + if now.duration_since(*front) > window { + deque.pop_front(); + } else { + break; + } + } + + deque.push_back(now); + + // Cap deque size to prevent unbounded growth + while deque.len() > self.max_deque_size { + deque.pop_front(); + } + + deque.len() as u64 + } + + /// Remove empty deques and entries where all timestamps are expired. + /// Uses a conservative 2-hour max window for expiry detection. + pub fn cleanup(&self) -> u32 { + let now = Instant::now(); + let max_window = Duration::from_secs(7200); // 2 hours — conservative upper bound + let mut removed = 0u32; + self.events.retain(|_, deque| { + if deque.is_empty() { + removed += 1; + return false; + } + // If all entries are older than max_window, remove the whole entry + if let Some(newest) = deque.back() + && now.checked_duration_since(*newest).unwrap_or(Duration::ZERO) > max_window + { + removed += 1; + return false; + } + true + }); + + // Enforce max key cap to prevent unbounded growth under DDoS + if self.events.len() > MAX_TRACKED_KEYS { + let excess = self.events.len() - MAX_TRACKED_KEYS; + let keys_to_remove: Vec = self.events.iter().take(excess).map(|e| e.key().clone()).collect(); + for key in keys_to_remove { + self.events.remove(&key); + removed += 1; + } + } + + removed + } +} diff --git a/net-guardia/src/core/soar/mod.rs b/net-guardia/src/core/soar/mod.rs index e3ed154..f010e26 100644 --- a/net-guardia/src/core/soar/mod.rs +++ b/net-guardia/src/core/soar/mod.rs @@ -1,2 +1,3 @@ pub mod engine; +pub mod frequency; pub mod scheduler; diff --git a/net-guardia/src/core/soar/scheduler.rs b/net-guardia/src/core/soar/scheduler.rs index 64d8ed8..88efd52 100644 --- a/net-guardia/src/core/soar/scheduler.rs +++ b/net-guardia/src/core/soar/scheduler.rs @@ -3,9 +3,9 @@ use std::sync::Arc; use macros::log; use tokio::time::{self, Duration}; -use crate::adapter::persistence::Database; use crate::core::soar::engine::SoarEngine; use crate::interface::port::access_control::AccessControlPort; +use crate::interface::port::soar::SoarPort; use crate::model::error::Error; use crate::model::error::soar::SoarError; use crate::model::log::soar::SoarLog; @@ -13,18 +13,22 @@ use crate::model::log::soar::SoarLog; /// TTL expiry scheduler: runs every 60 seconds, removes expired auto-block rules. /// Before removing from eBPF, checks if a manual ACL rule exists for the same IP. pub struct TtlScheduler { - db: Arc, + db: Arc, access_control: Arc, soar_engine: Arc, } impl TtlScheduler { pub fn new( - db: Arc, + db: Arc, access_control: Arc, soar_engine: Arc, ) -> Self { - Self { db, access_control, soar_engine } + Self { + db, + access_control, + soar_engine, + } } /// Spawn a background tokio task that runs the TTL sweep every 60 seconds. @@ -42,13 +46,19 @@ impl TtlScheduler { } /// Sweep expired block rules and remove from eBPF if no manual ACL conflict. - /// Also checks for expired rate limit adjustments. + /// Also checks for expired rate limit adjustments and cleans up stale cooldowns. async fn sweep(&self) -> Result<(), Error> { // Check rate limit restoration - if let Err(e) = self.soar_engine.check_rate_limit_restoration() { - log!(SoarLog::EventHandlingFailed(format!("Rate limit restoration check failed: {}", e))); + if let Err(e) = self.soar_engine.check_rate_limit_restoration().await { + log!(SoarLog::EventHandlingFailed(format!( + "Rate limit restoration check failed: {}", + e + ))); } + // Clean up expired cooldown + frequency tracker entries to prevent unbounded memory growth + self.soar_engine.cleanup_expired_cooldowns(); + let expired = self.db.get_expired_soar_blocks()?; if expired.is_empty() { @@ -67,13 +77,19 @@ impl TtlScheduler { self.db.mark_soar_block_unblocked(*id)?; self.soar_engine.decrement_block_count(); skipped += 1; - log!(SoarLog::WhitelistSkipped(source_ip.clone(), "TTL expired but manual ACL exists".to_string())); + log!(SoarLog::WhitelistSkipped( + source_ip.clone(), + "TTL expired but manual ACL exists".to_string() + )); continue; } // Remove from eBPF ACL via AccessControlPort if let Err(e) = self.access_control.unblock_ip(source_ip).await { - log!(SoarLog::RecoveryFailed(source_ip.clone(), format!("unblock failed: {}", e))); + log!(SoarLog::RecoveryFailed( + source_ip.clone(), + format!("unblock failed: {}", e) + )); } // Also remove from acl_rules DB table (the auto-added entry) diff --git a/net-guardia/src/core/stats_aggregator.rs b/net-guardia/src/core/stats_aggregator.rs index e4f2f92..e6efc0b 100644 --- a/net-guardia/src/core/stats_aggregator.rs +++ b/net-guardia/src/core/stats_aggregator.rs @@ -3,18 +3,20 @@ use std::sync::Arc; use tokio::time::{self, Duration}; use tracing::{error, info}; -use crate::adapter::persistence::Database; +use crate::interface::port::repository::RepositoryPort; +use crate::interface::port::stats::StatsPort; use crate::model::error::Error; /// Background service that periodically aggregates statistics from SOAR/ML tables /// and writes them to the settings table for the Report engine to consume. pub struct StatsAggregator { - db: Arc, + stats: Arc, + repo: Arc, } impl StatsAggregator { - pub fn new(db: Arc) -> Self { - Self { db } + pub fn new(stats: Arc, repo: Arc) -> Self { + Self { stats, repo } } /// Spawn a background task that runs aggregation every hour. @@ -40,31 +42,35 @@ impl StatsAggregator { let days = 7; // SOAR execution counts - let threats_count = self.db.count_weekly_executions(days)?; - self.db.set_setting("weekly_threats_count", &threats_count.to_string())?; + let threats_count = self.stats.count_weekly_executions(days)?; + self.repo + .set_setting("weekly_threats_count", &threats_count.to_string())?; - let blocks_count = self.db.count_weekly_blocks(days)?; - self.db.set_setting("weekly_soar_blocks", &blocks_count.to_string())?; - self.db.set_setting("weekly_soar_triggers", &threats_count.to_string())?; + let blocks_count = self.stats.count_weekly_blocks(days)?; + self.repo.set_setting("weekly_soar_blocks", &blocks_count.to_string())?; + self.repo + .set_setting("weekly_soar_triggers", &threats_count.to_string())?; - let unblocks_count = self.db.count_weekly_unblocks(days)?; - self.db.set_setting("weekly_soar_unblocks", &unblocks_count.to_string())?; + let unblocks_count = self.stats.count_weekly_unblocks(days)?; + self.repo + .set_setting("weekly_soar_unblocks", &unblocks_count.to_string())?; - self.db.set_setting("weekly_blocked_count", &blocks_count.to_string())?; + self.repo + .set_setting("weekly_blocked_count", &blocks_count.to_string())?; // Threat breakdown by type - let breakdown = self.db.weekly_threat_breakdown(days)?; + let breakdown = self.stats.weekly_threat_breakdown(days)?; let breakdown_json: serde_json::Map = breakdown .into_iter() .map(|(k, v)| (k, serde_json::Value::Number(v.into()))) .collect(); - self.db.set_setting( + self.repo.set_setting( "weekly_threat_breakdown", &serde_json::to_string(&breakdown_json).unwrap_or_else(|_| "{}".to_string()), )?; // Top blocked IPs - let top_ips = self.db.weekly_top_ips(days, 10)?; + let top_ips = self.stats.weekly_top_ips(days, 10)?; let top_ips_json: Vec = top_ips .into_iter() .map(|(ip, count)| { @@ -75,14 +81,14 @@ impl StatsAggregator { }) }) .collect(); - self.db.set_setting( + self.repo.set_setting( "weekly_top_ips", &serde_json::to_string(&top_ips_json).unwrap_or_else(|_| "[]".to_string()), )?; // Active rules count - let active_rules = self.db.count_acl_rules()?; - self.db.set_setting("active_rules_count", &active_rules.to_string())?; + let active_rules = self.stats.count_acl_rules()?; + self.repo.set_setting("active_rules_count", &active_rules.to_string())?; // System health snapshot using sysinfo { @@ -105,7 +111,7 @@ impl StatsAggregator { "disk_usage_percent": 0.0, "ebpf_status": "running", }); - self.db.set_setting( + self.repo.set_setting( "weekly_system_health", &serde_json::to_string(&health_json).unwrap_or_else(|_| "{}".to_string()), )?; @@ -118,12 +124,13 @@ impl StatsAggregator { } else { (uptime_secs as f64 / week_secs as f64) * 100.0 }; - self.db.set_setting("system_uptime_percent", &format!("{:.1}", uptime_percent))?; + self.repo + .set_setting("system_uptime_percent", &format!("{:.1}", uptime_percent))?; } // Geo distribution (initialize if not present) - if self.db.get_setting("weekly_geo_distribution")?.is_none() { - self.db.set_setting("weekly_geo_distribution", "[]")?; + if self.repo.get_setting("weekly_geo_distribution")?.is_none() { + self.repo.set_setting("weekly_geo_distribution", "[]")?; } info!( @@ -138,6 +145,7 @@ impl StatsAggregator { #[cfg(test)] mod tests { use super::*; + use crate::adapter::persistence::Database; #[test] fn aggregator_writes_weekly_stats() { @@ -145,11 +153,12 @@ mod tests { // Seed some SOAR executions db.seed_default_playbooks().ok(); - db.insert_soar_execution(1, Some("1.2.3.4"), "threat_detected", "[]").ok(); + db.insert_soar_execution(1, Some("1.2.3.4"), "threat_detected", "[]") + .ok(); db.insert_soar_execution(1, Some("5.6.7.8"), "brute_force", "[]").ok(); db.insert_soar_block_rule("1.2.3.4", 1, "2099-01-01 00:00:00").ok(); - let aggregator = StatsAggregator::new(db.clone()); + let aggregator = StatsAggregator::new(db.clone() as Arc, db.clone() as Arc); aggregator.aggregate().expect("aggregation should succeed"); // Verify settings were written @@ -178,8 +187,10 @@ mod tests { #[test] fn aggregator_handles_empty_db() { let db = Arc::new(Database::new(":memory:").expect("test db")); - let aggregator = StatsAggregator::new(db.clone()); - aggregator.aggregate().expect("aggregation should succeed with empty data"); + let aggregator = StatsAggregator::new(db.clone() as Arc, db.clone() as Arc); + aggregator + .aggregate() + .expect("aggregation should succeed with empty data"); let threats = db.get_setting("weekly_threats_count").unwrap().unwrap(); assert_eq!(threats, "0"); diff --git a/net-guardia/src/core/system.rs b/net-guardia/src/core/system.rs index 256e29c..020c2d3 100644 --- a/net-guardia/src/core/system.rs +++ b/net-guardia/src/core/system.rs @@ -1,33 +1,69 @@ use std::sync::Arc; -use aya::maps::{MapData, ProgramArray}; use aya::Ebpf; +use aya::maps::{MapData, ProgramArray}; use macros::log; +use crate::adapter::persistence::Database; use crate::core::acl_service::AclService; use crate::core::auth::jwt::JwtService; use crate::core::config_service::ConfigService; use crate::core::dns_filter_service::DnsFilterService; +use crate::core::ebpf::EbpfServices; +use crate::core::email::scheduler::ReportScheduler; +use crate::core::ml::config_loader::InferenceConfig; +use crate::core::ml::drift_detector::DriftDetector; use crate::core::notification_service::NotificationService; use crate::core::playbook_service::PlaybookService; use crate::core::rate_limit_service::RateLimitService; -use crate::adapter::persistence::Database; -use crate::core::ebpf::EbpfServices; -use crate::infrastructure::app_config::AppConfig; -use crate::infrastructure::app_services::AppServices; -use crate::infrastructure::communication_manager::CommunicationManager; -use crate::core::ml::config_loader::InferenceConfig; -use crate::infrastructure::http_server::HttpServerParams; -use crate::infrastructure::service_factory::ServiceFactory; -use crate::core::email::scheduler::ReportScheduler; use crate::core::soar::engine::SoarEngine; use crate::core::soar::scheduler::TtlScheduler; -use crate::interface::communication::event_types::ThreatDetectedEvent; +use crate::infrastructure::app_config::AppConfig; +use crate::infrastructure::app_services::AppServices; +use crate::infrastructure::audit_logger::AuditLogger; +use crate::infrastructure::communication_manager::CommunicationManager; +use crate::infrastructure::geoip::GeoIpService; +use crate::infrastructure::http_server::HttpServerParams; +use crate::infrastructure::secret_store::SecretStore; +use crate::infrastructure::service_factory::ServiceFactory; use crate::model::error::Error; use crate::model::error::system::SystemError; +use crate::model::event::{DetectionEvent, DetectionSource, DriftDetectedEvent}; +use crate::model::log::detection::DetectionLog; use crate::model::log::ml::MLLog; use crate::model::log::system::SystemLog; use crate::model::ml_detection::AlertMessage; +use crate::model::system::readiness::ReadinessState; + +/// API-triggered shutdown mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ShutdownMode { + Shutdown, + Restart, +} + +/// Handle for triggering shutdown from HTTP endpoints. +/// Uses a parking_lot::Mutex> so it can be shared as app_data. +pub struct ShutdownHandle { + tx: parking_lot::Mutex>>, +} + +impl ShutdownHandle { + fn new(tx: tokio::sync::oneshot::Sender) -> Self { + Self { + tx: parking_lot::Mutex::new(Some(tx)), + } + } + + /// Trigger shutdown. Returns false if already triggered. + pub fn trigger(&self, mode: ShutdownMode) -> bool { + if let Some(tx) = self.tx.lock().take() { + tx.send(mode).is_ok() + } else { + false + } + } +} /// Orchestrates system lifecycle: startup ordering and shutdown. /// Construction is delegated to `ServiceFactory::build()`. @@ -38,6 +74,7 @@ pub struct System { pub ebpf_services: Arc, pub app_services: Arc, pub db: Arc, + pub secret_store: Arc, pub jwt_service: Arc, pub comm: Arc, pub soar_engine: Arc, @@ -51,6 +88,9 @@ pub struct System { pub notification_service: Arc, pub playbook_service: Arc, pub rate_limit_service: Arc, + pub geoip: Option>, + pub drift_detector: Arc>, + pub shutdown_handle: Option>, _ingress_program_array: ProgramArray, } @@ -64,6 +104,7 @@ impl System { ebpf_services: state.ebpf_services, app_services: state.app_services, db: state.db, + secret_store: state.secret_store, jwt_service: state.jwt_service, comm: state.comm, soar_engine: state.soar_engine, @@ -77,12 +118,16 @@ impl System { notification_service: state.notification_service, playbook_service: state.playbook_service, rate_limit_service: state.rate_limit_service, + geoip: state.geoip, + drift_detector: state.drift_detector, + shutdown_handle: None, _ingress_program_array: state._ingress_program_array, }) } /// Start all services and HTTP server. Setup is already complete at this point. - pub async fn run(&mut self) -> Result<(), Error> { + /// Returns the shutdown mode requested (Shutdown or Restart). + pub async fn run(&mut self) -> Result { log!(SystemLog::Initializing); log!(MLLog::ModelsLoaded( @@ -122,16 +167,91 @@ impl System { report.run(); } + // Start audit logger (subscribe to AuditEvent + DriftDetectedEvent, persist to DB) + let audit_logger = Arc::new(AuditLogger::new( + self.db.clone() as Arc + )); + audit_logger.start(&self.comm); + // Start stats aggregator (writes weekly_* settings for Report engine) - let stats_aggregator = crate::core::stats_aggregator::StatsAggregator::new(self.db.clone()); + let stats_aggregator = crate::core::stats_aggregator::StatsAggregator::new( + self.db.clone() as Arc, + self.db.clone() as Arc, + ); stats_aggregator.start(); - // Bridge ML alerts → SOAR - let comm_for_bridge = self.comm.clone(); + // Start drift detection background task + { + let drift_detector = self.drift_detector.clone(); + let comm_drift = self.comm.clone(); + tokio::spawn(async move { + Self::run_drift_monitor(drift_detector, comm_drift).await; + }); + } + + // Start detection orchestrator (dedup + enrichment + source attribution) + let (detection_tx, detection_rx) = tokio::sync::mpsc::channel::(1024); + let orchestrator = crate::core::detection::orchestrator::DetectionOrchestrator::new( + detection_rx, + self.comm.clone(), + self.geoip.clone(), + ); + orchestrator.start(); + + // Clone detection_tx for correlation engine and beaconing detector + let correlation_detection_tx = detection_tx.clone(); + let beaconing_detection_tx = detection_tx.clone(); + + // Start cross-flow correlation engine (botnet, scan, lateral movement detection) + let correlation_alert_rx = self.app_services.ml_alert.subscribe_to_alerts(); + let correlation_engine = + crate::core::correlation::engine::CorrelationEngine::new(correlation_alert_rx, correlation_detection_tx); + correlation_engine.start(); + + // Start temporal beaconing detector (CV-based C2 periodicity detection) + let beaconing_alert_rx = self.app_services.ml_alert.subscribe_to_alerts(); + let beaconing_detector = + crate::core::detection::beaconing::BeaconingDetector::new(beaconing_alert_rx, beaconing_detection_tx); + beaconing_detector.start(); + + // Bridge ML alerts → DetectionEvent (thin adapter, no enrichment) tokio::spawn(async move { - Self::bridge_ml_to_soar(ml_alert_rx, comm_for_bridge).await; + Self::bridge_ml_to_detection(ml_alert_rx, detection_tx).await; }); + // Initialize force_https flag from DB setting + let force_https = Arc::new(std::sync::atomic::AtomicBool::new( + self.db + .get_setting("force_https") + .ok() + .flatten() + .map(|v| v == "true") + .unwrap_or(false), + )); + + // Build per-subsystem readiness flags for /health/ready + let readiness_state = Arc::new(ReadinessState::new()); + // DB is connected (System::new succeeded), ML models loaded (AppServices::new succeeded) + readiness_state + .db_connected + .store(true, std::sync::atomic::Ordering::SeqCst); + readiness_state + .ml_model_loaded + .store(true, std::sync::atomic::Ordering::SeqCst); + // eBPF was attached above (self.attach_ebpf succeeded) + readiness_state + .ebpf_attached + .store(true, std::sync::atomic::Ordering::SeqCst); + // SOAR engine started above (self.soar_engine.start succeeded) + readiness_state + .soar_engine_running + .store(true, std::sync::atomic::Ordering::SeqCst); + + // Create shutdown channel for API-triggered shutdown/restart + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::(); + let shutdown_handle = Arc::new(ShutdownHandle::new(shutdown_tx)); + self.shutdown_handle = Some(shutdown_handle.clone()); + // Start HTTP server in background (!Send, use actix::spawn) let setup_flag = Arc::new(std::sync::atomic::AtomicBool::new(true)); let ready_flag = Arc::new(std::sync::atomic::AtomicBool::new(false)); @@ -142,16 +262,20 @@ impl System { ebpf_services: self.ebpf_services.clone(), app_services: self.app_services.clone(), db: self.db.clone(), + secret_store: self.secret_store.clone(), jwt_service: self.jwt_service.clone(), comm: self.comm.clone(), setup_complete: setup_flag, ready: ready_flag, + readiness_state, acl_service: self.acl_service.clone(), config_service: self.config_service.clone(), dns_filter_service: self.dns_filter_service.clone(), notification_service: self.notification_service.clone(), playbook_service: self.playbook_service.clone(), rate_limit_service: self.rate_limit_service.clone(), + force_https, + shutdown_handle: shutdown_handle.clone(), }; let ready_for_http = ready_flag_for_set.clone(); actix::spawn(async move { @@ -187,9 +311,15 @@ impl System { } } - // Wait for shutdown signal - tokio::signal::ctrl_c().await.ok(); - Ok(()) + // Wait for shutdown signal (ctrl-c OR API-triggered) + tokio::select! { + _ = tokio::signal::ctrl_c() => { + Ok(ShutdownMode::Shutdown) + } + mode = shutdown_rx => { + Ok(mode.unwrap_or(ShutdownMode::Shutdown)) + } + } } pub async fn terminate(&self) -> Result<(), Error> { @@ -211,37 +341,69 @@ impl System { "Exploitation" => "threat_detected".to_string(), "Reconnaissance" => "port_scan".to_string(), other => { - log!(SystemLog::UnknownMlAttackType(other.to_string())); + log!(DetectionLog::UnknownMlAttackType(other.to_string())); "threat_detected".to_string() } } } - async fn bridge_ml_to_soar( - mut rx: tokio::sync::broadcast::Receiver, + /// Periodically check the drift detector and publish DriftDetectedEvent when drift is found. + async fn run_drift_monitor( + drift_detector: Arc>, comm: Arc, ) { - log!(SystemLog::MlSoarBridgeStarted); + let mut interval = tokio::time::interval(std::time::Duration::from_secs(60)); + loop { + interval.tick().await; + let report = drift_detector.lock().check_drift(); + if let Some(report) = report { + log!(SystemLog::DriftDetected( + report.drifted_features.len(), + report.max_deviation + )); + let event = DriftDetectedEvent { + drifted_features: report.drifted_features, + max_deviation: report.max_deviation, + }; + if let Err(e) = comm.publish_event(event).await { + log!(SystemError::DriftEventPublishFailed(e)); + } + } + } + } + + /// Thin ML bridge: converts AlertMessage → DetectionEvent and sends to orchestrator. + /// Enrichment (GeoIP, hit count, repeat offender) is handled by the DetectionOrchestrator. + async fn bridge_ml_to_detection( + mut rx: tokio::sync::broadcast::Receiver, + tx: tokio::sync::mpsc::Sender, + ) { + log!(DetectionLog::MlBridgeStarted); + loop { match rx.recv().await { Ok(alert) => { - let event = ThreatDetectedEvent { + let event = DetectionEvent { + source: DetectionSource::ML, attack_type: Self::normalize_attack_type( &alert.attack_type.unwrap_or_else(|| "unknown".into()), ), confidence: alert.confidence, source_ip: alert.src_ip, dest_ip: alert.dst_ip, + protocol: alert.protocol, + packet_count: alert.packet_count, + flow_duration_us: alert.flow_duration_us, }; - if let Err(e) = comm.publish_event(event).await { - log!(SystemError::MlSoarBridgeFailed(e)); + if tx.send(event).await.is_err() { + break; // Orchestrator dropped } } Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { - log!(SystemLog::MlSoarBridgeLagged(n)); + log!(DetectionLog::MlBridgeLagged(n)); } Err(tokio::sync::broadcast::error::RecvError::Closed) => { - log!(SystemLog::MlAlertChannelClosed); + log!(DetectionLog::MlAlertChannelClosed); break; } } diff --git a/net-guardia/src/infrastructure/app_config.rs b/net-guardia/src/infrastructure/app_config.rs index cae1138..6a6c5c3 100644 --- a/net-guardia/src/infrastructure/app_config.rs +++ b/net-guardia/src/infrastructure/app_config.rs @@ -1,9 +1,7 @@ use crate::adapter::persistence::Database; -use crate::model::config::{ - HttpConfig, InferenceConfig as InfConfig, MiscConfig, NetworkConfig, PipelineConfig, -}; -use crate::model::error::system::SystemError; +use crate::model::config::{HttpConfig, InferenceConfig as InfConfig, MiscConfig, NetworkConfig, PipelineConfig}; use crate::model::error::Error; +use crate::model::error::system::SystemError; pub struct AppConfig { pub http: HttpConfig, @@ -55,13 +53,27 @@ impl AppConfig { ("inference_interval_secs", "5".into()), ("aggregator_window_secs", "30".into()), ("inference_batch_size", "200".into()), - ("traffic_logging_mode", "true".into()), + ("traffic_logging_mode", "false".into()), ("traffic_log_csv_path", "traffic_log.csv".into()), // Misc ("geoip_db_name", "net-guardia/static/geo/dbip-city-lite.mmdb".into()), // Pipeline ("pipeline_ingress", "access_control,rate_limit,service".into()), ("pipeline_egress", "".into()), + // SOAR + ("soar_max_auto_block_cap", "100".into()), + ("soar_max_ttl_secs", "86400".into()), + // ML + ("ml_drift_window_secs", "3600".into()), + // Telegram + ("telegram_max_messages_per_minute", "20".into()), + // Directories + ("report_dir", "/var/lib/netguardia/reports".into()), + ("log_dir", "logs".into()), + // DNS + ("dns_max_domains_per_request", "1000".into()), + // HTTPS redirect + ("force_https", "false".into()), ]; for (key, value) in defaults { @@ -106,7 +118,7 @@ impl AppConfig { inference_interval_secs: 5, aggregator_window_secs: 30, inference_batch_size: 200, - traffic_logging_mode: true, + traffic_logging_mode: false, traffic_log_csv_path: "traffic_log.csv".into(), }, misc: MiscConfig { @@ -133,12 +145,14 @@ impl AppConfig { // HTTP if let Ok(Some(v)) = db.get_setting("http_port") - && let Ok(port) = v.parse::() { - config.http.http_server_bind_port = port; + && let Ok(port) = v.parse::() + { + config.http.http_server_bind_port = port; } if let Ok(Some(v)) = db.get_setting("jwt_expiry_hours") - && let Ok(hours) = v.parse::() { - config.http.jwt_expiry_hours = hours; + && let Ok(hours) = v.parse::() + { + config.http.jwt_expiry_hours = hours; } if let Ok(Some(v)) = db.get_setting("cors_allowed_origins") { config.http.cors_allowed_origins = if v.is_empty() { @@ -150,39 +164,87 @@ impl AppConfig { // XDP tuning if let Ok(Some(v)) = db.get_setting("combined_queue_count") - && let Ok(n) = v.parse::() { config.network.combined_queue_count = n; } + && let Ok(n) = v.parse::() + { + config.network.combined_queue_count = n; + } if let Ok(Some(v)) = db.get_setting("fill_queue_size") - && let Ok(n) = v.parse::() { config.network.fill_queue_size = n; } + && let Ok(n) = v.parse::() + { + config.network.fill_queue_size = n; + } if let Ok(Some(v)) = db.get_setting("comp_queue_size") - && let Ok(n) = v.parse::() { config.network.comp_queue_size = n; } + && let Ok(n) = v.parse::() + { + config.network.comp_queue_size = n; + } if let Ok(Some(v)) = db.get_setting("tx_queue_size") - && let Ok(n) = v.parse::() { config.network.tx_queue_size = n; } + && let Ok(n) = v.parse::() + { + config.network.tx_queue_size = n; + } if let Ok(Some(v)) = db.get_setting("rx_queue_size") - && let Ok(n) = v.parse::() { config.network.rx_queue_size = n; } + && let Ok(n) = v.parse::() + { + config.network.rx_queue_size = n; + } if let Ok(Some(v)) = db.get_setting("frame_size") - && let Ok(n) = v.parse::() { config.network.frame_size = n; } + && let Ok(n) = v.parse::() + { + config.network.frame_size = n; + } if let Ok(Some(v)) = db.get_setting("frame_count") - && let Ok(n) = v.parse::() { config.network.frame_count = n; } + && let Ok(n) = v.parse::() + { + config.network.frame_count = n; + } // Inference tuning if let Ok(Some(v)) = db.get_setting("max_concurrent_flows") - && let Ok(n) = v.parse::() { config.inference.max_concurrent_flows = n; } + && let Ok(n) = v.parse::() + { + config.inference.max_concurrent_flows = n; + } if let Ok(Some(v)) = db.get_setting("min_packets_for_inference") - && let Ok(n) = v.parse::() { config.inference.min_packets_for_inference = n; } + && let Ok(n) = v.parse::() + { + config.inference.min_packets_for_inference = n; + } if let Ok(Some(v)) = db.get_setting("inference_interval_secs") - && let Ok(n) = v.parse::() { config.inference.inference_interval_secs = n; } + && let Ok(n) = v.parse::() + { + config.inference.inference_interval_secs = n; + } if let Ok(Some(v)) = db.get_setting("aggregator_window_secs") - && let Ok(n) = v.parse::() { config.inference.aggregator_window_secs = n; } + && let Ok(n) = v.parse::() + { + config.inference.aggregator_window_secs = n; + } if let Ok(Some(v)) = db.get_setting("inference_batch_size") - && let Ok(n) = v.parse::() { config.inference.inference_batch_size = n; } + && let Ok(n) = v.parse::() + { + config.inference.inference_batch_size = n; + } if let Ok(Some(v)) = db.get_setting("refresh_interval") - && let Ok(n) = v.parse::() { config.network.refresh_interval = n; } + && let Ok(n) = v.parse::() + { + config.network.refresh_interval = n; + } if let Ok(Some(v)) = db.get_setting("channel_size") - && let Ok(n) = v.parse::() { config.network.channel_size = n; } + && let Ok(n) = v.parse::() + { + config.network.channel_size = n; + } if let Ok(Some(v)) = db.get_setting("packet_buffer_size") - && let Ok(n) = v.parse::() { config.network.packet_buffer_size = n; } + && let Ok(n) = v.parse::() + { + config.network.packet_buffer_size = n; + } if let Ok(Some(v)) = db.get_setting("buffer_pool_capacity") - && let Ok(n) = v.parse::() { config.network.buffer_pool_capacity = n; } + && let Ok(n) = v.parse::() + { + config.network.buffer_pool_capacity = n; + } // Bool settings if let Ok(Some(v)) = db.get_setting("traffic_logging_mode") { @@ -191,15 +253,30 @@ impl AppConfig { // File path settings if let Ok(Some(v)) = db.get_setting("deep_autoencoder_name") - && !v.is_empty() { config.inference.deep_autoencoder_name = v; } + && !v.is_empty() + { + config.inference.deep_autoencoder_name = v; + } if let Ok(Some(v)) = db.get_setting("classifier_name") - && !v.is_empty() { config.inference.classifier_name = v; } + && !v.is_empty() + { + config.inference.classifier_name = v; + } if let Ok(Some(v)) = db.get_setting("models_config_name") - && !v.is_empty() { config.inference.models_config_name = v; } + && !v.is_empty() + { + config.inference.models_config_name = v; + } if let Ok(Some(v)) = db.get_setting("traffic_log_csv_path") - && !v.is_empty() { config.inference.traffic_log_csv_path = v; } + && !v.is_empty() + { + config.inference.traffic_log_csv_path = v; + } if let Ok(Some(v)) = db.get_setting("geoip_db_name") - && !v.is_empty() { config.misc.geoip_db_name = v; } + && !v.is_empty() + { + config.misc.geoip_db_name = v; + } // Pipeline (stored as comma-separated) if let Ok(Some(v)) = db.get_setting("pipeline_ingress") { @@ -312,9 +389,18 @@ mod tests { let db = test_db(); AppConfig::seed_defaults(&db).expect("seed should succeed"); assert_eq!(db.get_setting("http_port").unwrap(), Some("8080".to_string())); - assert_eq!(db.get_setting("traffic_logging_mode").unwrap(), Some("true".to_string())); - assert_eq!(db.get_setting("pipeline_ingress").unwrap(), Some("access_control,rate_limit,service".to_string())); - assert_eq!(db.get_setting("geoip_db_name").unwrap(), Some("net-guardia/static/geo/dbip-city-lite.mmdb".to_string())); + assert_eq!( + db.get_setting("traffic_logging_mode").unwrap(), + Some("false".to_string()) + ); + assert_eq!( + db.get_setting("pipeline_ingress").unwrap(), + Some("access_control,rate_limit,service".to_string()) + ); + assert_eq!( + db.get_setting("geoip_db_name").unwrap(), + Some("net-guardia/static/geo/dbip-city-lite.mmdb".to_string()) + ); } #[test] @@ -327,8 +413,17 @@ mod tests { #[test] fn db_overrides_traffic_logging_mode() { + // Default is false (ML inference enabled). Override to true enables CSV logging only. + let db = test_db(); + db.set_setting("traffic_logging_mode", "true").unwrap(); + let config = AppConfig::new(&db).unwrap(); + assert!(config.inference.traffic_logging_mode); + } + + #[test] + fn default_traffic_logging_mode_is_false() { + // ML inference should be enabled by default, not CSV logging let db = test_db(); - db.set_setting("traffic_logging_mode", "false").unwrap(); let config = AppConfig::new(&db).unwrap(); assert!(!config.inference.traffic_logging_mode); } diff --git a/net-guardia/src/infrastructure/app_services.rs b/net-guardia/src/infrastructure/app_services.rs index 53f9672..ff989a3 100644 --- a/net-guardia/src/infrastructure/app_services.rs +++ b/net-guardia/src/infrastructure/app_services.rs @@ -5,20 +5,21 @@ use crossbeam::queue::SegQueue; use macros::log; use tokio::sync::oneshot; +use crate::core::ml::alert::MLAlert; +use crate::core::ml::config_loader::InferenceConfig; +use crate::core::ml::drift_detector::DriftDetector; +use crate::core::ml::engine::Engine; +use crate::core::ml::model_loader::MLModels; +use crate::core::ml::traffic_logger::TrafficLogger; use crate::infrastructure::app_config::AppConfig; use crate::infrastructure::health::SystemHealth; -use crate::core::ml::alert::MLAlert; use crate::infrastructure::statistics::FlowStatistics; -use crate::core::ml::config_loader::InferenceConfig; -use crate::core::ml::engine::Engine; -use crate::model::ml_detection::EngineConfig; -use crate::core::ml::feature_extractor::FlowFeatures; -use crate::core::ml::model_loader::MLModels; +use crate::model::detection::flow_features::FlowFeatures; +use crate::model::error::Error; use crate::model::error::misc::MiscError; use crate::model::error::system::SystemError; -use crate::model::error::Error; use crate::model::log::system::SystemLog; -use crate::core::ml::traffic_logger::TrafficLogger; +use crate::model::ml_detection::EngineConfig; /// Application-level service orchestrator. /// Holds all runtime services (health monitoring, ML inference, flow statistics) @@ -33,7 +34,11 @@ pub struct AppServices { } impl AppServices { - pub fn new(app_config: Arc, inference_config: Arc) -> Result { + pub fn new( + app_config: Arc, + inference_config: Arc, + drift_detector: Arc>, + ) -> Result { let health = SystemHealth::new(app_config.clone())?; let ml_models = Arc::new(MLModels::load_models(&app_config, &inference_config)?); @@ -63,6 +68,7 @@ impl AppServices { ml_models.clone(), inference_config.clone(), ml_alert.clone(), + drift_detector, engine_config, traffic_logger, app_config.network.combined_queue_count, diff --git a/net-guardia/src/infrastructure/audit_logger.rs b/net-guardia/src/infrastructure/audit_logger.rs new file mode 100644 index 0000000..92e37cd --- /dev/null +++ b/net-guardia/src/infrastructure/audit_logger.rs @@ -0,0 +1,103 @@ +use std::sync::Arc; + +use macros::log; + +use crate::infrastructure::communication_manager::CommunicationManager; +use crate::interface::port::audit::AuditPort; +use crate::model::event::{AuditEvent, DriftDetectedEvent}; +use crate::model::log::audit::AuditLog; + +/// Subscribes to `AuditEvent` and persists each entry to the `audit_log` table. +/// Falls back to log-only when DB writes fail (never panics). +pub struct AuditLogger { + db: Arc, +} + +impl AuditLogger { + pub fn new(db: Arc) -> Self { + Self { db } + } + + /// Subscribe to AuditEvent and DriftDetectedEvent on the communication manager + /// and start background tasks that persist events to DB + structured logs. + pub fn start(self: Arc, comm: &CommunicationManager) { + // Subscribe to AuditEvent + if let Ok(mut rx) = comm.subscribe_event::() { + let this = self.clone(); + tokio::spawn(async move { + loop { + match rx.recv().await { + Ok(event) => { + this.handle_audit_event(&event); + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + log!(AuditLog::AuditLagged { count: n }); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + log!(AuditLog::AuditChannelClosed); + break; + } + } + } + }); + } else { + log!(AuditLog::AuditSubscribeFailed); + } + + // Subscribe to DriftDetectedEvent — log as audit trail entry + if let Ok(mut rx) = comm.subscribe_event::() { + let this = self; + tokio::spawn(async move { + loop { + match rx.recv().await { + Ok(event) => { + this.handle_drift_event(&event); + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + log!(AuditLog::AuditLagged { count: n }); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + log!(AuditLog::AuditChannelClosed); + break; + } + } + } + }); + } else { + log!(AuditLog::AuditSubscribeFailed); + } + } + + fn handle_audit_event(&self, event: &AuditEvent) { + // Always emit a structured log line + log!(AuditLog::AuditEvent { + actor: event.actor.clone(), + action: event.action.clone() + }); + + // Attempt DB insert; on failure, log a warning but do not panic + if let Err(e) = self.db.insert_audit_log(&event.actor, &event.action, &event.detail) { + log!(AuditLog::AuditDbWriteFailed { + error: e.to_string(), + actor: event.actor.clone(), + action: event.action.clone() + }); + } + } + + fn handle_drift_event(&self, event: &DriftDetectedEvent) { + let detail = serde_json::json!({ + "drifted_features": event.drifted_features, + "max_deviation": event.max_deviation, + }) + .to_string(); + + log!(AuditLog::AuditDriftEvent { + count: event.drifted_features.len() + }); + + if let Err(e) = self.db.insert_audit_log("system", "ml_drift_detected", &detail) { + log!(AuditLog::AuditDriftDbWriteFailed { error: e.to_string() }); + } + } +} diff --git a/net-guardia/src/infrastructure/communication_manager.rs b/net-guardia/src/infrastructure/communication_manager.rs index a91297b..d8ee5d7 100644 --- a/net-guardia/src/infrastructure/communication_manager.rs +++ b/net-guardia/src/infrastructure/communication_manager.rs @@ -2,16 +2,14 @@ use crate::interface::communication::command::*; use crate::interface::communication::event::Event; use crate::interface::communication::event::EventBroadcaster; use crate::interface::communication::query::*; -use crate::model::error::misc::MiscError; +use crate::model::config::constants::DEFAULT_EVENT_CHANNEL_CAPACITY; use crate::model::error::Error; +use crate::model::error::misc::MiscError; use dashmap::DashMap; use std::any::{Any, TypeId}; use std::sync::Arc; use tokio::sync::broadcast; -/// Default broadcast channel capacity for event types. -const DEFAULT_CHANNEL_CAPACITY: usize = 256; - /// Inline TypedEventBroadcaster (adapted from MirrorSphere's model). pub struct TypedEventBroadcaster { pub sender: broadcast::Sender, @@ -44,28 +42,20 @@ impl CommunicationManager { command_handlers: DashMap::new(), query_handlers: DashMap::new(), event_broadcasters: DashMap::new(), - channel_capacity: DEFAULT_CHANNEL_CAPACITY, + channel_capacity: DEFAULT_EVENT_CHANNEL_CAPACITY, } } - pub fn with_service( - self: Arc, - service: Arc, - ) -> ServiceRegistrar { + pub fn with_service(self: Arc, service: Arc) -> ServiceRegistrar { ServiceRegistrar::new(service, self) } - pub fn register_command_handler( - &self, - handler: Arc + Send + Sync>, - ) { + pub fn register_command_handler(&self, handler: Arc + Send + Sync>) { let type_id = TypeId::of::(); let boxed_handler: CommandHandlerFn = Box::new(move |command: Box| { let handler = handler.clone(); Box::pin(async move { - let command = *command - .downcast::() - .map_err(|_| MiscError::TypeMismatch)?; + let command = *command.downcast::().map_err(|_| MiscError::TypeMismatch)?; handler.handle_command(command).await }) as CommandFuture }); @@ -82,10 +72,7 @@ impl CommunicationManager { } } - pub fn register_query_handler( - &self, - handler: Arc + Send + Sync>, - ) { + pub fn register_query_handler(&self, handler: Arc + Send + Sync>) { let type_id = TypeId::of::(); let boxed_handler: QueryHandlerFn = Box::new(move |query: Box| { let handler = handler.clone(); @@ -115,8 +102,7 @@ impl CommunicationManager { let type_id = TypeId::of::(); let (tx, _) = broadcast::channel::(self.channel_capacity); let broadcaster = TypedEventBroadcaster { sender: tx }; - self.event_broadcasters - .insert(type_id, Box::new(broadcaster)); + self.event_broadcasters.insert(type_id, Box::new(broadcaster)); } pub fn subscribe_event(&self) -> Result, Error> { @@ -171,8 +157,6 @@ impl ServiceRegistrar { self } - - pub fn build(self) -> Arc { self.comm } @@ -181,10 +165,10 @@ impl ServiceRegistrar { #[cfg(test)] mod tests { use super::*; - use crate::interface::communication::message::Message; use crate::interface::communication::command::Command; - use crate::interface::communication::query::Query; use crate::interface::communication::event::Event; + use crate::interface::communication::message::Message; + use crate::interface::communication::query::Query; use async_trait::async_trait; // ── Test Command ───────────────────────────────────────────────── @@ -243,7 +227,9 @@ mod tests { #[tokio::test] async fn test_command_dispatch() { let received = Arc::new(std::sync::Mutex::new(Vec::new())); - let handler = Arc::new(TestCommandHandler { received: received.clone() }); + let handler = Arc::new(TestCommandHandler { + received: received.clone(), + }); let comm = Arc::new(CommunicationManager::new()); comm.register_command_handler::(handler); @@ -307,7 +293,11 @@ mod tests { let mut rx1 = comm.subscribe_event::().unwrap(); let mut rx2 = comm.subscribe_event::().unwrap(); - comm.publish_event(TestEvent { message: "broadcast".into() }).await.unwrap(); + comm.publish_event(TestEvent { + message: "broadcast".into(), + }) + .await + .unwrap(); assert_eq!(rx1.recv().await.unwrap().message, "broadcast"); assert_eq!(rx2.recv().await.unwrap().message, "broadcast"); @@ -316,18 +306,20 @@ mod tests { #[tokio::test] async fn test_service_registrar() { let received = Arc::new(std::sync::Mutex::new(Vec::new())); - let handler = Arc::new(TestCommandHandler { received: received.clone() }); + let handler = Arc::new(TestCommandHandler { + received: received.clone(), + }); let comm = Arc::new(CommunicationManager::new()); - let _comm = comm.clone() - .with_service(handler) - .command::() - .build(); + let _comm = comm.clone().with_service(handler).command::().build(); - comm.send_command(TestCommand { value: "via_registrar".into() }).await.unwrap(); + comm.send_command(TestCommand { + value: "via_registrar".into(), + }) + .await + .unwrap(); let msgs = received.lock().unwrap(); assert_eq!(msgs[0], "via_registrar"); } - } diff --git a/net-guardia/src/infrastructure/enforce_mode_handler.rs b/net-guardia/src/infrastructure/enforce_mode_handler.rs index eef0709..d8dd9fa 100644 --- a/net-guardia/src/infrastructure/enforce_mode_handler.rs +++ b/net-guardia/src/infrastructure/enforce_mode_handler.rs @@ -1,24 +1,43 @@ use async_trait::async_trait; use std::sync::Arc; +use std::sync::atomic::{AtomicU8, Ordering}; use macros::log; +use crate::infrastructure::communication_manager::CommunicationManager; use crate::interface::communication::command::CommandHandler; use crate::interface::communication::command_types::ChangeEnforceModeCommand; use crate::interface::communication::query::QueryHandler; use crate::interface::communication::query_types::GetEnforceModeQuery; use crate::interface::port::repository::RepositoryPort; use crate::model::error::Error; +use crate::model::event::AuditEvent; use crate::model::log::system::SystemLog; +/// Map enforce-mode string to u8: monitor=0, ml_only=1, enforce=2. +pub fn enforce_mode_to_u8(mode: &str) -> u8 { + match mode { + "enforce" => 2, + "ml_only" => 1, + _ => 0, // "monitor" or unknown → safest default + } +} + /// Handles enforce-mode commands and queries by delegating to the repository. pub struct EnforceModeHandler { db: Arc, + comm: Arc, + /// Shared AtomicU8 cache: Monitor=0, MlOnly=1, Enforce=2. + enforce_cache: Arc, } impl EnforceModeHandler { - pub fn new(db: Arc) -> Self { - Self { db } + pub fn new(db: Arc, comm: Arc, enforce_cache: Arc) -> Self { + Self { + db, + comm, + enforce_cache, + } } } @@ -26,7 +45,20 @@ impl EnforceModeHandler { impl CommandHandler for EnforceModeHandler { async fn handle_command(&self, command: ChangeEnforceModeCommand) -> Result<(), Error> { self.db.set_setting("enforce_mode", &command.mode)?; - log!(SystemLog::EnforceModeChanged(command.mode)); + self.enforce_cache + .store(enforce_mode_to_u8(&command.mode), Ordering::SeqCst); + log!(SystemLog::EnforceModeChanged(command.mode.clone())); + + // Publish audit event for the mode change + let _ = self + .comm + .publish_event(AuditEvent { + actor: "admin".to_string(), + action: "enforce_mode_changed".to_string(), + detail: serde_json::json!({ "new_mode": command.mode }).to_string(), + }) + .await; + Ok(()) } } @@ -51,9 +83,12 @@ mod tests { fn test_handler() -> (Arc, Arc) { let db = Arc::new(Database::new(":memory:").unwrap()) as Arc; - let handler = Arc::new(EnforceModeHandler::new(db)); + let cache = Arc::new(AtomicU8::new(0)); let comm = Arc::new(CommunicationManager::new()); - let _ = comm.clone() + comm.register_event_type::(); + let handler = Arc::new(EnforceModeHandler::new(db, comm.clone(), cache)); + let _ = comm + .clone() .with_service(handler.clone()) .command::() .query::() @@ -71,7 +106,9 @@ mod tests { #[tokio::test] async fn test_change_to_enforce() { let (_, comm) = test_handler(); - comm.send_command(ChangeEnforceModeCommand { mode: "enforce".into() }).await.unwrap(); + comm.send_command(ChangeEnforceModeCommand { mode: "enforce".into() }) + .await + .unwrap(); let mode = comm.send_query(GetEnforceModeQuery).await.unwrap(); assert_eq!(mode, "enforce"); } @@ -79,9 +116,35 @@ mod tests { #[tokio::test] async fn test_change_back_to_monitor() { let (_, comm) = test_handler(); - comm.send_command(ChangeEnforceModeCommand { mode: "enforce".into() }).await.unwrap(); - comm.send_command(ChangeEnforceModeCommand { mode: "monitor".into() }).await.unwrap(); + comm.send_command(ChangeEnforceModeCommand { mode: "enforce".into() }) + .await + .unwrap(); + comm.send_command(ChangeEnforceModeCommand { mode: "monitor".into() }) + .await + .unwrap(); let mode = comm.send_query(GetEnforceModeQuery).await.unwrap(); assert_eq!(mode, "monitor"); } + + #[tokio::test] + async fn test_change_to_ml_only() { + let (_, comm) = test_handler(); + comm.send_command(ChangeEnforceModeCommand { mode: "ml_only".into() }) + .await + .unwrap(); + let mode = comm.send_query(GetEnforceModeQuery).await.unwrap(); + assert_eq!(mode, "ml_only"); + } + + #[tokio::test] + async fn test_cycle_all_modes() { + let (_, comm) = test_handler(); + for mode_str in ["enforce", "ml_only", "monitor"] { + comm.send_command(ChangeEnforceModeCommand { mode: mode_str.into() }) + .await + .unwrap(); + let mode = comm.send_query(GetEnforceModeQuery).await.unwrap(); + assert_eq!(mode, mode_str); + } + } } diff --git a/net-guardia/src/infrastructure/geoip.rs b/net-guardia/src/infrastructure/geoip.rs index 23fb1c3..3e5a679 100644 --- a/net-guardia/src/infrastructure/geoip.rs +++ b/net-guardia/src/infrastructure/geoip.rs @@ -2,24 +2,15 @@ use std::net::IpAddr; use std::path::{Path, PathBuf}; use std::sync::Arc; -use maxminddb::{geoip2, MaxMindDbError, Reader}; -use tokio::sync::RwLock; use lru::LruCache; +use maxminddb::{MaxMindDbError, Reader, geoip2}; use std::num::NonZeroUsize; +use tokio::sync::RwLock; use tokio::task; +use crate::model::monitoring::geolocation::GeoLocation; use crate::utils::ip_address; -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct GeoLocation { - pub country: Option, - pub country_code: Option, - pub city: Option, - pub latitude: Option, - pub longitude: Option, - pub timezone: Option, -} - pub struct GeoIpService { reader: Arc>>, cache: Arc>>>, @@ -31,13 +22,9 @@ impl GeoIpService { Self::with_cache_size(db_path, 10000) } - pub fn with_cache_size>( - db_path: P, - cache_size: usize, - ) -> Result { + pub fn with_cache_size>(db_path: P, cache_size: usize) -> Result { let reader = Reader::open_readfile(db_path)?; - let cache_capacity = NonZeroUsize::new(cache_size) - .unwrap_or_else(|| NonZeroUsize::new(10000).unwrap()); + let cache_capacity = NonZeroUsize::new(cache_size).unwrap_or_else(|| NonZeroUsize::new(10000).unwrap()); Ok(Self { reader: Arc::new(reader), @@ -65,9 +52,7 @@ impl GeoIpService { } let reader = self.reader.clone(); - let result = task::spawn_blocking(move || { - Self::lookup_from_db_blocking(&reader, ip) - }) + let result = task::spawn_blocking(move || Self::lookup_from_db_blocking(&reader, ip)) .await .map_err(|e| MaxMindDbError::InvalidDatabase { message: format!("Task join error: {}", e), @@ -82,22 +67,16 @@ impl GeoIpService { Ok(result) } - fn lookup_from_db_blocking( - reader: &Reader>, - ip: IpAddr, - ) -> Result, MaxMindDbError> { + fn lookup_from_db_blocking(reader: &Reader>, ip: IpAddr) -> Result, MaxMindDbError> { let lookup_result = reader.lookup(ip)?; let city_option: Option = lookup_result.decode()?; Ok(city_option.map(|city| { - let country_name = city.country.names.english - .map(|s| s.to_string()); + let country_name = city.country.names.english.map(|s| s.to_string()); - let country_code = city.country.iso_code - .map(|s| s.to_string()); + let country_code = city.country.iso_code.map(|s| s.to_string()); - let city_name = city.city.names.english - .map(|s| s.to_string()); + let city_name = city.city.names.english.map(|s| s.to_string()); let latitude = city.location.latitude; let longitude = city.location.longitude; @@ -113,4 +92,4 @@ impl GeoIpService { } })) } -} \ No newline at end of file +} diff --git a/net-guardia/src/infrastructure/health.rs b/net-guardia/src/infrastructure/health.rs index 92e29ae..83b2547 100644 --- a/net-guardia/src/infrastructure/health.rs +++ b/net-guardia/src/infrastructure/health.rs @@ -1,25 +1,18 @@ use std::sync::Arc; use std::time::Duration; -use sysinfo::{Components, Networks, System}; -use tokio::sync::{broadcast, oneshot, RwLock}; -use tokio::time::interval; use macros::log; +use sysinfo::{Components, Networks, System}; +use tokio::sync::{RwLock, broadcast, oneshot}; +use tokio::time::interval; use crate::infrastructure::app_config::AppConfig; -use crate::model::log::health::Health; use crate::model::error::Error; use crate::model::health::{ - ConfiguredNetworkStats, - CpuCoreInfo, - CpuDetails, - LoadAverage, - MemoryUsage, - NetworkStats, - SystemHealthMetrics, - SystemHealthStatus, - SystemInfo + ConfiguredNetworkStats, CpuCoreInfo, CpuDetails, LoadAverage, MemoryUsage, NetworkStats, SystemHealthMetrics, + SystemHealthStatus, SystemInfo, }; +use crate::model::log::health::Health; pub struct SystemHealth { system: RwLock, @@ -91,8 +84,9 @@ impl SystemHealth { drop(components); if self.broadcast_tx.receiver_count() > 0 - && let Err(e) = self.broadcast_tx.send(metrics) { - log!(Health::BroadcastFailed(e.to_string())); + && let Err(e) = self.broadcast_tx.send(metrics) + { + log!(Health::BroadcastFailed(e.to_string())); } } @@ -123,11 +117,7 @@ impl SystemHealth { swap_used: system.used_swap(), }; - let network_stats = Self::collect_configured_network_stats( - networks, - ingress_interface, - egress_interface, - ); + let network_stats = Self::collect_configured_network_stats(networks, ingress_interface, egress_interface); let load_average = System::load_average(); let load_average = if load_average.one != 0.0 || load_average.five != 0.0 || load_average.fifteen != 0.0 { @@ -227,15 +217,18 @@ impl SystemHealth { let egress = create_network_stats(egress_interface); if ingress.is_none() { - log!(Health::InterfaceNotFound("Ingress".to_string(), ingress_interface.to_string())); + log!(Health::InterfaceNotFound( + "Ingress".to_string(), + ingress_interface.to_string() + )); } if egress.is_none() { - log!(Health::InterfaceNotFound("Egress".to_string(), egress_interface.to_string())); - } - ConfiguredNetworkStats { - ingress, - egress, + log!(Health::InterfaceNotFound( + "Egress".to_string(), + egress_interface.to_string() + )); } + ConfiguredNetworkStats { ingress, egress } } pub async fn get_current_metrics(&self) -> SystemHealthMetrics { @@ -283,22 +276,17 @@ impl SystemHealth { metrics.memory_usage.usage_percent )); } else if metrics.memory_usage.usage_percent > 80.0 { - status.warnings.push(format!( - "High memory usage: {:.1}%", - metrics.memory_usage.usage_percent - )); + status + .warnings + .push(format!("High memory usage: {:.1}%", metrics.memory_usage.usage_percent)); } if let Some(temp) = metrics.temperature { if temp > 80.0 { status.overall_healthy = false; - status - .issues - .push(format!("High CPU temperature: {:.1}°C", temp)); + status.issues.push(format!("High CPU temperature: {:.1}°C", temp)); } else if temp > 70.0 { - status - .warnings - .push(format!("Elevated CPU temperature: {:.1}°C", temp)); + status.warnings.push(format!("Elevated CPU temperature: {:.1}°C", temp)); } } @@ -349,4 +337,4 @@ impl SystemHealth { } None } -} \ No newline at end of file +} diff --git a/net-guardia/src/infrastructure/http_server.rs b/net-guardia/src/infrastructure/http_server.rs index 3418199..aa9b90e 100644 --- a/net-guardia/src/infrastructure/http_server.rs +++ b/net-guardia/src/infrastructure/http_server.rs @@ -1,33 +1,44 @@ use std::sync::Arc; use actix_web::web::route; -use actix_web::{web, App, HttpServer}; +use actix_web::{App, HttpServer, web}; +use crate::adapter::http::{ + acl, api_keys, audit as audit_api, auth, default, filter, health as health_api, logs as logs_api, ml, + notification as notification_api, rate_limit as rate_limit_api, report as report_api, setup as setup_api, soar, + stats, system as system_api, +}; +use crate::adapter::persistence::Database; +use crate::adapter::websocket::routes as ws; use crate::core::acl_service::AclService; +use crate::core::auth::https_redirect::{ForceHttpsFlag, HttpsRedirect}; use crate::core::auth::jwt::JwtService; +use crate::core::auth::setup_guard::{SetupCompleteFlag, SetupGuard}; use crate::core::config_service::ConfigService; use crate::core::dns_filter_service::DnsFilterService; +use crate::core::ebpf::EbpfServices; +use crate::core::ml::config_loader::InferenceConfig; use crate::core::notification_service::NotificationService; use crate::core::playbook_service::PlaybookService; use crate::core::rate_limit_service::RateLimitService; -use crate::adapter::persistence::Database; -use crate::core::ebpf::EbpfServices; +use crate::core::system::ShutdownHandle; use crate::infrastructure::app_config::AppConfig; use crate::infrastructure::app_services::AppServices; use crate::infrastructure::communication_manager::CommunicationManager; -use crate::core::ml::config_loader::InferenceConfig; -use macros::log; -use crate::model::error::http::HttpError; -use crate::model::error::Error; -use crate::model::log::http::HttpLog; -use crate::adapter::http::{acl, auth, default, filter, health as health_api, mcp_keys, ml, notification as notification_api, rate_limit as rate_limit_api, report as report_api, setup as setup_api, soar, stats, system as system_api}; -use crate::core::auth::setup_guard::{SetupCompleteFlag, SetupGuard}; -use crate::adapter::websocket::routes as ws; +use crate::infrastructure::secret_store::SecretStore; +use crate::interface::port::api_key::ApiKeyPort; use crate::interface::port::repository::RepositoryPort; +use crate::model::config::constants::HTTP_FALLBACK_PORT; +use crate::model::error::Error; +use crate::model::error::http::HttpError; +use crate::model::log::http::HttpLog; +use macros::log; /// Shared flag: true when all services (eBPF, ML, SOAR) are fully initialized. pub type ReadyFlag = Arc; +use crate::model::system::readiness::ReadinessState; + /// Parameters for starting the HTTP server, avoiding `#[cfg]` on function params. pub struct HttpServerParams { pub app_config: Arc, @@ -35,16 +46,20 @@ pub struct HttpServerParams { pub ebpf_services: Arc, pub app_services: Arc, pub db: Arc, + pub secret_store: Arc, pub jwt_service: Arc, pub comm: Arc, pub setup_complete: SetupCompleteFlag, pub ready: ReadyFlag, + pub readiness_state: Arc, pub acl_service: Arc, pub config_service: Arc, pub dns_filter_service: Arc, pub notification_service: Arc, pub playbook_service: Arc, pub rate_limit_service: Arc, + pub force_https: ForceHttpsFlag, + pub shutdown_handle: Arc, } /// CORS configuration shared by both full and setup servers. @@ -52,6 +67,9 @@ pub struct HttpServerParams { /// When `allowed_origins` is non-empty, only those exact origins are permitted. /// When empty, RFC 1918 private-network origins (localhost, 127.0.0.1, /// 192.168.x.x, 10.x.x.x, 172.16-31.x.x) are allowed. +/// +/// The host is parsed as an IP address — domain names like "10.malware.net" +/// are rejected because they fail IP parsing. fn cors(allowed_origins: Vec) -> actix_cors::Cors { actix_cors::Cors::default() .allowed_origin_fn(move |origin, _req_head| { @@ -59,48 +77,68 @@ fn cors(allowed_origins: Vec) -> actix_cors::Cors { if !allowed_origins.is_empty() { return allowed_origins.iter().any(|o| o == origin_str); } - // Default: RFC 1918 private networks only - let bytes = origin.as_bytes(); - bytes.starts_with(b"http://localhost:") - || bytes.starts_with(b"http://127.0.0.1:") - || bytes.starts_with(b"https://localhost:") - || bytes.starts_with(b"https://127.0.0.1:") - || bytes.starts_with(b"http://192.168.") - || bytes.starts_with(b"https://192.168.") - || bytes.starts_with(b"http://10.") - || bytes.starts_with(b"https://10.") - || is_rfc1918_172(bytes) + is_private_origin(origin_str) }) .allow_any_method() .allow_any_header() .max_age(3600) } -/// Check if origin is from RFC 1918 172.16-31.x.x range. -fn is_rfc1918_172(origin: &[u8]) -> bool { - for prefix in [b"http://172." as &[u8], b"https://172." as &[u8]] { - if origin.starts_with(prefix) { - let rest = &origin[prefix.len()..]; - if let Some(dot_pos) = rest.iter().position(|&b| b == b'.') - && let Some(second_octet) = std::str::from_utf8(&rest[..dot_pos]) - .ok() - .and_then(|s| s.parse::().ok()) - { - return (16..=31).contains(&second_octet); - } - } +/// Extract the host portion from an origin string like "http://10.0.0.1:8080". +/// Returns the host without scheme or port. +fn extract_origin_host(origin: &str) -> Option<&str> { + // Strip scheme + let after_scheme = origin + .strip_prefix("http://") + .or_else(|| origin.strip_prefix("https://"))?; + // Strip port (if present) — find last colon that isn't part of IPv6 + // For IPv6 origins like http://[::1]:8080, strip brackets too + if after_scheme.starts_with('[') { + // IPv6 bracket notation: [::1]:8080 + let bracket_end = after_scheme.find(']')?; + Some(&after_scheme[1..bracket_end]) + } else { + // IPv4 or hostname: split at last colon for port + Some(after_scheme.split(':').next().unwrap_or(after_scheme)) } - false } -/// Default fallback port when the configured port is unavailable. -const FALLBACK_PORT: u16 = 8080; +/// Check if an origin URL points to a RFC 1918 private network address or localhost. +/// Only accepts actual IP addresses — domain names are rejected. +fn is_private_origin(origin: &str) -> bool { + let host = match extract_origin_host(origin) { + Some(h) => h, + None => return false, + }; + + if host == "localhost" { + return true; + } + + // Try parsing as IPv4 + if let Ok(ipv4) = host.parse::() { + let octets = ipv4.octets(); + return octets[0] == 127 // 127.0.0.0/8 + || octets[0] == 10 // 10.0.0.0/8 + || (octets[0] == 172 && (16..=31).contains(&octets[1])) // 172.16.0.0/12 + || (octets[0] == 192 && octets[1] == 168); // 192.168.0.0/16 + } + + // Try parsing as IPv6 + if let Ok(ipv6) = host.parse::() { + return ipv6.is_loopback(); + } + + // Not a valid IP address (e.g. "10.malware.net") — reject + false +} /// Minimal HTTP server for setup wizard mode. /// Only serves setup, auth, and health routes — no eBPF/ML dependencies. /// Returns a ServerHandle so the caller can stop it after setup completes. pub fn start_setup_server( db: Arc, + secret_store: Arc, jwt_service: Arc, setup_complete: SetupCompleteFlag, port: u16, @@ -109,7 +147,9 @@ pub fn start_setup_server( App::new() .wrap(cors(vec![])) .app_data(web::Data::from(db.clone() as Arc)) + .app_data(web::Data::from(db.clone() as Arc)) .app_data(web::Data::from(db.clone())) + .app_data(web::Data::from(secret_store.clone())) .app_data(web::Data::from(jwt_service.clone())) .app_data(web::Data::new(setup_complete.clone())) .service( @@ -117,7 +157,7 @@ pub fn start_setup_server( .wrap(crate::core::auth::middleware::AuthMiddleware) .service(auth::initialize()) .service(setup_api::initialize()) - .service(health_api::initialize()) + .service(health_api::initialize()), ) .default_service(route().to(default::default_route)) }; @@ -128,12 +168,12 @@ pub fn start_setup_server( .bind(format!("0.0.0.0:{}", port)) { Ok(s) => s, - Err(e) if port != FALLBACK_PORT => { - log!(HttpLog::SetupBindFallback(port, e.to_string(), FALLBACK_PORT)); + Err(e) if port != HTTP_FALLBACK_PORT => { + log!(HttpLog::SetupBindFallback(port, e.to_string(), HTTP_FALLBACK_PORT)); HttpServer::new(make_app) .workers(1) .shutdown_timeout(1) - .bind(format!("0.0.0.0:{}", FALLBACK_PORT)) + .bind(format!("0.0.0.0:{}", HTTP_FALLBACK_PORT)) .map_err(HttpError::BindPortError)? } Err(e) => return Err(HttpError::BindPortError(e).into()), @@ -167,21 +207,28 @@ pub async fn run(params: HttpServerParams) -> Result<(), Error> { let app_config = params.app_config; let inference_config = params.inference_config; let db = params.db; + let secret_store = params.secret_store; let jwt_service = params.jwt_service; let comm = params.comm; let setup_complete = params.setup_complete; let ready = params.ready; + let readiness_state = params.readiness_state; let acl_service = params.acl_service; let config_service = params.config_service; let dns_filter_service = params.dns_filter_service; let notification_service = params.notification_service; let playbook_service = params.playbook_service; let rate_limit_service = params.rate_limit_service; + let force_https = params.force_https; + let shutdown_handle = params.shutdown_handle; let port = app_config.http.http_server_bind_port; HttpServer::new(move || { let app = App::new() + .wrap(HttpsRedirect) .wrap(cors(app_config.http.cors_allowed_origins.clone())) + .app_data(web::Data::new(force_https.clone())) + .app_data(web::Data::from(shutdown_handle.clone())) .app_data(web::Data::from(app_config.clone())) .app_data(web::Data::from(inference_config.clone())) .app_data(web::Data::from(access_control.clone())) @@ -195,11 +242,14 @@ pub async fn run(params: HttpServerParams) -> Result<(), Error> { .app_data(web::Data::from(flow_statistics.clone())) .app_data(web::Data::from(drop_monitor.clone())) .app_data(web::Data::from(db.clone() as Arc)) + .app_data(web::Data::from(db.clone() as Arc)) .app_data(web::Data::from(db.clone())) + .app_data(web::Data::from(secret_store.clone())) .app_data(web::Data::from(jwt_service.clone())) .app_data(web::Data::from(comm.clone())) .app_data(web::Data::new(setup_complete.clone())) .app_data(web::Data::new(ready.clone())) + .app_data(web::Data::from(readiness_state.clone())) .app_data(web::Data::from(acl_service.clone())) .app_data(web::Data::from(config_service.clone())) .app_data(web::Data::from(dns_filter_service.clone())) @@ -221,8 +271,10 @@ pub async fn run(params: HttpServerParams) -> Result<(), Error> { .service(soar::initialize()) .service(notification_api::initialize()) .service(report_api::initialize()) - .service(mcp_keys::initialize()) - .service(setup_api::initialize()) + .service(api_keys::initialize()) + .service(logs_api::initialize()) + .service(audit_api::initialize()) + .service(setup_api::initialize()), ) .service(ws::initialize()) // Health-ready endpoint outside /api scope — no auth, no SetupGuard. @@ -238,7 +290,92 @@ pub async fn run(params: HttpServerParams) -> Result<(), Error> { Ok(()) } -async fn health_ready(ready: web::Data) -> actix_web::HttpResponse { - let is_ready = ready.load(std::sync::atomic::Ordering::SeqCst); - actix_web::HttpResponse::Ok().json(serde_json::json!({"ready": is_ready})) +async fn health_ready(ready: web::Data, state: web::Data) -> actix_web::HttpResponse { + use std::sync::atomic::Ordering::SeqCst; + + let is_ready = ready.load(SeqCst); + let uptime_secs = state.started_at.elapsed().as_secs(); + + actix_web::HttpResponse::Ok().json(serde_json::json!({ + "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, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_origin_host_ipv4() { + assert_eq!(extract_origin_host("http://10.0.0.1:8080"), Some("10.0.0.1")); + assert_eq!(extract_origin_host("https://192.168.1.1:443"), Some("192.168.1.1")); + assert_eq!(extract_origin_host("http://127.0.0.1:3000"), Some("127.0.0.1")); + } + + #[test] + fn test_extract_origin_host_hostname() { + assert_eq!(extract_origin_host("http://localhost:8080"), Some("localhost")); + assert_eq!( + extract_origin_host("http://10.malware.net:8080"), + Some("10.malware.net") + ); + } + + #[test] + fn test_extract_origin_host_ipv6() { + assert_eq!(extract_origin_host("http://[::1]:8080"), Some("::1")); + } + + #[test] + fn test_extract_origin_host_no_port() { + assert_eq!(extract_origin_host("http://10.0.0.1"), Some("10.0.0.1")); + assert_eq!(extract_origin_host("http://localhost"), Some("localhost")); + } + + #[test] + fn test_private_origin_valid_rfc1918() { + assert!(is_private_origin("http://10.0.0.1:8080")); + assert!(is_private_origin("http://10.255.255.255:8080")); + assert!(is_private_origin("https://192.168.1.100:443")); + assert!(is_private_origin("http://172.16.0.1:8080")); + assert!(is_private_origin("http://172.31.255.255:8080")); + assert!(is_private_origin("http://127.0.0.1:3000")); + assert!(is_private_origin("http://localhost:8080")); + } + + #[test] + fn test_private_origin_rejects_malicious_domains() { + // Domain names starting with private IP prefixes must be rejected + assert!(!is_private_origin("http://10.malware.net:8080")); + assert!(!is_private_origin("http://192.168.evil.com:8080")); + assert!(!is_private_origin("http://172.16.attack.org:8080")); + assert!(!is_private_origin("http://10.0.0.1.evil.com:8080")); + } + + #[test] + fn test_private_origin_rejects_public_ips() { + assert!(!is_private_origin("http://8.8.8.8:8080")); + assert!(!is_private_origin("http://1.1.1.1:443")); + assert!(!is_private_origin("http://172.32.0.1:8080")); // just outside 172.16-31 + assert!(!is_private_origin("http://172.15.0.1:8080")); // just below 172.16 + } + + #[test] + fn test_private_origin_rejects_garbage() { + assert!(!is_private_origin("")); + assert!(!is_private_origin("ftp://10.0.0.1")); + assert!(!is_private_origin("not-a-url")); + } + + #[test] + fn test_private_origin_ipv6_loopback() { + assert!(is_private_origin("http://[::1]:8080")); + } } diff --git a/net-guardia/src/infrastructure/mod.rs b/net-guardia/src/infrastructure/mod.rs index be53915..5ed0812 100644 --- a/net-guardia/src/infrastructure/mod.rs +++ b/net-guardia/src/infrastructure/mod.rs @@ -1,9 +1,11 @@ pub mod app_config; pub mod app_services; +pub mod audit_logger; pub mod communication_manager; pub mod enforce_mode_handler; pub mod geoip; pub mod health; pub mod http_server; +pub mod secret_store; pub mod service_factory; pub mod statistics; diff --git a/net-guardia/src/infrastructure/secret_store.rs b/net-guardia/src/infrastructure/secret_store.rs new file mode 100644 index 0000000..c416594 --- /dev/null +++ b/net-guardia/src/infrastructure/secret_store.rs @@ -0,0 +1,324 @@ +use std::sync::Arc; + +use aes_gcm::aead::{Aead, KeyInit, OsRng}; +use aes_gcm::{AeadCore, Aes256Gcm, Nonce}; +use base64::Engine; +use base64::engine::general_purpose::STANDARD as B64; +use hkdf::Hkdf; +use sha2::Sha256; + +use macros::log; + +use crate::adapter::persistence::Database; +use crate::interface::port::secret_store::SecretStorePort; +use crate::model::error::Error; +use crate::model::error::crypto::CryptoError; +use crate::model::log::crypto::CryptoLog; + +/// AES-256-GCM envelope encryption for sensitive values stored in `app_secrets`. +pub struct SecretStore { + db: Arc, + /// `None` means dev mode — secrets are base64-encoded but not encrypted. + cipher: Option, +} + +impl SecretStore { + pub fn new(db: Arc) -> Self { + let raw_key = std::env::var("NETGUARDIA_SECRETS_KEY") + .ok() + .filter(|k| !k.is_empty()) + .or_else(|| std::env::var("NETGUARDIA_DB_KEY").ok().filter(|k| !k.is_empty())); + + let cipher = raw_key.map(|key| { + let hk = Hkdf::::new(Some(b"netguardia-v1-salt"), key.as_bytes()); + let mut okm = [0u8; 32]; + // SAFETY: 32 bytes is a valid output length for HKDF-SHA256 + hk.expand(b"netguardia-envelope-v1", &mut okm).unwrap(); + // SAFETY: okm is exactly 32 bytes, which is the required key size for AES-256 + Aes256Gcm::new_from_slice(&okm).unwrap() + }); + + if cipher.is_some() { + log!(CryptoLog::EnvelopeEnabled); + } else { + log!(CryptoLog::EnvelopeDisabled); + } + + Self { db, cipher } + } + + fn encrypt(&self, plaintext: &str) -> Result { + match &self.cipher { + Some(cipher) => { + let nonce = Aes256Gcm::generate_nonce(&mut OsRng); + let ciphertext = cipher + .encrypt(&nonce, plaintext.as_bytes()) + .map_err(|e| CryptoError::EncryptionFailed { reason: e.to_string() })?; + let envelope = serde_json::json!({ + "v": 1, + "alg": "aes-256-gcm", + "nonce": B64.encode(nonce.as_slice()), + "ct": B64.encode(&ciphertext), + }); + Ok(envelope.to_string()) + } + None => { + // Dev mode: no encryption, just base64 + let envelope = serde_json::json!({ + "v": 1, + "alg": "none", + "nonce": "", + "ct": B64.encode(plaintext.as_bytes()), + }); + Ok(envelope.to_string()) + } + } + } + + fn decrypt(&self, envelope_json: &str) -> Result { + let env: serde_json::Value = + serde_json::from_str(envelope_json).map_err(|e| CryptoError::InvalidEnvelope { reason: e.to_string() })?; + + let version = env.get("v").and_then(|v| v.as_u64()).unwrap_or(0); + if version != 1 { + return Err(CryptoError::InvalidEnvelope { + reason: format!("unsupported envelope version: {version}"), + } + .into()); + } + + let alg = env.get("alg").and_then(|v| v.as_str()).unwrap_or(""); + let ct_b64 = env + .get("ct") + .and_then(|v| v.as_str()) + .ok_or_else(|| CryptoError::InvalidEnvelope { + reason: "missing ct field".to_string(), + })?; + + match alg { + "none" => { + // Reject alg:none when encryption is enabled (production mode). + // Prevents downgrade attack where attacker replaces encrypted envelope + // with alg:none + attacker-controlled plaintext. + if self.cipher.is_some() { + return Err(CryptoError::InvalidEnvelope { + reason: "alg:none rejected in production mode (encryption key is set)".to_string(), + } + .into()); + } + let plaintext_bytes = B64 + .decode(ct_b64) + .map_err(|e| CryptoError::DecryptionFailed { reason: e.to_string() })?; + String::from_utf8(plaintext_bytes) + .map_err(|e| CryptoError::DecryptionFailed { reason: e.to_string() }.into()) + } + "aes-256-gcm" => { + let cipher = self.cipher.as_ref().ok_or(CryptoError::MasterKeyUnavailable)?; + + let nonce_b64 = + env.get("nonce") + .and_then(|v| v.as_str()) + .ok_or_else(|| CryptoError::InvalidEnvelope { + reason: "missing nonce field".to_string(), + })?; + + let nonce_bytes = B64 + .decode(nonce_b64) + .map_err(|e| CryptoError::DecryptionFailed { reason: e.to_string() })?; + let nonce = + Nonce::from_exact_iter(nonce_bytes.into_iter()).ok_or_else(|| CryptoError::DecryptionFailed { + reason: "invalid nonce length".to_string(), + })?; + + let ciphertext = B64 + .decode(ct_b64) + .map_err(|e| CryptoError::DecryptionFailed { reason: e.to_string() })?; + + let plaintext_bytes = cipher + .decrypt(&nonce, ciphertext.as_ref()) + .map_err(|e| CryptoError::DecryptionFailed { reason: e.to_string() })?; + + String::from_utf8(plaintext_bytes) + .map_err(|e| CryptoError::DecryptionFailed { reason: e.to_string() }.into()) + } + other => Err(CryptoError::InvalidEnvelope { + reason: format!("unsupported algorithm: {other}"), + } + .into()), + } + } + + /// Idempotent startup migration: moves plaintext secrets from settings/notification_config + /// into the encrypted `app_secrets` table. + pub fn migrate_plaintext_secrets(&self) -> Result<(), Error> { + // Check if migration already done + if let Some(v) = self.db.get_setting("secrets_migrated")? + && v == "true" + { + log!(CryptoLog::MigrationSkipped); + return Ok(()); + } + + let mut count = 0usize; + + // 1. Migrate smtp_password + if let Some(password) = self.db.get_setting("smtp_password")? + && password != "__encrypted__" + && !password.is_empty() + { + self.set_secret("smtp_password", &password)?; + self.db.set_setting("smtp_password", "__encrypted__")?; + log!(CryptoLog::SecretMigrated("smtp_password".to_string())); + count += 1; + } + + // 2. Migrate telegram_bot_token from notification_config JSON + if let Some(json_str) = self.db.get_notification_config("telegram")? + && let Ok(mut config) = serde_json::from_str::(&json_str) + && let Some(token) = config.get("bot_token").and_then(|v| v.as_str()).map(|s| s.to_string()) + && token != "__encrypted__" + && !token.is_empty() + { + self.set_secret("telegram_bot_token", &token)?; + config["bot_token"] = serde_json::Value::String("__encrypted__".to_string()); + self.db.set_notification_config("telegram", &config.to_string())?; + log!(CryptoLog::SecretMigrated("telegram_bot_token".to_string())); + count += 1; + } + + // 3. Migrate jwt_secret + if let Some(secret) = self.db.get_setting("jwt_secret")? + && secret != "__encrypted__" + && !secret.is_empty() + { + self.set_secret("jwt_secret", &secret)?; + self.db.set_setting("jwt_secret", "__encrypted__")?; + log!(CryptoLog::SecretMigrated("jwt_secret".to_string())); + count += 1; + } + + self.db.set_setting("secrets_migrated", "true")?; + log!(CryptoLog::MigrationComplete(count)); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Create a SecretStore with encryption enabled (production mode). + fn store_with_cipher() -> SecretStore { + let hk = Hkdf::::new(Some(b"netguardia-v1-salt"), b"test-key-for-unit-tests"); + let mut okm = [0u8; 32]; + hk.expand(b"netguardia-envelope-v1", &mut okm).unwrap(); + let cipher = Aes256Gcm::new_from_slice(&okm).unwrap(); + SecretStore { + db: Arc::new(Database::new(":memory:").unwrap()), + cipher: Some(cipher), + } + } + + /// Create a SecretStore without encryption (dev mode). + fn store_without_cipher() -> SecretStore { + SecretStore { + db: Arc::new(Database::new(":memory:").unwrap()), + cipher: None, + } + } + + #[test] + fn encrypt_decrypt_round_trip() { + let store = store_with_cipher(); + let original = "my-smtp-password-123!@#"; + let encrypted = store.encrypt(original).unwrap(); + let decrypted = store.decrypt(&encrypted).unwrap(); + assert_eq!(decrypted, original); + } + + #[test] + fn encrypt_produces_different_ciphertext_each_time() { + let store = store_with_cipher(); + let plaintext = "same-value"; + let e1 = store.encrypt(plaintext).unwrap(); + let e2 = store.encrypt(plaintext).unwrap(); + assert_ne!(e1, e2, "Different nonces should produce different ciphertext"); + assert_eq!(store.decrypt(&e1).unwrap(), plaintext); + assert_eq!(store.decrypt(&e2).unwrap(), plaintext); + } + + #[test] + fn wrong_key_fails_decrypt() { + let store1 = store_with_cipher(); + let encrypted = store1.encrypt("secret-value").unwrap(); + + // Create a store with a different key + let hk = Hkdf::::new(Some(b"netguardia-v1-salt"), b"different-key"); + let mut okm = [0u8; 32]; + hk.expand(b"netguardia-envelope-v1", &mut okm).unwrap(); + let cipher = Aes256Gcm::new_from_slice(&okm).unwrap(); + let store2 = SecretStore { + db: Arc::new(Database::new(":memory:").unwrap()), + cipher: Some(cipher), + }; + + let result = store2.decrypt(&encrypted); + assert!(result.is_err(), "Decrypting with wrong key should fail"); + } + + #[test] + fn alg_none_rejected_in_production_mode() { + let store = store_with_cipher(); + let fake_envelope = serde_json::json!({ + "v": 1, + "alg": "none", + "nonce": "", + "ct": B64.encode(b"attacker-controlled-jwt-secret"), + }) + .to_string(); + let result = store.decrypt(&fake_envelope); + assert!(result.is_err(), "alg:none should be rejected when cipher is present"); + } + + #[test] + fn alg_none_allowed_in_dev_mode() { + let store = store_without_cipher(); + let encrypted = store.encrypt("dev-mode-secret").unwrap(); + let decrypted = store.decrypt(&encrypted).unwrap(); + assert_eq!(decrypted, "dev-mode-secret"); + } + + #[test] + fn invalid_envelope_json_fails() { + let store = store_with_cipher(); + assert!(store.decrypt("not-json").is_err()); + } + + #[test] + fn unsupported_version_fails() { + let store = store_with_cipher(); + let envelope = serde_json::json!({"v": 99, "alg": "aes-256-gcm", "ct": "abc"}).to_string(); + assert!(store.decrypt(&envelope).is_err()); + } + + #[test] + fn unsupported_algorithm_fails() { + let store = store_with_cipher(); + let envelope = serde_json::json!({"v": 1, "alg": "chacha20", "ct": "abc"}).to_string(); + assert!(store.decrypt(&envelope).is_err()); + } +} + +impl SecretStorePort for SecretStore { + fn get_secret(&self, key: &str) -> Result, Error> { + match self.db.get_app_secret(key)? { + Some(envelope_json) => Ok(Some(self.decrypt(&envelope_json)?)), + None => Ok(None), + } + } + + fn set_secret(&self, key: &str, plaintext: &str) -> Result<(), Error> { + let envelope = self.encrypt(plaintext)?; + self.db.set_app_secret(key, &envelope) + } +} diff --git a/net-guardia/src/infrastructure/service_factory.rs b/net-guardia/src/infrastructure/service_factory.rs index 8ae7217..9384c94 100644 --- a/net-guardia/src/infrastructure/service_factory.rs +++ b/net-guardia/src/infrastructure/service_factory.rs @@ -1,43 +1,49 @@ use std::collections::HashMap; use std::net::{Ipv4Addr, Ipv6Addr, SocketAddrV4, SocketAddrV6}; use std::sync::Arc; +use std::sync::atomic::AtomicU8; +use aya::Ebpf; use aya::maps::{Array, MapData, ProgramArray}; use aya::programs::{Xdp, XdpFlags}; -use aya::Ebpf; use aya_log::EbpfLogger; use common::define::pipeline::*; use crate::core::auth::jwt::JwtService; +use crate::adapter::access_control_adapter::EbpfAccessControlAdapter; use crate::adapter::persistence::Database; +use crate::adapter::telegram::TelegramAdapter; +use crate::core::acl_service::AclService; +use crate::core::config_service::ConfigService; +use crate::core::dns_filter_service::DnsFilterService; use crate::core::ebpf::EbpfServices; +use crate::core::email::scheduler::ReportScheduler; +use crate::core::ml::config_loader::InferenceConfig; +use crate::core::ml::drift_detector::DriftDetector; +use crate::core::notification_service::NotificationService; +use crate::core::playbook_service::PlaybookService; +use crate::core::rate_limit_service::RateLimitService; +use crate::core::soar::engine::SoarEngine; +use crate::core::soar::scheduler::TtlScheduler; use crate::infrastructure::app_config::AppConfig; use crate::infrastructure::app_services::AppServices; use crate::infrastructure::communication_manager::CommunicationManager; use crate::infrastructure::enforce_mode_handler::EnforceModeHandler; +use crate::infrastructure::geoip::GeoIpService; +use crate::infrastructure::secret_store::SecretStore; use crate::interface::communication::command_types::ChangeEnforceModeCommand; use crate::interface::communication::query_types::GetEnforceModeQuery; -use crate::interface::port::repository::RepositoryPort; -use crate::core::acl_service::AclService; -use crate::core::config_service::ConfigService; -use crate::core::dns_filter_service::DnsFilterService; -use crate::core::notification_service::NotificationService; -use crate::core::playbook_service::PlaybookService; -use crate::core::rate_limit_service::RateLimitService; use crate::interface::port::access_control::AccessControlPort; -use crate::interface::port::notification::AlertNotifier; -use crate::adapter::access_control_adapter::EbpfAccessControlAdapter; -use crate::adapter::telegram::TelegramAdapter; -use crate::core::soar::engine::SoarEngine; -use crate::core::soar::scheduler::TtlScheduler; -use crate::core::email::scheduler::ReportScheduler; -use crate::infrastructure::geoip::GeoIpService; -use crate::core::ml::config_loader::InferenceConfig; +use crate::interface::port::notification::{AlertNotifier, NotificationConfigPort}; +use crate::interface::port::repository::RepositoryPort; +use crate::interface::port::secret_store::SecretStorePort; +use crate::interface::port::soar::SoarPort; +use crate::model::detection::drift::FeatureBaselines; use crate::model::direction::FlowDirection; +use crate::model::error::Error; use crate::model::error::ebpf::EbpfError; use crate::model::error::misc::MiscError; -use crate::model::error::Error; use crate::model::list_type::ListType; use crate::model::log::ebpf::EbpfLog; use crate::model::log::system::SystemLog; @@ -50,6 +56,7 @@ pub struct AppState { pub ebpf_services: Arc, pub app_services: Arc, pub db: Arc, + pub secret_store: Arc, pub jwt_service: Arc, pub comm: Arc, pub soar_engine: Arc, @@ -61,6 +68,8 @@ pub struct AppState { pub notification_service: Arc, pub playbook_service: Arc, pub rate_limit_service: Arc, + pub geoip: Option>, + pub drift_detector: Arc>, pub ingress_ebpf: Ebpf, pub egress_ebpf: Ebpf, /// Held to keep the eBPF program array map FD alive. @@ -90,10 +99,7 @@ impl ServiceFactory { let mut ingress_ebpf = Self::load_ebpf("ingress")?; let mut egress_ebpf = Self::load_ebpf("egress")?; - let ingress_program_array = Self::configure_ingress_pipeline( - &mut ingress_ebpf, - &app_config.pipeline.ingress, - )?; + let ingress_program_array = Self::configure_ingress_pipeline(&mut ingress_ebpf, &app_config.pipeline.ingress)?; let inference_config = Arc::new(InferenceConfig::load_file(&app_config.inference.models_config_name)?); @@ -107,7 +113,12 @@ impl ServiceFactory { db.set_setting("enforce_mode", "monitor")?; } - let jwt_service = Arc::new(JwtService::new(db.as_ref(), app_config.http.jwt_expiry_hours)?); + // Create secret store and run plaintext migration before anything reads secrets + let secret_store = Arc::new(SecretStore::new(db.clone())); + secret_store.migrate_plaintext_secrets()?; + let secret_store_port: Arc = secret_store.clone(); + + let jwt_service = Arc::new(JwtService::new(&secret_store_port, app_config.http.jwt_expiry_hours)?); let ebpf_services = Arc::new(EbpfServices::new( app_config.clone(), @@ -115,22 +126,53 @@ impl ServiceFactory { &mut egress_ebpf, )?); - let app_services = Arc::new(AppServices::new(app_config.clone(), inference_config.clone())?); + // Initialize ML drift detector from inference config baselines + let baselines = FeatureBaselines::from_inference_config(&inference_config); + let drift_window_secs: u64 = db + .get_setting("ml_drift_window_secs") + .ok() + .flatten() + .and_then(|v| v.parse().ok()) + .unwrap_or(3600); + let drift_detector = Arc::new(parking_lot::Mutex::new(DriftDetector::new( + baselines, + std::time::Duration::from_secs(drift_window_secs), + ))); + + let app_services = Arc::new(AppServices::new( + app_config.clone(), + inference_config.clone(), + drift_detector.clone(), + )?); + + // Create AtomicU8 enforce-level cache (Monitor=0, MlOnly=1, Enforce=2) + let enforce_level_cache = Arc::new(AtomicU8::new({ + use crate::infrastructure::enforce_mode_handler::enforce_mode_to_u8; + let mode_str = db.get_setting("enforce_mode")?.unwrap_or_default(); + enforce_mode_to_u8(&mode_str) + })); // Create CommunicationManager and register enforce-mode handler let comm = Arc::new(CommunicationManager::new()); - let enforce_handler = Arc::new(EnforceModeHandler::new(db.clone() as Arc)); - let _ = comm.clone() + let enforce_handler = Arc::new(EnforceModeHandler::new( + db.clone() as Arc, + comm.clone(), + enforce_level_cache.clone(), + )); + let _ = comm + .clone() .with_service(enforce_handler) .command::() .query::() .build(); - // Register ThreatDetectedEvent channel for SOAR - comm.register_event_type::(); + // Register event type channels + comm.register_event_type::(); + comm.register_event_type::(); + comm.register_event_type::(); // Seed default SOAR playbooks if empty - db.seed_default_playbooks()?; + (db.as_ref() as &dyn SoarPort).seed_default_playbooks()?; // Restore persisted state from database Self::restore_dns_blacklist(&db, &ebpf_services); @@ -139,7 +181,11 @@ impl ServiceFactory { Self::restore_acl_rules(&db, &ebpf_services).await; // Create TelegramAdapter as alert notifier (may fail if not configured yet) - let alert_notifier: Option> = match TelegramAdapter::new(db.clone()) { + let alert_notifier: Option> = match TelegramAdapter::new( + db.clone() as Arc, + db.clone() as Arc, + Some(secret_store_port.clone()), + ) { Ok(adapter) => Some(Arc::new(adapter)), Err(e) => { log!(SystemLog::TelegramUnavailable(e.to_string())); @@ -160,9 +206,8 @@ impl ServiceFactory { }; // Create AccessControlPort adapter for SOAR/TTL (decoupled from eBPF) - let access_control_port: Arc = Arc::new( - EbpfAccessControlAdapter::new(ebpf_services.access_control.clone()) - ); + let access_control_port: Arc = + Arc::new(EbpfAccessControlAdapter::new(ebpf_services.access_control.clone())); // Create SOAR engine let soar_engine = Arc::new(SoarEngine::new( @@ -171,17 +216,16 @@ impl ServiceFactory { alert_notifier.clone(), geoip.clone(), Some(ebpf_services.rate_limit.clone()), + enforce_level_cache, + Some(secret_store_port.clone()), )?); // Create TTL scheduler - let ttl_scheduler = TtlScheduler::new( - db.clone(), - access_control_port.clone(), - soar_engine.clone(), - ); + let ttl_scheduler = TtlScheduler::new(db.clone(), access_control_port.clone(), soar_engine.clone()); // Create Report scheduler - let report_scheduler = ReportScheduler::new(db.clone() as Arc); + let report_scheduler = + ReportScheduler::new(db.clone() as Arc, Some(secret_store_port.clone())); // Create domain services (Phase 2B) let acl_service = Arc::new(AclService::new( @@ -202,8 +246,14 @@ impl ServiceFactory { soar_engine.clone(), access_control_port, )); - let config_service = Arc::new(ConfigService::new(db.clone() as Arc)); - let notification_service = Arc::new(NotificationService::new(db.clone())); + let config_service = Arc::new( + ConfigService::new(db.clone() as Arc).with_secret_store(secret_store_port.clone()), + ); + let notification_service = Arc::new(NotificationService::new( + db.clone() as Arc, + db.clone() as Arc, + secret_store_port, + )); Ok(AppState { app_config, @@ -211,6 +261,7 @@ impl ServiceFactory { ebpf_services, app_services, db, + secret_store, jwt_service, comm, soar_engine, @@ -222,6 +273,8 @@ impl ServiceFactory { notification_service, playbook_service, rate_limit_service, + geoip, + drift_detector, ingress_ebpf, egress_ebpf, _ingress_program_array: ingress_program_array, @@ -239,10 +292,7 @@ impl ServiceFactory { Ok(Ebpf::load(bytes).map_err(EbpfError::EbpfNotFound)?) } - fn configure_ingress_pipeline( - ebpf: &mut Ebpf, - stages: &[String], - ) -> Result, Error> { + fn configure_ingress_pipeline(ebpf: &mut Ebpf, stages: &[String]) -> Result, Error> { let registry = stage_registry(); let entry: &mut Xdp = ebpf @@ -269,9 +319,7 @@ impl ServiceFactory { let mut slots: Vec<(u32, u32)> = Vec::new(); for (i, stage_name) in stages.iter().enumerate() { - let (func_name, stage_id) = registry - .get(stage_name.as_str()) - .ok_or(EbpfError::ProgramNotFound)?; + let (func_name, stage_id) = registry.get(stage_name.as_str()).ok_or(EbpfError::ProgramNotFound)?; let slot = (i + 1) as u32; Self::load_program(ebpf, &mut program_array, func_name, slot)?; slots.push((*stage_id, slot)); @@ -309,9 +357,7 @@ impl ServiceFactory { .map_err(EbpfError::MapOperationError)?; program.load().map_err(EbpfError::AttachProgramFailed)?; let fd = program.fd().map_err(|_| EbpfError::UnknownError)?; - program_array - .set(slot, fd, 0) - .map_err(EbpfError::MapOperationError)?; + program_array.set(slot, fd, 0).map_err(EbpfError::MapOperationError)?; Ok(()) } @@ -391,12 +437,13 @@ impl ServiceFactory { fn restore_geo_countries(db: &Database, ebpf_services: &EbpfServices) { if let Ok(countries) = db.load_geo_countries() - && !countries.is_empty() { - if let Err(e) = ebpf_services.geo_block.block_countries(&countries) { - log!(SystemLog::GeoRestoreFailed(e.to_string())); - } else { - log!(SystemLog::GeoCountriesRestored(countries.len())); - } + && !countries.is_empty() + { + if let Err(e) = ebpf_services.geo_block.block_countries(&countries) { + log!(SystemLog::GeoRestoreFailed(e.to_string())); + } else { + log!(SystemLog::GeoCountriesRestored(countries.len())); + } } } @@ -442,31 +489,43 @@ impl ServiceFactory { } }; let result = match ip_version { - 4 => { - match ip_address.parse::() { - Ok(addr) => ebpf_services.access_control.add_ipv4_list(dir, lt, SocketAddrV4::new(addr, *port)).await, - Err(e) => { - log!(SystemLog::AclIpv4ParseFailed(ip_address.clone(), e.to_string())); - continue; - } + 4 => match ip_address.parse::() { + Ok(addr) => { + ebpf_services + .access_control + .add_ipv4_list(dir, lt, SocketAddrV4::new(addr, *port)) + .await } - } - 6 => { - match ip_address.parse::() { - Ok(addr) => ebpf_services.access_control.add_ipv6_list(dir, lt, SocketAddrV6::new(addr, *port, 0, 0)).await, - Err(e) => { - log!(SystemLog::AclIpv6ParseFailed(ip_address.clone(), e.to_string())); - continue; - } + Err(e) => { + log!(SystemLog::AclIpv4ParseFailed(ip_address.clone(), e.to_string())); + continue; } - } + }, + 6 => match ip_address.parse::() { + Ok(addr) => { + ebpf_services + .access_control + .add_ipv6_list(dir, lt, SocketAddrV6::new(addr, *port, 0, 0)) + .await + } + Err(e) => { + log!(SystemLog::AclIpv6ParseFailed(ip_address.clone(), e.to_string())); + continue; + } + }, other => { log!(SystemLog::AclUnknownIpVersion(*other)); continue; } }; if let Err(e) = result { - log!(SystemLog::AclRuleRestoreFailed(direction.clone(), list_type.clone(), ip_address.clone(), *port, e.to_string())); + log!(SystemLog::AclRuleRestoreFailed( + direction.clone(), + list_type.clone(), + ip_address.clone(), + *port, + e.to_string() + )); } else { restored += 1; } diff --git a/net-guardia/src/infrastructure/statistics.rs b/net-guardia/src/infrastructure/statistics.rs index 780b22c..b0b69fe 100644 --- a/net-guardia/src/infrastructure/statistics.rs +++ b/net-guardia/src/infrastructure/statistics.rs @@ -63,9 +63,7 @@ impl FlowStatistics { flows.retain(|f| f.last_seen_us >= cutoff); } - flows.sort_by(|a, b| { - (b.fwd_bytes + b.bwd_bytes).cmp(&(a.fwd_bytes + a.bwd_bytes)) - }); + flows.sort_by(|a, b| (b.fwd_bytes + b.bwd_bytes).cmp(&(a.fwd_bytes + a.bwd_bytes))); if let Some(n) = sub.top_n { flows.truncate(n.min(10000)); diff --git a/net-guardia/src/interface/communication/event_types.rs b/net-guardia/src/interface/communication/event_types.rs index 43fec99..9668ee6 100644 --- a/net-guardia/src/interface/communication/event_types.rs +++ b/net-guardia/src/interface/communication/event_types.rs @@ -1,17 +1 @@ -use crate::interface::communication::event::Event; - -// ── ML Events ──────────────────────────────────────────────────────── - -/// Fired when the ML engine detects a potential threat. -/// Consumed by the SOAR engine to trigger automated responses. -#[derive(Debug, Clone)] -pub struct ThreatDetectedEvent { - pub attack_type: String, - pub confidence: f32, - /// Source IP address (e.g. "192.168.1.100") - pub source_ip: String, - /// Destination IP address (e.g. "10.0.0.1") - pub dest_ip: String, -} - -impl Event for ThreatDetectedEvent {} +// Types are available via crate::model::event diff --git a/net-guardia/src/interface/communication/mod.rs b/net-guardia/src/interface/communication/mod.rs index b49b1a5..f12451f 100644 --- a/net-guardia/src/interface/communication/mod.rs +++ b/net-guardia/src/interface/communication/mod.rs @@ -1,7 +1,7 @@ -pub mod message; pub mod command; -pub mod query; -pub mod event; pub mod command_types; -pub mod query_types; +pub mod event; pub mod event_types; +pub mod message; +pub mod query; +pub mod query_types; diff --git a/net-guardia/src/interface/port/api_key.rs b/net-guardia/src/interface/port/api_key.rs new file mode 100644 index 0000000..697d4df --- /dev/null +++ b/net-guardia/src/interface/port/api_key.rs @@ -0,0 +1,14 @@ +use crate::model::auth::Claims; +use crate::model::error::Error; + +/// Type alias for API key list items: (id, name, permission_level, created_at, last_used_at) +#[allow(clippy::type_complexity)] +pub type ApiKeyListItem = (i64, String, String, String, Option); + +/// Port for API key management and validation. +pub trait ApiKeyPort: Send + Sync { + fn validate_api_key(&self, api_key: &str) -> Result, Error>; + fn insert_api_key(&self, key_hash: &str, name: &str, permission_level: &str) -> Result; + fn list_api_keys(&self) -> Result, Error>; + fn delete_api_key(&self, id: i64) -> Result; +} diff --git a/net-guardia/src/interface/port/audit.rs b/net-guardia/src/interface/port/audit.rs new file mode 100644 index 0000000..727c3e5 --- /dev/null +++ b/net-guardia/src/interface/port/audit.rs @@ -0,0 +1,6 @@ +use crate::model::error::Error; + +/// Port for audit trail persistence. +pub trait AuditPort: Send + Sync { + fn insert_audit_log(&self, actor: &str, action: &str, detail: &str) -> Result<(), Error>; +} diff --git a/net-guardia/src/interface/port/mod.rs b/net-guardia/src/interface/port/mod.rs index 3909533..e6b104e 100644 --- a/net-guardia/src/interface/port/mod.rs +++ b/net-guardia/src/interface/port/mod.rs @@ -1,3 +1,8 @@ pub mod access_control; +pub mod api_key; +pub mod audit; pub mod notification; pub mod repository; +pub mod secret_store; +pub mod soar; +pub mod stats; diff --git a/net-guardia/src/interface/port/notification.rs b/net-guardia/src/interface/port/notification.rs index 47c58bc..3ac2f46 100644 --- a/net-guardia/src/interface/port/notification.rs +++ b/net-guardia/src/interface/port/notification.rs @@ -20,3 +20,9 @@ pub trait AlertNotifier: Send + Sync { async fn send_alert(&self, payload: &AlertPayload) -> Result<(), Error>; async fn send_test_message(&self) -> Result<(), Error>; } + +/// Port for notification channel configuration (Telegram, email, etc.). +pub trait NotificationConfigPort: Send + Sync { + fn get_notification_config(&self, channel: &str) -> Result, Error>; + fn set_notification_config(&self, channel: &str, config_json: &str) -> Result<(), Error>; +} diff --git a/net-guardia/src/interface/port/repository.rs b/net-guardia/src/interface/port/repository.rs index b6d46bc..6c5efe2 100644 --- a/net-guardia/src/interface/port/repository.rs +++ b/net-guardia/src/interface/port/repository.rs @@ -22,8 +22,22 @@ pub type UserGroupTuple = (i64, String, String, String, String); #[allow(dead_code)] pub trait RepositoryPort: Send + Sync { // --- ACL --- - fn insert_acl_rule(&self, ip_version: u8, direction: &str, list_type: &str, ip_address: &str, port: u16) -> Result<(), Error>; - fn delete_acl_rule(&self, ip_version: u8, direction: &str, list_type: &str, ip_address: &str, port: u16) -> Result<(), Error>; + fn insert_acl_rule( + &self, + ip_version: u8, + direction: &str, + list_type: &str, + ip_address: &str, + port: u16, + ) -> Result<(), Error>; + fn delete_acl_rule( + &self, + ip_version: u8, + direction: &str, + list_type: &str, + ip_address: &str, + port: u16, + ) -> Result<(), Error>; fn load_acl_rules(&self) -> Result, Error>; // --- Rate Limit --- @@ -46,7 +60,13 @@ pub trait RepositoryPort: Send + Sync { // --- Users --- fn find_user(&self, username: &str) -> Result, Error>; - fn insert_user(&self, username: &str, password_hash: &str, role: &str, force_password_change: bool) -> Result; + fn insert_user( + &self, + username: &str, + password_hash: &str, + role: &str, + force_password_change: bool, + ) -> Result; fn update_user_password(&self, user_id: i64, password_hash: &str) -> Result<(), Error>; fn user_count(&self) -> Result; diff --git a/net-guardia/src/interface/port/secret_store.rs b/net-guardia/src/interface/port/secret_store.rs new file mode 100644 index 0000000..d289050 --- /dev/null +++ b/net-guardia/src/interface/port/secret_store.rs @@ -0,0 +1,6 @@ +use crate::model::error::Error; + +pub trait SecretStorePort: Send + Sync { + fn get_secret(&self, key: &str) -> Result, Error>; + fn set_secret(&self, key: &str, plaintext: &str) -> Result<(), Error>; +} diff --git a/net-guardia/src/interface/port/soar.rs b/net-guardia/src/interface/port/soar.rs new file mode 100644 index 0000000..0d04dad --- /dev/null +++ b/net-guardia/src/interface/port/soar.rs @@ -0,0 +1,119 @@ +use crate::model::error::Error; +use crate::model::soar::playbook_data::UpdatePlaybookRow; + +/// Type alias for playbook+action JOIN rows. +/// (id, name, enabled, trigger_event, threshold, count, window, cooldown, action_id, action_order, action_type, params) +#[allow(clippy::type_complexity)] +pub type PlaybookRow = ( + i64, + String, + bool, + String, + Option, + Option, + Option, + i64, + Option, + Option, + Option, + Option, +); + +/// Type alias for SOAR execution log rows. +/// (id, playbook_id, source_ip, trigger_event, actions_executed, executed_at) +#[allow(clippy::type_complexity)] +pub type SoarExecutionRow = (i64, i64, Option, String, String, String); + +/// Port for SOAR-related persistence: playbooks, block rules, execution log, admin whitelist, +/// plus the settings and ACL methods that SOAR actions depend on. +pub trait SoarPort: Send + Sync { + // --- Settings (used by rate-limit adjust/restore and email actions) --- + fn get_setting(&self, key: &str) -> Result, Error>; + fn set_setting(&self, key: &str, value: &str) -> Result<(), Error>; + + // --- ACL Rules (used by block_ip action and TTL scheduler cleanup) --- + fn insert_acl_rule( + &self, + ip_version: u8, + direction: &str, + list_type: &str, + ip_address: &str, + port: u16, + ) -> Result<(), Error>; + fn delete_acl_rule( + &self, + ip_version: u8, + direction: &str, + list_type: &str, + ip_address: &str, + port: u16, + ) -> Result<(), Error>; + + // --- Playbooks --- + fn insert_playbook( + &self, + name: &str, + trigger_event: &str, + threshold: Option, + count: Option, + window: Option, + cooldown: i64, + ) -> Result; + fn insert_playbook_action( + &self, + playbook_id: i64, + action_order: i64, + action_type: &str, + params_json: &str, + ) -> Result; + fn load_playbooks_with_actions(&self) -> Result, Error>; + fn update_playbook(&self, id: i64, row: &UpdatePlaybookRow) -> Result; + fn update_playbook_enabled(&self, id: i64, enabled: bool) -> Result; + fn delete_playbook(&self, id: i64) -> Result; + fn delete_playbook_actions(&self, playbook_id: i64) -> Result<(), Error>; + fn delete_playbook_conditions(&self, playbook_id: i64) -> Result<(), Error>; + fn seed_default_playbooks(&self) -> Result<(), Error>; + + // --- Playbook Conditions --- + fn insert_playbook_condition( + &self, + playbook_id: i64, + condition_type: &str, + operator: &str, + value: &str, + value2: Option<&str>, + ) -> Result; + /// Returns: (condition_id, playbook_id, condition_type, operator, value, value2) + #[allow(clippy::type_complexity)] + fn load_all_playbook_conditions(&self) -> Result)>, Error>; + + // --- Block Rules --- + fn insert_soar_block_rule(&self, source_ip: &str, playbook_id: i64, expires_at: &str) -> Result; + fn count_active_soar_blocks(&self) -> Result; + fn get_active_soar_blocks(&self) -> Result, Error>; + fn get_soar_block_by_id(&self, id: i64) -> Result, Error>; + fn get_expired_soar_blocks(&self) -> Result, Error>; + fn mark_soar_block_unblocked(&self, id: i64) -> Result<(), Error>; + fn has_manual_acl_rule(&self, ip_address: &str) -> Result; + + // --- Pending Unblock Recovery --- + fn insert_pending_unblock(&self, source_ip: &str) -> Result; + fn load_pending_unblocks(&self) -> Result, Error>; + fn delete_pending_unblock(&self, id: i64) -> Result<(), Error>; + fn increment_pending_unblock_retry(&self, id: i64) -> Result<(), Error>; + + // --- Execution Log --- + fn insert_soar_execution( + &self, + playbook_id: i64, + source_ip: Option<&str>, + trigger_event: &str, + actions_json: &str, + ) -> Result; + fn list_soar_executions(&self, limit: i64) -> Result, Error>; + + // --- Admin Whitelist --- + fn load_admin_whitelist(&self) -> Result, Error>; + fn insert_admin_whitelist(&self, ip: &str) -> Result<(), Error>; + fn delete_admin_whitelist(&self, ip: &str) -> Result<(), Error>; +} diff --git a/net-guardia/src/interface/port/stats.rs b/net-guardia/src/interface/port/stats.rs new file mode 100644 index 0000000..df56c15 --- /dev/null +++ b/net-guardia/src/interface/port/stats.rs @@ -0,0 +1,11 @@ +use crate::model::error::Error; + +/// Port for statistics aggregation queries. +pub trait StatsPort: Send + Sync { + fn count_weekly_executions(&self, days: i64) -> Result; + fn count_weekly_blocks(&self, days: i64) -> Result; + fn count_weekly_unblocks(&self, days: i64) -> Result; + fn weekly_threat_breakdown(&self, days: i64) -> Result, Error>; + fn weekly_top_ips(&self, days: i64, limit: i64) -> Result, Error>; + fn count_acl_rules(&self) -> Result; +} diff --git a/net-guardia/src/main.rs b/net-guardia/src/main.rs index 2df6cfc..5566905 100644 --- a/net-guardia/src/main.rs +++ b/net-guardia/src/main.rs @@ -14,6 +14,8 @@ use crate::adapter::persistence::Database; use crate::core::auth::jwt::JwtService; use crate::core::auth::password; use crate::core::system::System; +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::model::log::system::SystemLog; @@ -41,9 +43,45 @@ use crate::utils::logging::Logging; async fn main() -> Result<(), Error> { Logging::initialize()?; + // Handle DB encrypt/decrypt subcommands before full startup + let args: Vec = std::env::args().collect(); + if args.len() >= 2 { + let db_path = std::env::var("NETGUARDIA_DB_PATH").unwrap_or_else(|_| "net-guardia.db".to_string()); + match args[1].as_str() { + "--decrypt-db" => { + let key = match std::env::var("NETGUARDIA_DB_KEY") { + Ok(k) if !k.is_empty() => k, + _ => { + eprintln!("Error: NETGUARDIA_DB_KEY must be set for decrypt"); + std::process::exit(1); + } + }; + let dest = args.get(2).map(|s| s.as_str()).unwrap_or("net-guardia-decrypted.db"); + println!("Decrypting {} → {}", db_path, dest); + Database::decrypt_to_file(&db_path, &key, dest)?; + println!("Done. Decrypted database written to {}", dest); + return Ok(()); + } + "--encrypt-db" => { + let key = match std::env::var("NETGUARDIA_DB_KEY") { + Ok(k) if !k.is_empty() => k, + _ => { + eprintln!("Error: NETGUARDIA_DB_KEY must be set for encrypt"); + std::process::exit(1); + } + }; + let dest = args.get(2).map(|s| s.as_str()).unwrap_or("net-guardia-encrypted.db"); + println!("Encrypting {} → {}", db_path, dest); + Database::encrypt_to_file(&db_path, &key, dest)?; + println!("Done. Encrypted database written to {}", dest); + return Ok(()); + } + _ => {} + } + } + // Phase 1: Create DB (fast — needed for setup check and setup server) - let db_path = std::env::var("NETGUARDIA_DB_PATH") - .unwrap_or_else(|_| "net-guardia.db".to_string()); + let db_path = std::env::var("NETGUARDIA_DB_PATH").unwrap_or_else(|_| "net-guardia.db".to_string()); let db = Arc::new(Database::new(&db_path)?); // Seed default admin user if no users exist @@ -59,20 +97,24 @@ async fn main() -> Result<(), Error> { log!(SystemLog::DefaultAdminCreated); } - let setup_complete = db.get_setting("setup_complete")? - .map(|v| v == "true") - .unwrap_or(false); + let setup_complete = db.get_setting("setup_complete")?.map(|v| v == "true").unwrap_or(false); // Phase 2: If setup not complete, run lightweight setup server immediately if !setup_complete { log!(SystemLog::SetupMode); - let jwt_service = Arc::new(JwtService::new(db.as_ref(), 24)?); + let secret_store = Arc::new(SecretStore::new(db.clone())); + let secrets: Arc = secret_store.clone(); + let jwt_service = Arc::new(JwtService::new(&secrets, 24)?); let setup_flag = Arc::new(AtomicBool::new(false)); // Start setup server — returns handle for graceful shutdown let handle = infrastructure::http_server::start_setup_server( - db.clone(), jwt_service, setup_flag.clone(), 8080, + db.clone(), + secret_store, + jwt_service, + setup_flag.clone(), + 8080, )?; // Wait for setup completion or shutdown signal @@ -80,7 +122,9 @@ async fn main() -> Result<(), Error> { let setup_done = async move { loop { tokio::time::sleep(std::time::Duration::from_millis(500)).await; - if flag.load(Ordering::SeqCst) { return; } + if flag.load(Ordering::SeqCst) { + return; + } } }; @@ -102,7 +146,29 @@ async fn main() -> Result<(), Error> { // Phase 3: Full system build and run (setup is complete, DB has config) let mut system = System::new(db).await?; - system.run().await?; + let mode = system.run().await?; system.terminate().await?; + + match mode { + crate::core::system::ShutdownMode::Restart => { + log!(SystemLog::ApiRestart); + let _ = sd_notify::notify(false, &[sd_notify::NotifyState::Reloading]); + // Drop System to detach eBPF XDP programs before re-exec + drop(system); + // Brief delay for kernel to release XDP/AF_XDP resources + std::thread::sleep(std::time::Duration::from_millis(500)); + // Re-exec self — works with or without systemd + use std::os::unix::process::CommandExt; + let exe = std::env::current_exe().unwrap_or_else(|_| std::path::PathBuf::from("net-guardia")); + let err = std::process::Command::new(exe).args(std::env::args().skip(1)).exec(); // replaces current process + // If exec fails, fall through to exit + log!(SystemError::UnexpectedError(err)); + std::process::exit(1); + } + crate::core::system::ShutdownMode::Shutdown => { + log!(SystemLog::ApiShutdown); + let _ = sd_notify::notify(false, &[sd_notify::NotifyState::Stopping]); + } + } Ok(()) } diff --git a/net-guardia/src/model/access_control/ip_address.rs b/net-guardia/src/model/access_control/ip_address.rs index 488a082..06b4999 100644 --- a/net-guardia/src/model/access_control/ip_address.rs +++ b/net-guardia/src/model/access_control/ip_address.rs @@ -37,17 +37,11 @@ impl NativeConvert for AddrPortV4 { type Native = SocketAddrV4; fn into_native(self) -> Self::Native { - SocketAddrV4::new( - Ipv4Addr::from(u32::from_be(self.ip())), - self.port() - ) + SocketAddrV4::new(Ipv4Addr::from(u32::from_be(self.ip())), self.port()) } fn from_native(native: Self::Native) -> Self { - AddrPortV4::new( - native.ip().to_bits().to_be(), - native.port() - ) + AddrPortV4::new(native.ip().to_bits().to_be(), native.port()) } } @@ -55,18 +49,10 @@ impl NativeConvert for AddrPortV6 { type Native = SocketAddrV6; fn into_native(self) -> Self::Native { - SocketAddrV6::new( - Ipv6Addr::from(u128::from_be(self.ip())), - self.port(), - 0, - 0 - ) + SocketAddrV6::new(Ipv6Addr::from(u128::from_be(self.ip())), self.port(), 0, 0) } fn from_native(native: Self::Native) -> Self { - AddrPortV6::new( - native.ip().to_bits().to_be(), - native.port() - ) + AddrPortV6::new(native.ip().to_bits().to_be(), native.port()) } -} \ No newline at end of file +} diff --git a/net-guardia/src/model/config/constants.rs b/net-guardia/src/model/config/constants.rs new file mode 100644 index 0000000..1257401 --- /dev/null +++ b/net-guardia/src/model/config/constants.rs @@ -0,0 +1,25 @@ +//! Centralized constants for the NetGuardia application. +//! Tunable parameters are grouped by subsystem. Adjust here, not in individual files. + +// ── SOAR Engine ──────────────────────────────────────────────────── +pub const MAX_PENDING_UNBLOCK_RETRIES: i64 = 5; + +// ── ML Engine ────────────────────────────────────────────────────── +pub const ML_ALERT_CHANNEL_CAPACITY: usize = 1024; +pub const FLOW_MAX_PACKETS_PER_DIRECTION: usize = 1000; +pub const FLOW_MAX_PERIODS: usize = 1000; +pub const FLOW_IDLE_THRESHOLD_US: u64 = 1_000_000; +pub const FLOW_BULK_MIN_PACKETS: u64 = 4; +pub const FLOW_BULK_MIN_BYTES: u64 = 1000; +pub const FLOW_IDLE_TIMEOUT_US: u64 = 120_000_000; +pub const FLOW_TERMINATED_TIMEOUT_US: u64 = 5_000_000; + +// ── Notification ─────────────────────────────────────────────────── +pub const TELEGRAM_MAX_RETRIES: u32 = 2; + +// ── HTTP Server ──────────────────────────────────────────────────── +pub const HTTP_FALLBACK_PORT: u16 = 8080; + +// ── Infrastructure ───────────────────────────────────────────────── +pub const DEFAULT_EVENT_CHANNEL_CAPACITY: usize = 256; +pub const DROP_CHANNEL_CAPACITY: usize = 100; diff --git a/net-guardia/src/model/config/mod.rs b/net-guardia/src/model/config/mod.rs new file mode 100644 index 0000000..81c710f --- /dev/null +++ b/net-guardia/src/model/config/mod.rs @@ -0,0 +1,5 @@ +pub mod constants; + +// Backward-compatible re-exports: `crate::model::config::*` continues to resolve +// the domain config types that previously lived at `crate::model::system::config::*`. +pub use super::system::config::*; diff --git a/net-guardia/src/model/detection/drift.rs b/net-guardia/src/model/detection/drift.rs new file mode 100644 index 0000000..8a8ff3a --- /dev/null +++ b/net-guardia/src/model/detection/drift.rs @@ -0,0 +1,31 @@ +use crate::model::system::config::MLInferenceConfig; + +/// Baselines loaded from the inference config (scaler mean / std). +/// If inference_config has no scaler data, drift detection is disabled. +pub struct FeatureBaselines { + pub names: Vec, + pub means: Vec, + pub stds: Vec, +} + +impl FeatureBaselines { + /// Build baselines from the ML inference config. + /// Returns `None` if the config has no features (drift detection disabled). + pub fn from_inference_config(config: &MLInferenceConfig) -> Option { + if config.ae_feature_names.is_empty() { + return None; + } + Some(Self { + names: config.ae_feature_names.clone(), + means: config.ae_scaler_mean.clone(), + stds: config.ae_scaler_std.clone(), + }) + } +} + +/// Report emitted when feature drift is detected. +#[derive(Debug, Clone)] +pub struct DriftReport { + pub drifted_features: Vec, + pub max_deviation: f64, +} diff --git a/net-guardia/src/model/detection/flow_features.rs b/net-guardia/src/model/detection/flow_features.rs new file mode 100644 index 0000000..7b6d39c --- /dev/null +++ b/net-guardia/src/model/detection/flow_features.rs @@ -0,0 +1,138 @@ +use std::collections::HashMap; + +use crate::model::ml_detection::ClipParams; + +#[derive(Debug, Clone)] +pub struct FlowFeatures { + pub features: Vec, + pub feature_num: usize, +} + +impl FlowFeatures { + pub fn normalize(&mut self, means: &[f64], stds: &[f64]) { + for i in 0..self.feature_num { + if stds[i] > 0.0 { + self.features[i] = (self.features[i] - means[i]) / stds[i]; + } else { + self.features[i] = 0.0; + } + } + } + + pub fn clip(&mut self, clip_min: f64, clip_max: f64) { + for i in 0..self.feature_num { + self.features[i] = self.features[i].max(clip_min).min(clip_max); + } + } + + pub fn winsorize(&mut self, clip_params: &HashMap, feature_names: &[String]) { + for (i, feature_name) in feature_names.iter().enumerate() { + if i < self.feature_num + && let Some(params) = clip_params.get(feature_name) + { + self.features[i] = self.features[i].clamp(params.lower, params.upper); + } + } + } + + pub fn all_feature_names() -> Vec<&'static str> { + vec![ + "Destination Port", + "Protocol", + "Flow Duration", + "Total Fwd Packets", + "Total Backward Packets", + "Total Length of Fwd Packets", + "Total Length of Bwd Packets", + "Fwd Packet Length Max", + "Fwd Packet Length Min", + "Fwd Packet Length Mean", + "Fwd Packet Length Std", + "Bwd Packet Length Max", + "Bwd Packet Length Min", + "Bwd Packet Length Mean", + "Bwd Packet Length Std", + "Flow Bytes/s", + "Flow Packets/s", + "Flow IAT Mean", + "Flow IAT Std", + "Flow IAT Max", + "Flow IAT Min", + "Fwd IAT Total", + "Fwd IAT Mean", + "Fwd IAT Std", + "Fwd IAT Max", + "Fwd IAT Min", + "Bwd IAT Total", + "Bwd IAT Mean", + "Bwd IAT Std", + "Bwd IAT Max", + "Bwd IAT Min", + "Fwd PSH Flags", + "Bwd PSH Flags", + "Fwd URG Flags", + "Bwd URG Flags", + "Fwd Header Length", + "Bwd Header Length", + "Fwd Packets/s", + "Bwd Packets/s", + "Min Packet Length", + "Max Packet Length", + "Packet Length Mean", + "Packet Length Std", + "Packet Length Variance", + "FIN Flag Count", + "SYN Flag Count", + "RST Flag Count", + "PSH Flag Count", + "ACK Flag Count", + "URG Flag Count", + "CWE Flag Count", + "ECE Flag Count", + "Down/Up Ratio", + "Average Packet Size", + "Avg Fwd Segment Size", + "Avg Bwd Segment Size", + "Fwd Header Length.1", + "Fwd Avg Bytes/Bulk", + "Fwd Avg Packets/Bulk", + "Fwd Avg Bulk Rate", + "Bwd Avg Bytes/Bulk", + "Bwd Avg Packets/Bulk", + "Bwd Avg Bulk Rate", + "Subflow Fwd Packets", + "Subflow Fwd Bytes", + "Subflow Bwd Packets", + "Subflow Bwd Bytes", + "Init_Win_bytes_forward", + "Init_Win_bytes_backward", + "act_data_pkt_fwd", + "min_seg_size_forward", + "Active Mean", + "Active Std", + "Active Max", + "Active Min", + "Idle Mean", + "Idle Std", + "Idle Max", + "Idle Min", + // Phase 2: new features + "fwd_iat_std", + "bwd_iat_std", + "flow_iat_std", + "fwd_bwd_bytes_ratio", + "pkt_len_variance", + "fwd_iat_skewness", + ] + } + + pub fn all_feature_names_owned() -> Vec { + Self::all_feature_names().iter().map(|s| s.to_string()).collect() + } + + pub fn to_csv_record(&self) -> Vec { + let mut record: Vec = self.features.iter().map(|f| f.to_string()).collect(); + record.push("BENIGN".to_string()); + record + } +} diff --git a/net-guardia/src/model/detection/ml_detection.rs b/net-guardia/src/model/detection/ml_detection.rs index 2aaef6c..00c3f50 100644 --- a/net-guardia/src/model/detection/ml_detection.rs +++ b/net-guardia/src/model/detection/ml_detection.rs @@ -105,6 +105,8 @@ pub struct DetectionResult { pub confidence: f32, pub ae_score: f32, pub threshold: f32, + pub packet_count: u64, + pub flow_duration_us: u64, } #[derive(Debug, Clone, Default)] @@ -149,6 +151,8 @@ pub struct AlertMessage { pub attack_type: Option, pub confidence: f32, pub ae_score: f32, + pub packet_count: u64, + pub flow_duration_us: u64, } impl AlertMessage { @@ -170,6 +174,8 @@ impl AlertMessage { attack_type: result.attack_type.clone(), confidence: result.confidence, ae_score: result.ae_score, + packet_count: result.packet_count, + flow_duration_us: result.flow_duration_us, } } } diff --git a/net-guardia/src/model/detection/mod.rs b/net-guardia/src/model/detection/mod.rs index 18a86f4..f960169 100644 --- a/net-guardia/src/model/detection/mod.rs +++ b/net-guardia/src/model/detection/mod.rs @@ -1 +1,3 @@ +pub mod drift; +pub mod flow_features; pub mod ml_detection; diff --git a/net-guardia/src/model/error/crypto.rs b/net-guardia/src/model/error/crypto.rs new file mode 100644 index 0000000..d6a39a1 --- /dev/null +++ b/net-guardia/src/model/error/crypto.rs @@ -0,0 +1,21 @@ +use macros::traceable; + +traceable! { + CryptoError { + #[no_source] + #[error("Encryption failed: {reason}")] + EncryptionFailed { reason: String } => tracing::Level::ERROR, + + #[no_source] + #[error("Decryption failed: {reason}")] + DecryptionFailed { reason: String } => tracing::Level::ERROR, + + #[no_source] + #[error("Invalid secret envelope: {reason}")] + InvalidEnvelope { reason: String } => tracing::Level::ERROR, + + #[no_source] + #[error("Master key not available")] + MasterKeyUnavailable => tracing::Level::WARN, + } +} diff --git a/net-guardia/src/model/error/mod.rs b/net-guardia/src/model/error/mod.rs index 9b0159f..0ef7cd1 100644 --- a/net-guardia/src/model/error/mod.rs +++ b/net-guardia/src/model/error/mod.rs @@ -1,4 +1,5 @@ pub mod auth; +pub mod crypto; pub mod database; pub mod ebpf; pub mod http; @@ -13,6 +14,7 @@ pub mod system; use serde::{Deserialize, Serialize}; use crate::model::error::auth::AuthError; +use crate::model::error::crypto::CryptoError; use crate::model::error::database::DatabaseError; use crate::model::error::ebpf::EbpfError; use crate::model::error::http::HttpError; @@ -29,6 +31,8 @@ pub enum Error { #[error("{0}")] Auth(AuthError), #[error("{0}")] + Crypto(CryptoError), + #[error("{0}")] Database(DatabaseError), #[error("{0}")] Ebpf(EbpfError), @@ -56,6 +60,12 @@ impl From for Error { } } +impl From for Error { + fn from(error: CryptoError) -> Self { + Self::Crypto(error) + } +} + impl From for Error { fn from(error: DatabaseError) -> Self { Self::Database(error) diff --git a/net-guardia/src/model/error/soar.rs b/net-guardia/src/model/error/soar.rs index cea6f57..0d4e488 100644 --- a/net-guardia/src/model/error/soar.rs +++ b/net-guardia/src/model/error/soar.rs @@ -2,22 +2,10 @@ use macros::traceable; traceable! { SoarError { - #[no_source] - #[error("Playbook not found: id={playbook_id}")] - PlaybookNotFound { playbook_id: i64 } => tracing::Level::WARN, - #[no_source] #[error("Auto-block cap reached (max {max_cap} concurrent blocks)")] CapReached { max_cap: u32 } => tracing::Level::WARN, - #[no_source] - #[error("Cooldown active for playbook {playbook_id} and IP {source_ip}")] - CooldownActive { playbook_id: i64, source_ip: String } => tracing::Level::DEBUG, - - #[no_source] - #[error("IP {ip} is in admin whitelist, skipping auto-block")] - AdminWhitelisted { ip: String } => tracing::Level::INFO, - #[no_source] #[error("Invalid TTL: {ttl_secs}s exceeds maximum of {max_secs}s")] InvalidTtl { ttl_secs: u64, max_secs: u64 } => tracing::Level::WARN, @@ -26,11 +14,11 @@ traceable! { #[error("SOAR action failed: {action_type} — {reason}")] ActionFailed { action_type: String, reason: String } => tracing::Level::ERROR, - #[no_source] - #[error("Duplicate block rule for IP {ip}")] - DuplicateBlockRule { ip: String } => tracing::Level::DEBUG, - #[error("Failed to clean up ACL rule after unblock: {err}")] AclCleanupFailed => tracing::Level::WARN, + + #[no_source] + #[error("Invalid playbook condition: {condition_type} — {reason}")] + InvalidCondition { condition_type: String, reason: String } => tracing::Level::WARN, } } diff --git a/net-guardia/src/model/error/system.rs b/net-guardia/src/model/error/system.rs index 89a169e..33ad2e1 100644 --- a/net-guardia/src/model/error/system.rs +++ b/net-guardia/src/model/error/system.rs @@ -46,5 +46,8 @@ traceable! { #[error("Failed to mark setup as complete: {err}")] SetupCompleteFlagFailed => tracing::Level::ERROR, + + #[error("Failed to publish drift detected event")] + DriftEventPublishFailed => tracing::Level::WARN, } } diff --git a/net-guardia/src/model/event.rs b/net-guardia/src/model/event.rs new file mode 100644 index 0000000..2686ba1 --- /dev/null +++ b/net-guardia/src/model/event.rs @@ -0,0 +1,94 @@ +use std::fmt; + +use crate::interface::communication::event::Event; + +// -- Detection Source --------------------------------------------------------- + +/// Identifies which detection subsystem produced a detection. +/// Used for attribution tracking and future cross-source deduplication. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum DetectionSource { + ML, + Correlation, + Beaconing, +} + +impl fmt::Display for DetectionSource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + DetectionSource::ML => write!(f, "ML"), + DetectionSource::Correlation => write!(f, "Correlation"), + DetectionSource::Beaconing => write!(f, "Beaconing"), + } + } +} + +// -- Detection Event (internal pipeline) -------------------------------------- + +/// Raw detection from any source. Sent via mpsc channel to DetectionOrchestrator. +/// Not published through CommunicationManager — this is a private internal pipeline. +#[derive(Debug, Clone)] +pub struct DetectionEvent { + pub source: DetectionSource, + /// Normalized attack type (e.g. "brute_force", "port_scan", "threat_detected") + pub attack_type: String, + pub confidence: f32, + pub source_ip: String, + pub dest_ip: String, + pub protocol: u8, + pub packet_count: u64, + pub flow_duration_us: u64, +} + +// -- Threat Events ------------------------------------------------------------ + +/// Fired when the DetectionOrchestrator emits a deduplicated, enriched threat. +/// Consumed by the SOAR engine to trigger automated responses. +#[derive(Debug, Clone)] +pub struct ThreatDetectedEvent { + pub attack_type: String, + pub confidence: f32, + /// Source IP address (e.g. "192.168.1.100") + pub source_ip: String, + /// Destination IP address (e.g. "10.0.0.1") + pub dest_ip: String, + /// Number of alert flows from this src_ip in recent window + pub flow_count: u32, + /// Packets per second of the triggering flow + pub packet_rate: f64, + /// IP protocol number (6=TCP, 17=UDP) + pub protocol: u8, + /// Source country ISO 3166-1 alpha-2 code, None if GeoIP unavailable + pub geoip_country: Option, + /// Whether this src_ip had a block action in the past 24h + pub is_repeat_offender: bool, + /// Which detection sources contributed to this threat (for attribution) + pub sources: Vec, +} + +impl Event for ThreatDetectedEvent {} + +/// Fired when the ML drift detector finds feature drift beyond 3 sigma. +#[derive(Debug, Clone)] +pub struct DriftDetectedEvent { + pub drifted_features: Vec, + pub max_deviation: f64, +} + +impl Event for DriftDetectedEvent {} + +// -- Audit Events ------------------------------------------------------------- + +/// Fired for auditable actions (enforce mode changes, playbook CRUD, etc.). +/// Consumed by AuditLogger to persist to DB and structured logs. +#[derive(Debug, Clone)] +pub struct AuditEvent { + /// Who performed the action: "admin", "system", "soar" + pub actor: String, + /// What action was performed: "enforce_mode_changed", "playbook_created", etc. + pub action: String, + /// JSON string with action-specific details + pub detail: String, +} + +impl Event for AuditEvent {} diff --git a/net-guardia/src/model/log/audit.rs b/net-guardia/src/model/log/audit.rs new file mode 100644 index 0000000..8a269a5 --- /dev/null +++ b/net-guardia/src/model/log/audit.rs @@ -0,0 +1,27 @@ +use macros::loggable; +use tracing; + +loggable! { + AuditLog { + #[error("Audit event: actor={actor}, action={action}")] + AuditEvent { actor: String, action: String } => tracing::Level::DEBUG, + + #[error("Audit drift event: {count} features drifted")] + AuditDriftEvent { count: usize } => tracing::Level::DEBUG, + + #[error("AuditLogger: DB write failed ({error}), event logged only: actor={actor}, action={action}")] + AuditDbWriteFailed { error: String, actor: String, action: String } => tracing::Level::WARN, + + #[error("AuditLogger: DB write failed for drift event: {error}")] + AuditDriftDbWriteFailed { error: String } => tracing::Level::WARN, + + #[error("AuditLogger lagged by {count} events")] + AuditLagged { count: u64 } => tracing::Level::WARN, + + #[error("AuditLogger: event channel closed")] + AuditChannelClosed => tracing::Level::INFO, + + #[error("AuditLogger: failed to subscribe to events")] + AuditSubscribeFailed => tracing::Level::WARN, + } +} diff --git a/net-guardia/src/model/log/crypto.rs b/net-guardia/src/model/log/crypto.rs new file mode 100644 index 0000000..e17b630 --- /dev/null +++ b/net-guardia/src/model/log/crypto.rs @@ -0,0 +1,20 @@ +use macros::loggable; + +loggable! { + CryptoLog { + #[error("Envelope encryption enabled")] + EnvelopeEnabled => tracing::Level::INFO, + + #[error("Envelope encryption disabled — no master key (dev mode)")] + EnvelopeDisabled => tracing::Level::WARN, + + #[error("Migrated secret: {key}")] + SecretMigrated { key: String } => tracing::Level::INFO, + + #[error("Secret migration complete: {count} secrets encrypted")] + MigrationComplete { count: usize } => tracing::Level::INFO, + + #[error("Secret migration skipped — already done")] + MigrationSkipped => tracing::Level::DEBUG, + } +} diff --git a/net-guardia/src/model/log/detection.rs b/net-guardia/src/model/log/detection.rs new file mode 100644 index 0000000..4a01f00 --- /dev/null +++ b/net-guardia/src/model/log/detection.rs @@ -0,0 +1,48 @@ +use macros::loggable; +use tracing; + +loggable! { + DetectionLog { + #[error("Detection orchestrator started")] + OrchestratorStarted => tracing::Level::INFO, + + #[error("Detection deduplicated: {source_ip} {attack_type} (within window)")] + DetectionDeduplicated { source_ip: String, attack_type: String } => tracing::Level::DEBUG, + + #[error("Detection emitted: {source_ip} {attack_type} confidence={confidence:.2} sources={sources_count}")] + DetectionEmitted { source_ip: String, attack_type: String, confidence: f32, sources_count: usize } => tracing::Level::DEBUG, + + #[error("ML detection bridge started")] + MlBridgeStarted => tracing::Level::INFO, + + #[error("ML detection bridge lagged by {count} events")] + MlBridgeLagged { count: u64 } => tracing::Level::WARN, + + #[error("ML alert channel closed, detection bridge shutting down")] + MlAlertChannelClosed => tracing::Level::INFO, + + #[error("Unknown ML attack type '{attack_type}', mapping to 'threat_detected'")] + UnknownMlAttackType { attack_type: String } => tracing::Level::DEBUG, + + #[error("Correlation engine started")] + CorrelationEngineStarted => tracing::Level::INFO, + + #[error("Botnet detected: {unique_sources} unique sources → {dst_ip} in {window_secs}s window")] + BotnetDetected { dst_ip: String, unique_sources: usize, window_secs: u64 } => tracing::Level::WARN, + + #[error("Port scan detected: {src_ip} → {unique_ports} unique ports in {window_secs}s window")] + ScanDetected { src_ip: String, unique_ports: usize, window_secs: u64 } => tracing::Level::WARN, + + #[error("Lateral movement detected: {src_ip} → {unique_dests} unique internal destinations in {window_secs}s window")] + LateralMovementDetected { src_ip: String, unique_dests: usize, window_secs: u64 } => tracing::Level::WARN, + + #[error("Beaconing detector started")] + BeaconingDetectorStarted => tracing::Level::INFO, + + #[error("Beaconing detected: {src_ip} → {dst_ip}:{dst_port} CV={cv:.3} count={count}")] + BeaconingDetected { src_ip: String, dst_ip: String, dst_port: u16, cv: f64, count: usize } => tracing::Level::WARN, + + #[error("Correlation cleanup: removed {removed} expired entries")] + CorrelationCleanup { removed: usize } => tracing::Level::DEBUG, + } +} diff --git a/net-guardia/src/model/log/ebpf.rs b/net-guardia/src/model/log/ebpf.rs index 41979d1..e7d9085 100644 --- a/net-guardia/src/model/log/ebpf.rs +++ b/net-guardia/src/model/log/ebpf.rs @@ -6,15 +6,6 @@ loggable! { #[error("Attach XDP program success")] AttachProgramSuccess => tracing::Level::INFO, - #[error("Queue initialization incomplete")] - QueueInitIncomplete => tracing::Level::WARN, - - #[error("Queue refill incomplete")] - QueueRefillIncomplete => tracing::Level::WARN, - - #[error("No frames submit to queue")] - NoFrameSubmit => tracing::Level::WARN, - #[error("Queue pair {queue_id} started successfully")] QueuePairStarted { queue_id: u32 } => tracing::Level::INFO, @@ -22,11 +13,11 @@ loggable! { XSKShutdown => tracing::Level::INFO, #[error("Frame pool exhausted! Pending TX: {send_len} packets")] - FramePoolExhausted { send_len: usize } => tracing::Level::WARN, + FramePoolExhausted { send_len: usize } => tracing::Level::DEBUG, #[error("No frames available for TX")] - NoFramesAvailable => tracing::Level::WARN, - + NoFramesAvailable => tracing::Level::DEBUG, + #[error("TX wakeup failed: {error}")] TXWakeupFailed { error: String } => tracing::Level::WARN, @@ -43,7 +34,7 @@ loggable! { ThreadSpawnFailed { thread_name: String, error: String } => tracing::Level::ERROR, #[error("Forward channel full, dropping packet")] - ForwardChannelFull => tracing::Level::WARN, + ForwardChannelFull => tracing::Level::DEBUG, #[error("Forward channel disconnected")] ForwardChannelDisconnected => tracing::Level::ERROR, @@ -52,7 +43,7 @@ loggable! { FillQueueIncomplete { produced: usize, expected: usize } => tracing::Level::WARN, #[error("Invalid packet length exceeds buffer")] - InvalidPacketLength => tracing::Level::WARN, + InvalidPacketLength => tracing::Level::DEBUG, #[error("XDP attached to {interface} in native DRV_MODE")] XdpAttachedNative { interface: String } => tracing::Level::INFO, @@ -66,4 +57,4 @@ loggable! { #[error("XDP attach failed on {interface} with both DRV_MODE and SKB_MODE. Ensure the interface exists and supports XDP. Supported NICs: virtio-net, Intel i40e/ice/i350, Mellanox mlx5. SKB error: {error}")] XdpAttachFailed { interface: String, error: String } => tracing::Level::ERROR, } -} \ No newline at end of file +} diff --git a/net-guardia/src/model/log/misc.rs b/net-guardia/src/model/log/misc.rs index 00d5332..f3e2dd3 100644 --- a/net-guardia/src/model/log/misc.rs +++ b/net-guardia/src/model/log/misc.rs @@ -3,7 +3,19 @@ use tracing; loggable! { MiscLog { - #[error("GeoIP features will be disabled")] - GeoIPDisabled => tracing::Level::WARN, + #[error("NETGUARDIA_DB_KEY is not set — database will NOT be encrypted (dev mode)")] + DbEncryptionDisabled => tracing::Level::WARN, + + #[error("Database file exists but is neither valid plaintext nor valid encrypted — skipping migration")] + DbMigrationSkipped => tracing::Level::ERROR, + + #[error("Migrating plaintext database to encrypted format")] + DbMigrationStarted => tracing::Level::INFO, + + #[error("Database migration to encrypted format completed successfully")] + DbMigrationCompleted => tracing::Level::INFO, + + #[error("Database encryption migration failed — keeping original plaintext DB: {error}")] + DbMigrationFailed { error: String } => tracing::Level::ERROR, } } diff --git a/net-guardia/src/model/log/ml.rs b/net-guardia/src/model/log/ml.rs index 1acdfc1..2557416 100644 --- a/net-guardia/src/model/log/ml.rs +++ b/net-guardia/src/model/log/ml.rs @@ -10,13 +10,13 @@ loggable! { ConfigLoaded { features: usize, attacks: usize } => tracing::Level::INFO, #[error("Running inference on {size} flows")] - RunningInference { size: usize } => tracing::Level::INFO, + RunningInference { size: usize } => tracing::Level::TRACE, #[error("Inference completed: {total_flows} flows ({anomaly} anomaly, {benign} benign) in {duration_ms}ms ({throughput:.1} flows/s)")] - InferenceCompleted { total_flows: usize, anomaly: usize, benign: usize, duration_ms: u32, throughput: f32 } => tracing::Level::INFO, + InferenceCompleted { total_flows: usize, anomaly: usize, benign: usize, duration_ms: u32, throughput: f32 } => tracing::Level::TRACE, #[error("Inference returned fewer results: expected {size}, got {len}")] - InferenceResults { size: usize, len: usize } => tracing::Level::WARN, + InferenceResults { size: usize, len: usize } => tracing::Level::DEBUG, #[error("{model} inference failed: {error}")] InferenceFailed { model: String, error: String } => tracing::Level::ERROR, @@ -25,10 +25,10 @@ loggable! { ThreatDetected { direction: String, flow: String, attack_type: String, confidence: f32, ae_score: f32 } => tracing::Level::WARN, #[error("Flow stats: total={total_flows}, qualified={flows_len}, min_packets={min_packets}, packet_counts: {counts}")] - FlowStats { total_flows: usize, flows_len: usize, min_packets: usize, counts: String } => tracing::Level::INFO, + FlowStats { total_flows: usize, flows_len: usize, min_packets: usize, counts: String } => tracing::Level::TRACE, #[error("Failed to parse packet (length: {len})")] - ParsePacketFailed { len: usize } => tracing::Level::WARN, + ParsePacketFailed { len: usize } => tracing::Level::DEBUG, #[error("Failed to broadcast ML alert: {error}")] BroadcastAlertFailed { error: String } => tracing::Level::ERROR, diff --git a/net-guardia/src/model/log/mod.rs b/net-guardia/src/model/log/mod.rs index 90f754d..819d656 100644 --- a/net-guardia/src/model/log/mod.rs +++ b/net-guardia/src/model/log/mod.rs @@ -1,7 +1,10 @@ +pub mod audit; +pub mod crypto; +pub mod detection; pub mod ebpf; +pub mod health; pub mod http; +pub mod misc; pub mod ml; pub mod soar; pub mod system; -pub mod misc; -pub mod health; diff --git a/net-guardia/src/model/log/soar.rs b/net-guardia/src/model/log/soar.rs index e02b96a..4c11a50 100644 --- a/net-guardia/src/model/log/soar.rs +++ b/net-guardia/src/model/log/soar.rs @@ -28,7 +28,7 @@ loggable! { CooldownActive { name: String, source_ip: String } => tracing::Level::DEBUG, #[error("IP {ip} is in admin whitelist, skipping playbook '{name}'")] - WhitelistSkipped { ip: String, name: String } => tracing::Level::INFO, + WhitelistSkipped { ip: String, name: String } => tracing::Level::DEBUG, #[error("Blocked IP {ip} for {ttl_secs}s")] IpBlocked { ip: String, ttl_secs: u64 } => tracing::Level::INFO, @@ -42,11 +42,8 @@ loggable! { #[error("Telegram not configured, skipping send_telegram action")] TelegramNotConfigured => tracing::Level::DEBUG, - #[error("Logged at level '{level}'")] - ActionLogged { level: String } => tracing::Level::INFO, - #[error("SOAR fallback executed for IP {ip} (no matching playbook)")] - FallbackExecuted { ip: String } => tracing::Level::WARN, + FallbackExecuted { ip: String } => tracing::Level::INFO, #[error("SOAR recovery: re-applied {count} active block rules to eBPF")] RecoveryComplete { count: usize } => tracing::Level::INFO, @@ -64,15 +61,36 @@ loggable! { RateLimitRestoreFailed { errors: String } => tracing::Level::ERROR, #[error("[monitor] Action '{action_type}' skipped for IP {source_ip} — enforce mode is not active")] - MonitorModeSkipped { action_type: String, source_ip: String } => tracing::Level::INFO, + MonitorModeSkipped { action_type: String, source_ip: String } => tracing::Level::DEBUG, #[error("SOAR event handling failed: {error}")] EventHandlingFailed { error: String } => tracing::Level::ERROR, #[error("TTL sweep: {removed} blocks removed, {skipped} kept (manual ACL conflict)")] - TtlSweepComplete { removed: u32, skipped: u32 } => tracing::Level::INFO, + TtlSweepComplete { removed: u32, skipped: u32 } => tracing::Level::DEBUG, #[error("SOAR log action [{level}]: threat from {source_ip} — {attack_type} (confidence: {confidence})")] - ActionLog { level: String, source_ip: String, attack_type: String, confidence: String } => tracing::Level::WARN, + ActionLog { level: String, source_ip: String, attack_type: String, confidence: String } => tracing::Level::INFO, + + #[error("SOAR cooldown cleanup: {removed} expired entries removed")] + CooldownCleanup { removed: u32 } => tracing::Level::DEBUG, + + #[error("Webhook sent to {url} (HTTP {status})")] + WebhookSent { url: String, status: u16 } => tracing::Level::INFO, + + #[error("Webhook to {url} failed: {error}")] + WebhookFailed { url: String, error: String } => tracing::Level::WARN, + + #[error("Condition '{condition_type}' not met for playbook '{name}' (value: {value})")] + ConditionNotMet { condition_type: String, name: String, value: String } => tracing::Level::DEBUG, + + #[error("Frequency condition not met: {count}/{required} in {window_secs}s for playbook '{name}'")] + FrequencyNotMet { name: String, count: u64, required: u64, window_secs: u64 } => tracing::Level::DEBUG, + + #[error("Frequency cleanup: {removed} expired entries")] + FrequencyCleanup { removed: u32 } => tracing::Level::DEBUG, + + #[error("Invalid operator '{operator}' for condition type '{condition_type}' on playbook '{name}', condition skipped")] + InvalidConditionOperator { name: String, condition_type: String, operator: String } => tracing::Level::WARN, } } diff --git a/net-guardia/src/model/log/system.rs b/net-guardia/src/model/log/system.rs index 65d257c..f0f498a 100644 --- a/net-guardia/src/model/log/system.rs +++ b/net-guardia/src/model/log/system.rs @@ -18,12 +18,6 @@ loggable! { #[error("Termination completed")] TerminateComplete => tracing::Level::INFO, - #[error("Invalid configuration")] - InvalidConfig => tracing::Level::ERROR, - - #[error("Configuration not found")] - ConfigNotFound => tracing::Level::ERROR, - #[error("Traffic logging mode enabled — writing packets to: {path}")] TrafficLoggingEnabled { path: String } => tracing::Level::INFO, @@ -48,18 +42,6 @@ loggable! { #[error("Setup server stopped, starting full system...")] SetupServerStopped => tracing::Level::INFO, - #[error("ML → SOAR bridge started")] - MlSoarBridgeStarted => tracing::Level::INFO, - - #[error("ML→SOAR bridge lagged by {count} events")] - MlSoarBridgeLagged { count: u64 } => tracing::Level::WARN, - - #[error("ML alert channel closed, SOAR bridge shutting down")] - MlAlertChannelClosed => tracing::Level::INFO, - - #[error("Unknown ML attack type '{attack_type}', mapping to 'threat_detected'")] - UnknownMlAttackType { attack_type: String } => tracing::Level::DEBUG, - #[error("Enforce mode changed to: {mode}")] EnforceModeChanged { mode: String } => tracing::Level::INFO, @@ -111,5 +93,13 @@ loggable! { #[error("Failed to restore ACL rule ({direction} {list_type} {address}:{port}): {error}")] AclRuleRestoreFailed { direction: String, list_type: String, address: String, port: u16, error: String } => tracing::Level::WARN, + #[error("API-triggered shutdown initiated")] + ApiShutdown => tracing::Level::INFO, + + #[error("API-triggered restart initiated — process will exit and systemd will restart")] + ApiRestart => tracing::Level::INFO, + + #[error("ML drift detected: {count} features drifted, max deviation {deviation:.2}σ")] + DriftDetected { count: usize, deviation: f64 } => tracing::Level::WARN, } } diff --git a/net-guardia/src/model/mod.rs b/net-guardia/src/model/mod.rs index b75a013..0f4dc1c 100644 --- a/net-guardia/src/model/mod.rs +++ b/net-guardia/src/model/mod.rs @@ -1,10 +1,13 @@ // Bounded Context subdirectories pub mod access_control; +pub mod config; pub mod detection; pub mod error; +pub mod event; pub mod identity; pub mod log; pub mod monitoring; +pub mod report; pub mod soar; pub mod system; @@ -17,5 +20,4 @@ pub use monitoring::direction; pub use monitoring::drop_event; pub use monitoring::flow_stats; pub use monitoring::user_packet; -pub use system::config; pub use system::health; diff --git a/net-guardia/src/model/monitoring/geolocation.rs b/net-guardia/src/model/monitoring/geolocation.rs new file mode 100644 index 0000000..a38636d --- /dev/null +++ b/net-guardia/src/model/monitoring/geolocation.rs @@ -0,0 +1,9 @@ +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct GeoLocation { + pub country: Option, + pub country_code: Option, + pub city: Option, + pub latitude: Option, + pub longitude: Option, + pub timezone: Option, +} diff --git a/net-guardia/src/model/monitoring/mod.rs b/net-guardia/src/model/monitoring/mod.rs index 80413ea..4a6ad00 100644 --- a/net-guardia/src/model/monitoring/mod.rs +++ b/net-guardia/src/model/monitoring/mod.rs @@ -1,4 +1,5 @@ pub mod direction; pub mod drop_event; pub mod flow_stats; +pub mod geolocation; pub mod user_packet; diff --git a/net-guardia/src/model/report/data.rs b/net-guardia/src/model/report/data.rs new file mode 100644 index 0000000..10198ae --- /dev/null +++ b/net-guardia/src/model/report/data.rs @@ -0,0 +1,182 @@ +use serde::{Deserialize, Serialize}; + +use crate::interface::port::repository::RepositoryPort; +use crate::model::error::Error; + +/// Shared report data structure used by both HTML email and PDF report. +#[derive(Debug, Clone, Serialize)] +pub struct ReportData { + pub period: String, + pub generated_at: String, + pub executive_summary: ExecutiveSummary, + pub threat_breakdown: Vec, + pub top_blocked_ips: Vec, + pub geo_distribution: Vec, + pub soar_activity: SoarActivity, + pub system_health: SystemHealthSummary, + pub recommendations: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ExecutiveSummary { + pub total_threats: u64, + pub total_blocked: u64, + pub uptime_percent: f64, + pub active_rules: u64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct ThreatBreakdownItem { + pub threat_type: String, + pub count: u64, + pub trend: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BlockedIpItem { + pub ip: String, + pub count: u64, + pub country: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GeoItem { + pub country: String, + pub threat_count: u64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SoarActivity { + pub auto_blocks_executed: u64, + pub playbooks_triggered: u64, + pub auto_unblocks: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SystemHealthSummary { + pub avg_cpu_percent: f64, + pub avg_memory_percent: f64, + pub disk_usage_percent: f64, + pub ebpf_status: String, +} + +impl ReportData { + /// Build report data from database settings (aggregated by the ML pipeline). + pub fn from_database(db: &dyn RepositoryPort) -> Result { + let now = chrono::Local::now(); + let period = format!( + "{} — {}", + (now - chrono::Duration::days(7)).format("%Y-%m-%d"), + now.format("%Y-%m-%d") + ); + + let threats_count: u64 = db + .get_setting("weekly_threats_count")? + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + + let top_ips: Vec = db + .get_setting("weekly_top_ips")? + .and_then(|v| serde_json::from_str(&v).ok()) + .unwrap_or_else(|| { + vec![BlockedIpItem { + ip: "—".into(), + count: 0, + country: "N/A".into(), + }] + }); + + let breakdown: Vec = db + .get_setting("weekly_threat_breakdown")? + .and_then(|v| { + let obj: serde_json::Value = serde_json::from_str(&v).ok()?; + let items = obj + .as_object()? + .iter() + .map(|(k, v)| ThreatBreakdownItem { + threat_type: k.clone(), + count: v.as_u64().unwrap_or(0), + trend: "—".into(), + }) + .collect(); + Some(items) + }) + .unwrap_or_default(); + + let health: SystemHealthSummary = db + .get_setting("weekly_system_health")? + .and_then(|v| serde_json::from_str(&v).ok()) + .unwrap_or(SystemHealthSummary { + avg_cpu_percent: 0.0, + avg_memory_percent: 0.0, + disk_usage_percent: 0.0, + ebpf_status: "running".into(), + }); + + // Generate recommendations based on data + let mut recommendations = Vec::new(); + if threats_count > 10 { + recommendations.push("Consider enabling geo-blocking for high-risk regions".into()); + } + if breakdown.iter().any(|b| b.threat_type == "port_scan" && b.count > 50) { + recommendations.push("Review exposed ports and consider tightening protocol filter rules".into()); + } + if recommendations.is_empty() { + recommendations.push("No action needed — your network security posture is healthy".into()); + } + + let uptime_percent: f64 = db + .get_setting("system_uptime_percent")? + .and_then(|v| v.parse().ok()) + .unwrap_or(0.0); + + let active_rules: u64 = db + .get_setting("active_rules_count")? + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + + let geo_distribution: Vec = db + .get_setting("weekly_geo_distribution")? + .and_then(|v| serde_json::from_str(&v).ok()) + .unwrap_or_default(); + + let auto_blocks: u64 = db + .get_setting("weekly_soar_blocks")? + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + let playbooks_triggered: u64 = db + .get_setting("weekly_soar_triggers")? + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + let auto_unblocks: u64 = db + .get_setting("weekly_soar_unblocks")? + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + + let blocked_count: u64 = db + .get_setting("weekly_blocked_count")? + .and_then(|v| v.parse().ok()) + .unwrap_or(auto_blocks); + + Ok(ReportData { + period, + generated_at: now.format("%Y-%m-%d %H:%M:%S").to_string(), + executive_summary: ExecutiveSummary { + total_threats: threats_count, + total_blocked: blocked_count, + uptime_percent, + active_rules, + }, + threat_breakdown: breakdown, + top_blocked_ips: top_ips, + geo_distribution, + soar_activity: SoarActivity { + auto_blocks_executed: auto_blocks, + playbooks_triggered, + auto_unblocks, + }, + system_health: health, + recommendations, + }) + } +} diff --git a/net-guardia/src/model/report/mod.rs b/net-guardia/src/model/report/mod.rs new file mode 100644 index 0000000..7a345e4 --- /dev/null +++ b/net-guardia/src/model/report/mod.rs @@ -0,0 +1 @@ +pub mod data; diff --git a/net-guardia/src/model/soar/condition.rs b/net-guardia/src/model/soar/condition.rs new file mode 100644 index 0000000..ebb810e --- /dev/null +++ b/net-guardia/src/model/soar/condition.rs @@ -0,0 +1,60 @@ +use std::fmt; +use std::str::FromStr; + +use crate::model::error::soar::SoarError; + +/// Condition types for multi-condition playbook matching. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConditionType { + /// Confidence threshold: event.confidence >= value + Threshold, + /// Source country match: event.geoip_country in comma-separated list + SourceCountry, + /// CIDR pattern match: event.source_ip in CIDR range + IpPattern, + /// Repeat offender flag: event.is_repeat_offender == value + RepeatOffender, + /// Frequency: N events from same source_ip within window_secs + Frequency, +} + +impl fmt::Display for ConditionType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Threshold => write!(f, "threshold"), + Self::SourceCountry => write!(f, "source_country"), + Self::IpPattern => write!(f, "ip_pattern"), + Self::RepeatOffender => write!(f, "repeat_offender"), + Self::Frequency => write!(f, "frequency"), + } + } +} + +impl FromStr for ConditionType { + type Err = SoarError; + + fn from_str(s: &str) -> Result { + match s { + "threshold" => Ok(Self::Threshold), + "source_country" => Ok(Self::SourceCountry), + "ip_pattern" => Ok(Self::IpPattern), + "repeat_offender" => Ok(Self::RepeatOffender), + "frequency" => Ok(Self::Frequency), + other => Err(SoarError::InvalidCondition { + condition_type: other.to_string(), + reason: "unknown condition type".to_string(), + }), + } + } +} + +/// A single condition attached to a playbook. +#[derive(Debug, Clone)] +pub struct PlaybookCondition { + pub condition_type: ConditionType, + /// Comparison operator: ">=", "<=", "in", "not_in", "==". + pub operator: String, + pub value: String, + /// Secondary value: window_secs for Frequency conditions. + pub value2: Option, +} diff --git a/net-guardia/src/model/soar/mod.rs b/net-guardia/src/model/soar/mod.rs index c64db5b..25d41b9 100644 --- a/net-guardia/src/model/soar/mod.rs +++ b/net-guardia/src/model/soar/mod.rs @@ -1 +1,3 @@ +pub mod condition; pub mod playbook; +pub mod playbook_data; diff --git a/net-guardia/src/model/soar/playbook.rs b/net-guardia/src/model/soar/playbook.rs index 1e35cde..7d2d811 100644 --- a/net-guardia/src/model/soar/playbook.rs +++ b/net-guardia/src/model/soar/playbook.rs @@ -1,3 +1,5 @@ +use crate::model::soar::condition::PlaybookCondition; + /// In-memory playbook representation. #[derive(Debug, Clone)] pub struct Playbook { @@ -8,6 +10,8 @@ pub struct Playbook { pub condition_threshold: Option, pub cooldown_secs: i64, pub actions: Vec, + /// Multi-condition rules (AND logic). Empty = legacy threshold-only mode. + pub conditions: Vec, } #[derive(Debug, Clone)] diff --git a/net-guardia/src/model/soar/playbook_data.rs b/net-guardia/src/model/soar/playbook_data.rs new file mode 100644 index 0000000..4fa5717 --- /dev/null +++ b/net-guardia/src/model/soar/playbook_data.rs @@ -0,0 +1,77 @@ +/// Input for updating a playbook row (without actions/conditions). +pub struct UpdatePlaybookRow { + pub name: String, + pub trigger_event: String, + pub condition_threshold: Option, + pub condition_count: Option, + pub condition_window_secs: Option, + pub cooldown_secs: i64, +} + +/// Input for creating a single playbook condition. +pub struct CreateConditionInput { + pub condition_type: String, + pub operator: String, + pub value: String, + pub value2: Option, +} + +/// Input for creating a new playbook. +pub struct CreatePlaybookInput { + pub name: String, + pub trigger_event: String, + pub condition_threshold: Option, + pub condition_count: Option, + pub condition_window_secs: Option, + pub cooldown_secs: i64, + pub actions: Vec<(String, String)>, // (action_type, params_json) + pub conditions: Vec, +} + +/// Persisted condition row for API responses. +pub struct ConditionData { + pub id: i64, + pub condition_type: String, + pub operator: String, + pub value: String, + pub value2: Option, +} + +/// Flattened playbook representation for API responses. +pub struct PlaybookData { + pub id: i64, + pub name: String, + pub enabled: bool, + pub trigger_event: String, + pub condition_threshold: Option, + pub condition_count: Option, + pub condition_window_secs: Option, + pub cooldown_secs: i64, + pub actions: Vec, + pub conditions: Vec, +} + +pub struct ActionData { + pub id: i64, + pub action_order: i64, + pub action_type: String, + pub params: serde_json::Value, +} + +/// Execution record from soar_executions table. +pub struct ExecutionData { + pub id: i64, + pub playbook_id: i64, + pub source_ip: Option, + pub trigger_event: String, + pub actions_executed: serde_json::Value, + pub created_at: String, +} + +/// Active block record from soar_block_rules table. +pub struct ActiveBlockData { + pub id: i64, + pub source_ip: String, + pub playbook_id: i64, + pub expires_at: String, +} diff --git a/net-guardia/src/model/system/config.rs b/net-guardia/src/model/system/config.rs index 7b59da0..23f4ff9 100644 --- a/net-guardia/src/model/system/config.rs +++ b/net-guardia/src/model/system/config.rs @@ -4,7 +4,6 @@ use serde::{Deserialize, Serialize}; use crate::model::ml_detection::ClipParams; - #[derive(Serialize, Deserialize, Debug, Clone)] pub struct HttpConfig { pub http_server_bind_port: u16, @@ -15,7 +14,9 @@ pub struct HttpConfig { pub cors_allowed_origins: Vec, } -fn default_jwt_expiry() -> u64 { 24 } +fn default_jwt_expiry() -> u64 { + 24 +} #[derive(Serialize, Deserialize, Debug, Clone)] pub struct NetworkConfig { @@ -36,8 +37,12 @@ pub struct NetworkConfig { pub buffer_pool_capacity: usize, } -fn default_packet_buffer_size() -> usize { 2048 } -fn default_buffer_pool_capacity() -> usize { 1024 } +fn default_packet_buffer_size() -> usize { + 2048 +} +fn default_buffer_pool_capacity() -> usize { + 1024 +} #[derive(Serialize, Deserialize, Debug, Clone)] pub struct InferenceConfig { @@ -60,7 +65,9 @@ pub struct MiscConfig { pub database_path: String, } -fn default_db_path() -> String { "net-guardia.db".to_string() } +fn default_db_path() -> String { + "net-guardia.db".to_string() +} #[derive(Serialize, Deserialize, Debug, Clone)] pub struct PipelineConfig { diff --git a/net-guardia/src/model/system/mod.rs b/net-guardia/src/model/system/mod.rs index 9225abc..55dcb9a 100644 --- a/net-guardia/src/model/system/mod.rs +++ b/net-guardia/src/model/system/mod.rs @@ -1,2 +1,4 @@ pub mod config; pub mod health; +pub mod rate_limit_settings; +pub mod readiness; diff --git a/net-guardia/src/model/system/rate_limit_settings.rs b/net-guardia/src/model/system/rate_limit_settings.rs new file mode 100644 index 0000000..c756e45 --- /dev/null +++ b/net-guardia/src/model/system/rate_limit_settings.rs @@ -0,0 +1,10 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize)] +pub struct RateLimitSettings { + pub packet_rate: Option, + pub syn_rate: Option, + pub udp_rate: Option, + pub dns_rate: Option, + pub window_ns: Option, +} diff --git a/net-guardia/src/model/system/readiness.rs b/net-guardia/src/model/system/readiness.rs new file mode 100644 index 0000000..7312fcc --- /dev/null +++ b/net-guardia/src/model/system/readiness.rs @@ -0,0 +1,20 @@ +/// Per-subsystem readiness state exposed by `/health/ready`. +pub struct ReadinessState { + pub db_connected: std::sync::atomic::AtomicBool, + pub ml_model_loaded: std::sync::atomic::AtomicBool, + pub soar_engine_running: std::sync::atomic::AtomicBool, + pub ebpf_attached: std::sync::atomic::AtomicBool, + pub started_at: std::time::Instant, +} + +impl ReadinessState { + pub fn new() -> Self { + Self { + db_connected: std::sync::atomic::AtomicBool::new(false), + ml_model_loaded: std::sync::atomic::AtomicBool::new(false), + soar_engine_running: std::sync::atomic::AtomicBool::new(false), + ebpf_attached: std::sync::atomic::AtomicBool::new(false), + started_at: std::time::Instant::now(), + } + } +} diff --git a/net-guardia/src/utils/ip_address.rs b/net-guardia/src/utils/ip_address.rs index 4f5de49..a2ed095 100644 --- a/net-guardia/src/utils/ip_address.rs +++ b/net-guardia/src/utils/ip_address.rs @@ -2,12 +2,7 @@ use std::net::IpAddr; pub fn is_private_ip(ip: &IpAddr) -> bool { match ip { - IpAddr::V4(v4) => { - v4.is_private() - || v4.is_loopback() - || v4.is_link_local() - || v4.is_broadcast() - } + IpAddr::V4(v4) => v4.is_private() || v4.is_loopback() || v4.is_link_local() || v4.is_broadcast(), IpAddr::V6(v6) => { v6.is_loopback() || v6.is_unique_local() // fc00::/7 diff --git a/net-guardia/src/utils/logging.rs b/net-guardia/src/utils/logging.rs index 36305ca..c3829ba 100644 --- a/net-guardia/src/utils/logging.rs +++ b/net-guardia/src/utils/logging.rs @@ -1,20 +1,43 @@ use std::fs; +use std::sync::OnceLock; + use tracing::Level; use tracing_appender::rolling::{RollingFileAppender, Rotation}; use tracing_subscriber::filter::EnvFilter; use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::reload; use tracing_subscriber::util::SubscriberInitExt; -use crate::model::error::io::IOError; use crate::model::error::Error; +use crate::model::error::io::IOError; + +/// Type-erased reload handle stored as a trait object. +/// We erase the complex layered type by boxing the modify closure. +static FILTER_HANDLE: OnceLock> = OnceLock::new(); + +/// Trait to erase the complex generic type of reload::Handle. +trait FilterControl: Send + Sync { + fn reload_filter(&self, filter: EnvFilter) -> Result<(), String>; + fn current_filter(&self) -> String; +} + +impl FilterControl for reload::Handle { + fn reload_filter(&self, filter: EnvFilter) -> Result<(), String> { + self.reload(filter).map_err(|e| e.to_string()) + } + + fn current_filter(&self) -> String { + self.with_current(|f| f.to_string()) + .unwrap_or_else(|_| "unknown".to_string()) + } +} pub struct Logging; impl Logging { pub fn initialize() -> Result<(), Error> { let log_directory = "logs"; - fs::create_dir_all(log_directory) - .map_err(|err| IOError::CreateDirectoryFailed(log_directory, err))?; + fs::create_dir_all(log_directory).map_err(|err| IOError::CreateDirectoryFailed(log_directory, err))?; let file_appender = RollingFileAppender::new(Rotation::DAILY, log_directory, "NetGuardia"); @@ -36,18 +59,55 @@ impl Logging { let level = std::env::var("RUST_LOG") .ok() .and_then(|s| s.parse::().ok()) - .unwrap_or(if cfg!(debug_assertions) { Level::DEBUG } else { Level::INFO }); + .unwrap_or(if cfg!(debug_assertions) { + Level::DEBUG + } else { + Level::INFO + }); let filter = EnvFilter::from_default_env() .add_directive(level.into()) - .add_directive("maxminddb=warn".parse().expect("valid directive")); + // SAFETY: "maxminddb=warn" is a valid tracing directive literal + .add_directive("maxminddb=warn".parse().unwrap_or_else(|_| unreachable!())); + + let (filter_layer, reload_handle) = reload::Layer::new(filter); tracing_subscriber::registry() + .with(filter_layer) .with(stdout_layer) .with(file_layer) - .with(filter) .init(); + // Store type-erased handle for runtime log level changes + let _ = FILTER_HANDLE.set(Box::new(reload_handle)); + Ok(()) } + + /// Change the global log level at runtime. + pub fn set_level(level: &str) -> Result { + let handle = FILTER_HANDLE.get().ok_or("Logging not initialized")?; + + let parsed_level: Level = level.parse().map_err(|_| { + format!( + "Invalid log level '{}'. Valid levels: trace, debug, info, warn, error", + level + ) + })?; + + let new_filter = EnvFilter::new(parsed_level.to_string()) + .add_directive("maxminddb=warn".parse().unwrap_or_else(|_| unreachable!())); + + handle.reload_filter(new_filter)?; + + Ok(parsed_level.to_string()) + } + + /// Get the current log level filter string. + pub fn current_level() -> String { + FILTER_HANDLE + .get() + .map(|h| h.current_filter()) + .unwrap_or_else(|| "unknown".to_string()) + } } diff --git a/net-guardia/src/utils/mod.rs b/net-guardia/src/utils/mod.rs index ac28f95..87fea45 100644 --- a/net-guardia/src/utils/mod.rs +++ b/net-guardia/src/utils/mod.rs @@ -1,6 +1,6 @@ -pub mod logging; -pub mod static_files; pub mod boot_time; +pub mod logging; pub mod packet_parser; +pub mod static_files; -pub mod ip_address; \ No newline at end of file +pub mod ip_address; diff --git a/net-guardia/src/utils/packet_parser.rs b/net-guardia/src/utils/packet_parser.rs index f0d4673..0055e92 100644 --- a/net-guardia/src/utils/packet_parser.rs +++ b/net-guardia/src/utils/packet_parser.rs @@ -155,4 +155,3 @@ fn parse_ipv6(packet_data: &[u8], timestamp_us: u64) -> Option<(UserPacket, usiz Some((packet, payload_start)) } -