diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ce38276 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,102 @@ +name: CI + +on: + push: + branches: [master, dalaw2-dev] + pull_request: + branches: [master, dalaw2-dev] + +env: + CARGO_TERM_COLOR: always + +jobs: + build-and-test: + name: Build & Test + runs-on: ubuntu-latest + timeout-minutes: 60 + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + token: ${{ secrets.SUBMODULE_PAT }} + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + gcc m4 clang llvm \ + libelf-dev zlib1g-dev pkg-config + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'npm' + cache-dependency-path: net-guardia-frontend/package-lock.json + + - name: Install frontend dependencies + run: npm install + working-directory: net-guardia-frontend + + - name: Install Rust stable toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - name: Install Rust nightly toolchain (for eBPF) + uses: dtolnay/rust-toolchain@nightly + with: + components: rust-src + + # Ensure stable is the default so cargo check/test/clippy use stable. + # Nightly is only needed for the eBPF subprocess (which uses + # rust-toolchain.toml in ingress-ebpf/egress-ebpf). + - name: Set stable as default toolchain + run: rustup default stable + + - name: Cache cargo registry and build artifacts + uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true + + - name: Install bpf-linker + run: | + cargo install cargo-binstall --locked 2>/dev/null || true + if command -v cargo-binstall &>/dev/null; then + cargo binstall bpf-linker --no-confirm --force || cargo install bpf-linker --locked + else + cargo install bpf-linker --locked + fi + ls -la "$HOME/.cargo/bin/bpf-linker" + timeout-minutes: 45 + + - name: cargo check + run: cargo check --package net-guardia + + - name: cargo test + run: cargo test --package net-guardia + + - name: cargo clippy + run: cargo clippy --package net-guardia -- -D warnings -A dead_code + + integration-test: + name: Integration Test (placeholder) + runs-on: ubuntu-latest + needs: build-and-test + if: github.event_name == 'push' || github.event_name == 'pull_request' + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Integration test reminder + run: | + echo "============================================" + echo " Integration tests are not run in CI." + echo " They require the podman test environment." + echo "" + echo " Run on the dev server:" + echo " bash /home/dalaw2/test_netguardia.sh" + echo "============================================" diff --git a/.gitignore b/.gitignore index da226dd..f6ba2dd 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,25 @@ net-guardia/static/web # Profiling *.profraw *.profdata + +# License keys +license-generator/target/ +*.hex +license.key +license_priv.key +license_pub.key +.gstack/ +interfaces.txt +traffic_log.csv + +# Project docs (local only) +CLAUDE.md +DESIGN.md +TODOS.md +VERSION +CHANGELOG.md + +# SQLite database files +*.db +*.db-shm +*.db-wal diff --git a/Cargo.lock b/Cargo.lock index 71f9a02..0cbab4a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -300,6 +300,15 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -318,12 +327,44 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "170433209e817da6aae2c51aa0dd443009a613425dd041ebfb2492d1c4c11a25" +[[package]] +name = "ar_archive_writer" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" +dependencies = [ + "object 0.37.3", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + [[package]] name = "assert_matches" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -342,7 +383,7 @@ dependencies = [ "bytes", "libc", "log", - "object", + "object 0.36.7", "once_cell", "thiserror 1.0.69", "tokio", @@ -453,7 +494,7 @@ dependencies = [ "core-error", "hashbrown 0.15.5", "log", - "object", + "object 0.36.7", "thiserror 1.0.69", ] @@ -463,6 +504,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bindgen" version = "0.72.1" @@ -504,6 +551,15 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -534,6 +590,12 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + [[package]] name = "byteorder" version = "1.5.0" @@ -621,6 +683,27 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "chumsky" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9" +dependencies = [ + "hashbrown 0.14.5", + "stacker", +] + [[package]] name = "clang-sys" version = "1.8.1" @@ -642,6 +725,12 @@ dependencies = [ "serde", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "convert_case" version = "0.10.0" @@ -671,6 +760,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -761,12 +856,63 @@ dependencies = [ "typenum", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "data-encoding" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -818,6 +964,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -855,6 +1002,31 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15401da73a9ed8c80e3b2d4dc05fe10e7b72d7243b9f614e516a44fa99986e88" +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "egress-ebpf" version = "0.1.0" @@ -872,6 +1044,22 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "email-encoding" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" +dependencies = [ + "base64", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -897,6 +1085,30 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "filetime" version = "0.2.27" @@ -957,6 +1169,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + [[package]] name = "futures-macro" version = "0.3.32" @@ -987,9 +1205,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", + "futures-io", "futures-macro", "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -1011,8 +1231,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1071,6 +1293,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ "ahash", + "allocator-api2", ] [[package]] @@ -1095,6 +1318,26 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + [[package]] name = "http" version = "0.2.12" @@ -1128,6 +1371,30 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -1315,6 +1582,31 @@ dependencies = [ "libc", ] +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "kstring" version = "2.0.2" @@ -1337,6 +1629,35 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lettre" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e13e10e8818f8b2a60f52cb127041d388b89f3a96a62be9ceaffa22262fef7f" +dependencies = [ + "async-trait", + "base64", + "chumsky", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "hostname", + "httpdate", + "idna", + "mime", + "nom 8.0.0", + "percent-encoding", + "quoted_printable", + "rustls", + "socket2 0.6.3", + "tokio", + "tokio-rustls", + "url", + "webpki-roots", +] + [[package]] name = "libbpf-sys" version = "1.5.1+v1.5.1" @@ -1382,6 +1703,17 @@ dependencies = [ "redox_syscall 0.7.3", ] +[[package]] +name = "libsqlite3-sys" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbb8270bb4060bd76c6e96f20c52d80620f1d82a3470885694e41e0f81ef6fe7" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "libxdp-sys" version = "0.2.4+1.6.0" @@ -1638,14 +1970,22 @@ dependencies = [ "actix-cors", "actix-web", "actix-ws", + "argon2", + "async-trait", "aya", "aya-log", + "base64", "cargo_metadata", + "chrono", "common", "crossbeam", + "dashmap", "dotenvy", + "ed25519-dalek", "futures-util", "ipnetwork", + "jsonwebtoken", + "lettre", "libc", "libxdp-sys", "lru", @@ -1654,6 +1994,8 @@ dependencies = [ "mime_guess", "network-types", "parking_lot", + "rand 0.9.2", + "rusqlite", "rust-embed", "serde", "serde_json", @@ -1667,6 +2009,7 @@ dependencies = [ "tracing-subscriber", "tract-onnx", "url", + "which", "xsk-rs", ] @@ -1737,6 +2080,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-complex" version = "0.4.6" @@ -1823,6 +2176,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1852,12 +2214,33 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "pastey" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1913,6 +2296,16 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.32" @@ -2027,6 +2420,16 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "psm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3852766467df634d74f0b2d7819bf8dc483a0eb2e3b0f50f756f9cfe8b0d18d8" +dependencies = [ + "ar_archive_writer", + "cc", +] + [[package]] name = "quote" version = "1.0.45" @@ -2036,6 +2439,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73" + [[package]] name = "r-efi" version = "5.3.0" @@ -2170,6 +2579,34 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rusqlite" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37e34486da88d8e051c7c0e23c3f15fd806ea8546260aa2fec247e97242ec143" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rust-embed" version = "8.11.0" @@ -2246,6 +2683,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -2413,12 +2885,33 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + [[package]] name = "slab" version = "0.4.12" @@ -2451,12 +2944,35 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stacker" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d74a23609d509411d10e2176dc2a4346e3b4aea2e7b1869f19fdedbc71c013" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.59.0", +] + [[package]] name = "static_assertions" version = "1.1.0" @@ -2480,6 +2996,12 @@ dependencies = [ "serde", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "1.0.109" @@ -2671,6 +3193,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-tungstenite" version = "0.28.0" @@ -3025,6 +3557,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -3055,6 +3593,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -3086,6 +3630,60 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "which" version = "8.0.2" @@ -3236,6 +3834,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -3423,6 +4030,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.3" diff --git a/Cargo.toml b/Cargo.toml index 4c68ca5..d2b9023 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,6 +50,13 @@ sysinfo = "0.38.4" maxminddb = "0.27.3" ipnetwork = "0.21.1" lru = "0.16.3" +rusqlite = { version = "0.34", features = ["bundled"] } +jsonwebtoken = "9" +argon2 = "0.5" +rand = "0.9" +ed25519-dalek = { version = "2", features = ["std", "rand_core"] } +base64 = "0.22" +clap = { version = "4", features = ["derive"] } # Build dependencies cargo_metadata = { version = "0.23.1", default-features = false } diff --git a/common/src/model/http_method.rs b/common/src/model/http_method.rs index 6f91e71..550ebd8 100644 --- a/common/src/model/http_method.rs +++ b/common/src/model/http_method.rs @@ -1,4 +1,4 @@ -#[cfg(all(feature = "user"))] +#[cfg(feature = "user")] use std::vec::Vec; #[cfg(feature = "user")] @@ -23,7 +23,7 @@ pub enum HttpMethod { #[cfg(feature = "user")] impl HttpMethod { pub fn convert_from_bitmap(http_method_bitmap: HttpMethodBitmap) -> Vec { - let value = http_method_bitmap as u16; + let value = http_method_bitmap; let mut http_methods = Vec::new(); let all_methods = [ diff --git a/config.toml b/config.toml index 69ef752..6cadc3e 100644 --- a/config.toml +++ b/config.toml @@ -1,10 +1,11 @@ [Http] http_server_bind_port = 8080 +jwt_expiry_hours = 24 [Network] ingress_ifname = "ng-ext" egress_ifname = "ng-int" -combined_queue_count = 16 +combined_queue_count = 1 channel_size = 4096 fill_queue_size = 4096 comp_queue_size = 4096 @@ -28,6 +29,8 @@ traffic_log_csv_path = "traffic_log.csv" [Misc] geoip_db_name = "net-guardia/static/geo/GeoLite2-City.mmdb" +database_path = "net-guardia.db" +license_file = "license.key" [Pipeline] ingress = ["access_control", "rate_limit", "service"] diff --git a/deploy/compose/podman-compose.yml b/deploy/compose/podman-compose.yml index d148c8e..cbfd18f 100644 --- a/deploy/compose/podman-compose.yml +++ b/deploy/compose/podman-compose.yml @@ -5,6 +5,7 @@ services: build: context: .. dockerfile: compose/Containerfile.netguardia + image: netguardia:latest container_name: netguardia hostname: netguardia privileged: true @@ -32,6 +33,7 @@ services: build: context: .. dockerfile: compose/Containerfile.router + image: netguardia-router:latest container_name: router hostname: router cap_add: @@ -43,6 +45,7 @@ services: build: context: .. dockerfile: compose/Containerfile.endpoint + image: netguardia-endpoint:latest container_name: external hostname: external cap_add: @@ -55,6 +58,7 @@ services: build: context: .. dockerfile: compose/Containerfile.endpoint + image: netguardia-endpoint:latest container_name: internal hostname: internal cap_add: diff --git a/deploy/logrotate.conf b/deploy/logrotate.conf new file mode 100644 index 0000000..0124499 --- /dev/null +++ b/deploy/logrotate.conf @@ -0,0 +1,14 @@ +/var/log/netguardia/*.log { + daily + rotate 7 + compress + delaycompress + missingok + notifempty + maxsize 500M + create 0640 root root + sharedscripts + postrotate + systemctl reload netguardia.service 2>/dev/null || true + endscript +} diff --git a/deploy/netguardia.service b/deploy/netguardia.service new file mode 100644 index 0000000..1f7cbb7 --- /dev/null +++ b/deploy/netguardia.service @@ -0,0 +1,38 @@ +[Unit] +Description=NetGuardia Network Security Gateway +Documentation=https://github.com/dalaw2/NetGuardia +After=network.target +Wants=network.target + +[Service] +Type=notify +ExecStart=/opt/netguardia/bin/net-guardia +WorkingDirectory=/opt/netguardia +Restart=on-failure +RestartSec=5 + +# Watchdog: service must notify systemd within this interval or be killed +WatchdogSec=30 + +# Security hardening +NoNewPrivileges=false +ProtectSystem=strict +ProtectHome=yes +ReadWritePaths=/opt/netguardia /var/log/netguardia +PrivateTmp=yes + +# Resource limits +LimitNOFILE=65536 +LimitMEMLOCK=infinity + +# Logging +StandardOutput=journal +StandardError=journal +SyslogIdentifier=netguardia + +# Environment +Environment=RUST_LOG=info +Environment=CONFIG_PATH=/opt/netguardia/config.toml + +[Install] +WantedBy=multi-user.target diff --git a/deploy/packer/cloud-init/user-data b/deploy/packer/cloud-init/user-data new file mode 100644 index 0000000..232dff9 --- /dev/null +++ b/deploy/packer/cloud-init/user-data @@ -0,0 +1,56 @@ +#cloud-config +autoinstall: + version: 1 + locale: en_US.UTF-8 + keyboard: + layout: us + + identity: + hostname: netguardia + username: netguardia + # Password: netguardia (mkpasswd --method=SHA-512) + password: "$6$rounds=4096$randomsalt$PLACEHOLDER_HASH" + + ssh: + install-server: true + allow-pw: true + + storage: + layout: + name: lvm + sizing-policy: all + + network: + version: 2 + ethernets: + ens3: + dhcp4: true + + packages: + - whiptail + - jq + - curl + - net-tools + - iproute2 + - linux-tools-common + + late-commands: + # Create required directories + - mkdir -p /target/opt/netguardia/bin + - mkdir -p /target/var/log/netguardia + + # Enable serial console for headless access + - >- + 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. diff --git a/deploy/packer/netguardia.pkr.hcl b/deploy/packer/netguardia.pkr.hcl new file mode 100644 index 0000000..8eca159 --- /dev/null +++ b/deploy/packer/netguardia.pkr.hcl @@ -0,0 +1,213 @@ +packer { + required_plugins { + qemu = { + source = "github.com/hashicorp/qemu" + version = ">= 1.1.0" + } + virtualbox = { + source = "github.com/hashicorp/virtualbox" + version = ">= 1.0.0" + } + } +} + +# --------------------------------------------------------------------------- +# Variables +# --------------------------------------------------------------------------- +variable "ubuntu_iso_url" { + type = string + default = "https://releases.ubuntu.com/24.04/ubuntu-24.04-live-server-amd64.iso" +} + +variable "ubuntu_iso_checksum" { + type = string + default = "sha256:none" + description = "SHA-256 checksum of the Ubuntu 24.04 Server ISO. Update before building." +} + +variable "netguardia_binary" { + type = string + default = "../target/release/net-guardia" + description = "Path to the pre-built NetGuardia binary." +} + +variable "ssh_username" { + type = string + default = "netguardia" +} + +variable "ssh_password" { + type = string + default = "netguardia" + sensitive = true +} + +variable "disk_size" { + type = string + default = "20480" + description = "Virtual disk size in MB." +} + +variable "memory" { + type = string + default = "2048" +} + +variable "cpus" { + type = string + default = "2" +} + +# --------------------------------------------------------------------------- +# Source: QEMU (produces QCOW2) +# --------------------------------------------------------------------------- +source "qemu" "netguardia" { + iso_url = var.ubuntu_iso_url + iso_checksum = var.ubuntu_iso_checksum + output_directory = "output-qcow2" + format = "qcow2" + disk_size = var.disk_size + memory = var.memory + cpus = var.cpus + headless = true + + ssh_username = var.ssh_username + ssh_password = var.ssh_password + ssh_timeout = "30m" + + shutdown_command = "echo '${var.ssh_password}' | sudo -S shutdown -P now" + boot_wait = "5s" + + http_directory = "cloud-init" + boot_command = [ + "c", + "linux /casper/vmlinuz autoinstall ds='nocloud-net;s=http://{{ .HTTPIP }}:{{ .HTTPPort }}/' ", + "--- ", + "initrd /casper/initrd", + "boot" + ] + + vm_name = "netguardia" + net_device = "virtio-net" + disk_interface = "virtio" + accelerator = "kvm" +} + +# --------------------------------------------------------------------------- +# Source: VirtualBox (produces OVA) +# --------------------------------------------------------------------------- +source "virtualbox-iso" "netguardia" { + iso_url = var.ubuntu_iso_url + iso_checksum = var.ubuntu_iso_checksum + output_directory = "output-ova" + format = "ova" + disk_size = var.disk_size + memory = var.memory + cpus = var.cpus + headless = true + guest_os_type = "Ubuntu_64" + + ssh_username = var.ssh_username + ssh_password = var.ssh_password + ssh_timeout = "30m" + + shutdown_command = "echo '${var.ssh_password}' | sudo -S shutdown -P now" + boot_wait = "5s" + + http_directory = "cloud-init" + boot_command = [ + "c", + "linux /casper/vmlinuz autoinstall ds='nocloud-net;s=http://{{ .HTTPIP }}:{{ .HTTPPort }}/' ", + "--- ", + "initrd /casper/initrd", + "boot" + ] + + vboxmanage = [ + ["modifyvm", "{{ .Name }}", "--nic2", "intnet"], + ["modifyvm", "{{ .Name }}", "--intnet2", "netguardia-internal"] + ] +} + +# --------------------------------------------------------------------------- +# Build +# --------------------------------------------------------------------------- +build { + sources = [ + "source.qemu.netguardia", + "source.virtualbox-iso.netguardia" + ] + + # ------ Upload artifacts ------ + + provisioner "file" { + source = var.netguardia_binary + destination = "/tmp/net-guardia" + } + + provisioner "file" { + source = "../deploy/netguardia.service" + destination = "/tmp/netguardia.service" + } + + 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 ------ + + provisioner "shell" { + inline = [ + "set -ex", + + "# Create directories", + "sudo mkdir -p /opt/netguardia/bin", + "sudo mkdir -p /var/log/netguardia", + + "# Install binary", + "sudo install -m 0755 /tmp/net-guardia /opt/netguardia/bin/net-guardia", + + "# 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 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", + "if [ ! -f /opt/netguardia/config.toml ]; then", + " /opt/netguardia/bin/setup-wizard.sh", + "fi", + "exit 0", + "RCEOF", + "sudo chmod +x /etc/rc.local" + ] + } + + # ------ Final cleanup ------ + + provisioner "shell" { + inline = [ + "sudo apt-get -y autoremove", + "sudo apt-get -y clean", + "sudo rm -rf /tmp/* /var/tmp/*", + "sudo truncate -s 0 /var/log/syslog", + "history -c" + ] + } +} diff --git a/deploy/scripts/setup.sh b/deploy/scripts/setup.sh index ca6a272..e760a46 100644 --- a/deploy/scripts/setup.sh +++ b/deploy/scripts/setup.sh @@ -1,11 +1,15 @@ #!/bin/bash set -e +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +DEPLOY_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +COMPOSE_FILE="$DEPLOY_DIR/compose/podman-compose.yml" + if command -v podman-compose &>/dev/null; then - COMPOSE="podman-compose" + COMPOSE="podman-compose -f $COMPOSE_FILE" RT="podman" elif command -v docker &>/dev/null && docker compose version &>/dev/null 2>&1; then - COMPOSE="docker compose" + COMPOSE="docker compose -f $COMPOSE_FILE" RT="docker" else echo "ERROR: No container runtime found" diff --git a/deploy/setup-wizard.sh b/deploy/setup-wizard.sh new file mode 100755 index 0000000..07300c6 --- /dev/null +++ b/deploy/setup-wizard.sh @@ -0,0 +1,313 @@ +#!/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/egress-ebpf/build.rs b/egress-ebpf/build.rs index 0bdd7b5..5c46fdb 100644 --- a/egress-ebpf/build.rs +++ b/egress-ebpf/build.rs @@ -1,6 +1,8 @@ -use which::which; - fn main() { - let bpf_linker = which("bpf-linker").unwrap(); - println!("cargo:rerun-if-changed={}", bpf_linker.to_str().unwrap()); + // bpf-linker path is resolved and injected by net-guardia/build.rs + // via CARGO_TARGET_BPFEB_UNKNOWN_NONE_LINKER env var. + // This build.rs only needs to exist for cargo to run it. + if let Ok(linker) = which::which("bpf-linker") { + println!("cargo:rerun-if-changed={}", linker.display()); + } } diff --git a/ingress-ebpf/build.rs b/ingress-ebpf/build.rs index 0bdd7b5..de8e613 100644 --- a/ingress-ebpf/build.rs +++ b/ingress-ebpf/build.rs @@ -1,6 +1,8 @@ -use which::which; - fn main() { - let bpf_linker = which("bpf-linker").unwrap(); - println!("cargo:rerun-if-changed={}", bpf_linker.to_str().unwrap()); + // bpf-linker path is resolved and injected by net-guardia/build.rs + // via CARGO_TARGET_BPFEL_UNKNOWN_NONE_LINKER env var. + // This build.rs only needs to exist for cargo to run it. + if let Ok(linker) = which::which("bpf-linker") { + println!("cargo:rerun-if-changed={}", linker.display()); + } } diff --git a/license-generator/Cargo.toml b/license-generator/Cargo.toml new file mode 100644 index 0000000..e4f16d8 --- /dev/null +++ b/license-generator/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "license-generator" +version = "0.1.0" +edition = "2024" + +[dependencies] +ed25519-dalek = { version = "2", features = ["std", "rand_core"] } +base64 = "0.22" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +rand = "0.9" +clap = { version = "4", features = ["derive"] } +pnet = "0.36" diff --git a/license-generator/src/main.rs b/license-generator/src/main.rs new file mode 100644 index 0000000..b65ff49 --- /dev/null +++ b/license-generator/src/main.rs @@ -0,0 +1,217 @@ +use std::fs; + +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64; +use clap::{Parser, Subcommand}; +use ed25519_dalek::{Signer, SigningKey, Verifier, VerifyingKey, Signature}; +use pnet::datalink; +use rand::rngs::OsRng; +use serde::{Deserialize, Serialize}; + +#[derive(Parser)] +#[command(name = "license-generator", about = "NetGuardia license generator")] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Generate a new Ed25519 keypair + Keygen { + #[arg(short, long, default_value = "license")] + prefix: String, + }, + /// Issue a signed license bound to NIC MACs + Issue { + #[arg(short = 'k', long)] + private_key: String, + /// Ingress interface name (e.g. ng-ext) + #[arg(long)] + ingress: String, + /// Egress interface name (e.g. ng-int) + #[arg(long)] + egress: String, + /// Expiry date (YYYY-MM-DD) + #[arg(short, long)] + expires: String, + /// Comma-separated list of features + #[arg(short, long, default_value = "")] + features: String, + /// Output license file path + #[arg(short, long, default_value = "license.key")] + output: String, + }, + /// Verify a license file + Verify { + #[arg(short = 'k', long)] + public_key: String, + #[arg(short, long)] + license: String, + }, +} + +#[derive(Serialize, Deserialize, Debug)] +struct LicensePayload { + ingress_mac: String, + egress_mac: String, + expires: String, + features: Vec, +} + +fn get_mac(ifname: &str) -> String { + for iface in datalink::interfaces() { + if iface.name == ifname { + if let Some(mac) = iface.mac { + return format!( + "{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", + mac.0, mac.1, mac.2, mac.3, mac.4, mac.5 + ); + } + } + } + eprintln!("Interface '{}' not found or has no MAC address", ifname); + eprintln!("Available interfaces:"); + for iface in datalink::interfaces() { + if let Some(mac) = iface.mac { + eprintln!(" {} — {:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}", + iface.name, mac.0, mac.1, mac.2, mac.3, mac.4, mac.5); + } + } + std::process::exit(1); +} + +fn main() { + let cli = Cli::parse(); + + match cli.command { + Commands::Keygen { prefix } => keygen(&prefix), + Commands::Issue { private_key, ingress, egress, expires, features, output } => { + issue(&private_key, &ingress, &egress, &expires, &features, &output) + } + Commands::Verify { public_key, license } => verify(&public_key, &license), + } +} + +fn keygen(prefix: &str) { + let mut csprng = OsRng; + let signing_key = SigningKey::generate(&mut csprng); + let verifying_key = signing_key.verifying_key(); + + let priv_hex = hex_encode(signing_key.as_bytes()); + let pub_hex = hex_encode(verifying_key.as_bytes()); + + let priv_path = format!("{}_priv.key", prefix); + let pub_path = format!("{}_pub.key", prefix); + + fs::write(&priv_path, &priv_hex).expect("Failed to write private key"); + fs::write(&pub_path, &pub_hex).expect("Failed to write public key"); + + println!("Keypair generated:"); + println!(" Private key: {}", priv_path); + println!(" Public key: {}", pub_path); + println!(); + println!("Public key hex (embed in validator.rs):"); + println!(" {}", pub_hex); +} + +fn issue(private_key_path: &str, ingress: &str, egress: &str, expires: &str, features: &str, output: &str) { + let ingress_mac = get_mac(ingress); + let egress_mac = get_mac(egress); + + println!("Detected MACs:"); + println!(" {} — {}", ingress, ingress_mac); + println!(" {} — {}", egress, egress_mac); + + let priv_hex = fs::read_to_string(private_key_path) + .expect("Failed to read private key") + .trim() + .to_string(); + let priv_bytes = hex_decode(&priv_hex).expect("Invalid hex"); + let priv_array: [u8; 32] = priv_bytes.try_into().expect("Key must be 32 bytes"); + let signing_key = SigningKey::from_bytes(&priv_array); + + let feature_list: Vec = if features.is_empty() { + vec![] + } else { + features.split(',').map(|s| s.trim().to_string()).collect() + }; + + let payload = LicensePayload { + ingress_mac: ingress_mac.clone(), + egress_mac: egress_mac.clone(), + expires: expires.to_string(), + features: feature_list, + }; + + let payload_json = serde_json::to_string(&payload).expect("Failed to serialize"); + let payload_b64 = BASE64.encode(payload_json.as_bytes()); + let signature: Signature = signing_key.sign(payload_b64.as_bytes()); + let sig_b64 = BASE64.encode(signature.to_bytes()); + + let license_content = format!("{}.{}", payload_b64, sig_b64); + fs::write(output, &license_content).expect("Failed to write license"); + + println!(); + println!("License issued:"); + println!(" Ingress MAC: {}", ingress_mac); + println!(" Egress MAC: {}", egress_mac); + println!(" Expires: {}", expires); + println!(" Features: {:?}", payload.features); + println!(" Output: {}", output); +} + +fn verify(public_key_path: &str, license_path: &str) { + let pub_hex = fs::read_to_string(public_key_path) + .expect("Failed to read public key") + .trim() + .to_string(); + let pub_bytes = hex_decode(&pub_hex).expect("Invalid hex"); + let pub_array: [u8; 32] = pub_bytes.try_into().expect("Key must be 32 bytes"); + let verifying_key = VerifyingKey::from_bytes(&pub_array).expect("Invalid public key"); + + let contents = fs::read_to_string(license_path) + .expect("Failed to read license") + .trim() + .to_string(); + + let parts: Vec<&str> = contents.splitn(2, '.').collect(); + if parts.len() != 2 { + eprintln!("Invalid license format"); + std::process::exit(1); + } + + let sig_bytes = BASE64.decode(parts[1]).expect("Invalid signature"); + let sig_array: [u8; 64] = sig_bytes.try_into().expect("Signature must be 64 bytes"); + let signature = Signature::from_bytes(&sig_array); + + match verifying_key.verify(parts[0].as_bytes(), &signature) { + Ok(()) => { + let payload_bytes = BASE64.decode(parts[0]).expect("Invalid payload"); + let payload: LicensePayload = serde_json::from_slice(&payload_bytes).expect("Invalid JSON"); + println!("License VALID:"); + println!(" Ingress MAC: {}", payload.ingress_mac); + println!(" Egress MAC: {}", payload.egress_mac); + println!(" Expires: {}", payload.expires); + println!(" Features: {:?}", payload.features); + } + Err(e) => { + eprintln!("License INVALID: {}", e); + std::process::exit(1); + } + } +} + +fn hex_encode(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{:02x}", b)).collect() +} + +fn hex_decode(hex: &str) -> Result, String> { + if hex.len() % 2 != 0 { + return Err("Odd-length hex string".to_string()); + } + (0..hex.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).map_err(|e| e.to_string())) + .collect() +} diff --git a/macros/src/error_enum.rs b/macros/src/error_enum.rs index a491ec6..aee48fa 100644 --- a/macros/src/error_enum.rs +++ b/macros/src/error_enum.rs @@ -198,7 +198,7 @@ pub fn generate_error_enum(input: TokenStream, force_no_source: bool) -> TokenSt }); let expanded = quote! { - #[allow(dead_code)] + #[allow(dead_code, clippy::enum_variant_names)] #[derive(Debug, Clone, thiserror::Error, serde::Serialize, serde::Deserialize)] pub enum #enum_name { #(#enum_variants,)* diff --git a/net-guardia-frontend b/net-guardia-frontend index 4e8b39b..9bebac1 160000 --- a/net-guardia-frontend +++ b/net-guardia-frontend @@ -1 +1 @@ -Subproject commit 4e8b39bbb93641926bba18899b6c63cee187564d +Subproject commit 9bebac106b9ff3322f4236355c32dc9ae9c84f41 diff --git a/net-guardia/Cargo.toml b/net-guardia/Cargo.toml index e59c7cb..9d68914 100644 --- a/net-guardia/Cargo.toml +++ b/net-guardia/Cargo.toml @@ -43,6 +43,14 @@ tracing-subscriber = { workspace = true } # ML tract-onnx = { workspace = true } +# Email +lettre = { version = "0.11", default-features = false, features = ["builder", "hostname", "smtp-transport", "tokio1-rustls-tls"] } +chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } + +# Architecture +async-trait = "0.1" +dashmap = "6" + # Utilities parking_lot = { workspace = true } thiserror = { workspace = true } @@ -50,9 +58,20 @@ sysinfo = { workspace = true } maxminddb = { workspace = true } ipnetwork = { workspace = true } lru = { workspace = true } +rusqlite = { workspace = true } +jsonwebtoken = { workspace = true } +argon2 = { workspace = true } +rand = { workspace = true } +ed25519-dalek = { workspace = true, optional = true } +base64 = { workspace = true, optional = true } + +[features] +default = [] +license = ["dep:ed25519-dalek", "dep:base64"] [build-dependencies] cargo_metadata = { workspace = true } +which = { workspace = true } dotenvy = "0.15.7" [[bin]] diff --git a/net-guardia/build.rs b/net-guardia/build.rs index eb462a6..2ba07a2 100644 --- a/net-guardia/build.rs +++ b/net-guardia/build.rs @@ -11,6 +11,60 @@ fn main() { build_ebpf_package("ingress-ebpf", "ingress-ebpf"); build_ebpf_package("egress-ebpf", "egress-ebpf"); build_frontend(); + embed_license_public_key(); +} + +fn embed_license_public_key() { + let license_enabled = env::var("CARGO_FEATURE_LICENSE").is_ok(); + + let project_root = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()) + .parent() + .unwrap() + .to_path_buf(); + let key_path = project_root.join("license_pub.key"); + + println!("cargo:rerun-if-changed={}", key_path.display()); + + if key_path.exists() { + let key_hex = fs::read_to_string(&key_path) + .expect("Failed to read license_pub.key") + .trim() + .to_string(); + println!("cargo:rustc-env=LICENSE_PUBLIC_KEY={}", key_hex); + } else if license_enabled { + panic!( + "license feature enabled but license_pub.key not found at {}.\n\ + Generate it with: cd license-generator && cargo run -- keygen\n\ + Then copy license_pub.key to the repo root.", + key_path.display() + ); + } else { + println!("cargo:rustc-env=LICENSE_PUBLIC_KEY=DISABLED"); + } +} + +/// Resolve the absolute path of bpf-linker. +/// Searches PATH first, then falls back to CARGO_HOME/bin. +fn find_bpf_linker() -> PathBuf { + // Try PATH via which + if let Ok(path) = which::which("bpf-linker") { + return path; + } + + // Fallback: CARGO_HOME/bin (handles CI cache + which v8 issues) + let cargo_home = env::var("CARGO_HOME").unwrap_or_else(|_| { + let home = env::var("HOME").unwrap_or_default(); + format!("{home}/.cargo") + }); + let candidate = PathBuf::from(format!("{cargo_home}/bin/bpf-linker")); + if candidate.exists() { + return candidate; + } + + panic!( + "bpf-linker not found in PATH or $CARGO_HOME/bin.\n\ + Install with: cargo install bpf-linker" + ); } fn build_ebpf_package(package_name: &str, target_subdir: &str) { @@ -35,9 +89,13 @@ fn build_ebpf_package(package_name: &str, target_subdir: &str) { let build_ebpf = true; if build_ebpf { let arch = env::var_os("CARGO_CFG_TARGET_ARCH").unwrap(); - let target = format!("{target}-unknown-none"); + // 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 Package { manifest_path, .. } = ebpf_package; let ebpf_dir = manifest_path.parent().unwrap(); @@ -58,6 +116,13 @@ fn build_ebpf_package(package_name: &str, target_subdir: &str) { cmd.env("CARGO_CFG_BPF_TARGET_ARCH", arch); + // Tell cargo which linker to use for the BPF targets. + // This avoids relying on PATH in the subprocess. + let linker_env_bpfel = "CARGO_TARGET_BPFEL_UNKNOWN_NONE_LINKER"; + let linker_env_bpfeb = "CARGO_TARGET_BPFEB_UNKNOWN_NONE_LINKER"; + cmd.env(linker_env_bpfel, bpf_linker_str); + cmd.env(linker_env_bpfeb, bpf_linker_str); + for key in ["RUSTUP_TOOLCHAIN", "RUSTC", "RUSTC_WORKSPACE_WRAPPER"] { cmd.env_remove(key); } @@ -136,10 +201,6 @@ fn build_ebpf_package(package_name: &str, target_subdir: &str) { fn build_frontend() { let _ = dotenvy::dotenv(); - // let Some(frontend_dir) = env::var_os("FRONTEND_DIR") else { - // panic!("FRONTEND_DIR environment variable is required but not set"); - // }; - let project_root = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); let static_dir = project_root.join("static").join("web"); @@ -156,26 +217,11 @@ fn build_frontend() { println!("cargo:rerun-if-changed={}", frontend_dir.join("src").display()); println!("cargo:rerun-if-changed={}", frontend_dir.join("public").display()); println!("cargo:rerun-if-changed={}", frontend_dir.join("package.json").display()); - println!( - "cargo:rerun-if-changed={}", - frontend_dir.join("package-lock.json").display() - ); - println!( - "cargo:rerun-if-changed={}", - frontend_dir.join("next.config.js").display() - ); - println!( - "cargo:rerun-if-changed={}", - frontend_dir.join("tailwind.config.js").display() - ); - println!( - "cargo:rerun-if-changed={}", - frontend_dir.join("postcss.config.js").display() - ); - println!( - "cargo:rerun-if-changed={}", - frontend_dir.join("tsconfig.json").display() - ); + println!("cargo:rerun-if-changed={}", frontend_dir.join("package-lock.json").display()); + println!("cargo:rerun-if-changed={}", frontend_dir.join("next.config.js").display()); + println!("cargo:rerun-if-changed={}", frontend_dir.join("tailwind.config.js").display()); + println!("cargo:rerun-if-changed={}", frontend_dir.join("postcss.config.js").display()); + println!("cargo:rerun-if-changed={}", frontend_dir.join("tsconfig.json").display()); let out_dir = frontend_dir.join("out"); let need_build = needs_frontend_rebuild(&frontend_dir, &out_dir, &static_dir); @@ -183,22 +229,24 @@ fn build_frontend() { return; } - let mut cmd = Command::new("npm"); - cmd.arg("install") - .current_dir(&frontend_dir); + let npm = which::which("npm") + .unwrap_or_else(|_| panic!("npm not found in PATH. Install Node.js first.")); - let status = cmd + let status = Command::new(&npm) + .arg("install") + .current_dir(&frontend_dir) .status() .unwrap_or_else(|err| panic!("failed to run npm install: {err}")); if !status.success() { panic!("npm install failed with exit code: {:?}", status.code()); } - let mut cmd = Command::new("npx"); - cmd.args(["next", "build"]) - .current_dir(&frontend_dir); + let npx = which::which("npx") + .unwrap_or_else(|_| panic!("npx not found in PATH. Install Node.js first.")); - let status = cmd + let status = Command::new(&npx) + .args(["next", "build"]) + .current_dir(&frontend_dir) .status() .unwrap_or_else(|err| panic!("failed to run next build: {err}")); if !status.success() { @@ -213,38 +261,24 @@ fn build_frontend() { copy_dir_all(&out_dir, &static_dir).unwrap_or_else(|err| panic!("failed to copy frontend build: {err}")); } -fn needs_frontend_rebuild(frontend_dir: &PathBuf, out_dir: &PathBuf, static_dir: &PathBuf) -> bool { - if !out_dir.exists() { - return true; - } - - if !static_dir.exists() { +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; } let out_modified = match fs::metadata(out_dir).and_then(|m| m.modified()) { Ok(time) => time, - Err(_) => { - return true; - } + Err(_) => return true, }; let static_modified = match fs::metadata(static_dir).and_then(|m| m.modified()) { Ok(time) => time, - Err(_) => { - return true; - } + Err(_) => return true, }; let essential_items = [ - "src", - "public", - "package.json", - "next.config.js", - "tailwind.config.js", - "postcss.config.js", - "tsconfig.json", - "package-lock.json", + "src", "public", "package.json", "next.config.js", + "tailwind.config.js", "postcss.config.js", "tsconfig.json", "package-lock.json", ]; for item_name in essential_items { @@ -252,49 +286,39 @@ fn needs_frontend_rebuild(frontend_dir: &PathBuf, out_dir: &PathBuf, static_dir: if !item_path.exists() { continue; } - - let item_modified = match get_dir_last_modified(&item_path) { - Some(time) => time, - None => continue, - }; - - if item_modified > out_modified { + if let Some(item_modified) = get_dir_last_modified(&item_path) + && item_modified > out_modified + { return true; } } - if out_modified > static_modified { - return true; - } - - false + out_modified > static_modified } -fn get_dir_last_modified(path: &PathBuf) -> Option { +fn get_dir_last_modified(path: &std::path::Path) -> Option { if path.is_file() { return fs::metadata(path).and_then(|m| m.modified()).ok(); } if path.is_dir() { let mut latest = fs::metadata(path).and_then(|m| m.modified()).ok()?; - if let Ok(entries) = fs::read_dir(path) { for entry in entries.flatten() { - if let Some(modified) = get_dir_last_modified(&entry.path()) { - if modified > latest { - latest = modified; - } + if let Some(modified) = get_dir_last_modified(&entry.path()) + && modified > latest + { + latest = modified; } } } - return Some(latest); } None } -fn copy_dir_all(src: &PathBuf, dst: &PathBuf) -> std::io::Result<()> { +fn copy_dir_all(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> { for entry in fs::read_dir(src)? { let entry = entry?; let file_type = entry.file_type()?; diff --git a/net-guardia/src/web/api/acl.rs b/net-guardia/src/adapter/http/acl.rs similarity index 72% rename from net-guardia/src/web/api/acl.rs rename to net-guardia/src/adapter/http/acl.rs index 56b2a24..1c11161 100644 --- a/net-guardia/src/web/api/acl.rs +++ b/net-guardia/src/adapter/http/acl.rs @@ -3,6 +3,9 @@ use std::net::{SocketAddrV4, SocketAddrV6}; use actix_web::{web, HttpResponse, Responder, Scope}; use serde::Deserialize; +use crate::interface::port::repository::RepositoryPort; + +type Repo = dyn RepositoryPort; use crate::core::ebpf::access_control::AccessControl; use crate::core::ebpf::geo_block::GeoBlock; use crate::model::direction::FlowDirection; @@ -44,13 +47,25 @@ async fn get_ipv6_list( HttpResponse::Ok().json(list) } +fn direction_str(d: FlowDirection) -> &'static str { + match d { FlowDirection::Source => "source", FlowDirection::Destination => "destination" } +} + +fn list_type_str(l: ListType) -> &'static str { + match l { ListType::White => "whitelist", ListType::Black => "blacklist" } +} + async fn add_ipv4_list( address: web::Json, path: web::Path<(FlowDirection, ListType)>, access_control: web::Data, + db: web::Data, ) -> impl Responder { let address = address.into_inner(); let (direction, list_type) = path.into_inner(); + if let Err(e) = db.insert_acl_rule(4, direction_str(direction), list_type_str(list_type), &address.ip().to_string(), address.port()) { + return HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})); + } match access_control.add_ipv4_list(direction, list_type, address).await { Ok(_) => HttpResponse::Ok().finish(), Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), @@ -61,9 +76,13 @@ async fn add_ipv6_list( address: web::Json, path: web::Path<(FlowDirection, ListType)>, access_control: web::Data, + db: web::Data, ) -> impl Responder { let address = address.into_inner(); let (direction, list_type) = path.into_inner(); + if let Err(e) = db.insert_acl_rule(6, direction_str(direction), list_type_str(list_type), &address.ip().to_string(), address.port()) { + return HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})); + } match access_control.add_ipv6_list(direction, list_type, address).await { Ok(_) => HttpResponse::Ok().finish(), Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), @@ -74,9 +93,13 @@ async fn remove_ipv4_list( address: web::Json, path: web::Path<(FlowDirection, ListType)>, access_control: web::Data, + db: web::Data, ) -> impl Responder { let address = address.into_inner(); let (direction, list_type) = path.into_inner(); + if let Err(e) = db.delete_acl_rule(4, direction_str(direction), list_type_str(list_type), &address.ip().to_string(), address.port()) { + return HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})); + } match access_control.remove_ipv4_list(direction, list_type, address).await { Ok(_) => HttpResponse::Ok().finish(), Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), @@ -87,9 +110,13 @@ async fn remove_ipv6_list( address: web::Json, path: web::Path<(FlowDirection, ListType)>, access_control: web::Data, + db: web::Data, ) -> impl Responder { let address = address.into_inner(); let (direction, list_type) = path.into_inner(); + if let Err(e) = db.delete_acl_rule(6, direction_str(direction), list_type_str(list_type), &address.ip().to_string(), address.port()) { + return HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})); + } match access_control.remove_ipv6_list(direction, list_type, address).await { Ok(_) => HttpResponse::Ok().finish(), Err(e) => HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})), @@ -106,8 +133,15 @@ async fn get_geo_blocked( async fn block_geo_countries( body: web::Json, geo_block: web::Data, + db: web::Data, ) -> impl Responder { let codes = body.into_inner().country_codes; + for code in &codes { + if let Err(e) = db.insert_geo_country(code) { + return HttpResponse::InternalServerError() + .json(serde_json::json!({"error": e.to_string()})); + } + } match geo_block.block_countries(&codes) { Ok(total_prefixes) => HttpResponse::Ok().json(serde_json::json!({ "blocked_countries": geo_block.get_blocked_countries(), @@ -121,8 +155,15 @@ async fn block_geo_countries( async fn unblock_geo_countries( body: web::Json, geo_block: web::Data, + db: web::Data, ) -> impl Responder { let codes = body.into_inner().country_codes; + for code in &codes { + if let Err(e) = db.delete_geo_country(code) { + return HttpResponse::InternalServerError() + .json(serde_json::json!({"error": e.to_string()})); + } + } match geo_block.unblock_countries(&codes) { Ok(total_prefixes) => HttpResponse::Ok().json(serde_json::json!({ "blocked_countries": geo_block.get_blocked_countries(), diff --git a/net-guardia/src/adapter/http/auth.rs b/net-guardia/src/adapter/http/auth.rs new file mode 100644 index 0000000..2576988 --- /dev/null +++ b/net-guardia/src/adapter/http/auth.rs @@ -0,0 +1,806 @@ +use actix_web::{web, HttpMessage, HttpRequest, HttpResponse, Responder, Scope}; +use serde::Deserialize; + +use crate::core::auth::jwt::JwtService; +use crate::model::auth::Claims; +use crate::core::auth::password; +use crate::interface::port::repository::RepositoryPort; + +type Repo = dyn RepositoryPort; + +#[derive(Deserialize)] +struct LoginRequest { + username: String, + password: String, +} + +#[derive(Deserialize)] +struct RegisterRequest { + username: String, + password: String, + role: String, +} + +#[derive(Deserialize)] +struct ChangePasswordRequest { + current_password: String, + new_password: String, +} + +pub fn initialize() -> Scope { + web::scope("/auth") + .route("/login", web::post().to(login)) + .route("/register", web::post().to(register)) + .route("/me", web::get().to(me)) + .route("/change-password", web::post().to(change_password)) + .route("/users", web::get().to(list_users)) + .route("/users/{id}", web::delete().to(delete_user)) + .route("/users/{id}/role", web::put().to(update_role)) + .route("/users/{id}/reset-password", web::post().to(reset_password)) + .route("/users/{id}/groups", web::put().to(set_user_groups)) + .route("/groups", web::get().to(list_groups)) + .route("/groups", web::post().to(create_group)) + .route("/groups/{id}", web::get().to(get_group)) + .route("/groups/{id}", web::put().to(update_group)) + .route("/groups/{id}", web::delete().to(delete_group)) +} + +fn validate_username(username: &str) -> Result<(), &'static str> { + if username.is_empty() || username.len() > 32 { + return Err("Username must be 1-32 characters"); + } + if !username.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { + return Err("Username must contain only alphanumeric characters and underscores"); + } + Ok(()) +} + +fn validate_password(password: &str) -> Result<(), &'static str> { + if password.len() < 8 { + return Err("Password must be at least 8 characters"); + } + Ok(()) +} + +fn extract_claims(req: &HttpRequest) -> Option { + req.extensions().get::().cloned() +} + +fn has_permission(claims: &Claims, permission: &str) -> bool { + claims.permissions.iter().any(|p| p == permission) +} + +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, + })); + } + Err(_) => {} + Ok(None) => {} + } + + let user = match db.find_user(&req.username) { + Ok(Some(u)) => u, + _ => { + let _ = db.record_login_failure(&req.username); + return HttpResponse::Unauthorized() + .json(serde_json::json!({"error": "Invalid credentials"})); + } + }; + + let (id, username, hash, _db_role, force_password_change) = user; + + match password::verify_password(&req.password, &hash) { + Ok(true) => {} + _ => { + let _ = db.record_login_failure(&req.username); + return HttpResponse::Unauthorized() + .json(serde_json::json!({"error": "Invalid credentials"})); + } + } + + // Clear login failures on success + let _ = db.clear_login_failures(&req.username); + + // Permissions come exclusively from groups — no role-based fallback + let permissions = db.get_user_permissions(id).unwrap_or_default(); + + // Derive role from groups for backwards compat in JWT + let groups = db.get_user_groups(id).unwrap_or_default(); + let role = if groups.iter().any(|(_id, name, _desc, _perms)| name == "Administrator") { + "admin".to_string() + } else { + "viewer".to_string() + }; + + match jwt.create_token(id, &username, &role, permissions) { + Ok(token) => HttpResponse::Ok().json(serde_json::json!({ + "token": token, + "role": role, + "force_password_change": force_password_change, + })), + Err(_) => HttpResponse::InternalServerError() + .json(serde_json::json!({"error": "Failed to create token"})), + } +} + +async fn register( + req: HttpRequest, + body: web::Json, + db: web::Data, +) -> impl Responder { + // Check caller has users:admin permission + let _claims = match extract_claims(&req) { + Some(c) if has_permission(&c, "users:admin") => c, + _ => { + return HttpResponse::Forbidden() + .json(serde_json::json!({"error": "Admin access required"})); + } + }; + + let reg = body.into_inner(); + + // Validate input + if let Err(msg) = validate_username(®.username) { + return HttpResponse::BadRequest().json(serde_json::json!({"error": msg})); + } + if let Err(msg) = validate_password(®.password) { + return HttpResponse::BadRequest().json(serde_json::json!({"error": msg})); + } + + // Validate role + if reg.role != "admin" && reg.role != "viewer" { + return HttpResponse::BadRequest() + .json(serde_json::json!({"error": "Role must be 'admin' or 'viewer'"})); + } + + let hash = match password::hash_password(®.password) { + Ok(h) => h, + Err(_) => { + return HttpResponse::InternalServerError() + .json(serde_json::json!({"error": "Failed to hash password"})); + } + }; + + match db.insert_user(®.username, &hash, ®.role, false) { + Ok(new_user_id) => { + // Auto-assign to default group based on role + let default_group_name = if reg.role == "admin" { "Administrator" } else { "Viewer" }; + if let Ok(groups) = db.list_user_groups() + && let Some((group_id, _, _, _, _)) = groups.into_iter().find(|(_, name, _, _, _)| name == default_group_name) + { + let _ = db.set_user_groups(new_user_id, &[group_id]); + } + 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(req: HttpRequest, db: web::Data) -> impl Responder { + match extract_claims(&req) { + Some(claims) => { + let user_groups = db.get_user_groups(claims.sub).unwrap_or_default(); + let group_names: Vec = user_groups.iter() + .map(|(_id, name, _desc, _perms)| name.clone()) + .collect(); + // Derive role from groups for backwards compat + let role = if group_names.iter().any(|n| n == "Administrator") { + "admin" + } else { + "viewer" + }; + // Get fresh permissions from groups (not from JWT claims which may be stale) + let permissions = db.get_user_permissions(claims.sub).unwrap_or_default(); + HttpResponse::Ok().json(serde_json::json!({ + "id": claims.sub, + "username": claims.username, + "role": role, + "permissions": permissions, + "groups": group_names, + })) + } + None => HttpResponse::Unauthorized() + .json(serde_json::json!({"error": "Not authenticated"})), + } +} + +async fn change_password( + req: HttpRequest, + body: web::Json, + db: web::Data, +) -> impl Responder { + let claims = match extract_claims(&req) { + Some(c) => c, + None => { + return HttpResponse::Unauthorized() + .json(serde_json::json!({"error": "Not authenticated"})); + } + }; + + let change_req = body.into_inner(); + + // Validate new password + if let Err(msg) = validate_password(&change_req.new_password) { + return HttpResponse::BadRequest().json(serde_json::json!({"error": msg})); + } + + // Verify current password + let user = match db.find_user(&claims.username) { + Ok(Some(u)) => u, + _ => { + return HttpResponse::InternalServerError() + .json(serde_json::json!({"error": "User not found"})); + } + }; + + let (_id, _username, hash, _role, _force) = user; + + match password::verify_password(&change_req.current_password, &hash) { + Ok(true) => {} + _ => { + return HttpResponse::Unauthorized() + .json(serde_json::json!({"error": "Current password is incorrect"})); + } + } + + // Hash and update + let new_hash = match password::hash_password(&change_req.new_password) { + Ok(h) => h, + Err(_) => { + 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()})), + } +} + +// --- User Management (admin only) --- + +async fn list_users( + req: HttpRequest, + db: web::Data, +) -> impl Responder { + let claims = match extract_claims(&req) { + Some(c) => c, + None => { + return HttpResponse::Unauthorized() + .json(serde_json::json!({"error": "Not authenticated"})); + } + }; + + if !has_permission(&claims, "users:admin") { + return HttpResponse::Forbidden() + .json(serde_json::json!({"error": "Admin access required"})); + } + + match db.list_users() { + Ok(users) => { + let result: Vec = users.into_iter().map(|(id, username, _role, force_pw, created_at)| { + let user_groups = db.get_user_groups(id).unwrap_or_default(); + let groups: Vec = user_groups.iter() + .map(|(gid, name, _desc, _perms)| serde_json::json!({"id": gid, "name": name})) + .collect(); + // Derive role from groups for backwards compat + let role = if user_groups.iter().any(|(_id, name, _desc, _perms)| 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(); + HttpResponse::Ok().json(result) + } + Err(e) => HttpResponse::InternalServerError() + .json(serde_json::json!({"error": e.to_string()})), + } +} + +async fn delete_user( + req: HttpRequest, + path: web::Path, + db: web::Data, +) -> impl Responder { + let claims = match extract_claims(&req) { + Some(c) => c, + None => { + return HttpResponse::Unauthorized() + .json(serde_json::json!({"error": "Not authenticated"})); + } + }; + + if !has_permission(&claims, "users:admin") { + return HttpResponse::Forbidden() + .json(serde_json::json!({"error": "Admin access required"})); + } + + let user_id = path.into_inner(); + + // Can't delete self + if claims.sub == user_id { + return HttpResponse::BadRequest() + .json(serde_json::json!({"error": "Cannot delete your own account"})); + } + + // Protect the built-in admin account + match db.find_user_by_id(user_id) { + Ok(Some((_, ref username, _, _, _))) if username == "admin" => { + return HttpResponse::Forbidden() + .json(serde_json::json!({"error": "Cannot delete the built-in admin account"})); + } + _ => {} + } + + 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()})), + } +} + +async fn update_role( + req: HttpRequest, + path: web::Path, + body: web::Json, + db: web::Data, +) -> impl Responder { + let claims = match extract_claims(&req) { + Some(c) => c, + None => { + return HttpResponse::Unauthorized() + .json(serde_json::json!({"error": "Not authenticated"})); + } + }; + + if !has_permission(&claims, "users:admin") { + return HttpResponse::Forbidden() + .json(serde_json::json!({"error": "Admin access required"})); + } + + let user_id = path.into_inner(); + + // Can't change own role + if claims.sub == user_id { + return HttpResponse::BadRequest() + .json(serde_json::json!({"error": "Cannot change your own role"})); + } + + let role = match body.get("role").and_then(|v| v.as_str()) { + Some(r) if r == "admin" || r == "viewer" => r, + _ => { + return HttpResponse::BadRequest() + .json(serde_json::json!({"error": "Role must be 'admin' or 'viewer'"})); + } + }; + + // Check target user exists + match db.find_user_by_id(user_id) { + Ok(Some(_)) => {} + Ok(None) => { + return HttpResponse::NotFound() + .json(serde_json::json!({"error": "User not found"})); + } + Err(e) => { + 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()})), + } +} + +async fn reset_password( + req: HttpRequest, + path: web::Path, + body: web::Json, + db: web::Data, +) -> impl Responder { + let claims = match extract_claims(&req) { + Some(c) => c, + None => { + return HttpResponse::Unauthorized() + .json(serde_json::json!({"error": "Not authenticated"})); + } + }; + + if !has_permission(&claims, "users:admin") { + return HttpResponse::Forbidden() + .json(serde_json::json!({"error": "Admin access required"})); + } + + 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()) { + Some(p) => p, + None => { + return HttpResponse::BadRequest() + .json(serde_json::json!({"error": "Password is required"})); + } + }; + + if let Err(msg) = validate_password(new_password) { + return HttpResponse::BadRequest().json(serde_json::json!({"error": msg})); + } + + // Check target user exists + match db.find_user_by_id(user_id) { + Ok(Some(_)) => {} + Ok(None) => { + return HttpResponse::NotFound() + .json(serde_json::json!({"error": "User not found"})); + } + Err(e) => { + 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"})); + } + }; + + 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()})), + } +} + +// --- User Group Management (users:admin required) --- + +async fn list_groups( + req: HttpRequest, + db: web::Data, +) -> impl Responder { + let claims = match extract_claims(&req) { + Some(c) => c, + None => { + return HttpResponse::Unauthorized() + .json(serde_json::json!({"error": "Not authenticated"})); + } + }; + + if !has_permission(&claims, "users:admin") { + return HttpResponse::Forbidden() + .json(serde_json::json!({"error": "Admin access required"})); + } + + 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!([])); + serde_json::json!({ + "id": id, + "name": name, + "description": description, + "permissions": perms, + "created_at": created_at, + }) + }).collect(); + HttpResponse::Ok().json(result) + } + Err(e) => HttpResponse::InternalServerError() + .json(serde_json::json!({"error": e.to_string()})), + } +} + +async fn create_group( + req: HttpRequest, + body: web::Json, + db: web::Data, +) -> impl Responder { + let claims = match extract_claims(&req) { + Some(c) => c, + None => { + return HttpResponse::Unauthorized() + .json(serde_json::json!({"error": "Not authenticated"})); + } + }; + + if !has_permission(&claims, "users:admin") { + return HttpResponse::Forbidden() + .json(serde_json::json!({"error": "Admin access required"})); + } + + 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"})); + } + }; + + let description = body.get("description").and_then(|v| v.as_str()).unwrap_or(""); + let permissions = match body.get("permissions") { + Some(p) if p.is_array() => p.to_string(), + _ => "[]".to_string(), + }; + + match db.create_user_group(name, description, &permissions) { + Ok(id) => HttpResponse::Created().json(serde_json::json!({ + "id": id, + "name": name, + "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()})), + } +} + +async fn get_group( + req: HttpRequest, + path: web::Path, + db: web::Data, +) -> impl Responder { + let claims = match extract_claims(&req) { + Some(c) => c, + None => { + return HttpResponse::Unauthorized() + .json(serde_json::json!({"error": "Not authenticated"})); + } + }; + + if !has_permission(&claims, "users:admin") { + return HttpResponse::Forbidden() + .json(serde_json::json!({"error": "Admin access required"})); + } + + let group_id = path.into_inner(); + + match db.get_user_group(group_id) { + Ok(Some((id, name, description, permissions, created_at))) => { + let perms: serde_json::Value = serde_json::from_str(&permissions).unwrap_or(serde_json::json!([])); + let members = db.get_group_member_ids(group_id).unwrap_or_default(); + HttpResponse::Ok().json(serde_json::json!({ + "id": id, + "name": name, + "description": description, + "permissions": perms, + "created_at": created_at, + "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()})), + } +} + +async fn update_group( + req: HttpRequest, + path: web::Path, + body: web::Json, + db: web::Data, +) -> impl Responder { + let claims = match extract_claims(&req) { + Some(c) => c, + None => { + return HttpResponse::Unauthorized() + .json(serde_json::json!({"error": "Not authenticated"})); + } + }; + + if !has_permission(&claims, "users:admin") { + return HttpResponse::Forbidden() + .json(serde_json::json!({"error": "Admin access required"})); + } + + let group_id = path.into_inner(); + + // Check group exists + let existing = match db.get_user_group(group_id) { + 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"})); + } + g + } + Ok(None) => { + return HttpResponse::NotFound() + .json(serde_json::json!({"error": "Group not found"})); + } + Err(e) => { + return HttpResponse::InternalServerError() + .json(serde_json::json!({"error": e.to_string()})); + } + }; + + let name = body.get("name").and_then(|v| v.as_str()).unwrap_or(&existing.1); + let description = body.get("description").and_then(|v| v.as_str()).unwrap_or(&existing.2); + let permissions = match body.get("permissions") { + Some(p) if p.is_array() => p.to_string(), + _ => existing.3.clone(), + }; + + match db.update_user_group(group_id, name, description, &permissions) { + Ok(_) => HttpResponse::Ok().json(serde_json::json!({ + "id": group_id, + "name": name, + "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()})), + } +} + +async fn delete_group( + req: HttpRequest, + path: web::Path, + db: web::Data, +) -> impl Responder { + let claims = match extract_claims(&req) { + Some(c) => c, + None => { + return HttpResponse::Unauthorized() + .json(serde_json::json!({"error": "Not authenticated"})); + } + }; + + if !has_permission(&claims, "users:admin") { + return HttpResponse::Forbidden() + .json(serde_json::json!({"error": "Admin access required"})); + } + + 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"})); + } + _ => {} + } + + 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()})), + } +} + +async fn set_user_groups( + req: HttpRequest, + path: web::Path, + body: web::Json, + db: web::Data, +) -> impl Responder { + let claims = match extract_claims(&req) { + Some(c) => c, + None => { + return HttpResponse::Unauthorized() + .json(serde_json::json!({"error": "Not authenticated"})); + } + }; + + if !has_permission(&claims, "users:admin") { + return HttpResponse::Forbidden() + .json(serde_json::json!({"error": "Admin access required"})); + } + + let user_id = path.into_inner(); + + // Protect the default admin account + match db.find_user_by_id(user_id) { + Ok(Some((_, ref username, _, _, _))) if username == "admin" => { + return HttpResponse::Forbidden() + .json(serde_json::json!({"error": "Cannot modify groups for the built-in admin account"})); + } + Ok(Some(_)) => {} + Ok(None) => { + return HttpResponse::NotFound() + .json(serde_json::json!({"error": "User not found"})); + } + Err(e) => { + 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"})); + } + }; + + 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()})), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_validate_username_valid() { + assert!(validate_username("admin").is_ok()); + assert!(validate_username("user_123").is_ok()); + assert!(validate_username("a").is_ok()); + } + + #[test] + fn test_validate_username_empty() { + assert!(validate_username("").is_err()); + } + + #[test] + fn test_validate_username_too_long() { + let long = "a".repeat(33); + assert!(validate_username(&long).is_err()); + } + + #[test] + fn test_validate_username_special_chars() { + assert!(validate_username("admin@host").is_err()); + assert!(validate_username("user name").is_err()); + assert!(validate_username("user-name").is_err()); + assert!(validate_username("用戶").is_err()); + } + + #[test] + fn test_validate_password_valid() { + assert!(validate_password("12345678").is_ok()); + assert!(validate_password("a very long password").is_ok()); + } + + #[test] + fn test_validate_password_too_short() { + assert!(validate_password("").is_err()); + assert!(validate_password("1234567").is_err()); + assert!(validate_password("a").is_err()); + } +} diff --git a/net-guardia/src/web/api/default.rs b/net-guardia/src/adapter/http/default.rs similarity index 99% rename from net-guardia/src/web/api/default.rs rename to net-guardia/src/adapter/http/default.rs index b46f256..215f180 100644 --- a/net-guardia/src/web/api/default.rs +++ b/net-guardia/src/adapter/http/default.rs @@ -39,4 +39,4 @@ pub async fn default_route(req: HttpRequest) -> impl Responder { .body(page.data.into_owned()), None => HttpResponse::NotFound().body("404 Not Found"), } -} \ No newline at end of file +} diff --git a/net-guardia/src/web/api/filter.rs b/net-guardia/src/adapter/http/filter.rs similarity index 94% rename from net-guardia/src/web/api/filter.rs rename to net-guardia/src/adapter/http/filter.rs index 67f8d97..48be8c3 100644 --- a/net-guardia/src/web/api/filter.rs +++ b/net-guardia/src/adapter/http/filter.rs @@ -5,6 +5,9 @@ use actix_web::{web, HttpResponse, Responder, Scope}; use common::model::http_method::HttpMethod; use serde::Deserialize; +use crate::interface::port::repository::RepositoryPort; + +type Repo = dyn RepositoryPort; use crate::core::ebpf::dns_filter::DnsFilter; use crate::core::ebpf::protocol_filter::ProtocolFilter; @@ -48,12 +51,19 @@ async fn get_dns_blacklist(service: web::Data) -> impl Responder { async fn add_dns_blacklist( payload: web::Json, service: web::Data, + db: web::Data, ) -> impl Responder { let domains = payload.into_inner().domains; if domains.len() > MAX_DNS_DOMAINS_PER_REQUEST { return HttpResponse::BadRequest() .json(serde_json::json!({"error": format!("too many domains (max {})", MAX_DNS_DOMAINS_PER_REQUEST)})); } + for domain in &domains { + if let Err(e) = db.insert_dns_domain(domain) { + return HttpResponse::InternalServerError() + .json(serde_json::json!({"error": e.to_string()})); + } + } for domain in &domains { if let Err(e) = service.add_domain(domain) { return HttpResponse::InternalServerError() @@ -66,8 +76,15 @@ async fn add_dns_blacklist( async fn remove_dns_blacklist( payload: web::Json, service: web::Data, + db: web::Data, ) -> impl Responder { let domains = payload.into_inner().domains; + for domain in &domains { + if let Err(e) = db.delete_dns_domain(domain) { + return HttpResponse::InternalServerError() + .json(serde_json::json!({"error": e.to_string()})); + } + } for domain in &domains { if let Err(e) = service.remove_domain(domain) { return HttpResponse::InternalServerError() diff --git a/net-guardia/src/web/api/health.rs b/net-guardia/src/adapter/http/health.rs similarity index 91% rename from net-guardia/src/web/api/health.rs rename to net-guardia/src/adapter/http/health.rs index a347e95..c01d36a 100644 --- a/net-guardia/src/web/api/health.rs +++ b/net-guardia/src/adapter/http/health.rs @@ -1,6 +1,6 @@ use actix_web::{web, HttpResponse, Responder, Scope}; -use crate::core::infrastructure::health::SystemHealth; +use crate::infrastructure::health::SystemHealth; pub fn initialize() -> Scope { web::scope("/health") diff --git a/net-guardia/src/web/api/ml.rs b/net-guardia/src/adapter/http/ml.rs similarity index 100% rename from net-guardia/src/web/api/ml.rs rename to net-guardia/src/adapter/http/ml.rs diff --git a/net-guardia/src/web/api/mod.rs b/net-guardia/src/adapter/http/mod.rs similarity index 89% rename from net-guardia/src/web/api/mod.rs rename to net-guardia/src/adapter/http/mod.rs index 90d4c4c..bd119f0 100644 --- a/net-guardia/src/web/api/mod.rs +++ b/net-guardia/src/adapter/http/mod.rs @@ -1,9 +1,9 @@ pub mod acl; +pub mod auth; +pub mod default; pub mod filter; -pub mod rate_limit; -pub mod stats; pub mod health; pub mod ml; +pub mod rate_limit; +pub mod stats; pub mod system; -pub mod default; -pub mod ws; diff --git a/net-guardia/src/web/api/rate_limit.rs b/net-guardia/src/adapter/http/rate_limit.rs similarity index 70% rename from net-guardia/src/web/api/rate_limit.rs rename to net-guardia/src/adapter/http/rate_limit.rs index 5dd2f07..1b5f4a7 100644 --- a/net-guardia/src/web/api/rate_limit.rs +++ b/net-guardia/src/adapter/http/rate_limit.rs @@ -2,6 +2,9 @@ use actix_web::{web, HttpResponse, Responder, Scope}; use serde::{Deserialize, Serialize}; use common::define::setting::*; +use crate::interface::port::repository::RepositoryPort; + +type Repo = dyn RepositoryPort; use crate::core::ebpf::rate_limit::RateLimitConfig; #[derive(Serialize, Deserialize)] @@ -34,29 +37,45 @@ async fn get_config( async fn set_config( settings: web::Json, config: web::Data, + db: web::Data, ) -> impl Responder { let s = settings.into_inner(); if let Some(v) = s.packet_rate { + if let Err(e) = db.set_rate_limit("packet_rate", v) { + return HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})); + } if let Err(e) = config.set_packet_rate(v) { return HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})); } } if let Some(v) = s.syn_rate { + if let Err(e) = db.set_rate_limit("syn_rate", v) { + return HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})); + } if let Err(e) = config.set_syn_rate(v) { return HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})); } } if let Some(v) = s.udp_rate { + if let Err(e) = db.set_rate_limit("udp_rate", v) { + return HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})); + } if let Err(e) = config.set_udp_rate(v) { return HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})); } } if let Some(v) = s.dns_rate { + if let Err(e) = db.set_rate_limit("dns_rate", v) { + return HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})); + } if let Err(e) = config.set_dns_rate(v) { return HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})); } } if let Some(v) = s.window_ns { + if let Err(e) = db.set_rate_limit("window_ns", v) { + return HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})); + } if let Err(e) = config.set_window_ns(v) { return HttpResponse::InternalServerError().json(serde_json::json!({"error": e.to_string()})); } diff --git a/net-guardia/src/web/api/stats.rs b/net-guardia/src/adapter/http/stats.rs similarity index 94% rename from net-guardia/src/web/api/stats.rs rename to net-guardia/src/adapter/http/stats.rs index 1c069a9..a9a1171 100644 --- a/net-guardia/src/web/api/stats.rs +++ b/net-guardia/src/adapter/http/stats.rs @@ -1,7 +1,7 @@ use actix_web::{web, HttpResponse, Responder, Scope}; use crate::core::ebpf::drop_monitor::DropMonitor; -use crate::core::infrastructure::statistics::FlowStatistics; +use crate::infrastructure::statistics::FlowStatistics; pub fn initialize() -> Scope { web::scope("/stats") diff --git a/net-guardia/src/adapter/http/system.rs b/net-guardia/src/adapter/http/system.rs new file mode 100644 index 0000000..0a49fa8 --- /dev/null +++ b/net-guardia/src/adapter/http/system.rs @@ -0,0 +1,75 @@ +use actix_web::{web, HttpResponse, Responder, Scope}; +use serde::Deserialize; + +use crate::infrastructure::communication_manager::CommunicationManager; +use crate::interface::communication::command_types::ChangeEnforceModeCommand; +use crate::interface::communication::query_types::GetEnforceModeQuery; +use crate::interface::port::repository::RepositoryPort; + +type Repo = dyn RepositoryPort; + +#[derive(Deserialize)] +struct EnforceModeRequest { + mode: String, +} + +pub fn initialize() -> Scope { + let scope = web::scope("/system") + .route("/boot-time", web::get().to(get_boot_time)) + .route("/enforce-mode", web::get().to(get_enforce_mode)) + .route("/enforce-mode", web::put().to(set_enforce_mode)) + .route("/xdp-mode", web::get().to(get_xdp_mode)); + + #[cfg(feature = "license")] + let scope = scope.route("/license", web::get().to(get_license_info)); + + scope +} + +async fn get_boot_time() -> impl Responder { + HttpResponse::Ok().json(crate::utils::boot_time::boot_time()) +} + +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()})), + } +} + +async fn set_enforce_mode( + body: web::Json, + comm: web::Data, +) -> impl Responder { + let mode = &body.mode; + if mode != "monitor" && mode != "enforce" { + return HttpResponse::BadRequest() + .json(serde_json::json!({"error": "Mode must be 'monitor' 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()})), + } +} + +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()); + + HttpResponse::Ok().json(serde_json::json!({ + "ingress_mode": ingress, + "egress_mode": egress, + })) +} + +#[cfg(feature = "license")] +async fn get_license_info(license_info: web::Data) -> impl Responder { + HttpResponse::Ok().json(license_info.get_ref()) +} diff --git a/net-guardia/src/adapter/mod.rs b/net-guardia/src/adapter/mod.rs new file mode 100644 index 0000000..91f4394 --- /dev/null +++ b/net-guardia/src/adapter/mod.rs @@ -0,0 +1,3 @@ +pub mod http; +pub mod persistence; +pub mod websocket; diff --git a/net-guardia/src/adapter/persistence/mod.rs b/net-guardia/src/adapter/persistence/mod.rs new file mode 100644 index 0000000..b3879f6 --- /dev/null +++ b/net-guardia/src/adapter/persistence/mod.rs @@ -0,0 +1,3 @@ +pub mod repository; + +pub use repository::Database; diff --git a/net-guardia/src/adapter/persistence/repository.rs b/net-guardia/src/adapter/persistence/repository.rs new file mode 100644 index 0000000..11238bb --- /dev/null +++ b/net-guardia/src/adapter/persistence/repository.rs @@ -0,0 +1,777 @@ +use parking_lot::Mutex; +use rusqlite::{Connection, params}; + +use crate::model::error::database::DatabaseError; +use crate::model::error::Error; + +pub struct Database { + conn: Mutex, +} + +impl Database { + pub fn new(path: &str) -> Result { + let conn = Connection::open(path)?; + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?; + let db = Self { conn: Mutex::new(conn) }; + db.create_tables()?; + Ok(db) + } + + fn create_tables(&self) -> Result<(), Error> { + let conn = self.conn.lock(); + conn.execute_batch(" + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'viewer', + force_password_change INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS acl_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ip_version INTEGER NOT NULL, + direction TEXT NOT NULL, + list_type TEXT NOT NULL, + ip_address TEXT NOT NULL, + port INTEGER NOT NULL, + UNIQUE(ip_version, direction, list_type, ip_address, port) + ); + CREATE TABLE IF NOT EXISTS rate_limit_config ( + key TEXT PRIMARY KEY, + value INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS dns_blacklist ( + domain TEXT PRIMARY KEY + ); + CREATE TABLE IF NOT EXISTS geo_blocked_countries ( + country_code TEXT PRIMARY KEY + ); + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS user_groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + description TEXT NOT NULL DEFAULT '', + permissions TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE IF NOT EXISTS user_group_members ( + user_id INTEGER NOT NULL, + group_id INTEGER NOT NULL, + PRIMARY KEY (user_id, group_id), + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (group_id) REFERENCES user_groups(id) + ); + ")?; + + // Migration: add force_password_change column if missing (for existing DBs) + let conn_ref = &*conn; + let has_column: bool = conn_ref + .prepare("SELECT force_password_change FROM users LIMIT 0") + .is_ok(); + if !has_column { + conn_ref.execute_batch( + "ALTER TABLE users ADD COLUMN force_password_change INTEGER NOT NULL DEFAULT 0;" + )?; + } + + // Migration: seed default user groups if table is empty + 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(); + 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", + "system:read" + ]).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], + )?; + conn_ref.execute( + "INSERT INTO user_groups (name, description, permissions) VALUES (?1, ?2, ?3)", + params!["Viewer", "Read-only access to all modules", &viewer_permissions], + )?; + } + + // Migration: assign existing users to default groups if user_group_members is empty + let member_count: i64 = conn_ref.query_row( + "SELECT COUNT(*) FROM user_group_members", [], |row| row.get(0), + )?; + if member_count == 0 { + // Get admin group id and viewer group id + let admin_group_id: Option = conn_ref.query_row( + "SELECT id FROM user_groups WHERE name = 'Administrator'", [], + |row| row.get(0), + ).ok(); + let viewer_group_id: Option = conn_ref.query_row( + "SELECT id FROM user_groups WHERE name = 'Viewer'", [], + |row| row.get(0), + ).ok(); + + if let Some(ag_id) = admin_group_id { + let mut stmt = conn_ref.prepare("SELECT id FROM users WHERE role = 'admin'")?; + let admin_ids: Vec = stmt.query_map([], |row| row.get(0))? + .filter_map(|r| r.ok()).collect(); + for uid in admin_ids { + conn_ref.execute( + "INSERT OR IGNORE INTO user_group_members (user_id, group_id) VALUES (?1, ?2)", + params![uid, ag_id], + )?; + } + } + if let Some(vg_id) = viewer_group_id { + let mut stmt = conn_ref.prepare("SELECT id FROM users WHERE role = 'viewer'")?; + let viewer_ids: Vec = stmt.query_map([], |row| row.get(0))? + .filter_map(|r| r.ok()).collect(); + for uid in viewer_ids { + conn_ref.execute( + "INSERT OR IGNORE INTO user_group_members (user_id, group_id) VALUES (?1, ?2)", + params![uid, vg_id], + )?; + } + } + } + + Ok(()) + } + + // --- ACL --- + 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.lock(); + conn.execute( + "INSERT OR IGNORE INTO acl_rules (ip_version, direction, list_type, ip_address, port) VALUES (?1, ?2, ?3, ?4, ?5)", + params![ip_version, direction, list_type, ip_address, port as i64], + )?; + Ok(()) + } + + 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.lock(); + conn.execute( + "DELETE FROM acl_rules WHERE ip_version = ?1 AND direction = ?2 AND list_type = ?3 AND ip_address = ?4 AND port = ?5", + params![ip_version, direction, list_type, ip_address, port as i64], + )?; + Ok(()) + } + + pub fn load_acl_rules(&self) -> Result, Error> { + let conn = self.conn.lock(); + let mut stmt = conn.prepare("SELECT ip_version, direction, list_type, ip_address, port FROM acl_rules")?; + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, u8>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, i64>(4)? as u16, + )) + })?; + let mut results = Vec::new(); + for row in rows { + results.push(row?); + } + Ok(results) + } + + // --- Rate Limit --- + pub fn set_rate_limit(&self, key: &str, value: u64) -> Result<(), Error> { + let conn = self.conn.lock(); + conn.execute( + "INSERT OR REPLACE INTO rate_limit_config (key, value) VALUES (?1, ?2)", + params![key, value as i64], + )?; + Ok(()) + } + + pub fn load_rate_limit_config(&self) -> Result, Error> { + let conn = self.conn.lock(); + let mut stmt = conn.prepare("SELECT key, value FROM rate_limit_config")?; + let rows = stmt.query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as u64)) + })?; + let mut results = Vec::new(); + for row in rows { + results.push(row?); + } + Ok(results) + } + + // --- DNS --- + pub fn insert_dns_domain(&self, domain: &str) -> Result<(), Error> { + let conn = self.conn.lock(); + conn.execute("INSERT OR IGNORE INTO dns_blacklist (domain) VALUES (?1)", params![domain])?; + Ok(()) + } + + pub fn delete_dns_domain(&self, domain: &str) -> Result<(), Error> { + let conn = self.conn.lock(); + conn.execute("DELETE FROM dns_blacklist WHERE domain = ?1", params![domain])?; + Ok(()) + } + + pub fn load_dns_domains(&self) -> Result, Error> { + let conn = self.conn.lock(); + let mut stmt = conn.prepare("SELECT domain FROM dns_blacklist")?; + let rows = stmt.query_map([], |row| row.get(0))?; + let mut results = Vec::new(); + for row in rows { + results.push(row?); + } + Ok(results) + } + + // --- Geo --- + pub fn insert_geo_country(&self, code: &str) -> Result<(), Error> { + let conn = self.conn.lock(); + 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.lock(); + conn.execute("DELETE FROM geo_blocked_countries WHERE country_code = ?1", params![code])?; + Ok(()) + } + + pub fn load_geo_countries(&self) -> Result, Error> { + let conn = self.conn.lock(); + let mut stmt = conn.prepare("SELECT country_code FROM geo_blocked_countries")?; + let rows = stmt.query_map([], |row| row.get(0))?; + let mut results = Vec::new(); + for row in rows { + results.push(row?); + } + Ok(results) + } + + // --- Settings --- + pub fn get_setting(&self, key: &str) -> Result, Error> { + let conn = self.conn.lock(); + 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), + Err(e) => Err(e.into()), + } + } + + pub fn set_setting(&self, key: &str, value: &str) -> Result<(), Error> { + let conn = self.conn.lock(); + conn.execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)", + params![key, value], + )?; + Ok(()) + } + + // --- Users --- + pub fn find_user(&self, username: &str) -> Result, Error> { + let conn = self.conn.lock(); + 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)), + ); + match result { + Ok(user) => Ok(Some(user)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } + } + + pub fn insert_user(&self, username: &str, password_hash: &str, role: &str, force_password_change: bool) -> Result { + let conn = self.conn.lock(); + conn.execute( + "INSERT INTO users (username, password_hash, role, force_password_change) VALUES (?1, ?2, ?3, ?4)", + params![username, password_hash, role, force_password_change as i64], + ).map_err(|e| -> Error { + if e.to_string().contains("UNIQUE constraint") { + DatabaseError::UserAlreadyExists { username: username.to_string() }.into() + } else { + e.into() + } + })?; + Ok(conn.last_insert_rowid()) + } + + pub fn update_user_password(&self, user_id: i64, password_hash: &str) -> Result<(), Error> { + let conn = self.conn.lock(); + conn.execute( + "UPDATE users SET password_hash = ?1, force_password_change = 0 WHERE id = ?2", + params![password_hash, user_id], + )?; + Ok(()) + } + + pub fn user_count(&self) -> Result { + let conn = self.conn.lock(); + Ok(conn.query_row("SELECT COUNT(*) FROM users", [], |row| row.get(0))?) + } + + pub fn list_users(&self) -> Result, Error> { + let conn = self.conn.lock(); + let mut stmt = conn.prepare("SELECT id, username, role, force_password_change, created_at FROM users ORDER BY id")?; + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, i64>(3)? != 0, + row.get::<_, String>(4)?, + )) + })?; + let mut results = Vec::new(); + for row in rows { + results.push(row?); + } + Ok(results) + } + + pub fn delete_user(&self, user_id: i64) -> Result { + self.cleanup_user_memberships(user_id)?; + let conn = self.conn.lock(); + let affected = conn.execute("DELETE FROM users WHERE id = ?1", params![user_id])?; + Ok(affected > 0) + } + + pub fn update_user_role(&self, user_id: i64, role: &str) -> Result<(), Error> { + let conn = self.conn.lock(); + conn.execute("UPDATE users SET role = ?1 WHERE id = ?2", params![role, user_id])?; + Ok(()) + } + + pub fn reset_user_password(&self, user_id: i64, password_hash: &str) -> Result<(), Error> { + let conn = self.conn.lock(); + conn.execute( + "UPDATE users SET password_hash = ?1, force_password_change = 1 WHERE id = ?2", + params![password_hash, user_id], + )?; + Ok(()) + } + + pub fn find_user_by_id(&self, user_id: i64) -> Result, Error> { + let conn = self.conn.lock(); + 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)), + ); + match result { + Ok(user) => Ok(Some(user)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } + } + + // --- User Groups --- + pub fn list_user_groups(&self) -> Result, Error> { + let conn = self.conn.lock(); + 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)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + )) + })?; + let mut results = Vec::new(); + for row in rows { + results.push(row?); + } + Ok(results) + } + + pub fn create_user_group(&self, name: &str, description: &str, permissions: &str) -> Result { + let conn = self.conn.lock(); + conn.execute( + "INSERT INTO user_groups (name, description, permissions) VALUES (?1, ?2, ?3)", + params![name, description, permissions], + ).map_err(|e| -> Error { + if e.to_string().contains("UNIQUE constraint") { + DatabaseError::QueryFailed { reason: format!("Group '{}' already exists", name) }.into() + } else { + e.into() + } + })?; + Ok(conn.last_insert_rowid()) + } + + pub fn update_user_group(&self, id: i64, name: &str, description: &str, permissions: &str) -> Result<(), Error> { + let conn = self.conn.lock(); + conn.execute( + "UPDATE user_groups SET name = ?1, description = ?2, permissions = ?3 WHERE id = ?4", + params![name, description, permissions, id], + )?; + Ok(()) + } + + pub fn delete_user_group(&self, id: i64) -> Result { + let conn = self.conn.lock(); + conn.execute("DELETE FROM user_group_members WHERE group_id = ?1", params![id])?; + let affected = conn.execute("DELETE FROM user_groups WHERE id = ?1", params![id])?; + Ok(affected > 0) + } + + pub fn get_user_group(&self, id: i64) -> Result, Error> { + let conn = self.conn.lock(); + 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)?, + )), + ); + match result { + Ok(group) => Ok(Some(group)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } + } + + // --- User Group Membership --- + pub fn get_user_groups(&self, user_id: i64) -> Result, Error> { + let conn = self.conn.lock(); + 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" + )?; + let rows = stmt.query_map(params![user_id], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + )) + })?; + let mut results = Vec::new(); + for row in rows { + results.push(row?); + } + Ok(results) + } + + pub fn set_user_groups(&self, user_id: i64, group_ids: &[i64]) -> Result<(), Error> { + let conn = self.conn.lock(); + conn.execute("DELETE FROM user_group_members WHERE user_id = ?1", params![user_id])?; + for &gid in group_ids { + conn.execute( + "INSERT INTO user_group_members (user_id, group_id) VALUES (?1, ?2)", + params![user_id, gid], + )?; + } + Ok(()) + } + + pub fn get_user_permissions(&self, user_id: i64) -> Result, Error> { + let groups = self.get_user_groups(user_id)?; + let mut all_perms = std::collections::HashSet::new(); + for (_id, _name, _desc, perms_json) in groups { + if let Ok(perms) = serde_json::from_str::>(&perms_json) { + for p in perms { + all_perms.insert(p); + } + } + } + let mut result: Vec = all_perms.into_iter().collect(); + result.sort(); + Ok(result) + } + + pub fn cleanup_user_memberships(&self, user_id: i64) -> Result<(), Error> { + let conn = self.conn.lock(); + conn.execute("DELETE FROM user_group_members WHERE user_id = ?1", params![user_id])?; + Ok(()) + } + + pub fn get_group_member_ids(&self, group_id: i64) -> Result, Error> { + let conn = self.conn.lock(); + let mut stmt = conn.prepare("SELECT user_id FROM user_group_members WHERE group_id = ?1")?; + let rows = stmt.query_map(params![group_id], |row| row.get::<_, i64>(0))?; + let mut results = Vec::new(); + for row in rows { + results.push(row?); + } + Ok(results) + } + + // --- Login Rate Limiting --- + pub fn record_login_failure(&self, username: &str) -> Result<(u32, Option), Error> { + 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; + + self.set_setting(&key_count, &count.to_string())?; + + if count >= 5 { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let locked_until = now + 900; // 15 minutes + self.set_setting(&key_locked, &locked_until.to_string())?; + Ok((count, Some(locked_until))) + } else { + Ok((count, None)) + } + } + + 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)?; + } + Ok(None) + } + + pub fn clear_login_failures(&self, username: &str) -> Result<(), Error> { + let conn = self.conn.lock(); + 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(()) + } +} + +/// 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 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 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) } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_db() -> Database { + Database::new(":memory:").expect("Failed to create test database") + } + + #[test] + fn test_create_tables() { + let _db = test_db(); + } + + #[test] + fn test_user_crud() { + let db = test_db(); + assert_eq!(db.user_count().unwrap(), 0); + + db.insert_user("admin", "hash123", "admin", true).unwrap(); + assert_eq!(db.user_count().unwrap(), 1); + + let user = db.find_user("admin").unwrap().unwrap(); + assert_eq!(user.0, 1); // id + assert_eq!(user.1, "admin"); // username + assert_eq!(user.2, "hash123"); // password_hash + assert_eq!(user.3, "admin"); // role + assert!(user.4); // force_password_change + } + + #[test] + fn test_user_duplicate() { + let db = test_db(); + db.insert_user("admin", "hash", "admin", false).unwrap(); + let result = db.insert_user("admin", "hash2", "admin", false); + assert!(result.is_err()); + } + + #[test] + fn test_update_password_clears_force_change() { + let db = test_db(); + db.insert_user("admin", "old_hash", "admin", true).unwrap(); + + let user = db.find_user("admin").unwrap().unwrap(); + assert!(user.4); // force_password_change = true + + db.update_user_password(user.0, "new_hash").unwrap(); + + let user = db.find_user("admin").unwrap().unwrap(); + assert!(!user.4); // force_password_change = false + assert_eq!(user.2, "new_hash"); + } + + #[test] + fn test_settings_crud() { + let db = test_db(); + assert_eq!(db.get_setting("foo").unwrap(), None); + + db.set_setting("foo", "bar").unwrap(); + assert_eq!(db.get_setting("foo").unwrap(), Some("bar".to_string())); + + db.set_setting("foo", "baz").unwrap(); + assert_eq!(db.get_setting("foo").unwrap(), Some("baz".to_string())); + } + + #[test] + fn test_acl_crud() { + let db = test_db(); + 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)); + + db.delete_acl_rule(4, "source", "blacklist", "192.168.1.1", 80).unwrap(); + let rules = db.load_acl_rules().unwrap(); + assert!(rules.is_empty()); + } + + #[test] + fn test_dns_crud() { + let db = test_db(); + db.insert_dns_domain("evil.com").unwrap(); + let domains = db.load_dns_domains().unwrap(); + assert_eq!(domains, vec!["evil.com"]); + + db.delete_dns_domain("evil.com").unwrap(); + assert!(db.load_dns_domains().unwrap().is_empty()); + } + + #[test] + fn test_geo_crud() { + let db = test_db(); + db.insert_geo_country("CN").unwrap(); + let countries = db.load_geo_countries().unwrap(); + assert_eq!(countries, vec!["CN"]); + + db.delete_geo_country("CN").unwrap(); + assert!(db.load_geo_countries().unwrap().is_empty()); + } + + #[test] + fn test_rate_limit_crud() { + let db = test_db(); + db.set_rate_limit("packet_rate", 1000).unwrap(); + let configs = db.load_rate_limit_config().unwrap(); + assert_eq!(configs.len(), 1); + assert_eq!(configs[0], ("packet_rate".to_string(), 1000)); + } + + #[test] + fn test_login_lockout() { + let db = test_db(); + + // First 4 failures don't lock + for i in 1..5 { + let (count, locked) = db.record_login_failure("admin").unwrap(); + assert_eq!(count, i); + assert!(locked.is_none()); + } + + // 5th failure triggers lock + let (count, locked) = db.record_login_failure("admin").unwrap(); + assert_eq!(count, 5); + assert!(locked.is_some()); + + // Check locked + let remaining = db.check_login_locked("admin").unwrap(); + assert!(remaining.is_some()); + assert!(remaining.unwrap() > 0); + + // Clear and verify + db.clear_login_failures("admin").unwrap(); + let remaining = db.check_login_locked("admin").unwrap(); + assert!(remaining.is_none()); + } + + #[test] + fn test_find_nonexistent_user() { + let db = test_db(); + assert!(db.find_user("nobody").unwrap().is_none()); + } + + /// Verify that Database satisfies the RepositoryPort trait contract. + /// This test ensures the trait impl compiles and can be used via trait object. + #[test] + fn test_repository_port_trait_object() { + use crate::interface::port::repository::RepositoryPort; + + let db = test_db(); + let repo: &dyn RepositoryPort = &db; + + // Use via trait object — proves the abstraction works + repo.set_setting("test_key", "test_value").unwrap(); + assert_eq!(repo.get_setting("test_key").unwrap(), Some("test_value".to_string())); + + repo.insert_acl_rule(4, "source", "blacklist", "10.0.0.1", 443).unwrap(); + let rules = repo.load_acl_rules().unwrap(); + assert_eq!(rules.len(), 1); + + assert_eq!(repo.user_count().unwrap(), 0); + repo.insert_user("test", "hash", "viewer", false).unwrap(); + assert_eq!(repo.user_count().unwrap(), 1); + } +} diff --git a/net-guardia/src/web/websocket/alert_websocket.rs b/net-guardia/src/adapter/websocket/alert_websocket.rs similarity index 96% rename from net-guardia/src/web/websocket/alert_websocket.rs rename to net-guardia/src/adapter/websocket/alert_websocket.rs index e0de9b7..cd4f312 100644 --- a/net-guardia/src/web/websocket/alert_websocket.rs +++ b/net-guardia/src/adapter/websocket/alert_websocket.rs @@ -4,7 +4,8 @@ use futures_util::StreamExt; use macros::log; use tokio::sync::broadcast; -use crate::core::ml::alert::{MLAlert, AlertMessage}; +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; diff --git a/net-guardia/src/web/websocket/drop_websocket.rs b/net-guardia/src/adapter/websocket/drop_websocket.rs similarity index 96% rename from net-guardia/src/web/websocket/drop_websocket.rs rename to net-guardia/src/adapter/websocket/drop_websocket.rs index 149b531..32a681c 100644 --- a/net-guardia/src/web/websocket/drop_websocket.rs +++ b/net-guardia/src/adapter/websocket/drop_websocket.rs @@ -4,7 +4,8 @@ use futures_util::StreamExt; use macros::log; use tokio::sync::broadcast; -use crate::core::ebpf::drop_monitor::{DropMonitor, DropEventMessage}; +use crate::core::ebpf::drop_monitor::DropMonitor; +use crate::model::drop_event::DropEventMessage; use crate::model::error::http::HttpError; use crate::model::error::misc::MiscError; use crate::model::log::http::HttpLog; diff --git a/net-guardia/src/web/websocket/flow_websocket.rs b/net-guardia/src/adapter/websocket/flow_websocket.rs similarity index 91% rename from net-guardia/src/web/websocket/flow_websocket.rs rename to net-guardia/src/adapter/websocket/flow_websocket.rs index 9f7459e..6b2893b 100644 --- a/net-guardia/src/web/websocket/flow_websocket.rs +++ b/net-guardia/src/adapter/websocket/flow_websocket.rs @@ -5,7 +5,7 @@ use actix_ws::Message; use futures_util::StreamExt; use tokio::time::interval; -use crate::core::infrastructure::statistics::FlowStatistics; +use crate::infrastructure::statistics::FlowStatistics; use crate::model::flow_stats::FlowSubscription; /// Default subscription: all flows, no filter, 5 second interval @@ -35,10 +35,9 @@ pub async fn flow_stats_ws( tokio::select! { _ = ticker.tick() => { let flows = stats.get_filtered_flows(&subscription); - if let Ok(json) = serde_json::to_string(&flows) { - if session.text(json).await.is_err() { + if let Ok(json) = serde_json::to_string(&flows) + && session.text(json).await.is_err() { break; - } } } msg = msg_stream.next() => { @@ -52,10 +51,9 @@ pub async fn flow_stats_ws( ticker = interval(Duration::from_secs(new_interval)); let flows = stats.get_filtered_flows(&subscription); - if let Ok(json) = serde_json::to_string(&flows) { - if session.text(json).await.is_err() { + if let Ok(json) = serde_json::to_string(&flows) + && session.text(json).await.is_err() { break; - } } } Err(e) => { diff --git a/net-guardia/src/web/websocket/health_websocket.rs b/net-guardia/src/adapter/websocket/health_websocket.rs similarity index 98% rename from net-guardia/src/web/websocket/health_websocket.rs rename to net-guardia/src/adapter/websocket/health_websocket.rs index 43cc24e..df2a86c 100644 --- a/net-guardia/src/web/websocket/health_websocket.rs +++ b/net-guardia/src/adapter/websocket/health_websocket.rs @@ -4,7 +4,7 @@ use futures_util::StreamExt; use macros::log; use tokio::sync::broadcast; -use crate::core::infrastructure::health::SystemHealth; +use crate::infrastructure::health::SystemHealth; use crate::model::error::http::HttpError; use crate::model::error::misc::MiscError; use crate::model::log::http::HttpLog; diff --git a/net-guardia/src/web/websocket/mod.rs b/net-guardia/src/adapter/websocket/mod.rs similarity index 86% rename from net-guardia/src/web/websocket/mod.rs rename to net-guardia/src/adapter/websocket/mod.rs index d4e5d08..a488f84 100644 --- a/net-guardia/src/web/websocket/mod.rs +++ b/net-guardia/src/adapter/websocket/mod.rs @@ -2,3 +2,4 @@ pub mod alert_websocket; pub mod drop_websocket; pub mod flow_websocket; pub mod health_websocket; +pub mod routes; diff --git a/net-guardia/src/web/api/ws.rs b/net-guardia/src/adapter/websocket/routes.rs similarity index 56% rename from net-guardia/src/web/api/ws.rs rename to net-guardia/src/adapter/websocket/routes.rs index e624dd5..ff80762 100644 --- a/net-guardia/src/web/api/ws.rs +++ b/net-guardia/src/adapter/websocket/routes.rs @@ -1,10 +1,17 @@ use actix_web::{web, HttpRequest, HttpResponse, Responder, Scope}; +use serde::Deserialize; +use crate::core::auth::jwt::JwtService; use crate::core::ebpf::drop_monitor::DropMonitor; -use crate::core::infrastructure::health::SystemHealth; -use crate::core::infrastructure::statistics::FlowStatistics; +use crate::infrastructure::health::SystemHealth; +use crate::infrastructure::statistics::FlowStatistics; use crate::core::ml::alert::MLAlert; -use crate::web::websocket::{alert_websocket, drop_websocket, flow_websocket, health_websocket}; +use super::{alert_websocket, drop_websocket, flow_websocket, health_websocket}; + +#[derive(Deserialize)] +struct WsQuery { + token: Option, +} pub fn initialize() -> Scope { web::scope("/ws") @@ -14,11 +21,29 @@ pub fn initialize() -> Scope { .route("/drops", web::get().to(drops_ws)) } +fn validate_ws_token(query: &web::Query, jwt: &web::Data) -> Result<(), HttpResponse> { + match &query.token { + Some(token) => { + jwt.validate_token(token) + .map(|_| ()) + .map_err(|_| HttpResponse::Unauthorized() + .json(serde_json::json!({"error": "Invalid or expired token"}))) + } + None => Err(HttpResponse::Unauthorized() + .json(serde_json::json!({"error": "Missing token query parameter"}))), + } +} + async fn health_ws( req: HttpRequest, stream: web::Payload, health: web::Data, + query: web::Query, + jwt: web::Data, ) -> impl Responder { + if let Err(resp) = validate_ws_token(&query, &jwt) { + return resp; + } 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)})), @@ -29,7 +54,12 @@ async fn alerts_ws( req: HttpRequest, stream: web::Payload, ai: web::Data, + query: web::Query, + jwt: web::Data, ) -> impl Responder { + if let Err(resp) = validate_ws_token(&query, &jwt) { + return resp; + } 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)})), @@ -40,7 +70,12 @@ async fn flows_ws( req: HttpRequest, stream: web::Payload, stats: web::Data, + query: web::Query, + jwt: web::Data, ) -> impl Responder { + if let Err(resp) = validate_ws_token(&query, &jwt) { + return resp; + } 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)})), @@ -51,7 +86,12 @@ async fn drops_ws( req: HttpRequest, stream: web::Payload, monitor: web::Data, + query: web::Query, + jwt: web::Data, ) -> impl Responder { + if let Err(resp) = validate_ws_token(&query, &jwt) { + return resp; + } 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)})), diff --git a/net-guardia/src/core/auth/jwt.rs b/net-guardia/src/core/auth/jwt.rs new file mode 100644 index 0000000..dc0d53e --- /dev/null +++ b/net-guardia/src/core/auth/jwt.rs @@ -0,0 +1,144 @@ +use jsonwebtoken::{decode, encode, errors::ErrorKind, DecodingKey, EncodingKey, Header, Validation}; + +use crate::interface::port::repository::RepositoryPort; +use crate::model::auth::Claims; +use crate::model::error::auth::AuthError; +use crate::model::error::Error; + +pub struct JwtService { + encoding_key: EncodingKey, + decoding_key: DecodingKey, + expiry_hours: u64, +} + +impl JwtService { + pub fn new(db: &dyn RepositoryPort, expiry_hours: u64) -> Result { + let secret = match db.get_setting("jwt_secret")? { + Some(s) => s, + None => { + use rand::Rng; + let secret: Vec = rand::rng().random::<[u8; 32]>().to_vec(); + let encoded = hex_encode(&secret); + db.set_setting("jwt_secret", &encoded)?; + encoded + } + }; + + let secret_bytes = secret.as_bytes(); + Ok(Self { + encoding_key: EncodingKey::from_secret(secret_bytes), + decoding_key: DecodingKey::from_secret(secret_bytes), + expiry_hours, + }) + } + + 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() + .as_secs(); + + let claims = Claims { + sub: user_id, + username: username.to_string(), + role: role.to_string(), + permissions, + exp: (now + self.expiry_hours * 3600) as usize, + }; + + 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::default()) + .map_err(|e| { + match e.kind() { + ErrorKind::ExpiredSignature => Error::from(AuthError::TokenExpired), + _ => Error::from(AuthError::InvalidToken), + } + })?; + Ok(token_data.claims) + } +} + +fn hex_encode(data: &[u8]) -> String { + use std::fmt::Write; + let mut s = String::with_capacity(data.len() * 2); + for b in data { + write!(s, "{:02x}", b).unwrap(); + } + s +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::adapter::persistence::Database; + + fn test_jwt_service() -> JwtService { + let db = Database::new(":memory:").unwrap(); + JwtService::new(&db, 24).unwrap() + } + + #[test] + fn test_create_and_validate_token() { + let jwt = test_jwt_service(); + let perms = vec!["dashboard:read".to_string()]; + let token = jwt.create_token(1, "admin", "admin", perms.clone()).unwrap(); + let claims = jwt.validate_token(&token).unwrap(); + assert_eq!(claims.sub, 1); + assert_eq!(claims.username, "admin"); + assert_eq!(claims.role, "admin"); + assert_eq!(claims.permissions, perms); + } + + #[test] + fn test_invalid_token() { + let jwt = test_jwt_service(); + let result = jwt.validate_token("invalid.token.here"); + assert!(result.is_err()); + } + + #[test] + fn test_expired_token() { + let db = Database::new(":memory:").unwrap(); + let jwt = JwtService::new(&db, 0).unwrap(); // 0 hours = immediate expiry + + // Create token with 0 hour expiry — it expires in the past + let claims = Claims { + sub: 1, + username: "admin".to_string(), + role: "admin".to_string(), + permissions: vec![], + exp: 0, // epoch = expired + }; + let token = encode(&Header::default(), &claims, &jwt.encoding_key).unwrap(); + let result = jwt.validate_token(&token); + assert!(result.is_err()); + } + + #[test] + fn test_jwt_secret_persistence() { + let db = Database::new(":memory:").unwrap(); + + // First creation generates and stores secret + let jwt1 = JwtService::new(&db, 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 claims = jwt2.validate_token(&token).unwrap(); + assert_eq!(claims.username, "admin"); + } + + #[test] + fn test_different_secrets_reject() { + let jwt1 = test_jwt_service(); + let jwt2 = test_jwt_service(); // different in-memory DB = different secret + + let token = jwt1.create_token(1, "admin", "admin", vec![]).unwrap(); + let result = jwt2.validate_token(&token); + assert!(result.is_err()); + } +} diff --git a/net-guardia/src/core/auth/middleware.rs b/net-guardia/src/core/auth/middleware.rs new file mode 100644 index 0000000..b316d07 --- /dev/null +++ b/net-guardia/src/core/auth/middleware.rs @@ -0,0 +1,150 @@ +use std::future::{ready, Future, 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 crate::core::auth::jwt::JwtService; + +pub struct AuthMiddleware; + +impl Transform for AuthMiddleware +where + S: Service, Error = ActixError> + 'static, + B: 'static, +{ + type Response = ServiceResponse>; + type Error = ActixError; + type Transform = AuthMiddlewareService; + type InitError = (); + type Future = Ready>; + + fn new_transform(&self, service: S) -> Self::Future { + ready(Ok(AuthMiddlewareService { + service: Rc::new(service), + })) + } +} + +pub struct AuthMiddlewareService { + service: Rc, +} + +fn required_permission(path: &str, method: &actix_web::http::Method) -> Option { + let resource = if path.starts_with("/api/auth/") { + return None; // Auth endpoints handled separately + } else if path.starts_with("/api/health/") || path.starts_with("/api/stats/") { + "dashboard" + } else if path.starts_with("/api/ml/") { + "ai_detection" + } else if path.starts_with("/api/acl/geo/") { + "geo_block" + } else if path.starts_with("/api/acl/") { + "access_control" + } else if path.starts_with("/api/filter/dns/") { + "dns_filter" + } else if path.starts_with("/api/filter/http/") || path.starts_with("/api/filter/ssh/") { + "protocol_filter" + } else if path.starts_with("/api/rate-limit/") { + "rate_limit" + } else if path.starts_with("/api/system/") { + "system" + } else { + return None; + }; + + let action = match *method { + actix_web::http::Method::GET => "read", + _ => "write", + }; + + Some(format!("{}:{}", resource, action)) +} + +impl Service for AuthMiddlewareService +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 = Rc::clone(&self.service); + + Box::pin(async move { + let path = req.path().to_string(); + + // Skip auth for login endpoint and non-API routes + if path == "/api/auth/login" || !path.starts_with("/api/") { + let res = service.call(req).await?.map_into_left_body(); + return Ok(res); + } + + // Extract JWT service from app data + let jwt_service = match req.app_data::>() { + Some(s) => s.clone(), + None => { + let resp = HttpResponse::InternalServerError() + .json(serde_json::json!({"error": "Auth not configured"})); + return Ok(req.into_response(resp).map_into_right_body()); + } + }; + + // Extract token from Authorization header + let auth_header = req.headers().get("Authorization"); + let token = match auth_header { + Some(val) => { + let val_str = val.to_str().unwrap_or(""); + if let Some(token_str) = val_str.strip_prefix("Bearer ") { + token_str + } else { + let resp = HttpResponse::Unauthorized() + .json(serde_json::json!({"error": "Invalid authorization header"})); + return Ok(req.into_response(resp).map_into_right_body()); + } + } + None => { + let resp = HttpResponse::Unauthorized() + .json(serde_json::json!({"error": "Missing authorization header"})); + return Ok(req.into_response(resp).map_into_right_body()); + } + }; + + // Validate token + let claims = match jwt_service.validate_token(token) { + Ok(c) => c, + Err(_) => { + let resp = HttpResponse::Unauthorized() + .json(serde_json::json!({"error": "Invalid or expired token"})); + return Ok(req.into_response(resp).map_into_right_body()); + } + }; + + // Permission-based RBAC check + if let Some(required) = required_permission(&path, req.method()) + && !claims.permissions.contains(&required) + { + let resp = HttpResponse::Forbidden() + .json(serde_json::json!({"error": "Insufficient permissions"})); + return Ok(req.into_response(resp).map_into_right_body()); + } + + // Store claims in request extensions + req.extensions_mut().insert(claims); + + let res = service.call(req).await?.map_into_left_body(); + Ok(res) + }) + } +} diff --git a/net-guardia/src/core/auth/mod.rs b/net-guardia/src/core/auth/mod.rs new file mode 100644 index 0000000..d694405 --- /dev/null +++ b/net-guardia/src/core/auth/mod.rs @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..53a6dd3 --- /dev/null +++ b/net-guardia/src/core/auth/password.rs @@ -0,0 +1,49 @@ +use argon2::password_hash::rand_core::OsRng; +use argon2::password_hash::SaltString; +use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier}; + +use crate::model::error::auth::AuthError; +use crate::model::error::Error; + +pub fn hash_password(password: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + let hash = argon2 + .hash_password(password.as_bytes(), &salt) + .map_err(|_| AuthError::InvalidCredentials)?; + Ok(hash.to_string()) +} + +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()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hash_and_verify() { + let hash = hash_password("mypassword123").unwrap(); + assert!(verify_password("mypassword123", &hash).unwrap()); + assert!(!verify_password("wrongpassword", &hash).unwrap()); + } + + #[test] + fn test_different_hashes_for_same_password() { + let hash1 = hash_password("same").unwrap(); + let hash2 = hash_password("same").unwrap(); + assert_ne!(hash1, hash2); // different salts + assert!(verify_password("same", &hash1).unwrap()); + assert!(verify_password("same", &hash2).unwrap()); + } + + #[test] + fn test_verify_invalid_hash() { + let result = verify_password("password", "not-a-valid-hash"); + assert!(result.is_err()); + } +} diff --git a/net-guardia/src/core/ebpf/dns_filter.rs b/net-guardia/src/core/ebpf/dns_filter.rs index 9962f4d..d132833 100644 --- a/net-guardia/src/core/ebpf/dns_filter.rs +++ b/net-guardia/src/core/ebpf/dns_filter.rs @@ -33,7 +33,7 @@ impl DnsFilter { self.blacklist .read() .iter() - .filter_map(|name| wire_format_to_domain(name)) + .filter_map(wire_format_to_domain) .collect() } @@ -169,7 +169,7 @@ impl DnsFilter { out += 1; for j in 0..ll { let mut b = raw[pos + 1 + j]; - if b >= b'A' && b <= b'Z' { + if b.is_ascii_uppercase() { b += 32; } name.data[out] = b; diff --git a/net-guardia/src/core/ebpf/drop_monitor.rs b/net-guardia/src/core/ebpf/drop_monitor.rs index b598517..8c743f1 100644 --- a/net-guardia/src/core/ebpf/drop_monitor.rs +++ b/net-guardia/src/core/ebpf/drop_monitor.rs @@ -3,40 +3,16 @@ use std::mem; use std::time::Duration; use aya::maps::{MapData, RingBuf}; -use serde::Serialize; use tokio::sync::{broadcast, oneshot}; use common::define::drop_reason::*; use common::model::drop_event::DropEvent as RawDropEvent; use parking_lot::Mutex; +use crate::model::drop_event::{DropCounters, DropEventMessage}; + const DROP_CHANNEL_CAPACITY: usize = 100; -#[derive(Debug, Clone, Serialize)] -pub struct DropEventMessage { - pub timestamp_ns: u64, - pub src_ip: String, - pub dst_ip: String, - pub src_port: u16, - pub dst_port: u16, - pub protocol: u8, - pub reason: String, - pub ip_version: u8, -} - -#[derive(Default, Clone, Serialize)] -pub struct DropCounters { - pub acl_blacklist: u64, - pub rate_limit_pkt: u64, - pub rate_limit_syn: u64, - pub rate_limit_udp: u64, - pub rate_limit_dns: u64, - pub protocol_filter: u64, - pub dns_blacklist: u64, - pub geo_block: u64, - pub total: u64, -} - pub struct DropMonitor { broadcast_tx: broadcast::Sender, counters: Mutex, diff --git a/net-guardia/src/core/ebpf/geo_block.rs b/net-guardia/src/core/ebpf/geo_block.rs index 0ce04ef..87ab12f 100644 --- a/net-guardia/src/core/ebpf/geo_block.rs +++ b/net-guardia/src/core/ebpf/geo_block.rs @@ -5,15 +5,13 @@ use aya::maps::lpm_trie::{Key, LpmTrie}; use aya::maps::MapData; use aya::Ebpf; use ipnetwork::IpNetwork; -use macros::log; use maxminddb::{geoip2, Reader}; use parking_lot::RwLock; -use crate::core::infrastructure::app_config::AppConfig; +use crate::infrastructure::app_config::AppConfig; use crate::model::error::ebpf::EbpfError; use crate::model::error::misc::MiscError; use crate::model::error::Error; -use crate::model::log::system::SystemLog; /// Pre-indexed GeoIP prefix table, built once at startup. struct GeoIndex { diff --git a/net-guardia/src/core/ebpf/mod.rs b/net-guardia/src/core/ebpf/mod.rs index d91c3e9..57bdbce 100644 --- a/net-guardia/src/core/ebpf/mod.rs +++ b/net-guardia/src/core/ebpf/mod.rs @@ -22,7 +22,7 @@ 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::xsk_manager::XskManager; -use crate::core::infrastructure::app_config::AppConfig; +use crate::infrastructure::app_config::AppConfig; use crate::core::ml::engine::Engine; use crate::model::error::ebpf::EbpfError; use crate::model::error::system::SystemError; @@ -70,7 +70,8 @@ impl EbpfServices { let xsk_manager = self.xsk_manager.clone(); xsk_manager.run(Some(ml_engine), Some(self.dns_filter.clone()), &self.shutdowns)?; - if let Some(ring_buf) = self.drop_ring_buf.lock().take() { + let ring_buf = self.drop_ring_buf.lock().take(); + if let Some(ring_buf) = ring_buf { let shutdown = drop_monitor::start_consumer(ring_buf, self.drop_monitor.clone()).await; self.shutdowns.push(shutdown); } diff --git a/net-guardia/src/core/ebpf/protocol_filter.rs b/net-guardia/src/core/ebpf/protocol_filter.rs index 4cb6cb3..134ba61 100644 --- a/net-guardia/src/core/ebpf/protocol_filter.rs +++ b/net-guardia/src/core/ebpf/protocol_filter.rs @@ -191,11 +191,7 @@ impl WhiteListControl { fn is_white_list_enable(&self) -> bool { match self.map.get(&0, 0) { Ok(status) => { - if status == 0 { - false - } else { - true - } + status != 0 } Err(_) => false, } @@ -253,7 +249,7 @@ impl HttpServiceWrapper { } else { let new_http_method = HttpMethod::convert_to_bitmap(http_method); self.map - .insert(&address, new_http_method, 0) + .insert(address, new_http_method, 0) .map_err(EbpfError::MapOperationError)?; } Ok(()) diff --git a/net-guardia/src/core/ebpf/xsk_manager.rs b/net-guardia/src/core/ebpf/xsk_manager.rs index 2286e54..7fd7f96 100644 --- a/net-guardia/src/core/ebpf/xsk_manager.rs +++ b/net-guardia/src/core/ebpf/xsk_manager.rs @@ -17,7 +17,7 @@ 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::core::infrastructure::app_config::AppConfig; +use crate::infrastructure::app_config::AppConfig; use crate::core::ml::engine::Engine; use crate::core::ml::flow_tracker::FlowTracker; use crate::model::config::NetworkConfig; @@ -314,7 +314,7 @@ impl XskPair { for rx_desc in rx_descs.iter().take(rx_count) { let lengths = rx_desc.lengths(); - let packet_len = lengths.data() as usize; + let packet_len = lengths.data(); let data = unsafe { self.umem.data(rx_desc) }; let contents = data.contents(); @@ -326,20 +326,17 @@ impl XskPair { let raw = &contents[..packet_len]; // DNS blacklist check — drop blacklisted DNS queries before forwarding - if let Some(ref dns) = self.dns_filter { - if let Some((dns_name, name_len)) = DnsFilter::parse_query_name(raw) { - if dns.is_blacklisted(&dns_name, name_len) { - continue; - } - } + 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; } // Parse directly from UMEM (zero-copy for ML path). // Only clone for the forwarding path afterwards. - if let Some(ref tracker) = self.tracker { - if let Some((packet_info, _)) = parse_packet(raw) { + if let Some(ref tracker) = self.tracker + && let Some((packet_info, _)) = parse_packet(raw) { tracker.lock().process_packet(packet_info, is_ingress); - } } // Clone into pooled buffer for forwarding @@ -428,10 +425,9 @@ impl XskPair { } } - if let Err(e) = self.tx.wakeup() { - if e.kind() != std::io::ErrorKind::WouldBlock { + if let Err(e) = self.tx.wakeup() + && 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/mod.rs b/net-guardia/src/core/email/mod.rs new file mode 100644 index 0000000..dc02c6a --- /dev/null +++ b/net-guardia/src/core/email/mod.rs @@ -0,0 +1,2 @@ +pub mod report; +pub mod scheduler; diff --git a/net-guardia/src/core/email/report.rs b/net-guardia/src/core/email/report.rs new file mode 100644 index 0000000..8339119 --- /dev/null +++ b/net-guardia/src/core/email/report.rs @@ -0,0 +1,205 @@ +use crate::interface::port::repository::RepositoryPort; +use crate::model::error::Error; + +/// Generate an HTML weekly report email body. +/// +/// Reads aggregated statistics from the Database settings table and formats +/// them into a self-contained HTML email. Keys consumed: +/// - `weekly_threats_count` +/// - `weekly_top_ips` (JSON array of `{ "ip": "...", "count": N }`) +/// - `weekly_threat_breakdown` (JSON object `{ "type": count, ... }`) +/// - `weekly_bandwidth_bytes` +/// - `weekly_system_health` (JSON object with cpu, memory, disk fields) +/// +/// If a key is missing the report falls back to placeholder data so it can +/// be exercised before the ML aggregation pipeline is wired up. +pub fn generate_weekly_report(db: &dyn RepositoryPort) -> Result { + let threats_count = db + .get_setting("weekly_threats_count") +? + .unwrap_or_else(|| "0".to_string()); + + let top_ips_json = db + .get_setting("weekly_top_ips") +? + .unwrap_or_else(|| { + serde_json::json!([ + {"ip": "192.168.1.100", "count": 42}, + {"ip": "10.0.0.55", "count": 31}, + {"ip": "172.16.0.12", "count": 27}, + {"ip": "192.168.2.200", "count": 19}, + {"ip": "10.0.1.88", "count": 14} + ]) + .to_string() + }); + + let threat_breakdown_json = db + .get_setting("weekly_threat_breakdown") +? + .unwrap_or_else(|| { + serde_json::json!({ + "Port Scan": 38, + "DDoS": 22, + "Brute Force": 15, + "DNS Tunneling": 8, + "Data Exfiltration": 3 + }) + .to_string() + }); + + let bandwidth = db + .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": 24.5, + "memory_percent": 61.2, + "disk_percent": 43.8 + }) + .to_string() + }); + + // ── Parse JSON blobs ─────────────────────────────────────────────── + + 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(); + + // ── Build HTML ───────────────────────────────────────────────────── + + 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) { + let ip = entry["ip"].as_str().unwrap_or("unknown"); + let count = entry["count"].as_u64().unwrap_or(0); + top_ips_rows.push_str(&format!( + "{}\ + {}\ + {}", + i + 1, + ip, + count, + )); + } + + let mut breakdown_rows = String::new(); + for (threat_type, count) in &threat_breakdown { + let n = count.as_u64().unwrap_or(0); + breakdown_rows.push_str(&format!( + "{}\ + {}", + threat_type, n, + )); + } + + let cpu = health["cpu_percent"].as_f64().unwrap_or(0.0); + let mem = health["memory_percent"].as_f64().unwrap_or(0.0); + let disk = health["disk_percent"].as_f64().unwrap_or(0.0); + + let now = chrono::Local::now().format("%Y-%m-%d %H:%M"); + + let html = format!( + r#" + + + +
+ + +
+

NetGuardia Weekly Report

+

Generated {now}

+
+ +
+ + +

+ Threat Summary +

+

{threats_count} + threats detected this week +

+ + +

+ Top 5 Blocked IPs +

+ + + + + + + + + {top_ips_rows} +
#IP AddressEvents
+ + +

+ Threat Type Breakdown +

+ + + + + + + + {breakdown_rows} +
TypeCount
+ + +

+ Bandwidth +

+

{bandwidth_mb:.2} MB processed this week

+ + +

+ System Health +

+ + + + + + + + + + + + + +
CPU{cpu:.1}%
Memory{mem:.1}%
Disk{disk:.1}%
+ +
+ + +
+ NetGuardia — Automated Weekly Report +
+ +
+ +"# + ); + + Ok(html) +} diff --git a/net-guardia/src/core/email/scheduler.rs b/net-guardia/src/core/email/scheduler.rs new file mode 100644 index 0000000..bcf89f1 --- /dev/null +++ b/net-guardia/src/core/email/scheduler.rs @@ -0,0 +1,180 @@ +use crate::interface::port::repository::RepositoryPort; +use crate::model::error::database::DatabaseError; +use crate::model::error::Error; +use lettre::message::header::ContentType; +use lettre::transport::smtp::authentication::Credentials; +use lettre::{Message, SmtpTransport, Transport}; +use std::sync::Arc; +use tokio::time::{self, Duration}; +use tracing::{error, info, warn}; + +/// SMTP client wrapper that builds a `lettre::SmtpTransport` from Database +/// settings and sends an email. +pub struct SmtpClient { + host: String, + port: u16, + username: String, + password: String, +} + +impl SmtpClient { + /// Try to construct an `SmtpClient` from Database settings. + /// + /// 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> { + 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), + }; + let password = match db.get_setting("smtp_password")? { + Some(v) if !v.is_empty() => v, + _ => return Ok(None), + }; + + let port: u16 = port_str.parse().unwrap_or(587); + + Ok(Some(Self { + host, + port, + username, + password, + })) + } + + /// 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| { + Error::Database(DatabaseError::QueryFailed { + reason: format!("invalid from address: {e}"), + }) + })?; + let to_addr = to.parse().map_err(|e| { + Error::Database(DatabaseError::QueryFailed { + reason: format!("invalid to address: {e}"), + }) + })?; + + let email = Message::builder() + .from(from_addr) + .to(to_addr) + .subject(subject) + .header(ContentType::TEXT_HTML) + .body(html_body.to_string()) + .map_err(|e| { + Error::Database(DatabaseError::QueryFailed { + reason: format!("failed to build email: {e}"), + }) + })?; + + let creds = Credentials::new(self.username.clone(), self.password.clone()); + + let mailer = SmtpTransport::starttls_relay(&self.host) + .map_err(|e| { + Error::Database(DatabaseError::QueryFailed { + reason: format!("SMTP relay error: {e}"), + }) + })? + .port(self.port) + .credentials(creds) + .build(); + + mailer.send(&email).map_err(|e| { + Error::Database(DatabaseError::QueryFailed { + reason: format!("SMTP send error: {e}"), + }) + })?; + + Ok(()) + } +} + +/// Scheduler that checks once per hour whether it is time to send the weekly +/// report (Monday 08:00 local time) and dispatches it via SMTP. +pub struct ReportScheduler { + db: Arc, +} + +impl ReportScheduler { + pub fn new(db: Arc) -> Self { + Self { db } + } + + /// Spawn a background tokio task that runs the weekly check loop. + pub fn run(&self) -> tokio::task::JoinHandle<()> { + let db = Arc::clone(&self.db); + tokio::spawn(async move { + info!("Weekly report scheduler started"); + let mut interval = time::interval(Duration::from_secs(3600)); + loop { + interval.tick().await; + + if !is_send_window() { + continue; + } + + info!("Weekly report window reached — preparing report"); + + let smtp = match SmtpClient::from_database(&*db) { + Ok(Some(client)) => client, + Ok(None) => { + warn!( + "SMTP is not configured (missing smtp_host/port/username/password). \ + Skipping weekly report." + ); + continue; + } + Err(e) => { + error!("Failed to read SMTP settings: {e}"); + continue; + } + }; + + let recipient = match db.get_setting("smtp_recipient") { + Ok(Some(r)) if !r.is_empty() => r, + _ => { + warn!("No smtp_recipient configured. Skipping weekly report."); + continue; + } + }; + + let html = match super::report::generate_weekly_report(&*db) { + Ok(h) => h, + Err(e) => { + error!("Failed to generate weekly report: {e}"); + continue; + } + }; + + let subject = format!( + "NetGuardia Weekly Report — {}", + chrono::Local::now().format("%Y-%m-%d") + ); + let send_result = + tokio::task::spawn_blocking(move || smtp.send(&recipient, &subject, &html)) + .await; + + match send_result { + Ok(Ok(())) => info!("Weekly report sent successfully"), + Ok(Err(e)) => error!("Failed to send weekly report: {e}"), + Err(e) => error!("Send task panicked: {e}"), + } + } + }) + } +} + +/// Returns `true` when the current local time falls within the Monday 08:00 +/// hour (i.e. Monday, hour == 8). +fn is_send_window() -> bool { + let now = chrono::Local::now(); + now.format("%A").to_string() == "Monday" && now.format("%H").to_string() == "08" +} diff --git a/net-guardia/src/core/license/mod.rs b/net-guardia/src/core/license/mod.rs new file mode 100644 index 0000000..b939b54 --- /dev/null +++ b/net-guardia/src/core/license/mod.rs @@ -0,0 +1,3 @@ +pub mod validator; + +pub use crate::model::license::LicenseInfo; diff --git a/net-guardia/src/core/license/validator.rs b/net-guardia/src/core/license/validator.rs new file mode 100644 index 0000000..85b2aea --- /dev/null +++ b/net-guardia/src/core/license/validator.rs @@ -0,0 +1,302 @@ +use std::path::Path; + +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64; +use ed25519_dalek::{Signature, VerifyingKey, Verifier}; + +use crate::model::error::license::LicenseError; +use crate::model::license::{LicenseInfo, LicensePayload}; + +/// Public key auto-embedded from license_pub.key at compile time. +/// Generate with: cd license-generator && cargo run -- keygen +/// Then place license_pub.key in the repo root. +const PUBLIC_KEY_HEX: &str = env!("LICENSE_PUBLIC_KEY"); + +pub fn validate_license(license_path: &str, ingress_ifname: &str, egress_ifname: &str) -> Result { + // Guard against builds where the license feature was not configured + if PUBLIC_KEY_HEX == "DISABLED" { + return Err(LicenseError::ValidationFailed { + reason: "License validation not configured".to_string(), + }.into()); + } + + // If path is empty, license is optional — return unlicensed + if license_path.is_empty() { + tracing::warn!("No license file configured — running without license"); + return Ok(LicenseInfo::unlicensed()); + } + + let path = Path::new(license_path); + if !path.exists() { + tracing::warn!("License file '{}' not found — running without license", license_path); + return Ok(LicenseInfo::unlicensed()); + } + + let contents = std::fs::read_to_string(path) + .map_err(|_| LicenseError::FileNotFound { path: license_path.to_string() })?; + + let contents = contents.trim(); + + // Format: base64(json_payload).base64(ed25519_signature) + let parts: Vec<&str> = contents.splitn(2, '.').collect(); + if parts.len() != 2 { + return Err(LicenseError::ValidationFailed { + reason: "Invalid license format: expected .".to_string(), + }.into()); + } + + let payload_b64 = parts[0]; + let signature_b64 = parts[1]; + + // Decode payload + let payload_bytes = BASE64.decode(payload_b64) + .map_err(|e| LicenseError::ValidationFailed { + reason: format!("Failed to decode payload: {}", e), + })?; + + // Decode signature + let sig_bytes = BASE64.decode(signature_b64) + .map_err(|e| LicenseError::ValidationFailed { + reason: format!("Failed to decode signature: {}", e), + })?; + + // Parse public key + let pub_key_bytes = hex_decode(PUBLIC_KEY_HEX) + .map_err(|e| LicenseError::ValidationFailed { + reason: format!("Invalid embedded public key: {}", e), + })?; + + let pub_key_array: [u8; 32] = pub_key_bytes.try_into() + .map_err(|_| LicenseError::ValidationFailed { + reason: "Public key must be 32 bytes".to_string(), + })?; + + let verifying_key = VerifyingKey::from_bytes(&pub_key_array) + .map_err(|_| LicenseError::ValidationFailed { + reason: "Invalid public key".to_string(), + })?; + + // Parse signature + let sig_array: [u8; 64] = sig_bytes.try_into() + .map_err(|_| LicenseError::ValidationFailed { + reason: "Signature must be 64 bytes".to_string(), + })?; + + let signature = Signature::from_bytes(&sig_array); + + // Verify signature over the raw base64-encoded payload (not decoded bytes) + verifying_key.verify(payload_b64.as_bytes(), &signature) + .map_err(|_| LicenseError::InvalidSignature)?; + + // Parse payload JSON + let payload: LicensePayload = serde_json::from_slice(&payload_bytes) + .map_err(|e| LicenseError::ValidationFailed { + reason: format!("Failed to parse license payload: {}", e), + })?; + + // Verify NIC MAC addresses + let actual_ingress_mac = get_interface_mac(ingress_ifname).unwrap_or_default(); + let actual_egress_mac = get_interface_mac(egress_ifname).unwrap_or_default(); + + if actual_ingress_mac != payload.ingress_mac { + return Err(LicenseError::ValidationFailed { + reason: "Ingress MAC mismatch — license not bound to this device".to_string(), + }.into()); + } + + if actual_egress_mac != payload.egress_mac { + return Err(LicenseError::ValidationFailed { + reason: "Egress MAC mismatch — license not bound to this device".to_string(), + }.into()); + } + + // Check expiry + let today = chrono_free_today(); + let days_remaining = days_until(&payload.expires, &today) + .map_err(|e| LicenseError::ValidationFailed { + reason: format!("Invalid expiry date: {}", e), + })?; + + if days_remaining < 0 { + return Err(LicenseError::Expired.into()); + } + + tracing::info!( + "License valid — ingress={}, egress={}, expires={}, days_remaining={}, features={:?}", + payload.ingress_mac, payload.egress_mac, payload.expires, days_remaining, payload.features + ); + + Ok(LicenseInfo { + payload: Some(payload), + valid: true, + days_remaining, + }) +} + +/// Read MAC address from /sys/class/net//address (Linux only). +fn get_interface_mac(ifname: &str) -> Option { + // Prevent path traversal + if !ifname.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') { + return None; + } + let path = format!("/sys/class/net/{}/address", ifname); + std::fs::read_to_string(&path) + .ok() + .map(|s| s.trim().to_lowercase()) +} + +/// Simple hex decoder without external dependency. +fn hex_decode(hex: &str) -> Result, String> { + if hex.len() % 2 != 0 { + return Err("Odd-length hex string".to_string()); + } + (0..hex.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).map_err(|e| e.to_string())) + .collect() +} + +/// Parse YYYY-MM-DD date and return days until expiry (no chrono dependency). +fn chrono_free_today() -> (i32, u32, u32) { + // Use UNIX_EPOCH to get today's date + let secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + let days_since_epoch = (secs / 86400) as i32; + epoch_days_to_ymd(days_since_epoch) +} + +fn parse_date(s: &str) -> Result<(i32, u32, u32), String> { + let parts: Vec<&str> = s.split('-').collect(); + if parts.len() != 3 { + return Err("Expected YYYY-MM-DD".to_string()); + } + let y = parts[0].parse::().map_err(|e| e.to_string())?; + let m = parts[1].parse::().map_err(|e| e.to_string())?; + let d = parts[2].parse::().map_err(|e| e.to_string())?; + Ok((y, m, d)) +} + +fn ymd_to_epoch_days(y: i32, m: u32, d: u32) -> i32 { + // Algorithm from Howard Hinnant + let y = if m <= 2 { y - 1 } else { y }; + let era = if y >= 0 { y } else { y - 399 } / 400; + let yoe = (y - era * 400) as u32; + let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + era * 146097 + doe as i32 - 719468 +} + +fn epoch_days_to_ymd(days: i32) -> (i32, u32, u32) { + let z = days + 719468; + let era = if z >= 0 { z } else { z - 146096 } / 146097; + let doe = (z - era * 146097) as u32; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let y = yoe as i32 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + (y, m, d) +} + +fn days_until(expiry_str: &str, today: &(i32, u32, u32)) -> Result { + let (ey, em, ed) = parse_date(expiry_str)?; + let expiry_days = ymd_to_epoch_days(ey, em, ed) as i64; + let today_days = ymd_to_epoch_days(today.0, today.1, today.2) as i64; + Ok(expiry_days - today_days) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hex_decode_valid() { + assert_eq!(hex_decode("48656c6c6f").unwrap(), b"Hello"); + assert_eq!(hex_decode("ff00").unwrap(), vec![0xff, 0x00]); + } + + #[test] + fn test_hex_decode_odd_length() { + assert!(hex_decode("abc").is_err()); + } + + #[test] + fn test_hex_decode_invalid_chars() { + assert!(hex_decode("zzzz").is_err()); + } + + #[test] + fn test_parse_date_valid() { + assert_eq!(parse_date("2026-03-21").unwrap(), (2026, 3, 21)); + assert_eq!(parse_date("2000-01-01").unwrap(), (2000, 1, 1)); + } + + #[test] + fn test_parse_date_invalid() { + assert!(parse_date("not-a-date").is_err()); + assert!(parse_date("2026-13").is_err()); + assert!(parse_date("").is_err()); + } + + #[test] + fn test_epoch_roundtrip() { + // Test several dates + let dates = vec![ + (2026, 3, 21), + (2000, 1, 1), + (1970, 1, 1), + (2024, 2, 29), // leap year + (2025, 12, 31), + ]; + for (y, m, d) in dates { + let days = ymd_to_epoch_days(y, m, d); + let (ry, rm, rd) = epoch_days_to_ymd(days); + assert_eq!((ry, rm, rd), (y, m, d), "Roundtrip failed for {}-{}-{}", y, m, d); + } + } + + #[test] + fn test_epoch_day_1970() { + assert_eq!(ymd_to_epoch_days(1970, 1, 1), 0); + } + + #[test] + fn test_days_until() { + let today = (2026, 3, 21); + assert_eq!(days_until("2026-03-21", &today).unwrap(), 0); + assert_eq!(days_until("2026-03-22", &today).unwrap(), 1); + assert_eq!(days_until("2026-03-20", &today).unwrap(), -1); + assert_eq!(days_until("2027-03-21", &today).unwrap(), 365); + } + + #[test] + fn test_chrono_free_today_returns_reasonable_date() { + let (y, m, d) = chrono_free_today(); + assert!(y >= 2025 && y <= 2030); + assert!(m >= 1 && m <= 12); + assert!(d >= 1 && d <= 31); + } + + #[test] + fn test_get_interface_mac_path_traversal() { + // Should reject path traversal attempts + assert!(get_interface_mac("../etc/passwd").is_none()); + assert!(get_interface_mac("eth0/../..").is_none()); + } + + #[test] + fn test_license_format_invalid() { + // Test with invalid license content (no file, just the parsing logic) + let bad_formats = vec!["", "nodot", "too.many.dots"]; + for fmt in bad_formats { + let parts: Vec<&str> = fmt.splitn(2, '.').collect(); + if parts.len() != 2 { + continue; // expected — this is what validate_license checks + } + } + } +} diff --git a/net-guardia/src/core/ml/alert.rs b/net-guardia/src/core/ml/alert.rs index db04f60..85152fa 100644 --- a/net-guardia/src/core/ml/alert.rs +++ b/net-guardia/src/core/ml/alert.rs @@ -1,50 +1,11 @@ use macros::log; -use serde::Serialize; use tokio::sync::broadcast; use crate::model::log::ml::MLLog; -use crate::model::ml_detection::DetectionResult; +use crate::model::ml_detection::{AlertMessage, DetectionResult}; const ALERT_CHANNEL_CAPACITY: usize = 100; -#[derive(Debug, Clone, Serialize)] -pub struct AlertMessage { - pub timestamp: u64, - pub flow_key: String, - pub src_ip: String, - pub dst_ip: String, - pub src_port: u16, - pub dst_port: u16, - pub protocol: u8, - pub is_attack: bool, - pub attack_type: Option, - pub confidence: f32, - pub ae_score: f32, -} - -impl AlertMessage { - pub fn from_detection_result(result: &DetectionResult) -> Self { - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - - Self { - timestamp, - flow_key: result.flow_key.clone(), - src_ip: result.flow_key_raw.src_ip_string(), - dst_ip: result.flow_key_raw.dst_ip_string(), - src_port: result.flow_key_raw.src_port, - dst_port: result.flow_key_raw.dst_port, - protocol: result.flow_key_raw.protocol, - is_attack: result.is_attack, - attack_type: result.attack_type.clone(), - confidence: result.confidence, - ae_score: result.ae_score, - } - } -} - pub struct MLAlert { broadcast_tx: broadcast::Sender, } diff --git a/net-guardia/src/core/ml/config_loader.rs b/net-guardia/src/core/ml/config_loader.rs index 6c6251b..a66cdc1 100644 --- a/net-guardia/src/core/ml/config_loader.rs +++ b/net-guardia/src/core/ml/config_loader.rs @@ -1,30 +1,19 @@ -use std::collections::HashMap; use std::fs; use std::path::PathBuf; -use serde::{Deserialize, Serialize}; use crate::model::error::ml::MLError; -use crate::model::ml_detection::ClipParams; -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct InferenceConfig { - pub ae_feature_names: Vec, - pub ae_clip_params: HashMap, - pub ae_scaler_mean: Vec, - pub ae_scaler_std: Vec, - pub ae_post_clip_min: f64, - pub ae_post_clip_max: f64, - pub ae_threshold: f32, - pub classifier_feature_names: Vec, - pub attack_labels: HashMap, -} +pub use crate::model::config::MLInferenceConfig; -impl InferenceConfig { +/// Backward-compatible alias so existing `use config_loader::InferenceConfig` paths still compile. +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: InferenceConfig = serde_json::from_str(&content) + 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")); @@ -37,17 +26,4 @@ impl InferenceConfig { } Ok(config) } - - pub fn num_ae_features(&self) -> usize { - self.ae_feature_names.len() - } - - pub fn num_classifier_features(&self) -> usize { - self.classifier_feature_names.len() - } - - pub fn num_attack_types(&self) -> usize { - self.attack_labels.len() - } - -} \ No newline at end of file +} diff --git a/net-guardia/src/core/ml/engine.rs b/net-guardia/src/core/ml/engine.rs index 87b3a4e..777c4c8 100644 --- a/net-guardia/src/core/ml/engine.rs +++ b/net-guardia/src/core/ml/engine.rs @@ -30,7 +30,6 @@ pub struct Engine { min_packets: usize, batch_size: usize, inference_interval_secs: u64, - flow_timeout_us: u64, traffic_logger: Option>, } @@ -61,7 +60,6 @@ impl Engine { min_packets: engine_config.min_packets, batch_size: engine_config.batch_size, inference_interval_secs: engine_config.inference_interval_secs, - flow_timeout_us: engine_config.flow_timeout_us, traffic_logger, } } diff --git a/net-guardia/src/core/ml/feature_extractor.rs b/net-guardia/src/core/ml/feature_extractor.rs index 2db330e..3cce761 100644 --- a/net-guardia/src/core/ml/feature_extractor.rs +++ b/net-guardia/src/core/ml/feature_extractor.rs @@ -43,10 +43,9 @@ impl FlowFeatures { 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 { - if let Some(params) = clip_params.get(feature_name) { + if i < self.feature_num + && let Some(params) = clip_params.get(feature_name) { self.features[i] = self.features[i].clamp(params.lower, params.upper); - } } } } diff --git a/net-guardia/src/core/ml/flow_tracker.rs b/net-guardia/src/core/ml/flow_tracker.rs index c5b90ab..7b672f8 100644 --- a/net-guardia/src/core/ml/flow_tracker.rs +++ b/net-guardia/src/core/ml/flow_tracker.rs @@ -95,9 +95,8 @@ impl FlowData { if iat > IDLE_THRESHOLD_US { if self.idle_periods.len() < MAX_PERIODS { self.idle_periods.push(iat); } - } else if iat > 0 { - if self.active_periods.len() < MAX_PERIODS { self.active_periods.push(iat); } - } + } else if iat > 0 + && self.active_periods.len() < MAX_PERIODS { self.active_periods.push(iat); } self.last_packet_time = packet.timestamp_us; self.last_time_us = packet.timestamp_us; @@ -239,13 +238,12 @@ impl FlowTracker { flow.add_packet(&packet); - if self.active.len() > self.max_flows { - if let Some(oldest_key) = self.active.iter() + 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); - } + { + self.active.remove(&oldest_key); } } diff --git a/net-guardia/src/core/ml/model_loader.rs b/net-guardia/src/core/ml/model_loader.rs index 2d21526..a525d75 100644 --- a/net-guardia/src/core/ml/model_loader.rs +++ b/net-guardia/src/core/ml/model_loader.rs @@ -1,7 +1,7 @@ use tract_onnx::prelude::*; use std::path::PathBuf; -use crate::core::infrastructure::app_config::AppConfig; +use crate::infrastructure::app_config::AppConfig; use crate::model::error::ml::MLError; use crate::model::ml_detection::RunnableModel; @@ -24,7 +24,7 @@ impl MLModels { let load = || -> Result> { let mut model = onnx().model_for_path(&model_path)?; - model.set_input_fact(0, f32::fact(&[1, features]).into())?; + model.set_input_fact(0, f32::fact([1, features]).into())?; Ok(model.into_optimized()?.into_runnable()?) }; diff --git a/net-guardia/src/core/mod.rs b/net-guardia/src/core/mod.rs index 018e5df..0ae5e8d 100644 --- a/net-guardia/src/core/mod.rs +++ b/net-guardia/src/core/mod.rs @@ -1,4 +1,7 @@ +pub mod auth; +pub mod email; pub mod ebpf; -pub mod infrastructure; +#[cfg(feature = "license")] +pub mod license; pub mod ml; pub mod system; diff --git a/net-guardia/src/core/system.rs b/net-guardia/src/core/system.rs index bea6c79..c3f01ff 100644 --- a/net-guardia/src/core/system.rs +++ b/net-guardia/src/core/system.rs @@ -1,42 +1,39 @@ -use std::collections::HashMap; use std::sync::Arc; -use actix_web::web::route; -use actix_web::{web, App, HttpServer}; -use aya::maps::{Array, MapData, ProgramArray}; -use aya::programs::{Xdp, XdpFlags}; +use aya::maps::{MapData, ProgramArray}; use aya::Ebpf; -use aya_log::EbpfLogger; -use common::define::pipeline::*; use macros::log; +use crate::core::auth::jwt::JwtService; +use crate::adapter::persistence::Database; use crate::core::ebpf::EbpfServices; -use crate::core::infrastructure::app_config::AppConfig; -use crate::core::infrastructure::MLService; +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::model::error::ebpf::EbpfError; -use crate::model::error::http::HttpError; -use crate::model::error::misc::MiscError; +#[cfg(feature = "license")] +use crate::core::license::LicenseInfo; +use crate::infrastructure::http_server::HttpServerParams; +use crate::infrastructure::service_factory::ServiceFactory; use crate::model::error::Error; use crate::model::log::ml::MLLog; use crate::model::log::system::SystemLog; use crate::utils::logging::Logging; -use crate::web::api::{acl, filter, rate_limit as rate_limit_api, stats, health as health_api, ml, system as system_api, default, ws}; - -/// Maps stage name (from config.toml) to (function_name, stage_id) -fn stage_registry() -> HashMap<&'static str, (&'static str, u32)> { - HashMap::from([ - ("access_control", ("access_control", STAGE_ACCESS_CONTROL)), - ("rate_limit", ("rate_limit", STAGE_RATE_LIMIT)), - ("service", ("protocol_filter", STAGE_SERVICE)), - ]) -} +/// Thin wrapper around infrastructure services. +/// Delegates construction to `ServiceFactory::build()` and HTTP to +/// `infrastructure::http_server::run()`. +/// Will be removed in a later refactoring phase. pub struct System { pub app_config: Arc, pub inference_config: Arc, pub ebpf_services: Arc, - pub app_services: Arc, + pub app_services: Arc, + pub db: Arc, + pub jwt_service: Arc, + pub comm: Arc, + #[cfg(feature = "license")] + pub license_info: Arc, pub ingress_ebpf: Ebpf, pub egress_ebpf: Ebpf, #[allow(dead_code)] @@ -45,38 +42,20 @@ pub struct System { impl System { pub async fn new() -> Result { - let mut ingress_ebpf = Self::load_ebpf("ingress")?; - let mut egress_ebpf = Self::load_ebpf("egress")?; - let app_config = Arc::new(AppConfig::new()?); - - 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)?); - - // Write queue count to eBPF maps for symmetric hash redirect - let num_queues = app_config.network.combined_queue_count; - Self::write_num_queues(&mut ingress_ebpf, num_queues)?; - Self::write_num_queues(&mut egress_ebpf, num_queues)?; - - let ebpf_services = Arc::new(EbpfServices::new( - app_config.clone(), - &mut ingress_ebpf, - &mut egress_ebpf, - )?); - - let app_services = Arc::new(MLService::new(app_config.clone(), inference_config.clone())?); - + let state = ServiceFactory::build().await?; Ok(System { - app_config, - inference_config, - ebpf_services, - app_services, - ingress_ebpf, - egress_ebpf, - ingress_program_array, + app_config: state.app_config, + inference_config: state.inference_config, + ebpf_services: state.ebpf_services, + app_services: state.app_services, + db: state.db, + jwt_service: state.jwt_service, + comm: state.comm, + #[cfg(feature = "license")] + license_info: state.license_info, + ingress_ebpf: state.ingress_ebpf, + egress_ebpf: state.egress_ebpf, + ingress_program_array: state.ingress_program_array, }) } @@ -98,7 +77,7 @@ impl System { attacks: self.inference_config.num_attack_types() }); - self.aya_log_init()?; + ServiceFactory::aya_log_init(&mut self.ingress_ebpf, &mut self.egress_ebpf)?; log!(SystemLog::InitializeComplete); self.attach_ebpf()?; @@ -119,195 +98,37 @@ impl System { Ok(()) } - fn aya_log_init(&mut self) -> Result<(), Error> { - EbpfLogger::init(&mut self.ingress_ebpf).map_err(EbpfError::LoggerInitFailed)?; - EbpfLogger::init(&mut self.egress_ebpf).map_err(EbpfError::LoggerInitFailed)?; - Ok(()) - } - fn attach_ebpf(&mut self) -> Result<(), Error> { let ingress_ifname = self.app_config.network.ingress_ifname.clone(); let egress_ifname = self.app_config.network.egress_ifname.clone(); - Self::set_memory_limit()?; + ServiceFactory::set_memory_limit()?; - Self::attach_xdp(&mut self.ingress_ebpf, &ingress_ifname, true)?; - Self::attach_xdp(&mut self.egress_ebpf, &egress_ifname, false)?; - Ok(()) - } + let ingress_mode = ServiceFactory::attach_xdp(&mut self.ingress_ebpf, &ingress_ifname, true)?; + let egress_mode = ServiceFactory::attach_xdp(&mut self.egress_ebpf, &egress_ifname, false)?; - fn attach_xdp(ebpf: &mut Ebpf, ifname: &str, already_loaded: bool) -> Result<(), Error> { - let xdp: &mut Xdp = ebpf - .program_mut("net_guardia") - .ok_or(EbpfError::ProgramNotFound)? - .try_into() - .map_err(EbpfError::GetProgramFailed)?; - if !already_loaded { - xdp.load().map_err(EbpfError::LoadProgramFailed)?; + // Store XDP mode in settings for health API reporting + if let Err(e) = self.db.set_setting("xdp_ingress_mode", &ingress_mode) { + tracing::warn!("Failed to store XDP ingress mode: {}", e); } - xdp.attach(ifname, XdpFlags::DRV_MODE) - .map_err(EbpfError::AttachProgramFailed)?; + if let Err(e) = self.db.set_setting("xdp_egress_mode", &egress_mode) { + tracing::warn!("Failed to store XDP egress mode: {}", e); + } + Ok(()) } async fn run_http_server(&self) -> Result<(), Error> { - let app_config = self.app_config.clone(); - let inference_config = self.inference_config.clone(); - let access_control = self.ebpf_services.access_control.clone(); - let protocol_filter = self.ebpf_services.protocol_filter.clone(); - let dns_filter = self.ebpf_services.dns_filter.clone(); - let geo_block = self.ebpf_services.geo_block.clone(); - let rate_limit = self.ebpf_services.rate_limit.clone(); - let health = self.app_services.health.clone(); - let ml_alert = self.app_services.ml_alert.clone(); - let ml_engine = self.app_services.ml_engine.clone(); - let flow_statistics = self.app_services.flow_statistics.clone(); - let drop_monitor = self.ebpf_services.drop_monitor.clone(); - let port = self.app_config.http.http_server_bind_port; - HttpServer::new(move || { - let cors = actix_cors::Cors::default() - .allowed_origin("http://localhost:8080") - .allowed_origin("http://127.0.0.1:8080") - .allow_any_method() - .allow_any_header() - .max_age(3600); - App::new() - .wrap(cors) - .app_data(web::Data::from(app_config.clone())) - .app_data(web::Data::from(inference_config.clone())) - .app_data(web::Data::from(access_control.clone())) - .app_data(web::Data::from(protocol_filter.clone())) - .app_data(web::Data::from(dns_filter.clone())) - .app_data(web::Data::from(geo_block.clone())) - .app_data(web::Data::from(rate_limit.clone())) - .app_data(web::Data::from(health.clone())) - .app_data(web::Data::from(ml_alert.clone())) - .app_data(web::Data::from(ml_engine.clone())) - .app_data(web::Data::from(flow_statistics.clone())) - .app_data(web::Data::from(drop_monitor.clone())) - .service( - web::scope("/api") - .service(acl::initialize()) - .service(filter::initialize()) - .service(rate_limit_api::initialize()) - .service(stats::initialize()) - .service(health_api::initialize()) - .service(ml::initialize()) - .service(system_api::initialize()) - ) - .service(ws::initialize()) - .default_service(route().to(default::default_route)) - }) - .bind(format!("0.0.0.0:{}", port)) - .map_err(HttpError::BindPortError)? - .run() - .await - .map_err(HttpError::ServerPanic)?; - Ok(()) - } - - fn load_ebpf(name: &str) -> Result { - let bytes = match name { - "ingress" => aya::include_bytes_aligned!(concat!(env!("OUT_DIR"), "/net-guardia-ingress")), - "egress" => aya::include_bytes_aligned!(concat!(env!("OUT_DIR"), "/net-guardia-egress")), - _ => return Err(EbpfError::ProgramNotFound.into()), + let params = HttpServerParams { + app_config: self.app_config.clone(), + inference_config: self.inference_config.clone(), + ebpf_services: self.ebpf_services.clone(), + app_services: self.app_services.clone(), + db: self.db.clone(), + jwt_service: self.jwt_service.clone(), + comm: self.comm.clone(), + #[cfg(feature = "license")] + license_info: self.license_info.clone(), }; - Ok(Ebpf::load(bytes).map_err(EbpfError::EbpfNotFound)?) - } - - /// Configure the ingress pipeline based on config.toml [Pipeline] section. - /// Loads each stage program into ProgramArray and wires NEXT_STAGE map. - fn configure_ingress_pipeline( - ebpf: &mut Ebpf, - stages: &[String], - ) -> Result, Error> { - let registry = stage_registry(); - - let entry: &mut Xdp = ebpf - .program_mut("net_guardia") - .ok_or(EbpfError::ProgramNotFound)? - .try_into() - .map_err(EbpfError::GetProgramFailed)?; - entry.load().map_err(EbpfError::LoadProgramFailed)?; - - let pa_map = ebpf.take_map("PROGRAM_ARRAY").ok_or(EbpfError::MapNotFound)?; - let mut program_array = ProgramArray::try_from(pa_map).map_err(EbpfError::MapOperationError)?; - - let ns_map = ebpf.take_map("NEXT_STAGE").ok_or(EbpfError::MapNotFound)?; - let mut next_stage = Array::::try_from(ns_map).map_err(EbpfError::MapOperationError)?; - - Self::load_program(ebpf, &mut program_array, "transmission", STAGE_TRANSMISSION)?; - - if stages.is_empty() { - next_stage - .set(STAGE_ENTRY as u32, STAGE_TRANSMISSION, 0) - .map_err(EbpfError::MapOperationError)?; - return Ok(program_array); - } - - 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 slot = (i + 1) as u32; - Self::load_program(ebpf, &mut program_array, func_name, slot)?; - slots.push((*stage_id, slot)); - } - - next_stage - .set(STAGE_ENTRY as u32, slots[0].1, 0) - .map_err(EbpfError::MapOperationError)?; - - for i in 0..slots.len() { - let (stage_id, _) = slots[i]; - let next_slot = if i + 1 < slots.len() { - slots[i + 1].1 - } else { - STAGE_TRANSMISSION - }; - next_stage - .set(stage_id as u32, next_slot, 0) - .map_err(EbpfError::MapOperationError)?; - } - - Ok(program_array) - } - - fn load_program( - ebpf: &mut Ebpf, - program_array: &mut ProgramArray, - function_name: &str, - slot: u32, - ) -> Result<(), Error> { - let program: &mut Xdp = ebpf - .program_mut(function_name) - .ok_or(EbpfError::ProgramNotFound)? - .try_into() - .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)?; - Ok(()) - } - - fn set_memory_limit() -> Result<(), Error> { - let rlim = libc::rlimit { - rlim_cur: libc::RLIM_INFINITY, - rlim_max: libc::RLIM_INFINITY, - }; - let ret = unsafe { libc::setrlimit(libc::RLIMIT_MEMLOCK, &rlim) }; - if ret != 0 { - Err(MiscError::RamLimitUnlockError(ret))? - } - Ok(()) - } - - fn write_num_queues(ebpf: &mut Ebpf, num_queues: u32) -> Result<(), Error> { - let map = ebpf.map_mut("NUM_QUEUES").ok_or(EbpfError::MapNotFound)?; - let mut arr = Array::<_, u32>::try_from(map).map_err(EbpfError::MapOperationError)?; - arr.set(0, num_queues, 0).map_err(EbpfError::MapOperationError)?; - Ok(()) + crate::infrastructure::http_server::run(params).await } } diff --git a/net-guardia/src/core/infrastructure/app_config.rs b/net-guardia/src/infrastructure/app_config.rs similarity index 100% rename from net-guardia/src/core/infrastructure/app_config.rs rename to net-guardia/src/infrastructure/app_config.rs diff --git a/net-guardia/src/core/infrastructure/mod.rs b/net-guardia/src/infrastructure/app_services.rs similarity index 89% rename from net-guardia/src/core/infrastructure/mod.rs rename to net-guardia/src/infrastructure/app_services.rs index fefebd5..6435e0b 100644 --- a/net-guardia/src/core/infrastructure/mod.rs +++ b/net-guardia/src/infrastructure/app_services.rs @@ -1,8 +1,3 @@ -pub mod app_config; -pub mod geoip; -pub mod health; -pub mod statistics; - use std::sync::Arc; use std::time::Duration; @@ -10,10 +5,10 @@ use crossbeam::queue::SegQueue; use macros::log; use tokio::sync::oneshot; -use crate::core::infrastructure::app_config::AppConfig; -use crate::core::infrastructure::health::SystemHealth; +use crate::infrastructure::app_config::AppConfig; +use crate::infrastructure::health::SystemHealth; use crate::core::ml::alert::MLAlert; -use crate::core::infrastructure::statistics::FlowStatistics; +use crate::infrastructure::statistics::FlowStatistics; use crate::core::ml::config_loader::InferenceConfig; use crate::core::ml::engine::Engine; use crate::model::ml_detection::EngineConfig; @@ -25,7 +20,10 @@ use crate::model::error::Error; use crate::model::log::system::SystemLog; use crate::core::ml::traffic_logger::TrafficLogger; -pub struct MLService { +/// Application-level service orchestrator. +/// Holds all runtime services (health monitoring, ML inference, flow statistics) +/// and manages their lifecycle (start/shutdown). +pub struct AppServices { pub health: Arc, pub ml_alert: Arc, pub ml_models: Arc, @@ -34,7 +32,7 @@ pub struct MLService { shutdowns: SegQueue>, } -impl MLService { +impl AppServices { pub fn new(app_config: Arc, inference_config: Arc) -> Result { let health = SystemHealth::new(app_config.clone())?; diff --git a/net-guardia/src/infrastructure/communication_manager.rs b/net-guardia/src/infrastructure/communication_manager.rs new file mode 100644 index 0000000..02511db --- /dev/null +++ b/net-guardia/src/infrastructure/communication_manager.rs @@ -0,0 +1,360 @@ +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::error::Error; +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, +} + +impl EventBroadcaster for TypedEventBroadcaster { + fn subscribe_typed(&self) -> Box { + Box::new(self.sender.subscribe()) + } + + fn broadcast_event(&self, event: Box) -> Result<(), Error> { + let typed_event = *event.downcast::().map_err(|_| MiscError::TypeMismatch)?; + let _ = self.sender.send(typed_event); + Ok(()) + } +} + +/// Central communication hub using the command/query/event pattern. +/// Adapted from MirrorSphere's CommunicationManager for NetGuardia. +pub struct CommunicationManager { + command_handlers: DashMap, + query_handlers: DashMap, + event_broadcasters: DashMap>, + channel_capacity: usize, +} + +impl CommunicationManager { + pub fn new() -> Self { + Self { + command_handlers: DashMap::new(), + query_handlers: DashMap::new(), + event_broadcasters: DashMap::new(), + channel_capacity: DEFAULT_CHANNEL_CAPACITY, + } + } + + pub fn with_capacity(channel_capacity: usize) -> Self { + Self { + command_handlers: DashMap::new(), + query_handlers: DashMap::new(), + event_broadcasters: DashMap::new(), + channel_capacity, + } + } + + pub fn with_service( + self: Arc, + service: Arc, + ) -> ServiceRegistrar { + ServiceRegistrar::new(service, self) + } + + 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)?; + handler.handle_command(command).await + }) as CommandFuture + }); + + self.command_handlers.insert(type_id, boxed_handler); + } + + pub async fn send_command(&self, command: C) -> Result<(), Error> { + let type_id = TypeId::of::(); + if let Some(handler) = self.command_handlers.get(&type_id) { + handler(Box::new(command)).await + } else { + Err(MiscError::HandlerNotFound)? + } + } + + 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(); + Box::pin(async move { + let query = *query.downcast::().map_err(|_| MiscError::TypeMismatch)?; + let response = handler.handle_query(query).await?; + Ok(Box::new(response) as Box) + }) as QueryFuture + }); + + self.query_handlers.insert(type_id, boxed_handler); + } + + pub async fn send_query(&self, query: Q) -> Result { + let type_id = TypeId::of::(); + if let Some(handler) = self.query_handlers.get(&type_id) { + let response = handler(Box::new(query)).await?; + Ok(*response + .downcast::() + .map_err(|_| MiscError::TypeMismatch)?) + } else { + Err(MiscError::HandlerNotFound)? + } + } + + pub fn register_event_type(&self) { + 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)); + } + + pub fn subscribe_event(&self) -> Result, Error> { + let type_id = TypeId::of::(); + let broadcaster = self + .event_broadcasters + .get(&type_id) + .ok_or(MiscError::TypeNotRegistered)?; + let receiver_box = broadcaster.subscribe_typed(); + let receiver = *receiver_box + .downcast::>() + .map_err(|_| MiscError::TypeMismatch)?; + Ok(receiver) + } + + pub async fn publish_event(&self, event: E) -> Result<(), Error> { + let type_id = TypeId::of::(); + let broadcaster = self + .event_broadcasters + .get(&type_id) + .ok_or(MiscError::TypeNotRegistered)?; + broadcaster.broadcast_event(Box::new(event)) + } + + pub fn clear_handlers(&self) { + self.command_handlers.clear(); + self.query_handlers.clear(); + self.event_broadcasters.clear(); + } +} + +/// Fluent builder for registering a service's command/query/event handlers. +pub struct ServiceRegistrar { + service: Arc, + comm: Arc, +} + +impl ServiceRegistrar { + fn new(service: Arc, comm: Arc) -> Self { + Self { service, comm } + } + + pub fn command(self) -> Self + where + S: CommandHandler, + { + let handler: Arc + Send + Sync> = self.service.clone(); + self.comm.register_command_handler::(handler); + self + } + + pub fn query(self) -> Self + where + S: QueryHandler, + { + let handler: Arc + Send + Sync> = self.service.clone(); + self.comm.register_query_handler::(handler); + self + } + + pub fn event(self) -> Self { + self.comm.register_event_type::(); + self + } + + pub fn build(self) -> Arc { + self.comm + } +} + +#[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 async_trait::async_trait; + + // ── Test Command ───────────────────────────────────────────────── + + struct TestCommand { + value: String, + } + + impl Message for TestCommand { + type Response = (); + } + impl Command for TestCommand {} + + struct TestCommandHandler { + received: Arc>>, + } + + #[async_trait] + impl CommandHandler for TestCommandHandler { + async fn handle_command(&self, command: TestCommand) -> Result<(), Error> { + self.received.lock().unwrap().push(command.value); + Ok(()) + } + } + + // ── Test Query ─────────────────────────────────────────────────── + + struct TestQuery { + input: i32, + } + + impl Message for TestQuery { + type Response = i32; + } + impl Query for TestQuery {} + + struct TestQueryHandler; + + #[async_trait] + impl QueryHandler for TestQueryHandler { + async fn handle_query(&self, query: TestQuery) -> Result { + Ok(query.input * 2) + } + } + + // ── Test Event ─────────────────────────────────────────────────── + + #[derive(Debug, Clone)] + struct TestEvent { + message: String, + } + impl Event for TestEvent {} + + // ── 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 comm = Arc::new(CommunicationManager::new()); + comm.register_command_handler::(handler); + + comm.send_command(TestCommand { value: "hello".into() }).await.unwrap(); + + let msgs = received.lock().unwrap(); + assert_eq!(msgs.len(), 1); + assert_eq!(msgs[0], "hello"); + } + + #[tokio::test] + async fn test_command_not_found() { + let comm = CommunicationManager::new(); + let result = comm.send_command(TestCommand { value: "nope".into() }).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_query_dispatch() { + let handler = Arc::new(TestQueryHandler); + let comm = Arc::new(CommunicationManager::new()); + comm.register_query_handler::(handler); + + let result = comm.send_query(TestQuery { input: 21 }).await.unwrap(); + assert_eq!(result, 42); + } + + #[tokio::test] + async fn test_query_not_found() { + let comm = CommunicationManager::new(); + let result = comm.send_query(TestQuery { input: 1 }).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_event_pub_sub() { + let comm = CommunicationManager::new(); + comm.register_event_type::(); + + let mut receiver = comm.subscribe_event::().unwrap(); + + comm.publish_event(TestEvent { message: "ping".into() }).await.unwrap(); + + let event = receiver.recv().await.unwrap(); + assert_eq!(event.message, "ping"); + } + + #[tokio::test] + async fn test_event_not_registered() { + let comm = CommunicationManager::new(); + let result = comm.subscribe_event::(); + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_event_multiple_subscribers() { + let comm = CommunicationManager::new(); + comm.register_event_type::(); + + let mut rx1 = comm.subscribe_event::().unwrap(); + let mut rx2 = comm.subscribe_event::().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"); + } + + #[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 comm = Arc::new(CommunicationManager::new()); + let _comm = comm.clone() + .with_service(handler) + .command::() + .build(); + + comm.send_command(TestCommand { value: "via_registrar".into() }).await.unwrap(); + + let msgs = received.lock().unwrap(); + assert_eq!(msgs[0], "via_registrar"); + } + + #[test] + fn test_clear_handlers() { + let comm = CommunicationManager::new(); + comm.register_event_type::(); + assert!(comm.subscribe_event::().is_ok()); + + comm.clear_handlers(); + assert!(comm.subscribe_event::().is_err()); + } +} diff --git a/net-guardia/src/infrastructure/enforce_mode_handler.rs b/net-guardia/src/infrastructure/enforce_mode_handler.rs new file mode 100644 index 0000000..6c1637c --- /dev/null +++ b/net-guardia/src/infrastructure/enforce_mode_handler.rs @@ -0,0 +1,84 @@ +use async_trait::async_trait; +use std::sync::Arc; + +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; + +/// Handles enforce-mode commands and queries by delegating to the repository. +pub struct EnforceModeHandler { + db: Arc, +} + +impl EnforceModeHandler { + pub fn new(db: Arc) -> Self { + Self { db } + } +} + +#[async_trait] +impl CommandHandler for EnforceModeHandler { + async fn handle_command(&self, command: ChangeEnforceModeCommand) -> Result<(), Error> { + self.db.set_setting("enforce_mode", &command.mode)?; + tracing::info!("Enforce mode changed to: {}", command.mode); + Ok(()) + } +} + +#[async_trait] +impl QueryHandler for EnforceModeHandler { + async fn handle_query(&self, _query: GetEnforceModeQuery) -> Result { + match self.db.get_setting("enforce_mode")? { + Some(mode) => Ok(mode), + None => Ok("monitor".to_string()), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::adapter::persistence::Database; + use crate::infrastructure::communication_manager::CommunicationManager; + use crate::interface::communication::command_types::ChangeEnforceModeCommand; + use crate::interface::communication::query_types::GetEnforceModeQuery; + + fn test_handler() -> (Arc, Arc) { + let db = Arc::new(Database::new(":memory:").unwrap()) as Arc; + let handler = Arc::new(EnforceModeHandler::new(db)); + let comm = Arc::new(CommunicationManager::new()); + let _ = comm.clone() + .with_service(handler.clone()) + .command::() + .query::() + .build(); + (handler, comm) + } + + #[tokio::test] + async fn test_default_mode_is_monitor() { + let (_, comm) = test_handler(); + let mode = comm.send_query(GetEnforceModeQuery).await.unwrap(); + assert_eq!(mode, "monitor"); + } + + #[tokio::test] + async fn test_change_to_enforce() { + let (_, comm) = test_handler(); + comm.send_command(ChangeEnforceModeCommand { mode: "enforce".into() }).await.unwrap(); + let mode = comm.send_query(GetEnforceModeQuery).await.unwrap(); + assert_eq!(mode, "enforce"); + } + + #[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(); + let mode = comm.send_query(GetEnforceModeQuery).await.unwrap(); + assert_eq!(mode, "monitor"); + } +} diff --git a/net-guardia/src/core/infrastructure/geoip.rs b/net-guardia/src/infrastructure/geoip.rs similarity index 100% rename from net-guardia/src/core/infrastructure/geoip.rs rename to net-guardia/src/infrastructure/geoip.rs diff --git a/net-guardia/src/core/infrastructure/health.rs b/net-guardia/src/infrastructure/health.rs similarity index 86% rename from net-guardia/src/core/infrastructure/health.rs rename to net-guardia/src/infrastructure/health.rs index ef3fc2c..92e29ae 100644 --- a/net-guardia/src/core/infrastructure/health.rs +++ b/net-guardia/src/infrastructure/health.rs @@ -6,7 +6,7 @@ use tokio::sync::{broadcast, oneshot, RwLock}; use tokio::time::interval; use macros::log; -use crate::core::infrastructure::app_config::AppConfig; +use crate::infrastructure::app_config::AppConfig; use crate::model::log::health::Health; use crate::model::error::Error; use crate::model::health::{ @@ -90,10 +90,9 @@ impl SystemHealth { drop(networks); drop(components); - if self.broadcast_tx.receiver_count() > 0 { - if let Err(e) = self.broadcast_tx.send(metrics) { + if self.broadcast_tx.receiver_count() > 0 + && let Err(e) = self.broadcast_tx.send(metrics) { log!(Health::BroadcastFailed(e.to_string())); - } } } @@ -312,6 +311,42 @@ impl SystemHealth { status.issues.push("Egress interface not available".to_string()); } + // Disk usage check + let disk_usage = Self::check_disk_usage(); + if let Some((usage_percent, available_gb)) = disk_usage { + if usage_percent > 95.0 { + status.overall_healthy = false; + status.issues.push(format!( + "Critical disk usage: {:.1}% (only {:.1} GB free). Traffic logging paused.", + usage_percent, available_gb + )); + } else if usage_percent > 90.0 { + status.warnings.push(format!( + "High disk usage: {:.1}% ({:.1} GB free)", + usage_percent, available_gb + )); + } + } + status } + + fn check_disk_usage() -> Option<(f32, f64)> { + use sysinfo::Disks; + let disks = Disks::new_with_refreshed_list(); + // Find the root disk or the disk containing /opt/netguardia + for disk in disks.list() { + let mount = disk.mount_point().to_string_lossy(); + if mount == "/" || mount.starts_with("/opt") { + let total = disk.total_space() as f64; + let available = disk.available_space() as f64; + if total > 0.0 { + let usage_percent = ((total - available) / total * 100.0) as f32; + let available_gb = available / (1024.0 * 1024.0 * 1024.0); + return Some((usage_percent, available_gb)); + } + } + } + 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 new file mode 100644 index 0000000..da9954e --- /dev/null +++ b/net-guardia/src/infrastructure/http_server.rs @@ -0,0 +1,101 @@ +use std::sync::Arc; + +use actix_web::web::route; +use actix_web::{web, App, HttpServer}; + +use crate::core::auth::jwt::JwtService; +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; +#[cfg(feature = "license")] +use crate::core::license::LicenseInfo; +use crate::model::error::http::HttpError; +use crate::model::error::Error; +use crate::adapter::http::{acl, auth, default, filter, health as health_api, ml, rate_limit as rate_limit_api, stats, system as system_api}; +use crate::adapter::websocket::routes as ws; +use crate::interface::port::repository::RepositoryPort; + +/// Parameters for starting the HTTP server, avoiding `#[cfg]` on function params. +pub struct HttpServerParams { + pub app_config: Arc, + pub inference_config: Arc, + pub ebpf_services: Arc, + pub app_services: Arc, + pub db: Arc, + pub jwt_service: Arc, + pub comm: Arc, + #[cfg(feature = "license")] + pub license_info: Arc, +} + +/// Run the HTTP server with the given parameters. +pub async fn run(params: HttpServerParams) -> Result<(), Error> { + let access_control = params.ebpf_services.access_control.clone(); + let protocol_filter = params.ebpf_services.protocol_filter.clone(); + let dns_filter = params.ebpf_services.dns_filter.clone(); + let geo_block = params.ebpf_services.geo_block.clone(); + let rate_limit = params.ebpf_services.rate_limit.clone(); + let health = params.app_services.health.clone(); + let ml_alert = params.app_services.ml_alert.clone(); + let ml_engine = params.app_services.ml_engine.clone(); + let flow_statistics = params.app_services.flow_statistics.clone(); + let drop_monitor = params.ebpf_services.drop_monitor.clone(); + let app_config = params.app_config; + let inference_config = params.inference_config; + let db = params.db; + let jwt_service = params.jwt_service; + let comm = params.comm; + #[cfg(feature = "license")] + let license_info = params.license_info; + let port = app_config.http.http_server_bind_port; + + HttpServer::new(move || { + let cors = actix_cors::Cors::default() + .allow_any_origin() + .allow_any_method() + .allow_any_header() + .max_age(3600); + let app = App::new() + .wrap(cors) + .app_data(web::Data::from(app_config.clone())) + .app_data(web::Data::from(inference_config.clone())) + .app_data(web::Data::from(access_control.clone())) + .app_data(web::Data::from(protocol_filter.clone())) + .app_data(web::Data::from(dns_filter.clone())) + .app_data(web::Data::from(geo_block.clone())) + .app_data(web::Data::from(rate_limit.clone())) + .app_data(web::Data::from(health.clone())) + .app_data(web::Data::from(ml_alert.clone())) + .app_data(web::Data::from(ml_engine.clone())) + .app_data(web::Data::from(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(jwt_service.clone())) + .app_data(web::Data::from(comm.clone())); + #[cfg(feature = "license")] + let app = app.app_data(web::Data::from(license_info.clone())); + app.service( + web::scope("/api") + .wrap(crate::core::auth::middleware::AuthMiddleware) + .service(auth::initialize()) + .service(acl::initialize()) + .service(filter::initialize()) + .service(rate_limit_api::initialize()) + .service(stats::initialize()) + .service(health_api::initialize()) + .service(ml::initialize()) + .service(system_api::initialize()) + ) + .service(ws::initialize()) + .default_service(route().to(default::default_route)) + }) + .bind(format!("0.0.0.0:{}", port)) + .map_err(HttpError::BindPortError)? + .run() + .await + .map_err(HttpError::ServerPanic)?; + Ok(()) +} diff --git a/net-guardia/src/infrastructure/mod.rs b/net-guardia/src/infrastructure/mod.rs new file mode 100644 index 0000000..be53915 --- /dev/null +++ b/net-guardia/src/infrastructure/mod.rs @@ -0,0 +1,9 @@ +pub mod app_config; +pub mod app_services; +pub mod communication_manager; +pub mod enforce_mode_handler; +pub mod geoip; +pub mod health; +pub mod http_server; +pub mod service_factory; +pub mod statistics; diff --git a/net-guardia/src/infrastructure/service_factory.rs b/net-guardia/src/infrastructure/service_factory.rs new file mode 100644 index 0000000..effaaaf --- /dev/null +++ b/net-guardia/src/infrastructure/service_factory.rs @@ -0,0 +1,410 @@ +use std::collections::HashMap; +use std::net::{Ipv4Addr, Ipv6Addr, SocketAddrV4, SocketAddrV6}; +use std::sync::Arc; + +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::core::auth::password; +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::infrastructure::enforce_mode_handler::EnforceModeHandler; +use crate::interface::communication::command_types::ChangeEnforceModeCommand; +use crate::interface::communication::query_types::GetEnforceModeQuery; +use crate::interface::port::repository::RepositoryPort; +#[cfg(feature = "license")] +use crate::core::license::LicenseInfo; +#[cfg(feature = "license")] +use crate::core::license::validator::validate_license; +use crate::core::ml::config_loader::InferenceConfig; +use crate::model::direction::FlowDirection; +use crate::model::error::ebpf::EbpfError; +use crate::model::error::misc::MiscError; +use crate::model::error::Error; +use crate::model::list_type::ListType; + +/// Holds all Arc-wrapped services that make up the running application. +pub struct AppState { + pub app_config: Arc, + pub inference_config: Arc, + pub ebpf_services: Arc, + pub app_services: Arc, + pub db: Arc, + pub jwt_service: Arc, + pub comm: Arc, + #[cfg(feature = "license")] + pub license_info: Arc, + pub ingress_ebpf: Ebpf, + pub egress_ebpf: Ebpf, + #[allow(dead_code)] + pub ingress_program_array: ProgramArray, +} + +/// Maps stage name (from config.toml) to (function_name, stage_id). +fn stage_registry() -> HashMap<&'static str, (&'static str, u32)> { + HashMap::from([ + ("access_control", ("access_control", STAGE_ACCESS_CONTROL)), + ("rate_limit", ("rate_limit", STAGE_RATE_LIMIT)), + ("service", ("protocol_filter", STAGE_SERVICE)), + ]) +} + +/// Factory responsible for creating and wiring all application services. +pub struct ServiceFactory; + +impl ServiceFactory { + /// Build all services and return the complete application state. + pub async fn build() -> Result { + let mut ingress_ebpf = Self::load_ebpf("ingress")?; + let mut egress_ebpf = Self::load_ebpf("egress")?; + let app_config = Arc::new(AppConfig::new()?); + + #[cfg(feature = "license")] + let license_info = Arc::new(validate_license( + &app_config.misc.license_file, + &app_config.network.ingress_ifname, + &app_config.network.egress_ifname, + )?); + + 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)?); + + // Write queue count to eBPF maps for symmetric hash redirect + let num_queues = app_config.network.combined_queue_count; + Self::write_num_queues(&mut ingress_ebpf, num_queues)?; + Self::write_num_queues(&mut egress_ebpf, num_queues)?; + + let db = Arc::new(Database::new(&app_config.misc.database_path)?); + + // Create default admin user if no users exist + if db.user_count().unwrap_or(0) == 0 { + let hash = password::hash_password("admin")?; + let admin_user_id = db.insert_user("admin", &hash, "admin", true)?; + // Assign to Administrator group + if let Ok(groups) = db.list_user_groups() + && let Some((group_id, _, _, _, _)) = groups.into_iter().find(|(_, name, _, _, _)| name == "Administrator") + { + let _ = db.set_user_groups(admin_user_id, &[group_id]); + } + tracing::warn!("Default admin user created with password 'admin' — you must change it on first login"); + } + + // Ensure enforce_mode setting exists (default: monitor) + if db.get_setting("enforce_mode")?.is_none() { + db.set_setting("enforce_mode", "monitor")?; + } + + let jwt_service = Arc::new(JwtService::new(db.as_ref(), app_config.http.jwt_expiry_hours)?); + + let ebpf_services = Arc::new(EbpfServices::new( + app_config.clone(), + &mut ingress_ebpf, + &mut egress_ebpf, + )?); + + let app_services = Arc::new(AppServices::new(app_config.clone(), inference_config.clone())?); + + // 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() + .with_service(enforce_handler) + .command::() + .query::() + .build(); + + // Restore persisted state from database + Self::restore_dns_blacklist(&db, &ebpf_services); + Self::restore_geo_countries(&db, &ebpf_services); + Self::restore_rate_limits(&db, &ebpf_services); + Self::restore_acl_rules(&db, &ebpf_services).await; + + Ok(AppState { + app_config, + inference_config, + ebpf_services, + app_services, + db, + jwt_service, + comm, + #[cfg(feature = "license")] + license_info, + ingress_ebpf, + egress_ebpf, + ingress_program_array, + }) + } + + // --- eBPF loading helpers --- + + fn load_ebpf(name: &str) -> Result { + let bytes = match name { + "ingress" => aya::include_bytes_aligned!(concat!(env!("OUT_DIR"), "/net-guardia-ingress")), + "egress" => aya::include_bytes_aligned!(concat!(env!("OUT_DIR"), "/net-guardia-egress")), + _ => return Err(EbpfError::ProgramNotFound.into()), + }; + Ok(Ebpf::load(bytes).map_err(EbpfError::EbpfNotFound)?) + } + + fn configure_ingress_pipeline( + ebpf: &mut Ebpf, + stages: &[String], + ) -> Result, Error> { + let registry = stage_registry(); + + let entry: &mut Xdp = ebpf + .program_mut("net_guardia") + .ok_or(EbpfError::ProgramNotFound)? + .try_into() + .map_err(EbpfError::GetProgramFailed)?; + entry.load().map_err(EbpfError::LoadProgramFailed)?; + + let pa_map = ebpf.take_map("PROGRAM_ARRAY").ok_or(EbpfError::MapNotFound)?; + let mut program_array = ProgramArray::try_from(pa_map).map_err(EbpfError::MapOperationError)?; + + let ns_map = ebpf.take_map("NEXT_STAGE").ok_or(EbpfError::MapNotFound)?; + let mut next_stage = Array::::try_from(ns_map).map_err(EbpfError::MapOperationError)?; + + Self::load_program(ebpf, &mut program_array, "transmission", STAGE_TRANSMISSION)?; + + if stages.is_empty() { + next_stage + .set(STAGE_ENTRY, STAGE_TRANSMISSION, 0) + .map_err(EbpfError::MapOperationError)?; + return Ok(program_array); + } + + 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 slot = (i + 1) as u32; + Self::load_program(ebpf, &mut program_array, func_name, slot)?; + slots.push((*stage_id, slot)); + } + + next_stage + .set(STAGE_ENTRY, slots[0].1, 0) + .map_err(EbpfError::MapOperationError)?; + + for i in 0..slots.len() { + let (stage_id, _) = slots[i]; + let next_slot = if i + 1 < slots.len() { + slots[i + 1].1 + } else { + STAGE_TRANSMISSION + }; + next_stage + .set(stage_id, next_slot, 0) + .map_err(EbpfError::MapOperationError)?; + } + + Ok(program_array) + } + + fn load_program( + ebpf: &mut Ebpf, + program_array: &mut ProgramArray, + function_name: &str, + slot: u32, + ) -> Result<(), Error> { + let program: &mut Xdp = ebpf + .program_mut(function_name) + .ok_or(EbpfError::ProgramNotFound)? + .try_into() + .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)?; + Ok(()) + } + + fn write_num_queues(ebpf: &mut Ebpf, num_queues: u32) -> Result<(), Error> { + let map = ebpf.map_mut("NUM_QUEUES").ok_or(EbpfError::MapNotFound)?; + let mut arr = Array::<_, u32>::try_from(map).map_err(EbpfError::MapOperationError)?; + arr.set(0, num_queues, 0).map_err(EbpfError::MapOperationError)?; + Ok(()) + } + + pub fn set_memory_limit() -> Result<(), Error> { + let rlim = libc::rlimit { + rlim_cur: libc::RLIM_INFINITY, + rlim_max: libc::RLIM_INFINITY, + }; + let ret = unsafe { libc::setrlimit(libc::RLIMIT_MEMLOCK, &rlim) }; + if ret != 0 { + Err(MiscError::RamLimitUnlockError(ret))? + } + Ok(()) + } + + pub fn attach_xdp(ebpf: &mut Ebpf, ifname: &str, already_loaded: bool) -> Result { + let xdp: &mut Xdp = ebpf + .program_mut("net_guardia") + .ok_or(EbpfError::ProgramNotFound)? + .try_into() + .map_err(EbpfError::GetProgramFailed)?; + if !already_loaded { + xdp.load().map_err(EbpfError::LoadProgramFailed)?; + } + + // Try DRV_MODE first (native XDP, best performance) + match xdp.attach(ifname, XdpFlags::DRV_MODE) { + Ok(_) => { + tracing::info!("XDP attached to {} in native DRV_MODE", ifname); + return Ok("drv".to_string()); + } + Err(drv_err) => { + tracing::warn!( + "XDP DRV_MODE failed on {}: {}. Falling back to SKB_MODE.", + ifname, drv_err + ); + } + } + + // Fallback to SKB_MODE (generic XDP, reduced performance) + match xdp.attach(ifname, XdpFlags::SKB_MODE) { + Ok(_) => { + tracing::warn!( + "XDP attached to {} in generic SKB_MODE (reduced performance). \ + For best performance, use a NIC with native XDP support (e.g., virtio-net, Intel i40e/ice).", + ifname + ); + Ok("skb".to_string()) + } + Err(skb_err) => { + tracing::error!( + "XDP attach failed on {} 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: {}", + ifname, skb_err + ); + Err(EbpfError::AttachProgramFailed(skb_err).into()) + } + } + } + + pub fn aya_log_init(ingress_ebpf: &mut Ebpf, egress_ebpf: &mut Ebpf) -> Result<(), Error> { + EbpfLogger::init(ingress_ebpf).map_err(EbpfError::LoggerInitFailed)?; + EbpfLogger::init(egress_ebpf).map_err(EbpfError::LoggerInitFailed)?; + Ok(()) + } + + // --- State restoration helpers --- + + fn restore_dns_blacklist(db: &Database, ebpf_services: &EbpfServices) { + if let Ok(domains) = db.load_dns_domains() { + for domain in &domains { + if let Err(e) = ebpf_services.dns_filter.add_domain(domain) { + tracing::warn!("Failed to restore DNS domain '{}': {}", domain, e); + } + } + if !domains.is_empty() { + tracing::info!("Restored {} DNS blacklist domains from database", domains.len()); + } + } + } + + 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) { + tracing::warn!("Failed to restore geo-blocked countries: {}", e); + } else { + tracing::info!("Restored {} geo-blocked countries from database", countries.len()); + } + } + } + + fn restore_rate_limits(db: &Database, ebpf_services: &EbpfServices) { + if let Ok(configs) = db.load_rate_limit_config() { + for (key, value) in &configs { + let result = match key.as_str() { + "packet_rate" => ebpf_services.rate_limit.set_packet_rate(*value), + "syn_rate" => ebpf_services.rate_limit.set_syn_rate(*value), + "udp_rate" => ebpf_services.rate_limit.set_udp_rate(*value), + "dns_rate" => ebpf_services.rate_limit.set_dns_rate(*value), + "window_ns" => ebpf_services.rate_limit.set_window_ns(*value), + _ => Ok(()), + }; + if let Err(e) = result { + tracing::warn!("Failed to restore rate limit '{}': {}", key, e); + } + } + if !configs.is_empty() { + tracing::info!("Restored {} rate limit settings from database", configs.len()); + } + } + } + + async fn restore_acl_rules(db: &Database, ebpf_services: &EbpfServices) { + if let Ok(rules) = db.load_acl_rules() { + let mut restored = 0u32; + for (ip_version, direction, list_type, ip_address, port) in &rules { + let dir = match direction.as_str() { + "source" => FlowDirection::Source, + "destination" => FlowDirection::Destination, + other => { + tracing::warn!("Unknown ACL direction '{}', skipping", other); + continue; + } + }; + let lt = match list_type.as_str() { + "whitelist" => ListType::White, + "blacklist" => ListType::Black, + other => { + tracing::warn!("Unknown ACL list type '{}', skipping", other); + continue; + } + }; + 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) => { + tracing::warn!("Failed to parse IPv4 address '{}': {}", ip_address, e); + 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) => { + tracing::warn!("Failed to parse IPv6 address '{}': {}", ip_address, e); + continue; + } + } + } + other => { + tracing::warn!("Unknown IP version {}, skipping", other); + continue; + } + }; + if let Err(e) = result { + tracing::warn!("Failed to restore ACL rule ({} {} {}:{}): {}", direction, list_type, ip_address, port, e); + } else { + restored += 1; + } + } + if restored > 0 { + tracing::info!("Restored {} ACL rules from database", restored); + } + } + } +} diff --git a/net-guardia/src/core/infrastructure/statistics.rs b/net-guardia/src/infrastructure/statistics.rs similarity index 70% rename from net-guardia/src/core/infrastructure/statistics.rs rename to net-guardia/src/infrastructure/statistics.rs index 15014b7..11209dc 100644 --- a/net-guardia/src/core/infrastructure/statistics.rs +++ b/net-guardia/src/infrastructure/statistics.rs @@ -2,8 +2,30 @@ use std::sync::Arc; use std::time; use crate::core::ml::engine::Engine; +use crate::core::ml::flow_tracker::FlowData; use crate::model::flow_stats::{FlowStatsEntry, FlowSubscription, StatsSummary}; +/// Conversion from core::ml::FlowData to model::FlowStatsEntry. +/// Placed here (core layer) to maintain dependency rule: model/ must not import core/. +impl From<&FlowData> for FlowStatsEntry { + fn from(flow: &FlowData) -> Self { + Self { + direction: flow.direction, + src_ip: flow.flow_key.src_ip_string(), + dst_ip: flow.flow_key.dst_ip_string(), + src_port: flow.flow_key.src_port, + dst_port: flow.flow_key.dst_port, + protocol: flow.flow_key.protocol, + fwd_packets: flow.fwd_packets.len(), + bwd_packets: flow.bwd_packets.len(), + fwd_bytes: flow.fwd_total_bytes, + bwd_bytes: flow.bwd_total_bytes, + duration_us: flow.duration_us(), + last_seen_us: flow.last_time_us, + } + } +} + pub struct FlowStatistics { engine: Arc, } diff --git a/net-guardia/src/interface/communication/command.rs b/net-guardia/src/interface/communication/command.rs new file mode 100644 index 0000000..31ada71 --- /dev/null +++ b/net-guardia/src/interface/communication/command.rs @@ -0,0 +1,16 @@ +use crate::interface::communication::message::Message; +use crate::model::error::Error; +use async_trait::async_trait; +use std::any::Any; +use std::future::Future; +use std::pin::Pin; + +pub type CommandFuture = Pin> + Send + 'static>>; +pub type CommandHandlerFn = Box) -> CommandFuture + Send + Sync>; + +pub trait Command: Message {} + +#[async_trait] +pub trait CommandHandler { + async fn handle_command(&self, command: C) -> Result<(), Error>; +} diff --git a/net-guardia/src/interface/communication/command_types.rs b/net-guardia/src/interface/communication/command_types.rs new file mode 100644 index 0000000..708ccbb --- /dev/null +++ b/net-guardia/src/interface/communication/command_types.rs @@ -0,0 +1,116 @@ +use crate::interface::communication::command::Command; +use crate::interface::communication::message::Message; + +// ── ACL Commands ───────────────────────────────────────────────────── + +pub struct AddAclRuleCommand { + pub ip_version: u8, + pub direction: String, + pub list_type: String, + pub ip_address: String, + pub port: u16, +} + +impl Message for AddAclRuleCommand { + type Response = (); +} +impl Command for AddAclRuleCommand {} + +pub struct RemoveAclRuleCommand { + pub ip_version: u8, + pub direction: String, + pub list_type: String, + pub ip_address: String, + pub port: u16, +} + +impl Message for RemoveAclRuleCommand { + type Response = (); +} +impl Command for RemoveAclRuleCommand {} + +// ── Geo Commands ───────────────────────────────────────────────────── + +pub struct BlockGeoCountriesCommand { + pub country_codes: Vec, +} + +impl Message for BlockGeoCountriesCommand { + type Response = (); +} +impl Command for BlockGeoCountriesCommand {} + +pub struct UnblockGeoCountriesCommand { + pub country_codes: Vec, +} + +impl Message for UnblockGeoCountriesCommand { + type Response = (); +} +impl Command for UnblockGeoCountriesCommand {} + +// ── DNS Commands ───────────────────────────────────────────────────── + +pub struct AddDnsDomainCommand { + pub domain: String, +} + +impl Message for AddDnsDomainCommand { + type Response = (); +} +impl Command for AddDnsDomainCommand {} + +pub struct RemoveDnsDomainCommand { + pub domain: String, +} + +impl Message for RemoveDnsDomainCommand { + type Response = (); +} +impl Command for RemoveDnsDomainCommand {} + +// ── Rate Limit Commands ────────────────────────────────────────────── + +pub struct SetRateLimitCommand { + pub key: String, + pub value: u64, +} + +impl Message for SetRateLimitCommand { + type Response = (); +} +impl Command for SetRateLimitCommand {} + +// ── System Commands ────────────────────────────────────────────────── + +pub struct ChangeEnforceModeCommand { + pub mode: String, +} + +impl Message for ChangeEnforceModeCommand { + type Response = (); +} +impl Command for ChangeEnforceModeCommand {} + +// ── Auth Commands ──────────────────────────────────────────────────── + +pub struct ChangePasswordCommand { + pub user_id: i64, + pub new_password_hash: String, +} + +impl Message for ChangePasswordCommand { + type Response = (); +} +impl Command for ChangePasswordCommand {} + +pub struct RegisterUserCommand { + pub username: String, + pub password_hash: String, + pub role: String, +} + +impl Message for RegisterUserCommand { + type Response = (); +} +impl Command for RegisterUserCommand {} diff --git a/net-guardia/src/interface/communication/event.rs b/net-guardia/src/interface/communication/event.rs new file mode 100644 index 0000000..faeaa07 --- /dev/null +++ b/net-guardia/src/interface/communication/event.rs @@ -0,0 +1,9 @@ +use crate::model::error::Error; +use std::any::Any; + +pub trait Event: Send + Clone + 'static {} + +pub trait EventBroadcaster: Send + Sync { + fn subscribe_typed(&self) -> Box; + fn broadcast_event(&self, event: Box) -> Result<(), Error>; +} diff --git a/net-guardia/src/interface/communication/event_types.rs b/net-guardia/src/interface/communication/event_types.rs new file mode 100644 index 0000000..bfcf470 --- /dev/null +++ b/net-guardia/src/interface/communication/event_types.rs @@ -0,0 +1,83 @@ +use crate::interface::communication::event::Event; +use crate::model::direction::Direction; + +// ── ML Events ──────────────────────────────────────────────────────── + +/// Fired when the ML engine detects a potential threat. +#[derive(Debug, Clone)] +pub struct ThreatDetectedEvent { + pub flow_key: String, + pub direction: Direction, + pub attack_type: String, + pub confidence: f32, + pub ae_score: f32, +} + +impl Event for ThreatDetectedEvent {} + +/// Fired after each ML inference tick with summary stats. +#[derive(Debug, Clone)] +pub struct InferenceCompletedEvent { + pub total_flows: usize, + pub malicious_flows: usize, + pub benign_flows: usize, + pub elapsed_ms: u32, +} + +impl Event for InferenceCompletedEvent {} + +// ── System Events ──────────────────────────────────────────────────── + +/// Fired when enforce mode changes (monitor ↔ enforce). +#[derive(Debug, Clone)] +pub struct EnforceModeChangedEvent { + pub old_mode: String, + pub new_mode: String, +} + +impl Event for EnforceModeChangedEvent {} + +/// Fired when XDP attachment completes (or falls back). +#[derive(Debug, Clone)] +pub struct XdpAttachedEvent { + pub interface: String, + pub mode: String, // "drv" or "skb" +} + +impl Event for XdpAttachedEvent {} + +// ── ACL Events ─────────────────────────────────────────────────────── + +/// Fired when an ACL rule is added or removed. +#[derive(Debug, Clone)] +pub struct AclRuleChangedEvent { + pub action: String, // "added" or "removed" + pub ip_version: u8, + pub direction: String, + pub list_type: String, + pub ip_address: String, + pub port: u16, +} + +impl Event for AclRuleChangedEvent {} + +// ── Auth Events ────────────────────────────────────────────────────── + +/// Fired when a login attempt fails (for auditing). +#[derive(Debug, Clone)] +pub struct LoginFailedEvent { + pub username: String, + pub failure_count: u32, + pub locked: bool, +} + +impl Event for LoginFailedEvent {} + +/// Fired when a user changes their password. +#[derive(Debug, Clone)] +pub struct PasswordChangedEvent { + pub user_id: i64, + pub username: String, +} + +impl Event for PasswordChangedEvent {} diff --git a/net-guardia/src/interface/communication/message.rs b/net-guardia/src/interface/communication/message.rs new file mode 100644 index 0000000..aaf1df9 --- /dev/null +++ b/net-guardia/src/interface/communication/message.rs @@ -0,0 +1,3 @@ +pub trait Message: Send + 'static { + type Response: Send + 'static; +} diff --git a/net-guardia/src/interface/communication/mod.rs b/net-guardia/src/interface/communication/mod.rs new file mode 100644 index 0000000..b49b1a5 --- /dev/null +++ b/net-guardia/src/interface/communication/mod.rs @@ -0,0 +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_types; diff --git a/net-guardia/src/interface/communication/query.rs b/net-guardia/src/interface/communication/query.rs new file mode 100644 index 0000000..5a5c6de --- /dev/null +++ b/net-guardia/src/interface/communication/query.rs @@ -0,0 +1,16 @@ +use crate::interface::communication::message::Message; +use crate::model::error::Error; +use async_trait::async_trait; +use std::any::Any; +use std::future::Future; +use std::pin::Pin; + +pub type QueryFuture = Pin, Error>> + Send + 'static>>; +pub type QueryHandlerFn = Box) -> QueryFuture + Send + Sync>; + +pub trait Query: Message {} + +#[async_trait] +pub trait QueryHandler { + async fn handle_query(&self, query: Q) -> Result; +} diff --git a/net-guardia/src/interface/communication/query_types.rs b/net-guardia/src/interface/communication/query_types.rs new file mode 100644 index 0000000..56278ec --- /dev/null +++ b/net-guardia/src/interface/communication/query_types.rs @@ -0,0 +1,88 @@ +use crate::interface::communication::message::Message; +use crate::interface::communication::query::Query; +use crate::model::health::{SystemHealthMetrics, SystemHealthStatus}; + +// ── System Queries ─────────────────────────────────────────────────── + +pub struct GetEnforceModeQuery; + +impl Message for GetEnforceModeQuery { + type Response = String; +} +impl Query for GetEnforceModeQuery {} + +pub struct GetXdpModeQuery; + +impl Message for GetXdpModeQuery { + type Response = XdpModeResponse; +} +impl Query for GetXdpModeQuery {} + +#[derive(Debug, Clone)] +pub struct XdpModeResponse { + pub ingress_mode: String, + pub egress_mode: String, +} + +// ── Health Queries ─────────────────────────────────────────────────── + +pub struct GetHealthMetricsQuery; + +impl Message for GetHealthMetricsQuery { + type Response = SystemHealthMetrics; +} +impl Query for GetHealthMetricsQuery {} + +pub struct GetHealthStatusQuery; + +impl Message for GetHealthStatusQuery { + type Response = SystemHealthStatus; +} +impl Query for GetHealthStatusQuery {} + +// ── ACL Queries ────────────────────────────────────────────────────── + +pub struct GetAclRulesQuery; + +impl Message for GetAclRulesQuery { + type Response = Vec<(u8, String, String, String, u16)>; +} +impl Query for GetAclRulesQuery {} + +// ── Settings Queries ───────────────────────────────────────────────── + +pub struct GetSettingQuery { + pub key: String, +} + +impl Message for GetSettingQuery { + type Response = Option; +} +impl Query for GetSettingQuery {} + +// ── Rate Limit Queries ─────────────────────────────────────────────── + +pub struct GetRateLimitConfigQuery; + +impl Message for GetRateLimitConfigQuery { + type Response = Vec<(String, u64)>; +} +impl Query for GetRateLimitConfigQuery {} + +// ── DNS Queries ────────────────────────────────────────────────────── + +pub struct GetDnsDomainsQuery; + +impl Message for GetDnsDomainsQuery { + type Response = Vec; +} +impl Query for GetDnsDomainsQuery {} + +// ── Geo Queries ────────────────────────────────────────────────────── + +pub struct GetGeoBlockedCountriesQuery; + +impl Message for GetGeoBlockedCountriesQuery { + type Response = Vec; +} +impl Query for GetGeoBlockedCountriesQuery {} diff --git a/net-guardia/src/interface/mod.rs b/net-guardia/src/interface/mod.rs new file mode 100644 index 0000000..27daa2a --- /dev/null +++ b/net-guardia/src/interface/mod.rs @@ -0,0 +1,2 @@ +pub mod communication; +pub mod port; diff --git a/net-guardia/src/interface/port/auth.rs b/net-guardia/src/interface/port/auth.rs new file mode 100644 index 0000000..8c9639d --- /dev/null +++ b/net-guardia/src/interface/port/auth.rs @@ -0,0 +1,20 @@ +use crate::model::error::Error; + +/// Claims extracted from a validated JWT token. +#[derive(Debug, Clone)] +pub struct TokenClaims { + pub sub: i64, + pub username: String, + pub role: String, + pub permissions: Vec, + pub exp: usize, +} + +/// Port for authentication operations. +/// Adapters: JWT (current), could be OAuth, etc. +pub trait AuthPort: Send + Sync { + fn create_token(&self, user_id: i64, username: &str, role: &str, permissions: Vec) -> Result; + fn validate_token(&self, token: &str) -> Result; + fn hash_password(&self, password: &str) -> Result; + fn verify_password(&self, password: &str, hash: &str) -> Result; +} diff --git a/net-guardia/src/interface/port/health.rs b/net-guardia/src/interface/port/health.rs new file mode 100644 index 0000000..109df47 --- /dev/null +++ b/net-guardia/src/interface/port/health.rs @@ -0,0 +1,10 @@ +use crate::model::health::{SystemHealthMetrics, SystemHealthStatus}; +use async_trait::async_trait; + +/// Port for system health monitoring. +/// Adapters: sysinfo-based (current) +#[async_trait] +pub trait HealthPort: Send + Sync { + async fn get_metrics(&self) -> SystemHealthMetrics; + async fn is_healthy(&self) -> SystemHealthStatus; +} diff --git a/net-guardia/src/interface/port/mod.rs b/net-guardia/src/interface/port/mod.rs new file mode 100644 index 0000000..12766f8 --- /dev/null +++ b/net-guardia/src/interface/port/mod.rs @@ -0,0 +1,4 @@ +pub mod repository; +pub mod auth; +pub mod notification; +pub mod health; diff --git a/net-guardia/src/interface/port/notification.rs b/net-guardia/src/interface/port/notification.rs new file mode 100644 index 0000000..fd34d35 --- /dev/null +++ b/net-guardia/src/interface/port/notification.rs @@ -0,0 +1,9 @@ +use crate::model::error::Error; +use async_trait::async_trait; + +/// Port for outbound notifications (alerts, reports). +/// Adapters: WebSocket (alerts), SMTP (weekly report) +#[async_trait] +pub trait NotificationPort: Send + Sync { + async fn send_weekly_report(&self) -> Result<(), Error>; +} diff --git a/net-guardia/src/interface/port/repository.rs b/net-guardia/src/interface/port/repository.rs new file mode 100644 index 0000000..fa51554 --- /dev/null +++ b/net-guardia/src/interface/port/repository.rs @@ -0,0 +1,72 @@ +use crate::model::error::Error; + +/// Type alias for ACL rule tuples: (ip_version, direction, list_type, ip_address, port) +pub type AclRuleTuple = (u8, String, String, String, u16); + +/// Type alias for user record tuples: (id, username, password_hash, role, force_password_change) +pub type UserTuple = (i64, String, String, String, bool); + +/// Type alias for user list items: (id, username, role, force_password_change, created_at) +pub type UserListItem = (i64, String, String, bool, String); + +/// Type alias for user group tuples: (id, name, description, permissions, created_at) +pub type UserGroupTuple = (i64, String, String, String, String); + +/// Port for persistent storage operations. +/// Adapters: SQLite (current), could be Postgres, etc. +pub trait RepositoryPort: Send + Sync { + // --- ACL --- + fn insert_acl_rule(&self, ip_version: u8, direction: &str, list_type: &str, ip_address: &str, port: u16) -> Result<(), Error>; + fn delete_acl_rule(&self, ip_version: u8, direction: &str, list_type: &str, ip_address: &str, port: u16) -> Result<(), Error>; + fn load_acl_rules(&self) -> Result, Error>; + + // --- Rate Limit --- + fn set_rate_limit(&self, key: &str, value: u64) -> Result<(), Error>; + fn load_rate_limit_config(&self) -> Result, Error>; + + // --- DNS --- + fn insert_dns_domain(&self, domain: &str) -> Result<(), Error>; + fn delete_dns_domain(&self, domain: &str) -> Result<(), Error>; + fn load_dns_domains(&self) -> Result, Error>; + + // --- Geo --- + fn insert_geo_country(&self, code: &str) -> Result<(), Error>; + fn delete_geo_country(&self, code: &str) -> Result<(), Error>; + fn load_geo_countries(&self) -> Result, Error>; + + // --- Settings --- + fn get_setting(&self, key: &str) -> Result, Error>; + fn set_setting(&self, key: &str, value: &str) -> Result<(), Error>; + + // --- 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 update_user_password(&self, user_id: i64, password_hash: &str) -> Result<(), Error>; + fn user_count(&self) -> Result; + + // --- User Management --- + fn list_users(&self) -> Result, Error>; + fn delete_user(&self, user_id: i64) -> Result; + fn update_user_role(&self, user_id: i64, role: &str) -> Result<(), Error>; + fn reset_user_password(&self, user_id: i64, password_hash: &str) -> Result<(), Error>; + fn find_user_by_id(&self, user_id: i64) -> Result, Error>; + + // --- User Groups --- + fn list_user_groups(&self) -> Result, Error>; + fn create_user_group(&self, name: &str, description: &str, permissions: &str) -> Result; + fn update_user_group(&self, id: i64, name: &str, description: &str, permissions: &str) -> Result<(), Error>; + fn delete_user_group(&self, id: i64) -> Result; + fn get_user_group(&self, id: i64) -> Result, Error>; + + // --- User Group Membership --- + fn get_user_groups(&self, user_id: i64) -> Result, Error>; + fn set_user_groups(&self, user_id: i64, group_ids: &[i64]) -> Result<(), Error>; + fn get_user_permissions(&self, user_id: i64) -> Result, Error>; + fn cleanup_user_memberships(&self, user_id: i64) -> Result<(), Error>; + fn get_group_member_ids(&self, group_id: i64) -> Result, Error>; + + // --- Login Rate Limiting --- + fn record_login_failure(&self, username: &str) -> Result<(u32, Option), Error>; + fn check_login_locked(&self, username: &str) -> Result, Error>; + fn clear_login_failures(&self, username: &str) -> Result<(), Error>; +} diff --git a/net-guardia/src/main.rs b/net-guardia/src/main.rs index 3f9d173..82440f0 100644 --- a/net-guardia/src/main.rs +++ b/net-guardia/src/main.rs @@ -1,7 +1,9 @@ +mod adapter; mod core; +mod infrastructure; +mod interface; mod model; mod utils; -mod web; use crate::core::system::System; use crate::model::error::Error; diff --git a/net-guardia/src/model/auth.rs b/net-guardia/src/model/auth.rs new file mode 100644 index 0000000..a5eb270 --- /dev/null +++ b/net-guardia/src/model/auth.rs @@ -0,0 +1,10 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Claims { + pub sub: i64, + pub username: String, + pub role: String, + pub permissions: Vec, + pub exp: usize, +} diff --git a/net-guardia/src/model/config.rs b/net-guardia/src/model/config.rs index 15c98e0..e484da5 100644 --- a/net-guardia/src/model/config.rs +++ b/net-guardia/src/model/config.rs @@ -1,5 +1,9 @@ +use std::collections::HashMap; + use serde::{Deserialize, Serialize}; +use crate::model::ml_detection::ClipParams; + #[derive(Debug, Deserialize)] pub struct AppConfigTable { #[serde(rename = "Http")] @@ -17,8 +21,12 @@ pub struct AppConfigTable { #[derive(Serialize, Deserialize, Debug, Clone)] pub struct HttpConfig { pub http_server_bind_port: u16, + #[serde(default = "default_jwt_expiry")] + pub jwt_expiry_hours: u64, } +fn default_jwt_expiry() -> u64 { 24 } + #[derive(Serialize, Deserialize, Debug, Clone)] pub struct NetworkConfig { pub ingress_ifname: String, @@ -58,10 +66,44 @@ pub struct InferenceConfig { #[derive(Serialize, Deserialize, Debug, Clone)] pub struct MiscConfig { pub geoip_db_name: String, + #[serde(default = "default_db_path")] + pub database_path: String, + #[serde(default = "default_license_path")] + pub license_file: String, } +fn default_db_path() -> String { "net-guardia.db".to_string() } +fn default_license_path() -> String { "license.key".to_string() } + #[derive(Serialize, Deserialize, Debug, Clone)] pub struct PipelineConfig { pub ingress: Vec, pub egress: Vec, } + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MLInferenceConfig { + pub ae_feature_names: Vec, + pub ae_clip_params: HashMap, + pub ae_scaler_mean: Vec, + pub ae_scaler_std: Vec, + pub ae_post_clip_min: f64, + pub ae_post_clip_max: f64, + pub ae_threshold: f32, + pub classifier_feature_names: Vec, + pub attack_labels: HashMap, +} + +impl MLInferenceConfig { + pub fn num_ae_features(&self) -> usize { + self.ae_feature_names.len() + } + + pub fn num_classifier_features(&self) -> usize { + self.classifier_feature_names.len() + } + + pub fn num_attack_types(&self) -> usize { + self.attack_labels.len() + } +} diff --git a/net-guardia/src/model/drop_event.rs b/net-guardia/src/model/drop_event.rs new file mode 100644 index 0000000..192bbd1 --- /dev/null +++ b/net-guardia/src/model/drop_event.rs @@ -0,0 +1,26 @@ +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +pub struct DropEventMessage { + pub timestamp_ns: u64, + pub src_ip: String, + pub dst_ip: String, + pub src_port: u16, + pub dst_port: u16, + pub protocol: u8, + pub reason: String, + pub ip_version: u8, +} + +#[derive(Default, Clone, Serialize)] +pub struct DropCounters { + pub acl_blacklist: u64, + pub rate_limit_pkt: u64, + pub rate_limit_syn: u64, + pub rate_limit_udp: u64, + pub rate_limit_dns: u64, + pub protocol_filter: u64, + pub dns_blacklist: u64, + pub geo_block: u64, + pub total: u64, +} diff --git a/net-guardia/src/model/error/auth.rs b/net-guardia/src/model/error/auth.rs new file mode 100644 index 0000000..9891342 --- /dev/null +++ b/net-guardia/src/model/error/auth.rs @@ -0,0 +1,25 @@ +use macros::traceable; + +traceable! { + AuthError { + #[no_source] + #[error("Invalid credentials")] + InvalidCredentials => tracing::Level::WARN, + + #[no_source] + #[error("Token expired")] + TokenExpired => tracing::Level::WARN, + + #[no_source] + #[error("Invalid token")] + InvalidToken => tracing::Level::WARN, + + #[no_source] + #[error("Insufficient permissions")] + InsufficientPermissions => tracing::Level::WARN, + + #[no_source] + #[error("Missing authorization header")] + MissingAuthHeader => tracing::Level::WARN, + } +} diff --git a/net-guardia/src/model/error/database.rs b/net-guardia/src/model/error/database.rs new file mode 100644 index 0000000..d3d1c80 --- /dev/null +++ b/net-guardia/src/model/error/database.rs @@ -0,0 +1,28 @@ +use macros::traceable; + +traceable! { + DatabaseError { + #[no_source] + #[error("Database error: {reason}")] + QueryFailed { reason: String } => tracing::Level::ERROR, + + #[error("Database connection failed")] + ConnectionFailed => tracing::Level::ERROR, + + #[no_source] + #[error("User '{username}' already exists")] + UserAlreadyExists { username: String } => tracing::Level::WARN, + } +} + +impl From for DatabaseError { + fn from(e: rusqlite::Error) -> Self { + DatabaseError::QueryFailed { reason: e.to_string() } + } +} + +impl From for super::Error { + fn from(e: rusqlite::Error) -> Self { + Self::Database(DatabaseError::from(e)) + } +} diff --git a/net-guardia/src/model/error/license.rs b/net-guardia/src/model/error/license.rs new file mode 100644 index 0000000..24cb4f7 --- /dev/null +++ b/net-guardia/src/model/error/license.rs @@ -0,0 +1,19 @@ +use macros::traceable; + +traceable! { + LicenseError { + #[no_source] + #[error("License file not found: {path}")] + FileNotFound { path: String } => tracing::Level::WARN, + + #[error("Invalid license signature")] + InvalidSignature => tracing::Level::ERROR, + + #[error("License has expired")] + Expired => tracing::Level::WARN, + + #[no_source] + #[error("License validation failed: {reason}")] + ValidationFailed { reason: String } => tracing::Level::ERROR, + } +} diff --git a/net-guardia/src/model/error/misc.rs b/net-guardia/src/model/error/misc.rs index 6947201..b7ea19d 100644 --- a/net-guardia/src/model/error/misc.rs +++ b/net-guardia/src/model/error/misc.rs @@ -30,5 +30,17 @@ traceable! { #[no_source] #[error("Invalid DNS domain name: {reason}")] InvalidDnsName { reason: String } => tracing::Level::WARN, + + #[no_source] + #[error("Type mismatch during message dispatch")] + TypeMismatch => tracing::Level::ERROR, + + #[no_source] + #[error("No handler registered for this message type")] + HandlerNotFound => tracing::Level::ERROR, + + #[no_source] + #[error("Event type not registered with communication manager")] + TypeNotRegistered => tracing::Level::ERROR, } } diff --git a/net-guardia/src/model/error/mod.rs b/net-guardia/src/model/error/mod.rs index 94f94bf..b2d50a9 100644 --- a/net-guardia/src/model/error/mod.rs +++ b/net-guardia/src/model/error/mod.rs @@ -1,21 +1,33 @@ +pub mod auth; +pub mod database; pub mod ebpf; pub mod http; pub mod io; +#[cfg(feature = "license")] +pub mod license; pub mod misc; pub mod ml; pub mod system; use serde::{Deserialize, Serialize}; +use crate::model::error::auth::AuthError; +use crate::model::error::database::DatabaseError; use crate::model::error::ebpf::EbpfError; use crate::model::error::http::HttpError; use crate::model::error::io::IOError; +#[cfg(feature = "license")] +use crate::model::error::license::LicenseError; use crate::model::error::misc::MiscError; use crate::model::error::ml::MLError; use crate::model::error::system::SystemError; #[derive(Clone, Debug, thiserror::Error, Serialize, Deserialize)] pub enum Error { + #[error("{0}")] + Auth(AuthError), + #[error("{0}")] + Database(DatabaseError), #[error("{0}")] Ebpf(EbpfError), #[error("{0}")] @@ -24,12 +36,27 @@ pub enum Error { ML(MLError), #[error("{0}")] IO(IOError), + #[cfg(feature = "license")] + #[error("{0}")] + License(LicenseError), #[error("{0}")] Misc(MiscError), #[error("{0}")] System(SystemError), } +impl From for Error { + fn from(error: AuthError) -> Self { + Self::Auth(error) + } +} + +impl From for Error { + fn from(error: DatabaseError) -> Self { + Self::Database(error) + } +} + impl From for Error { fn from(error: EbpfError) -> Self { Self::Ebpf(error) @@ -48,6 +75,13 @@ impl From for Error { } } +#[cfg(feature = "license")] +impl From for Error { + fn from(error: LicenseError) -> Self { + Self::License(error) + } +} + impl From for Error { fn from(error: MiscError) -> Self { Self::Misc(error) @@ -64,4 +98,4 @@ impl From for Error { fn from(error: MLError) -> Self { Self::ML(error) } -} \ No newline at end of file +} diff --git a/net-guardia/src/model/flow_stats.rs b/net-guardia/src/model/flow_stats.rs index a9f75be..a82956f 100644 --- a/net-guardia/src/model/flow_stats.rs +++ b/net-guardia/src/model/flow_stats.rs @@ -1,6 +1,5 @@ use serde::{Deserialize, Serialize}; -use crate::core::ml::flow_tracker::FlowData; use crate::model::direction::Direction; #[derive(Debug, Clone, Serialize)] @@ -19,24 +18,8 @@ pub struct FlowStatsEntry { pub last_seen_us: u64, } -impl From<&FlowData> for FlowStatsEntry { - fn from(flow: &FlowData) -> Self { - Self { - direction: flow.direction, - src_ip: flow.flow_key.src_ip_string(), - dst_ip: flow.flow_key.dst_ip_string(), - src_port: flow.flow_key.src_port, - dst_port: flow.flow_key.dst_port, - protocol: flow.flow_key.protocol, - fwd_packets: flow.fwd_packets.len(), - bwd_packets: flow.bwd_packets.len(), - fwd_bytes: flow.fwd_total_bytes, - bwd_bytes: flow.bwd_total_bytes, - duration_us: flow.duration_us(), - last_seen_us: flow.last_time_us, - } - } -} +// NOTE: From<&FlowData> impl moved to core/infrastructure/statistics.rs +// to maintain the dependency rule: model/ must not import core/ #[derive(Debug, Clone, Serialize)] pub struct StatsSummary { diff --git a/net-guardia/src/model/license.rs b/net-guardia/src/model/license.rs new file mode 100644 index 0000000..5b41782 --- /dev/null +++ b/net-guardia/src/model/license.rs @@ -0,0 +1,26 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LicensePayload { + pub ingress_mac: String, + pub egress_mac: String, + pub expires: String, + pub features: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LicenseInfo { + pub payload: Option, + pub valid: bool, + pub days_remaining: i64, +} + +impl LicenseInfo { + pub fn unlicensed() -> Self { + Self { + payload: None, + valid: false, + days_remaining: 0, + } + } +} diff --git a/net-guardia/src/model/ml_detection.rs b/net-guardia/src/model/ml_detection.rs index e94ab0f..29901c3 100644 --- a/net-guardia/src/model/ml_detection.rs +++ b/net-guardia/src/model/ml_detection.rs @@ -139,3 +139,41 @@ impl InferenceStats { } } } + +#[derive(Debug, Clone, Serialize)] +pub struct AlertMessage { + pub timestamp: u64, + pub flow_key: String, + pub src_ip: String, + pub dst_ip: String, + pub src_port: u16, + pub dst_port: u16, + pub protocol: u8, + pub is_attack: bool, + pub attack_type: Option, + pub confidence: f32, + pub ae_score: f32, +} + +impl AlertMessage { + pub fn from_detection_result(result: &DetectionResult) -> Self { + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + Self { + timestamp, + flow_key: result.flow_key.clone(), + src_ip: result.flow_key_raw.src_ip_string(), + dst_ip: result.flow_key_raw.dst_ip_string(), + src_port: result.flow_key_raw.src_port, + dst_port: result.flow_key_raw.dst_port, + protocol: result.flow_key_raw.protocol, + is_attack: result.is_attack, + attack_type: result.attack_type.clone(), + confidence: result.confidence, + ae_score: result.ae_score, + } + } +} diff --git a/net-guardia/src/model/mod.rs b/net-guardia/src/model/mod.rs index 120a823..f9752cf 100644 --- a/net-guardia/src/model/mod.rs +++ b/net-guardia/src/model/mod.rs @@ -1,9 +1,13 @@ +pub mod auth; pub mod config; pub mod direction; +pub mod drop_event; pub mod error; pub mod flow_stats; pub mod health; pub mod ip_address; +#[cfg(feature = "license")] +pub mod license; pub mod list_type; pub mod log; pub mod ml_detection; diff --git a/net-guardia/src/model/user_packet.rs b/net-guardia/src/model/user_packet.rs index 1f46b57..c7baa51 100644 --- a/net-guardia/src/model/user_packet.rs +++ b/net-guardia/src/model/user_packet.rs @@ -1,5 +1,3 @@ -use std::net::{Ipv4Addr, Ipv6Addr}; - pub struct UserPacket { pub ip_version: u8, pub protocol: u8, @@ -15,23 +13,3 @@ pub struct UserPacket { pub timestamp_us: u64, pub is_forward: bool, } - -impl UserPacket { - /// Format source IP as a human-readable string. - pub fn src_ip_string(&self) -> String { - Self::ip_bytes_to_string(self.ip_version, &self.src_ip) - } - - /// Format destination IP as a human-readable string. - pub fn dst_ip_string(&self) -> String { - Self::ip_bytes_to_string(self.ip_version, &self.dst_ip) - } - - fn ip_bytes_to_string(ip_version: u8, bytes: &[u8; 16]) -> String { - if ip_version == 6 { - Ipv6Addr::from(*bytes).to_string() - } else { - Ipv4Addr::from([bytes[0], bytes[1], bytes[2], bytes[3]]).to_string() - } - } -} diff --git a/net-guardia/src/web/api/system.rs b/net-guardia/src/web/api/system.rs deleted file mode 100644 index c9c9737..0000000 --- a/net-guardia/src/web/api/system.rs +++ /dev/null @@ -1,10 +0,0 @@ -use actix_web::{web, HttpResponse, Responder, Scope}; - -pub fn initialize() -> Scope { - web::scope("/system") - .route("/boot-time", web::get().to(get_boot_time)) -} - -async fn get_boot_time() -> impl Responder { - HttpResponse::Ok().json(crate::utils::boot_time::boot_time()) -} diff --git a/net-guardia/src/web/mod.rs b/net-guardia/src/web/mod.rs deleted file mode 100644 index a3766ff..0000000 --- a/net-guardia/src/web/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod api; -pub mod websocket; diff --git a/tests/integration_test.sh b/tests/integration_test.sh new file mode 100755 index 0000000..5297fdb --- /dev/null +++ b/tests/integration_test.sh @@ -0,0 +1,315 @@ +#!/bin/bash +# NetGuardia 功能測試腳本 +# 測試:XDP 封包轉發、Web API、ML 引擎 +# 使用 graceful shutdown,所有操作設有 timeout + +set -uo pipefail + +TIMEOUT=10 +NG_API="http://10.10.3.10:8080" +PASS=0 +FAIL=0 +TESTS=() + +run_sudo() { + sudo "$@" 2>/dev/null +} + +ng_exec() { + run_sudo podman exec netguardia bash -c "$1" +} + +router_exec() { + run_sudo podman exec router bash -c "$1" +} + +ext_exec() { + run_sudo podman exec external bash -c "$1" +} + +int_exec() { + run_sudo podman exec internal bash -c "$1" +} + +test_result() { + local name="$1" + local result="$2" + if [ "$result" -eq 0 ]; then + echo " ✅ $name" + PASS=$((PASS + 1)) + else + echo " ❌ $name" + FAIL=$((FAIL + 1)) + fi + TESTS+=("$name:$result") +} + +echo "==========================================" +echo " NetGuardia 功能測試" +echo "==========================================" +echo "" + +# ============================================================ +# 1. 基礎檢查 +# ============================================================ +echo "--- 1. 基礎檢查 ---" + +# 1a. 容器運行中 +run_sudo podman ps --filter name=netguardia --format "{{.Status}}" | grep -q "Up" 2>/dev/null +test_result "netguardia 容器運行中" $? + +# 1b. XDP 程式已附加 +ng_exec "ip link show ng-ext 2>/dev/null | grep -q xdp" +test_result "ng-ext XDP 程式已附加" $? + +ng_exec "ip link show ng-int 2>/dev/null | grep -q xdp" +test_result "ng-int XDP 程式已附加" $? + +# 1c. 管理網路可達 +ng_exec "ping -c1 -W $TIMEOUT 10.10.3.1 >/dev/null 2>&1" +test_result "管理網路 (10.10.3.1) 可達" $? + +echo "" + +# ============================================================ +# 2. XDP 封包轉發測試 +# ============================================================ +echo "--- 2. XDP 封包轉發測試 ---" + +# 2a. Router -> Internal (透過 NetGuardia) +router_exec "ping -c 3 -W $TIMEOUT 10.10.2.2 >/dev/null 2>&1" +test_result "Router → Internal ICMP 轉發 (10.10.2.2)" $? + +# 2b. Internal -> Router (反向轉發) +int_exec "ping -c 3 -W $TIMEOUT 10.10.2.1 >/dev/null 2>&1" +test_result "Internal → Router ICMP 轉發 (10.10.2.1)" $? + +# 2c. External -> Internal (全路徑: external -> router -> netguardia -> internal) +ext_exec "ping -c 3 -W $TIMEOUT 10.10.2.2 >/dev/null 2>&1" +test_result "External → Internal 全路徑 ICMP" $? + +# 2d. Internal -> External (全路徑反向) +int_exec "ping -c 3 -W $TIMEOUT 10.10.1.2 >/dev/null 2>&1" +test_result "Internal → External 全路徑 ICMP" $? + +# 2e. TCP 轉發 (HTTP) +ext_exec "timeout $TIMEOUT curl -s -o /dev/null -w '%{http_code}' http://10.10.2.2/ 2>/dev/null" | grep -q "200" +test_result "External → Internal HTTP (TCP 轉發)" $? + +echo "" + +# ============================================================ +# 3. Web API 測試 +# ============================================================ +echo "--- 3. Web API 測試 ---" + +# 3a. Health endpoint +ng_exec "timeout $TIMEOUT curl -s -o /dev/null -w '%{http_code}' $NG_API/api/health/status" | grep -q "200" +test_result "GET /api/health/status" $? + +# 3b. Health metrics +ng_exec "timeout $TIMEOUT curl -s $NG_API/api/health/metrics" | grep -q "cpu" 2>/dev/null +test_result "GET /api/health/metrics (含 CPU 資訊)" $? + +# 3c. Flow stats +ng_exec "timeout $TIMEOUT curl -s -o /dev/null -w '%{http_code}' $NG_API/api/stats/flows" | grep -q "200" +test_result "GET /api/stats/flows" $? + +# 3e. Stats summary +ng_exec "timeout $TIMEOUT curl -s -o /dev/null -w '%{http_code}' $NG_API/api/stats/summary" | grep -q "200" +test_result "GET /api/stats/summary" $? + +# 3f. ML status +ng_exec "timeout $TIMEOUT curl -s -o /dev/null -w '%{http_code}' $NG_API/api/ml/status" | grep -q "200" +test_result "GET /api/ml/status" $? + +# 3g. ACL list +ng_exec "timeout $TIMEOUT curl -s -o /dev/null -w '%{http_code}' $NG_API/api/acl/ipv4/source/whitelist" | grep -q "200" +test_result "GET /api/acl/ipv4/source/whitelist" $? + +# 3h. Rate limit config +ng_exec "timeout $TIMEOUT curl -s -o /dev/null -w '%{http_code}' $NG_API/api/rate-limit/config" | grep -q "200" +test_result "GET /api/rate-limit/config" $? + +echo "" + +# ============================================================ +# 4. ACL 功能測試 +# ============================================================ +echo "--- 4. ACL 功能測試 ---" + +# 4a. 添加黑名單規則 (封鎖 10.10.1.5) — API 接收 SocketAddrV4 格式 "ip:port" +ng_exec "timeout $TIMEOUT curl -s -o /dev/null -w '%{http_code}' -X PUT '$NG_API/api/acl/ipv4/source/blacklist' -H 'Content-Type: application/json' -d '\"10.10.1.5:0\"'" | grep -q "200" +test_result "PUT ACL 黑名單規則 (封鎖 10.10.1.5)" $? + +# 4b. 驗證封鎖生效 (10.10.1.5 不該能 ping 到 internal) +sleep 1 +ext_exec "ping -c 2 -W 3 -I 10.10.1.5 10.10.2.2 >/dev/null 2>&1" && BLOCKED=1 || BLOCKED=0 +test_result "10.10.1.5 被封鎖 (ping 失敗)" $BLOCKED + +# 4c. 未封鎖的 IP 仍然可達 +ext_exec "ping -c 2 -W $TIMEOUT -I 10.10.1.2 10.10.2.2 >/dev/null 2>&1" +test_result "10.10.1.2 未受影響 (ping 成功)" $? + +# 4d. 移除黑名單規則 +ng_exec "timeout $TIMEOUT curl -s -o /dev/null -w '%{http_code}' -X DELETE '$NG_API/api/acl/ipv4/source/blacklist' -H 'Content-Type: application/json' -d '\"10.10.1.5:0\"'" | grep -q "200" +test_result "DELETE ACL 黑名單規則 (解除 10.10.1.5)" $? + +# 4e. 驗證解除封鎖 +sleep 1 +ext_exec "ping -c 2 -W $TIMEOUT -I 10.10.1.5 10.10.2.2 >/dev/null 2>&1" +test_result "10.10.1.5 解除封鎖 (ping 恢復)" $? + +echo "" + +# ============================================================ +# 5. Rate Limit 測試 +# ============================================================ +echo "--- 5. Rate Limit 測試 ---" + +# 5a. 讀取當前 rate limit 設定 +RL_CONFIG=$(ng_exec "timeout $TIMEOUT curl -s $NG_API/api/rate-limit/config") +echo "$RL_CONFIG" | grep -q "packet_rate" 2>/dev/null +test_result "Rate limit 設定可讀取" $? + +echo "" + +# ============================================================ +# 6. ML 引擎測試 +# ============================================================ +echo "--- 6. ML 引擎測試 ---" + +ML_STATUS=$(ng_exec "timeout $TIMEOUT curl -s $NG_API/api/ml/status") +echo "$ML_STATUS" | grep -q "active\|logging\|disabled" 2>/dev/null +test_result "ML 引擎狀態可查詢" $? + +# 檢查 traffic log 是否在寫入 (traffic_logging_mode = true) +ng_exec "test -f /root/NetGuardia/traffic_log.csv && wc -l < /root/NetGuardia/traffic_log.csv || echo 0" | grep -qv "^0$" 2>/dev/null +test_result "Traffic log CSV 有寫入資料" $? + +echo "" + +# ============================================================ +# 7. WebSocket 測試 +# ============================================================ +echo "--- 7. WebSocket 測試 ---" + +# 簡單測試 WS endpoint 是否回應 (upgrade request) +# curl -sv 輸出 status code 到 stderr,101 Switching Protocols 表示成功 +WS_CODE=$(ng_exec "timeout 3 curl -s -o /dev/null -w '%{http_code}' -H 'Upgrade: websocket' -H 'Connection: Upgrade' -H 'Sec-WebSocket-Key: dGVzdA==' -H 'Sec-WebSocket-Version: 13' $NG_API/ws/health 2>/dev/null || true") +echo "$WS_CODE" | grep -q "101" +test_result "WebSocket /ws/health 升級成功 (101)" $? + +echo "" + +# ============================================================ +# 8. GeoIP 國家封鎖 API 測試 +# ============================================================ +echo "--- 8. GeoIP 國家封鎖 API 測試 ---" + +# 8a. 查詢目前封鎖的國家列表 +ng_exec "timeout $TIMEOUT curl -s -o /dev/null -w '%{http_code}' $NG_API/api/acl/geo/blocked" | grep -q "200" +test_result "GET /api/acl/geo/blocked" $? + +# 8b. 封鎖國家 (CN, RU) — GeoIP rebuild 需要掃描 MaxMind DB,timeout 加長到 30s +ng_exec "timeout 30 curl -s -o /dev/null -w '%{http_code}' -X PUT '$NG_API/api/acl/geo/block' -H 'Content-Type: application/json' -d '{\"country_codes\":[\"CN\",\"RU\"]}'" | grep -q "200" +test_result "PUT /api/acl/geo/block 封鎖 CN, RU" $? + +# 8c. 驗證封鎖列表包含 CN +GEO_BLOCKED=$(ng_exec "timeout $TIMEOUT curl -s $NG_API/api/acl/geo/blocked") +echo "$GEO_BLOCKED" | grep -q "CN" 2>/dev/null +test_result "封鎖列表包含 CN" $? + +# 8d. 回傳包含 total_prefixes +GEO_PUT_RESP=$(ng_exec "timeout 30 curl -s -X PUT '$NG_API/api/acl/geo/block' -H 'Content-Type: application/json' -d '{\"country_codes\":[\"KP\"]}'") +echo "$GEO_PUT_RESP" | grep -q "total_prefixes" 2>/dev/null +test_result "PUT 回傳 total_prefixes 欄位" $? + +# 8e. 解除封鎖 +ng_exec "timeout 30 curl -s -o /dev/null -w '%{http_code}' -X DELETE '$NG_API/api/acl/geo/unblock' -H 'Content-Type: application/json' -d '{\"country_codes\":[\"CN\",\"RU\",\"KP\"]}'" | grep -q "200" +test_result "DELETE /api/acl/geo/unblock 清除所有 GeoIP 規則" $? + +# 8f. 驗證清空 +GEO_AFTER=$(ng_exec "timeout $TIMEOUT curl -s $NG_API/api/acl/geo/blocked") +echo "$GEO_AFTER" | grep -q '"blocked_countries":\[\]' 2>/dev/null || echo "$GEO_AFTER" | grep -q '"blocked_countries": \[\]' 2>/dev/null +test_result "封鎖列表已清空" $? + +echo "" + +# ============================================================ +# 9. DNS 黑名單 API 測試 +# ============================================================ +echo "--- 9. DNS 黑名單 API 測試 ---" + +# 9a. 查詢 DNS 黑名單 +ng_exec "timeout $TIMEOUT curl -s -o /dev/null -w '%{http_code}' $NG_API/api/filter/dns/blacklist" | grep -q "200" +test_result "GET /api/filter/dns/blacklist" $? + +# 9b. 新增域名到黑名單 +ng_exec "timeout $TIMEOUT curl -s -o /dev/null -w '%{http_code}' -X PUT '$NG_API/api/filter/dns/blacklist' -H 'Content-Type: application/json' -d '{\"domains\":[\"malware.example.com\",\"phishing.test.org\"]}'" | grep -q "200" +test_result "PUT /api/filter/dns/blacklist 新增域名" $? + +# 9c. 驗證黑名單已更新 +DNS_DOMAINS=$(ng_exec "timeout $TIMEOUT curl -s $NG_API/api/filter/dns/blacklist") +echo "$DNS_DOMAINS" | grep -q "malware.example.com" 2>/dev/null +test_result "黑名單包含 malware.example.com" $? + +# 9d. 移除域名 +ng_exec "timeout $TIMEOUT curl -s -o /dev/null -w '%{http_code}' -X DELETE '$NG_API/api/filter/dns/blacklist' -H 'Content-Type: application/json' -d '{\"domains\":[\"phishing.test.org\"]}'" | grep -q "200" +test_result "DELETE 移除 phishing.test.org" $? + +# 9e. 驗證移除結果 +DNS_AFTER=$(ng_exec "timeout $TIMEOUT curl -s $NG_API/api/filter/dns/blacklist") +echo "$DNS_AFTER" | grep -q "malware.example.com" 2>/dev/null +test_result "移除後仍包含 malware.example.com" $? + +# 9f. 清除所有 DNS 黑名單 +ng_exec "timeout $TIMEOUT curl -s -o /dev/null -w '%{http_code}' -X DELETE '$NG_API/api/filter/dns/blacklist' -H 'Content-Type: application/json' -d '{\"domains\":[\"malware.example.com\"]}'" | grep -q "200" +test_result "清除所有 DNS 黑名單規則" $? + +echo "" + +# ============================================================ +# 10. DNS 黑名單封鎖流量驗證 +# ============================================================ +echo "--- 10. DNS 黑名單封鎖流量驗證 ---" + +# 10a. 新增測試域名到黑名單 +ng_exec "timeout $TIMEOUT curl -s -o /dev/null -w '%{http_code}' -X PUT '$NG_API/api/filter/dns/blacklist' -H 'Content-Type: application/json' -d '{\"domains\":[\"evil.example.com\"]}'" | grep -q "200" +test_result "新增 evil.example.com 到 DNS 黑名單" $? + +# 10b. 對黑名單域名的 DNS 查詢應被丟棄 (timeout) +sleep 1 +ext_exec "timeout 3 dig @10.10.2.2 evil.example.com +time=2 +tries=1 >/dev/null 2>&1" && DNS_BLOCKED=1 || DNS_BLOCKED=0 +test_result "evil.example.com DNS 查詢被封鎖" $DNS_BLOCKED + +# 10c. 子域名也應被封鎖 +ext_exec "timeout 3 dig @10.10.2.2 sub.evil.example.com +time=2 +tries=1 >/dev/null 2>&1" && SUB_BLOCKED=1 || SUB_BLOCKED=0 +test_result "sub.evil.example.com 子域名也被封鎖" $SUB_BLOCKED + +# 10d. 清除 +ng_exec "timeout $TIMEOUT curl -s -o /dev/null -w '%{http_code}' -X DELETE '$NG_API/api/filter/dns/blacklist' -H 'Content-Type: application/json' -d '{\"domains\":[\"evil.example.com\"]}'" | grep -q "200" +test_result "清除 DNS 黑名單測試規則" $? + +echo "" + +# ============================================================ +# 結果總結 +# ============================================================ +echo "==========================================" +echo " 測試結果: $PASS 通過 / $FAIL 失敗 / $((PASS + FAIL)) 總計" +echo "==========================================" + +if [ "$FAIL" -gt 0 ]; then + echo "" + echo "失敗的測試:" + for t in "${TESTS[@]}"; do + name="${t%:*}" + result="${t##*:}" + if [ "$result" -ne 0 ]; then + echo " ❌ $name" + fi + done +fi + +exit $FAIL