feat: RBAC, clippy fixes, frontend CI, integration test (#16)

* feat: SQLite persistence for ACL, rate limit, DNS, and GeoIP rules

Add rusqlite with WAL mode for persisting all security rules. On
startup, load persisted state into eBPF maps. On API writes, persist
to DB alongside eBPF updates (DB-first for crash safety).

Tables: users, acl_rules, rate_limit_config, dns_blacklist,
geo_blocked_countries, settings. Database module uses parking_lot
Mutex for thread-safe access.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: JWT authentication with RBAC and default admin

Auth: JWT token-based authentication with argon2 password hashing.
Auto-generated secret persisted in SQLite settings table. Middleware
validates Bearer tokens on all /api/* endpoints except /api/auth/login.

RBAC: admin (all operations) and viewer (GET only). Default admin
user created on first run (password: "admin", logged as warning).

Endpoints: POST /api/auth/login, POST /api/auth/register (admin only),
GET /api/auth/me. WebSocket endpoints validate ?token= query parameter.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: Ed25519 license validation and generator

Validator (net-guardia): Reads license.key (base64 payload + signature),
verifies Ed25519 signature against embedded public key, checks expiry.
Optional — missing license logs warning, invalid/expired fails startup.
GET /api/system/license exposes license info.

Generator (license-generator): Standalone crate, not in workspace.
Subcommands: keygen (Ed25519 keypair), issue (sign license with
device_id/expires/features), verify (check license file).

Public key placeholder (all zeros) — replace after running keygen.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: security hardening, monitor mode, SMTP reports, XDP fallback, tests & deploy tooling

- Security: force password change on first login, auth input validation
  (password ≥8 chars, username alphanumeric), login rate limiting (5 failures
  → 15min lockout), change-password API endpoint
- DB: From<rusqlite::Error> trait impl eliminates ~20 duplicated map_err calls
- XDP: fallback chain DRV_MODE → SKB_MODE → clear error with supported NIC list
- Monitor mode: enforce_mode setting (monitor/enforce) with GET/PUT API
- CORS: switched from hardcoded localhost to permissive for appliance deployment
- Email: SMTP weekly report module (lettre) with HTML template and cron scheduler
- Health: disk usage monitoring with >90% warning and >95% critical alerts
- System API: XDP mode reporting, enforce mode toggle endpoints
- Tests: 19 unit tests covering DB CRUD, JWT lifecycle, password hashing,
  license date calculations, and login lockout
- Deploy: setup wizard (bash/whiptail), systemd service with watchdog,
  logrotate config, Packer VM template (OVA + QCOW2)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: hexagonal architecture with ports, adapters, and CQRS event bus

- Architecture: reorganize into hexagonal layers (interface/, adapter/,
  infrastructure/) with strict unidirectional dependency rules
- interface/communication: CQRS message bus (Command, Query, Event traits)
  adapted from MirrorSphere's CommunicationManager pattern
- interface/port: define 5 port traits (RepositoryPort, AuthPort, HealthPort,
  NotificationPort, PacketProcessorPort) for dependency inversion
- infrastructure: extract ServiceFactory and HttpServer from God Object
  (system.rs reduced from 503 to ~120 lines), add CommunicationManager
- adapter/http: move web/api/ handlers, use dyn RepositoryPort trait objects
  instead of concrete Database type
- adapter/websocket: move web/websocket/ handlers + route definitions
- adapter/persistence: move core/database/, implement RepositoryPort trait
- Fix layer violations: model/ no longer imports core/, adapters don't
  cross-import each other
- Define 10 command types, 8 query types, 6 event types for subsystem
  communication
- Add 10 new tests (29 total): CommunicationManager dispatch (9 tests),
  RepositoryPort trait object verification (1 test)

Dependency rules enforced:
  model/ → (no imports from other layers)
  interface/ → model/ only
  adapter/ → interface/ + model/ (no cross-adapter imports)
  infrastructure/ → all layers (composition root)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: rename MLService to AppServices and move to infrastructure/

MLService was misleadingly named — it held SystemHealth and FlowStatistics
alongside ML components. Renamed to AppServices and moved from
core/infrastructure/ to infrastructure/ where service orchestration belongs
in the hexagonal architecture.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: move core/infrastructure/ to infrastructure/, fix layer violations

- Move app_config, health, statistics, geoip from core/infrastructure/ to
  infrastructure/ — completes hexagonal layer separation
- Rename MLService to AppServices (name reflected actual contents: health,
  statistics, ML engine, not just ML)
- Fix core/ → adapter/ dependency violations: jwt.rs, email/scheduler.rs,
  email/report.rs now use dyn RepositoryPort trait instead of concrete Database
- Move misplaced data types to model/:
  - Claims → model/auth.rs
  - AlertMessage → model/ml_detection.rs
  - LicensePayload + LicenseInfo → model/license.rs
  - DropEventMessage + DropCounters → model/drop_event.rs
  - InferenceConfig (ML JSON) → model/config.rs as MLInferenceConfig
- Wire CommunicationManager: enforce mode flow now goes through CQRS
  (ChangeEnforceModeCommand + GetEnforceModeQuery via EnforceModeHandler)
- Add GitHub Actions CI workflow (cargo check + test + clippy)
- Add 9 new tests (38 total): enforce mode handler (3), auth validation (6)
- core/ now contains only business logic with no adapter imports
  (except #[cfg(test)] blocks which need concrete types)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update frontend submodule to feat/ml-page branch

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: frontend overhaul, design system, CI fix

Frontend (submodule update):
- JWT authentication with login page and route protection
- WebSocket refactor: 26 connections → 4 with subscription filtering
- All API paths migrated from /ebpf/ to /api/ with JWT headers
- 6 new pages: drops, geo-block, dns-filter, rate-limit,
  protocol-filter, system settings
- 3-group sidebar navigation (監控/安全/系統)
- Updated all existing pages to new backend API

Design system:
- DESIGN.md: Industrial/Utilitarian aesthetic, Geist + JetBrains Mono,
  Slate palette, compact spacing, accessibility specs
- CLAUDE.md: design system reference for future work
- TODOS.md: implementation tracking

CI fix:
- Add Node.js 22 setup + npm install for frontend build in build.rs
- Fix bpf-linker resolution: find_bpf_linker() in net-guardia/build.rs
  resolves path and passes via CARGO_TARGET_BPFEL_UNKNOWN_NONE_LINKER
  env var to eBPF subprocess (no more PATH guessing)
- Add which crate to net-guardia build-dependencies
- Force-install bpf-linker to avoid stale cache false positive
- Set stable as default toolchain so clippy runs on stable
- Make ingress/egress-ebpf build.rs non-fatal on which() failure

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: clean image names, gitignore project docs, frontend design review

- deploy: add explicit image names (netguardia, netguardia-router, netguardia-endpoint)
- gitignore: exclude CLAUDE.md, DESIGN.md, TODOS.md from tracking
- frontend: Toast system, skeleton loading, mobile sidebar, a11y, cross-nav links

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update frontend submodule — UX fixes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: RBAC user groups, permission middleware, account management APIs

Backend:
- User groups with junction table (user_group_members)
- Permission-based middleware replacing role-based (viewer=GET only)
- Permissions resolved as union of all user's group permissions
- Default groups: Administrator (all perms) + Viewer (read-only)
- Auto-migration: seed groups + assign existing users on first run
- User management APIs: list, delete, reset-password
- Group management APIs: CRUD + member assignment
- Protected: admin account (no delete/group change), built-in groups (no edit/delete)
- JWT claims include permissions array from groups

Deploy:
- setup.sh uses absolute path for compose file
- config.toml: combined_queue_count=1 for veth interfaces

Frontend submodule updated to include RBAC UI + i18n.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: resolve clippy errors, add frontend CI steps and integration test

- Fix 10 clippy errors: type_complexity (add UserListItem/UserGroupTuple
  type aliases) and collapsible_if (collapse nested if-let chains)
- Add frontend type check (tsc --noEmit) and build (next build) to CI
- Move integration test script into repo at tests/integration_test.sh
  with hardcoded password removed (uses direct sudo instead)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: gitignore VERSION, CHANGELOG, and SQLite db files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update frontend submodule — add CI pipeline

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove frontend CI from main repo (frontend has its own CI)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update frontend submodule

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
DaLaw2 2026-03-22 19:50:53 +08:00 committed by GitHub
parent 6f98737cdc
commit e71abca1b7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
104 changed files with 6766 additions and 553 deletions

102
.github/workflows/ci.yml vendored Normal file
View File

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

22
.gitignore vendored
View File

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

617
Cargo.lock generated
View File

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

View File

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

View File

@ -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<HttpMethod> {
let value = http_method_bitmap as u16;
let value = http_method_bitmap;
let mut http_methods = Vec::new();
let all_methods = [

View File

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

View File

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

14
deploy/logrotate.conf Normal file
View File

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

38
deploy/netguardia.service Normal file
View File

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

View File

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

View File

@ -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<wait>",
"linux /casper/vmlinuz autoinstall ds='nocloud-net;s=http://{{ .HTTPIP }}:{{ .HTTPPort }}/' ",
"--- <enter><wait>",
"initrd /casper/initrd<enter><wait>",
"boot<enter>"
]
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<wait>",
"linux /casper/vmlinuz autoinstall ds='nocloud-net;s=http://{{ .HTTPIP }}:{{ .HTTPPort }}/' ",
"--- <enter><wait>",
"initrd /casper/initrd<enter><wait>",
"boot<enter>"
]
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"
]
}
}

View File

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

313
deploy/setup-wizard.sh Executable file
View File

@ -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}" <<TOML
[Http]
http_server_bind_port = ${bind_port}
jwt_expiry_hours = 24
[Network]
ingress_ifname = "${INGRESS_NIC}"
egress_ifname = "${EGRESS_NIC}"
combined_queue_count = 16
channel_size = 4096
fill_queue_size = 4096
comp_queue_size = 4096
tx_queue_size = 4096
rx_queue_size = 4096
frame_size = 4096
frame_count = 4096
refresh_interval = 5
[Inference]
deep_autoencoder_name = "deep_autoencoder.onnx"
classifier_name = "classifier.onnx"
models_config_name = "inference_config.json"
max_concurrent_flows = 10000
min_packets_for_inference = 5
inference_interval_secs = 5
aggregator_window_secs = 30
inference_batch_size = 200
traffic_logging_mode = true
traffic_log_csv_path = "traffic_log.csv"
[Misc]
geoip_db_name = "net-guardia/static/geo/GeoLite2-City.mmdb"
database_path = "net-guardia.db"
license_file = "license.key"
[Pipeline]
ingress = ["access_control", "rate_limit", "service"]
egress = []
TOML
# Append static network config as a comment block for reference
if [[ "${NET_MODE}" == "static" ]]; then
cat >> "${CONFIG_FILE}" <<TOML
# Management network (static)
# ip = "${STATIC_IP}/${STATIC_MASK}"
# gateway = "${STATIC_GW}"
TOML
fi
chmod 0644 "${CONFIG_FILE}"
log "Config written to ${CONFIG_FILE}"
}
# ---------------------------------------------------------------------------
# Step 6: Start systemd service
# ---------------------------------------------------------------------------
start_service() {
log "Enabling and starting netguardia.service"
systemctl daemon-reload
systemctl enable netguardia.service
systemctl start netguardia.service
# Brief wait then check status
sleep 2
if systemctl is-active --quiet netguardia.service; then
log "netguardia.service is active"
else
die "netguardia.service failed to start. Check 'journalctl -u netguardia' for details."
fi
}
# ---------------------------------------------------------------------------
# Step 7: Display dashboard URL
# ---------------------------------------------------------------------------
show_dashboard_url() {
local mgmt_ip
if [[ "${NET_MODE}" == "static" ]]; then
mgmt_ip="${STATIC_IP}"
else
# Try to resolve the current IP on the egress interface
mgmt_ip=$(ip -4 addr show "${EGRESS_NIC}" 2>/dev/null \
| grep -oP 'inet \K[0-9.]+' | head -1)
if [[ -z "${mgmt_ip}" ]]; then
mgmt_ip="<this-host-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 "$@"

View File

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

View File

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

View File

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

View File

@ -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<String>,
}
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<String> = 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<Vec<u8>, 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()
}

View File

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

@ -1 +1 @@
Subproject commit 4e8b39bbb93641926bba18899b6c63cee187564d
Subproject commit 9bebac106b9ff3322f4236355c32dc9ae9c84f41

View File

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

View File

@ -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<SystemTime> {
fn get_dir_last_modified(path: &std::path::Path) -> Option<SystemTime> {
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()?;

View File

@ -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<SocketAddrV4>,
path: web::Path<(FlowDirection, ListType)>,
access_control: web::Data<AccessControl>,
db: web::Data<Repo>,
) -> 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<SocketAddrV6>,
path: web::Path<(FlowDirection, ListType)>,
access_control: web::Data<AccessControl>,
db: web::Data<Repo>,
) -> 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<SocketAddrV4>,
path: web::Path<(FlowDirection, ListType)>,
access_control: web::Data<AccessControl>,
db: web::Data<Repo>,
) -> 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<SocketAddrV6>,
path: web::Path<(FlowDirection, ListType)>,
access_control: web::Data<AccessControl>,
db: web::Data<Repo>,
) -> 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<CountryCodesRequest>,
geo_block: web::Data<GeoBlock>,
db: web::Data<Repo>,
) -> 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<CountryCodesRequest>,
geo_block: web::Data<GeoBlock>,
db: web::Data<Repo>,
) -> 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(),

View File

@ -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<Claims> {
req.extensions().get::<Claims>().cloned()
}
fn has_permission(claims: &Claims, permission: &str) -> bool {
claims.permissions.iter().any(|p| p == permission)
}
async fn login(
body: web::Json<LoginRequest>,
db: web::Data<Repo>,
jwt: web::Data<JwtService>,
) -> 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<RegisterRequest>,
db: web::Data<Repo>,
) -> 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(&reg.username) {
return HttpResponse::BadRequest().json(serde_json::json!({"error": msg}));
}
if let Err(msg) = validate_password(&reg.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(&reg.password) {
Ok(h) => h,
Err(_) => {
return HttpResponse::InternalServerError()
.json(serde_json::json!({"error": "Failed to hash password"}));
}
};
match db.insert_user(&reg.username, &hash, &reg.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<Repo>) -> impl Responder {
match extract_claims(&req) {
Some(claims) => {
let user_groups = db.get_user_groups(claims.sub).unwrap_or_default();
let group_names: Vec<String> = 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<ChangePasswordRequest>,
db: web::Data<Repo>,
) -> 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<Repo>,
) -> 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<serde_json::Value> = 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<serde_json::Value> = 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<i64>,
db: web::Data<Repo>,
) -> 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<i64>,
body: web::Json<serde_json::Value>,
db: web::Data<Repo>,
) -> 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<i64>,
body: web::Json<serde_json::Value>,
db: web::Data<Repo>,
) -> 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<Repo>,
) -> 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<serde_json::Value> = groups.into_iter().map(|(id, name, description, permissions, created_at)| {
let perms: serde_json::Value = serde_json::from_str(&permissions).unwrap_or(serde_json::json!([]));
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<serde_json::Value>,
db: web::Data<Repo>,
) -> 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::<serde_json::Value>(&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<i64>,
db: web::Data<Repo>,
) -> 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<i64>,
body: web::Json<serde_json::Value>,
db: web::Data<Repo>,
) -> 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::<serde_json::Value>(&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<i64>,
db: web::Data<Repo>,
) -> 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<i64>,
body: web::Json<serde_json::Value>,
db: web::Data<Repo>,
) -> 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<i64> = 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());
}
}

View File

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

View File

@ -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<DnsFilter>) -> impl Responder {
async fn add_dns_blacklist(
payload: web::Json<DnsDomainsPayload>,
service: web::Data<DnsFilter>,
db: web::Data<Repo>,
) -> 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<DnsDomainsPayload>,
service: web::Data<DnsFilter>,
db: web::Data<Repo>,
) -> 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()

View File

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

View File

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

View File

@ -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<RateLimitSettings>,
config: web::Data<RateLimitConfig>,
db: web::Data<Repo>,
) -> 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()}));
}

View File

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

View File

@ -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<CommunicationManager>) -> impl Responder {
match comm.send_query(GetEnforceModeQuery).await {
Ok(mode) => HttpResponse::Ok().json(serde_json::json!({"mode": mode})),
Err(e) => HttpResponse::InternalServerError()
.json(serde_json::json!({"error": e.to_string()})),
}
}
async fn set_enforce_mode(
body: web::Json<EnforceModeRequest>,
comm: web::Data<CommunicationManager>,
) -> 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<Repo>) -> impl Responder {
let ingress = db.get_setting("xdp_ingress_mode")
.ok().flatten().unwrap_or_else(|| "unknown".to_string());
let egress = db.get_setting("xdp_egress_mode")
.ok().flatten().unwrap_or_else(|| "unknown".to_string());
HttpResponse::Ok().json(serde_json::json!({
"ingress_mode": ingress,
"egress_mode": egress,
}))
}
#[cfg(feature = "license")]
async fn get_license_info(license_info: web::Data<crate::core::license::LicenseInfo>) -> impl Responder {
HttpResponse::Ok().json(license_info.get_ref())
}

View File

@ -0,0 +1,3 @@
pub mod http;
pub mod persistence;
pub mod websocket;

View File

@ -0,0 +1,3 @@
pub mod repository;
pub use repository::Database;

View File

@ -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<Connection>,
}
impl Database {
pub fn new(path: &str) -> Result<Self, Error> {
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<i64> = conn_ref.query_row(
"SELECT id FROM user_groups WHERE name = 'Administrator'", [],
|row| row.get(0),
).ok();
let viewer_group_id: Option<i64> = 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<i64> = 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<i64> = 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<Vec<crate::interface::port::repository::AclRuleTuple>, 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<Vec<(String, u64)>, 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<Vec<String>, 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<Vec<String>, 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<Option<String>, 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<Option<crate::interface::port::repository::UserTuple>, 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<i64, Error> {
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<i64, Error> {
let conn = self.conn.lock();
Ok(conn.query_row("SELECT COUNT(*) FROM users", [], |row| row.get(0))?)
}
pub fn list_users(&self) -> Result<Vec<crate::interface::port::repository::UserListItem>, 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<bool, Error> {
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<Option<crate::interface::port::repository::UserTuple>, 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<Vec<crate::interface::port::repository::UserGroupTuple>, 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<i64, Error> {
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<bool, Error> {
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<Option<crate::interface::port::repository::UserGroupTuple>, 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<Vec<(i64, String, String, String)>, 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<Vec<String>, 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::<Vec<String>>(&perms_json) {
for p in perms {
all_perms.insert(p);
}
}
}
let mut result: Vec<String> = 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<Vec<i64>, 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<u64>), 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<Option<u64>, 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::<u64>() {
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<Vec<crate::interface::port::repository::AclRuleTuple>, 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<Vec<(String, u64)>, 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<Vec<String>, 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<Vec<String>, Error> { self.load_geo_countries() }
fn get_setting(&self, key: &str) -> Result<Option<String>, 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<Option<crate::interface::port::repository::UserTuple>, Error> { self.find_user(username) }
fn insert_user(&self, username: &str, password_hash: &str, role: &str, force_password_change: bool) -> Result<i64, Error> { self.insert_user(username, password_hash, role, force_password_change) }
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<i64, Error> { self.user_count() }
fn list_users(&self) -> Result<Vec<crate::interface::port::repository::UserListItem>, Error> { self.list_users() }
fn delete_user(&self, user_id: i64) -> Result<bool, Error> { 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<Option<crate::interface::port::repository::UserTuple>, Error> { self.find_user_by_id(user_id) }
fn list_user_groups(&self) -> Result<Vec<crate::interface::port::repository::UserGroupTuple>, Error> { self.list_user_groups() }
fn create_user_group(&self, name: &str, description: &str, permissions: &str) -> Result<i64, Error> { 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<bool, Error> { self.delete_user_group(id) }
fn get_user_group(&self, id: i64) -> Result<Option<crate::interface::port::repository::UserGroupTuple>, Error> { self.get_user_group(id) }
fn get_user_groups(&self, user_id: i64) -> Result<Vec<(i64, String, String, String)>, 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<Vec<String>, 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<Vec<i64>, Error> { self.get_group_member_ids(group_id) }
fn record_login_failure(&self, username: &str) -> Result<(u32, Option<u64>), Error> { self.record_login_failure(username) }
fn check_login_locked(&self, username: &str) -> Result<Option<u64>, 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);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -2,3 +2,4 @@ pub mod alert_websocket;
pub mod drop_websocket;
pub mod flow_websocket;
pub mod health_websocket;
pub mod routes;

View File

@ -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<String>,
}
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<WsQuery>, jwt: &web::Data<JwtService>) -> 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<SystemHealth>,
query: web::Query<WsQuery>,
jwt: web::Data<JwtService>,
) -> 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<MLAlert>,
query: web::Query<WsQuery>,
jwt: web::Data<JwtService>,
) -> 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<FlowStatistics>,
query: web::Query<WsQuery>,
jwt: web::Data<JwtService>,
) -> 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<DropMonitor>,
query: web::Query<WsQuery>,
jwt: web::Data<JwtService>,
) -> 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)})),

View File

@ -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<Self, Error> {
let secret = match db.get_setting("jwt_secret")? {
Some(s) => s,
None => {
use rand::Rng;
let secret: Vec<u8> = 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<String>) -> Result<String, Error> {
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<Claims, Error> {
let token_data = decode::<Claims>(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());
}
}

View File

@ -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<S, B> Transform<S, ServiceRequest> for AuthMiddleware
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
B: 'static,
{
type Response = ServiceResponse<EitherBody<B>>;
type Error = ActixError;
type Transform = AuthMiddlewareService<S>;
type InitError = ();
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ready(Ok(AuthMiddlewareService {
service: Rc::new(service),
}))
}
}
pub struct AuthMiddlewareService<S> {
service: Rc<S>,
}
fn required_permission(path: &str, method: &actix_web::http::Method) -> Option<String> {
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<S, B> Service<ServiceRequest> for AuthMiddlewareService<S>
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = ActixError> + 'static,
B: 'static,
{
type Response = ServiceResponse<EitherBody<B>>;
type Error = ActixError;
type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>>>>;
fn poll_ready(
&self,
ctx: &mut core::task::Context<'_>,
) -> std::task::Poll<Result<(), Self::Error>> {
self.service.poll_ready(ctx)
}
fn call(&self, req: ServiceRequest) -> Self::Future {
let service = Rc::clone(&self.service);
Box::pin(async move {
let path = req.path().to_string();
// Skip auth for 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::<web::Data<JwtService>>() {
Some(s) => s.clone(),
None => {
let resp = HttpResponse::InternalServerError()
.json(serde_json::json!({"error": "Auth not configured"}));
return Ok(req.into_response(resp).map_into_right_body());
}
};
// 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)
})
}
}

View File

@ -0,0 +1,3 @@
pub mod jwt;
pub mod middleware;
pub mod password;

View File

@ -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<String, Error> {
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<bool, Error> {
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());
}
}

View File

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

View File

@ -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<DropEventMessage>,
counters: Mutex<DropCounters>,

View File

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

View File

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

View File

@ -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<T: NativeConvert + Pod> HttpServiceWrapper<T> {
} 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(())

View File

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

View File

@ -0,0 +1,2 @@
pub mod report;
pub mod scheduler;

View File

@ -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<String, Error> {
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::Value> =
serde_json::from_str(&top_ips_json).unwrap_or_default();
let threat_breakdown: serde_json::Map<String, serde_json::Value> =
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::<f64>()
.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!(
"<tr><td style=\"padding:6px 12px;border-bottom:1px solid #e0e0e0;\">{}</td>\
<td style=\"padding:6px 12px;border-bottom:1px solid #e0e0e0;\">{}</td>\
<td style=\"padding:6px 12px;border-bottom:1px solid #e0e0e0;text-align:right;\">{}</td></tr>",
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!(
"<tr><td style=\"padding:6px 12px;border-bottom:1px solid #e0e0e0;\">{}</td>\
<td style=\"padding:6px 12px;border-bottom:1px solid #e0e0e0;text-align:right;\">{}</td></tr>",
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#"<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="font-family:Arial,Helvetica,sans-serif;background:#f4f6f9;margin:0;padding:20px;">
<div style="max-width:640px;margin:0 auto;background:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.08);">
<!-- Header -->
<div style="background:#1a237e;color:#ffffff;padding:24px 32px;">
<h1 style="margin:0;font-size:22px;">NetGuardia Weekly Report</h1>
<p style="margin:6px 0 0;font-size:13px;opacity:0.85;">Generated {now}</p>
</div>
<div style="padding:24px 32px;">
<!-- Threats summary -->
<h2 style="font-size:16px;color:#1a237e;border-bottom:2px solid #1a237e;padding-bottom:6px;">
Threat Summary
</h2>
<p style="font-size:28px;font-weight:bold;margin:8px 0;">{threats_count}
<span style="font-size:14px;font-weight:normal;color:#666;"> threats detected this week</span>
</p>
<!-- Top blocked IPs -->
<h2 style="font-size:16px;color:#1a237e;border-bottom:2px solid #1a237e;padding-bottom:6px;margin-top:24px;">
Top 5 Blocked IPs
</h2>
<table style="width:100%;border-collapse:collapse;font-size:14px;">
<thead>
<tr style="background:#f0f0f0;">
<th style="padding:8px 12px;text-align:left;">#</th>
<th style="padding:8px 12px;text-align:left;">IP Address</th>
<th style="padding:8px 12px;text-align:right;">Events</th>
</tr>
</thead>
<tbody>{top_ips_rows}</tbody>
</table>
<!-- Threat breakdown -->
<h2 style="font-size:16px;color:#1a237e;border-bottom:2px solid #1a237e;padding-bottom:6px;margin-top:24px;">
Threat Type Breakdown
</h2>
<table style="width:100%;border-collapse:collapse;font-size:14px;">
<thead>
<tr style="background:#f0f0f0;">
<th style="padding:8px 12px;text-align:left;">Type</th>
<th style="padding:8px 12px;text-align:right;">Count</th>
</tr>
</thead>
<tbody>{breakdown_rows}</tbody>
</table>
<!-- Bandwidth -->
<h2 style="font-size:16px;color:#1a237e;border-bottom:2px solid #1a237e;padding-bottom:6px;margin-top:24px;">
Bandwidth
</h2>
<p style="font-size:14px;">{bandwidth_mb:.2} MB processed this week</p>
<!-- System Health -->
<h2 style="font-size:16px;color:#1a237e;border-bottom:2px solid #1a237e;padding-bottom:6px;margin-top:24px;">
System Health
</h2>
<table style="width:100%;border-collapse:collapse;font-size:14px;">
<tr>
<td style="padding:6px 12px;">CPU</td>
<td style="padding:6px 12px;text-align:right;">{cpu:.1}%</td>
</tr>
<tr>
<td style="padding:6px 12px;">Memory</td>
<td style="padding:6px 12px;text-align:right;">{mem:.1}%</td>
</tr>
<tr>
<td style="padding:6px 12px;">Disk</td>
<td style="padding:6px 12px;text-align:right;">{disk:.1}%</td>
</tr>
</table>
</div>
<!-- Footer -->
<div style="background:#f0f0f0;padding:16px 32px;font-size:12px;color:#888;text-align:center;">
NetGuardia &mdash; Automated Weekly Report
</div>
</div>
</body>
</html>"#
);
Ok(html)
}

View File

@ -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<Option<Self>, 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<dyn RepositoryPort>,
}
impl ReportScheduler {
pub fn new(db: Arc<dyn RepositoryPort>) -> 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"
}

View File

@ -0,0 +1,3 @@
pub mod validator;
pub use crate::model::license::LicenseInfo;

View File

@ -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<LicenseInfo, crate::model::error::Error> {
// 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 <payload>.<signature>".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/<ifname>/address (Linux only).
fn get_interface_mac(ifname: &str) -> Option<String> {
// 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<Vec<u8>, 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::<i32>().map_err(|e| e.to_string())?;
let m = parts[1].parse::<u32>().map_err(|e| e.to_string())?;
let d = parts[2].parse::<u32>().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<i64, String> {
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
}
}
}
}

View File

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

View File

@ -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<String>,
pub ae_clip_params: HashMap<String, ClipParams>,
pub ae_scaler_mean: Vec<f64>,
pub ae_scaler_std: Vec<f64>,
pub ae_post_clip_min: f64,
pub ae_post_clip_max: f64,
pub ae_threshold: f32,
pub classifier_feature_names: Vec<String>,
pub attack_labels: HashMap<String, String>,
}
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<Self, MLError> {
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()
}
}
}

View File

@ -30,7 +30,6 @@ pub struct Engine {
min_packets: usize,
batch_size: usize,
inference_interval_secs: u64,
flow_timeout_us: u64,
traffic_logger: Option<Arc<TrafficLogger>>,
}
@ -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,
}
}

View File

@ -43,10 +43,9 @@ impl FlowFeatures {
pub fn winsorize(&mut self, clip_params: &HashMap<String, ClipParams>, 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);
}
}
}
}

View File

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

View File

@ -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<RunnableModel, Box<dyn std::error::Error>> {
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()?)
};

View File

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

View File

@ -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<AppConfig>,
pub inference_config: Arc<InferenceConfig>,
pub ebpf_services: Arc<EbpfServices>,
pub app_services: Arc<MLService>,
pub app_services: Arc<AppServices>,
pub db: Arc<Database>,
pub jwt_service: Arc<JwtService>,
pub comm: Arc<CommunicationManager>,
#[cfg(feature = "license")]
pub license_info: Arc<LicenseInfo>,
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<Self, Error> {
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<Ebpf, Error> {
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<ProgramArray<MapData>, 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::<MapData, u32>::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<MapData>,
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
}
}

View File

@ -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<SystemHealth>,
pub ml_alert: Arc<MLAlert>,
pub ml_models: Arc<MLModels>,
@ -34,7 +32,7 @@ pub struct MLService {
shutdowns: SegQueue<oneshot::Sender<()>>,
}
impl MLService {
impl AppServices {
pub fn new(app_config: Arc<AppConfig>, inference_config: Arc<InferenceConfig>) -> Result<Self, Error> {
let health = SystemHealth::new(app_config.clone())?;

View File

@ -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<E: Event> {
pub sender: broadcast::Sender<E>,
}
impl<E: Event + 'static> EventBroadcaster for TypedEventBroadcaster<E> {
fn subscribe_typed(&self) -> Box<dyn Any + Send> {
Box::new(self.sender.subscribe())
}
fn broadcast_event(&self, event: Box<dyn Any + Send>) -> Result<(), Error> {
let typed_event = *event.downcast::<E>().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<TypeId, CommandHandlerFn>,
query_handlers: DashMap<TypeId, QueryHandlerFn>,
event_broadcasters: DashMap<TypeId, Box<dyn EventBroadcaster>>,
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<S: Send + Sync + 'static>(
self: Arc<Self>,
service: Arc<S>,
) -> ServiceRegistrar<S> {
ServiceRegistrar::new(service, self)
}
pub fn register_command_handler<C: Command + 'static>(
&self,
handler: Arc<dyn CommandHandler<C> + Send + Sync>,
) {
let type_id = TypeId::of::<C>();
let boxed_handler: CommandHandlerFn = Box::new(move |command: Box<dyn Any + Send>| {
let handler = handler.clone();
Box::pin(async move {
let command = *command
.downcast::<C>()
.map_err(|_| MiscError::TypeMismatch)?;
handler.handle_command(command).await
}) as CommandFuture
});
self.command_handlers.insert(type_id, boxed_handler);
}
pub async fn send_command<C: Command + 'static>(&self, command: C) -> Result<(), Error> {
let type_id = TypeId::of::<C>();
if let Some(handler) = self.command_handlers.get(&type_id) {
handler(Box::new(command)).await
} else {
Err(MiscError::HandlerNotFound)?
}
}
pub fn register_query_handler<Q: Query + 'static>(
&self,
handler: Arc<dyn QueryHandler<Q> + Send + Sync>,
) {
let type_id = TypeId::of::<Q>();
let boxed_handler: QueryHandlerFn = Box::new(move |query: Box<dyn Any + Send>| {
let handler = handler.clone();
Box::pin(async move {
let query = *query.downcast::<Q>().map_err(|_| MiscError::TypeMismatch)?;
let response = handler.handle_query(query).await?;
Ok(Box::new(response) as Box<dyn Any + Send>)
}) as QueryFuture
});
self.query_handlers.insert(type_id, boxed_handler);
}
pub async fn send_query<Q: Query + 'static>(&self, query: Q) -> Result<Q::Response, Error> {
let type_id = TypeId::of::<Q>();
if let Some(handler) = self.query_handlers.get(&type_id) {
let response = handler(Box::new(query)).await?;
Ok(*response
.downcast::<Q::Response>()
.map_err(|_| MiscError::TypeMismatch)?)
} else {
Err(MiscError::HandlerNotFound)?
}
}
pub fn register_event_type<E: Event + 'static>(&self) {
let type_id = TypeId::of::<E>();
let (tx, _) = broadcast::channel::<E>(self.channel_capacity);
let broadcaster = TypedEventBroadcaster { sender: tx };
self.event_broadcasters
.insert(type_id, Box::new(broadcaster));
}
pub fn subscribe_event<E: Event + 'static>(&self) -> Result<broadcast::Receiver<E>, Error> {
let type_id = TypeId::of::<E>();
let broadcaster = self
.event_broadcasters
.get(&type_id)
.ok_or(MiscError::TypeNotRegistered)?;
let receiver_box = broadcaster.subscribe_typed();
let receiver = *receiver_box
.downcast::<broadcast::Receiver<E>>()
.map_err(|_| MiscError::TypeMismatch)?;
Ok(receiver)
}
pub async fn publish_event<E: Event + 'static>(&self, event: E) -> Result<(), Error> {
let type_id = TypeId::of::<E>();
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<S> {
service: Arc<S>,
comm: Arc<CommunicationManager>,
}
impl<S: Send + Sync + 'static> ServiceRegistrar<S> {
fn new(service: Arc<S>, comm: Arc<CommunicationManager>) -> Self {
Self { service, comm }
}
pub fn command<C: Command + 'static>(self) -> Self
where
S: CommandHandler<C>,
{
let handler: Arc<dyn CommandHandler<C> + Send + Sync> = self.service.clone();
self.comm.register_command_handler::<C>(handler);
self
}
pub fn query<Q: Query + 'static>(self) -> Self
where
S: QueryHandler<Q>,
{
let handler: Arc<dyn QueryHandler<Q> + Send + Sync> = self.service.clone();
self.comm.register_query_handler::<Q>(handler);
self
}
pub fn event<E: Event + 'static>(self) -> Self {
self.comm.register_event_type::<E>();
self
}
pub fn build(self) -> Arc<CommunicationManager> {
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<std::sync::Mutex<Vec<String>>>,
}
#[async_trait]
impl CommandHandler<TestCommand> 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<TestQuery> for TestQueryHandler {
async fn handle_query(&self, query: TestQuery) -> Result<i32, Error> {
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::<TestCommand>(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::<TestQuery>(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::<TestEvent>();
let mut receiver = comm.subscribe_event::<TestEvent>().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::<TestEvent>();
assert!(result.is_err());
}
#[tokio::test]
async fn test_event_multiple_subscribers() {
let comm = CommunicationManager::new();
comm.register_event_type::<TestEvent>();
let mut rx1 = comm.subscribe_event::<TestEvent>().unwrap();
let mut rx2 = comm.subscribe_event::<TestEvent>().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::<TestCommand>()
.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::<TestEvent>();
assert!(comm.subscribe_event::<TestEvent>().is_ok());
comm.clear_handlers();
assert!(comm.subscribe_event::<TestEvent>().is_err());
}
}

View File

@ -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<dyn RepositoryPort>,
}
impl EnforceModeHandler {
pub fn new(db: Arc<dyn RepositoryPort>) -> Self {
Self { db }
}
}
#[async_trait]
impl CommandHandler<ChangeEnforceModeCommand> 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<GetEnforceModeQuery> for EnforceModeHandler {
async fn handle_query(&self, _query: GetEnforceModeQuery) -> Result<String, Error> {
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<EnforceModeHandler>, Arc<CommunicationManager>) {
let db = Arc::new(Database::new(":memory:").unwrap()) as Arc<dyn RepositoryPort>;
let handler = Arc::new(EnforceModeHandler::new(db));
let comm = Arc::new(CommunicationManager::new());
let _ = comm.clone()
.with_service(handler.clone())
.command::<ChangeEnforceModeCommand>()
.query::<GetEnforceModeQuery>()
.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");
}
}

View File

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

View File

@ -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<AppConfig>,
pub inference_config: Arc<InferenceConfig>,
pub ebpf_services: Arc<EbpfServices>,
pub app_services: Arc<AppServices>,
pub db: Arc<Database>,
pub jwt_service: Arc<JwtService>,
pub comm: Arc<CommunicationManager>,
#[cfg(feature = "license")]
pub license_info: Arc<LicenseInfo>,
}
/// 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<dyn RepositoryPort>))
.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(())
}

View File

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

View File

@ -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<AppConfig>,
pub inference_config: Arc<InferenceConfig>,
pub ebpf_services: Arc<EbpfServices>,
pub app_services: Arc<AppServices>,
pub db: Arc<Database>,
pub jwt_service: Arc<JwtService>,
pub comm: Arc<CommunicationManager>,
#[cfg(feature = "license")]
pub license_info: Arc<LicenseInfo>,
pub ingress_ebpf: Ebpf,
pub egress_ebpf: Ebpf,
#[allow(dead_code)]
pub ingress_program_array: ProgramArray<MapData>,
}
/// 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<AppState, Error> {
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<dyn RepositoryPort>));
let _ = comm.clone()
.with_service(enforce_handler)
.command::<ChangeEnforceModeCommand>()
.query::<GetEnforceModeQuery>()
.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<Ebpf, Error> {
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<ProgramArray<MapData>, 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::<MapData, u32>::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<MapData>,
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<String, 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)?;
}
// 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::<Ipv4Addr>() {
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::<Ipv6Addr>() {
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);
}
}
}
}

View File

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

View File

@ -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<Box<dyn Future<Output = Result<(), Error>> + Send + 'static>>;
pub type CommandHandlerFn = Box<dyn Fn(Box<dyn Any + Send>) -> CommandFuture + Send + Sync>;
pub trait Command: Message<Response = ()> {}
#[async_trait]
pub trait CommandHandler<C: Command> {
async fn handle_command(&self, command: C) -> Result<(), Error>;
}

View File

@ -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<String>,
}
impl Message for BlockGeoCountriesCommand {
type Response = ();
}
impl Command for BlockGeoCountriesCommand {}
pub struct UnblockGeoCountriesCommand {
pub country_codes: Vec<String>,
}
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 {}

View File

@ -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<dyn Any + Send>;
fn broadcast_event(&self, event: Box<dyn Any + Send>) -> Result<(), Error>;
}

View File

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

View File

@ -0,0 +1,3 @@
pub trait Message: Send + 'static {
type Response: Send + 'static;
}

View File

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

View File

@ -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<Box<dyn Future<Output = Result<Box<dyn Any + Send>, Error>> + Send + 'static>>;
pub type QueryHandlerFn = Box<dyn Fn(Box<dyn Any + Send>) -> QueryFuture + Send + Sync>;
pub trait Query: Message {}
#[async_trait]
pub trait QueryHandler<Q: Query> {
async fn handle_query(&self, query: Q) -> Result<Q::Response, Error>;
}

View File

@ -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<String>;
}
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<String>;
}
impl Query for GetDnsDomainsQuery {}
// ── Geo Queries ──────────────────────────────────────────────────────
pub struct GetGeoBlockedCountriesQuery;
impl Message for GetGeoBlockedCountriesQuery {
type Response = Vec<String>;
}
impl Query for GetGeoBlockedCountriesQuery {}

View File

@ -0,0 +1,2 @@
pub mod communication;
pub mod port;

View File

@ -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<String>,
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<String>) -> Result<String, Error>;
fn validate_token(&self, token: &str) -> Result<TokenClaims, Error>;
fn hash_password(&self, password: &str) -> Result<String, Error>;
fn verify_password(&self, password: &str, hash: &str) -> Result<bool, Error>;
}

View File

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

View File

@ -0,0 +1,4 @@
pub mod repository;
pub mod auth;
pub mod notification;
pub mod health;

View File

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

View File

@ -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<Vec<AclRuleTuple>, Error>;
// --- Rate Limit ---
fn set_rate_limit(&self, key: &str, value: u64) -> Result<(), Error>;
fn load_rate_limit_config(&self) -> Result<Vec<(String, u64)>, 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<Vec<String>, 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<Vec<String>, Error>;
// --- Settings ---
fn get_setting(&self, key: &str) -> Result<Option<String>, Error>;
fn set_setting(&self, key: &str, value: &str) -> Result<(), Error>;
// --- Users ---
fn find_user(&self, username: &str) -> Result<Option<UserTuple>, Error>;
fn insert_user(&self, username: &str, password_hash: &str, role: &str, force_password_change: bool) -> Result<i64, Error>;
fn update_user_password(&self, user_id: i64, password_hash: &str) -> Result<(), Error>;
fn user_count(&self) -> Result<i64, Error>;
// --- User Management ---
fn list_users(&self) -> Result<Vec<UserListItem>, Error>;
fn delete_user(&self, user_id: i64) -> Result<bool, Error>;
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<Option<UserTuple>, Error>;
// --- User Groups ---
fn list_user_groups(&self) -> Result<Vec<UserGroupTuple>, Error>;
fn create_user_group(&self, name: &str, description: &str, permissions: &str) -> Result<i64, Error>;
fn update_user_group(&self, id: i64, name: &str, description: &str, permissions: &str) -> Result<(), Error>;
fn delete_user_group(&self, id: i64) -> Result<bool, Error>;
fn get_user_group(&self, id: i64) -> Result<Option<UserGroupTuple>, Error>;
// --- User Group Membership ---
fn get_user_groups(&self, user_id: i64) -> Result<Vec<(i64, String, String, String)>, Error>;
fn set_user_groups(&self, user_id: i64, group_ids: &[i64]) -> Result<(), Error>;
fn get_user_permissions(&self, user_id: i64) -> Result<Vec<String>, Error>;
fn cleanup_user_memberships(&self, user_id: i64) -> Result<(), Error>;
fn get_group_member_ids(&self, group_id: i64) -> Result<Vec<i64>, Error>;
// --- Login Rate Limiting ---
fn record_login_failure(&self, username: &str) -> Result<(u32, Option<u64>), Error>;
fn check_login_locked(&self, username: &str) -> Result<Option<u64>, Error>;
fn clear_login_failures(&self, username: &str) -> Result<(), Error>;
}

View File

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

View File

@ -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<String>,
pub exp: usize,
}

View File

@ -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<String>,
pub egress: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MLInferenceConfig {
pub ae_feature_names: Vec<String>,
pub ae_clip_params: HashMap<String, ClipParams>,
pub ae_scaler_mean: Vec<f64>,
pub ae_scaler_std: Vec<f64>,
pub ae_post_clip_min: f64,
pub ae_post_clip_max: f64,
pub ae_threshold: f32,
pub classifier_feature_names: Vec<String>,
pub attack_labels: HashMap<String, String>,
}
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()
}
}

View File

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

View File

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

View File

@ -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<rusqlite::Error> for DatabaseError {
fn from(e: rusqlite::Error) -> Self {
DatabaseError::QueryFailed { reason: e.to_string() }
}
}
impl From<rusqlite::Error> for super::Error {
fn from(e: rusqlite::Error) -> Self {
Self::Database(DatabaseError::from(e))
}
}

View File

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

View File

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

View File

@ -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<AuthError> for Error {
fn from(error: AuthError) -> Self {
Self::Auth(error)
}
}
impl From<DatabaseError> for Error {
fn from(error: DatabaseError) -> Self {
Self::Database(error)
}
}
impl From<EbpfError> for Error {
fn from(error: EbpfError) -> Self {
Self::Ebpf(error)
@ -48,6 +75,13 @@ impl From<IOError> for Error {
}
}
#[cfg(feature = "license")]
impl From<LicenseError> for Error {
fn from(error: LicenseError) -> Self {
Self::License(error)
}
}
impl From<MiscError> for Error {
fn from(error: MiscError) -> Self {
Self::Misc(error)
@ -64,4 +98,4 @@ impl From<MLError> for Error {
fn from(error: MLError) -> Self {
Self::ML(error)
}
}
}

View File

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

View File

@ -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<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LicenseInfo {
pub payload: Option<LicensePayload>,
pub valid: bool,
pub days_remaining: i64,
}
impl LicenseInfo {
pub fn unlicensed() -> Self {
Self {
payload: None,
valid: false,
days_remaining: 0,
}
}
}

View File

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

View File

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

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