mirror of
https://github.com/DaLaw2/NetGuardia.git
synced 2026-08-24 14:10:28 +09:00
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>
This commit is contained in:
parent
34d1832e21
commit
8138c5d751
259
Cargo.lock
generated
259
Cargo.lock
generated
@ -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,6 +327,15 @@ 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"
|
||||
@ -336,6 +354,17 @@ 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"
|
||||
@ -354,7 +383,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"log",
|
||||
"object",
|
||||
"object 0.36.7",
|
||||
"once_cell",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
@ -465,7 +494,7 @@ dependencies = [
|
||||
"core-error",
|
||||
"hashbrown 0.15.5",
|
||||
"log",
|
||||
"object",
|
||||
"object 0.36.7",
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
@ -654,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"
|
||||
@ -710,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"
|
||||
@ -974,6 +1030,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"
|
||||
@ -1011,6 +1083,12 @@ 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"
|
||||
@ -1077,6 +1155,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"
|
||||
@ -1107,9 +1191,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",
|
||||
]
|
||||
@ -1193,6 +1279,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"allocator-api2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1226,6 +1313,17 @@ 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"
|
||||
@ -1259,6 +1357,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"
|
||||
@ -1493,6 +1615,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"
|
||||
@ -1810,6 +1961,7 @@ dependencies = [
|
||||
"aya-log",
|
||||
"base64",
|
||||
"cargo_metadata",
|
||||
"chrono",
|
||||
"common",
|
||||
"crossbeam",
|
||||
"dotenvy",
|
||||
@ -1817,6 +1969,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"ipnetwork",
|
||||
"jsonwebtoken",
|
||||
"lettre",
|
||||
"libc",
|
||||
"libxdp-sys",
|
||||
"lru",
|
||||
@ -2006,6 +2159,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"
|
||||
@ -2241,6 +2403,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"
|
||||
@ -2250,6 +2422,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"
|
||||
@ -2488,6 +2666,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"
|
||||
@ -2730,6 +2943,20 @@ 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.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "static_assertions"
|
||||
version = "1.1.0"
|
||||
@ -2950,6 +3177,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"
|
||||
@ -3422,6 +3659,15 @@ 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"
|
||||
@ -3572,6 +3818,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"
|
||||
|
||||
14
deploy/logrotate.conf
Normal file
14
deploy/logrotate.conf
Normal 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
38
deploy/netguardia.service
Normal 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
|
||||
56
deploy/packer/cloud-init/user-data
Normal file
56
deploy/packer/cloud-init/user-data
Normal 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.
|
||||
213
deploy/packer/netguardia.pkr.hcl
Normal file
213
deploy/packer/netguardia.pkr.hcl
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
313
deploy/setup-wizard.sh
Executable file
313
deploy/setup-wizard.sh
Executable 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 "$@"
|
||||
@ -43,6 +43,10 @@ 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"] }
|
||||
|
||||
# Utilities
|
||||
parking_lot = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
@ -77,3 +77,71 @@ fn hex_encode(data: &[u8]) -> String {
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
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 token = jwt.create_token(1, "admin", "admin").unwrap();
|
||||
let claims = jwt.validate_token(&token).unwrap();
|
||||
assert_eq!(claims.sub, 1);
|
||||
assert_eq!(claims.username, "admin");
|
||||
assert_eq!(claims.role, "admin");
|
||||
}
|
||||
|
||||
#[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(),
|
||||
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").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").unwrap();
|
||||
let result = jwt2.validate_token(&token);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,3 +20,30 @@ pub fn verify_password(password: &str, hash: &str) -> Result<bool, Error> {
|
||||
.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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,12 +10,8 @@ pub struct Database {
|
||||
|
||||
impl Database {
|
||||
pub fn new(path: &str) -> Result<Self, Error> {
|
||||
let conn = Connection::open(path)
|
||||
.map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?;
|
||||
|
||||
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")
|
||||
.map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?;
|
||||
|
||||
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)
|
||||
@ -29,6 +25,7 @@ impl Database {
|
||||
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 (
|
||||
@ -54,7 +51,19 @@ impl Database {
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
").map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?;
|
||||
")?;
|
||||
|
||||
// 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;"
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -64,7 +73,7 @@ impl Database {
|
||||
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],
|
||||
).map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?;
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -73,14 +82,13 @@ impl Database {
|
||||
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],
|
||||
).map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?;
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_acl_rules(&self) -> Result<Vec<(u8, String, String, String, u16)>, Error> {
|
||||
let conn = self.conn.lock();
|
||||
let mut stmt = conn.prepare("SELECT ip_version, direction, list_type, ip_address, port FROM acl_rules")
|
||||
.map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?;
|
||||
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)?,
|
||||
@ -89,10 +97,10 @@ impl Database {
|
||||
row.get::<_, String>(3)?,
|
||||
row.get::<_, i64>(4)? as u16,
|
||||
))
|
||||
}).map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?;
|
||||
})?;
|
||||
let mut results = Vec::new();
|
||||
for row in rows {
|
||||
results.push(row.map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?);
|
||||
results.push(row?);
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
@ -103,20 +111,19 @@ impl Database {
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO rate_limit_config (key, value) VALUES (?1, ?2)",
|
||||
params![key, value as i64],
|
||||
).map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?;
|
||||
)?;
|
||||
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")
|
||||
.map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?;
|
||||
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))
|
||||
}).map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?;
|
||||
})?;
|
||||
let mut results = Vec::new();
|
||||
for row in rows {
|
||||
results.push(row.map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?);
|
||||
results.push(row?);
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
@ -124,27 +131,23 @@ impl Database {
|
||||
// --- 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])
|
||||
.map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?;
|
||||
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])
|
||||
.map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?;
|
||||
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")
|
||||
.map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?;
|
||||
let rows = stmt.query_map([], |row| row.get(0))
|
||||
.map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?;
|
||||
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.map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?);
|
||||
results.push(row?);
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
@ -152,27 +155,23 @@ impl Database {
|
||||
// --- 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])
|
||||
.map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?;
|
||||
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])
|
||||
.map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?;
|
||||
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")
|
||||
.map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?;
|
||||
let rows = stmt.query_map([], |row| row.get(0))
|
||||
.map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?;
|
||||
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.map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?);
|
||||
results.push(row?);
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
@ -188,7 +187,7 @@ impl Database {
|
||||
match result {
|
||||
Ok(val) => Ok(Some(val)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(DatabaseError::QueryFailed { reason: e.to_string() }.into()),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
@ -197,43 +196,242 @@ impl Database {
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
|
||||
params![key, value],
|
||||
).map_err(|e| DatabaseError::QueryFailed { reason: e.to_string() })?;
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Users ---
|
||||
pub fn find_user(&self, username: &str) -> Result<Option<(i64, String, String, String)>, Error> {
|
||||
pub fn find_user(&self, username: &str) -> Result<Option<(i64, String, String, String, bool)>, Error> {
|
||||
let conn = self.conn.lock();
|
||||
let result = conn.query_row(
|
||||
"SELECT id, username, password_hash, role FROM users WHERE username = ?1",
|
||||
"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| 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(DatabaseError::QueryFailed { reason: e.to_string() }.into()),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert_user(&self, username: &str, password_hash: &str, role: &str) -> Result<(), Error> {
|
||||
pub fn insert_user(&self, username: &str, password_hash: &str, role: &str, force_password_change: bool) -> Result<(), Error> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"INSERT INTO users (username, password_hash, role) VALUES (?1, ?2, ?3)",
|
||||
params![username, password_hash, role],
|
||||
"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 {
|
||||
DatabaseError::QueryFailed { reason: e.to_string() }.into()
|
||||
e.into()
|
||||
}
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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();
|
||||
conn.query_row("SELECT COUNT(*) FROM users", [], |row| row.get(0))
|
||||
.map_err(|e| -> Error { DatabaseError::QueryFailed { reason: e.to_string() }.into() })
|
||||
Ok(conn.query_row("SELECT COUNT(*) FROM users", [], |row| row.get(0))?)
|
||||
}
|
||||
|
||||
// --- 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)? {
|
||||
if 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(())
|
||||
}
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
|
||||
5
net-guardia/src/core/email/mod.rs
Normal file
5
net-guardia/src/core/email/mod.rs
Normal file
@ -0,0 +1,5 @@
|
||||
pub mod report;
|
||||
pub mod scheduler;
|
||||
|
||||
pub use report::generate_weekly_report;
|
||||
pub use scheduler::ReportScheduler;
|
||||
206
net-guardia/src/core/email/report.rs
Normal file
206
net-guardia/src/core/email/report.rs
Normal file
@ -0,0 +1,206 @@
|
||||
use crate::core::database::Database;
|
||||
use crate::model::error::Error;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// 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: &Arc<Database>) -> 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 — Automated Weekly Report
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>"#
|
||||
);
|
||||
|
||||
Ok(html)
|
||||
}
|
||||
180
net-guardia/src/core/email/scheduler.rs
Normal file
180
net-guardia/src/core/email/scheduler.rs
Normal file
@ -0,0 +1,180 @@
|
||||
use crate::core::database::Database;
|
||||
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: &Database) -> 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<Database>,
|
||||
}
|
||||
|
||||
impl ReportScheduler {
|
||||
pub fn new(db: Arc<Database>) -> 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"
|
||||
}
|
||||
@ -312,6 +312,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
|
||||
}
|
||||
}
|
||||
@ -233,3 +233,95 @@ fn days_until(expiry_str: &str, today: &(i32, u32, u32)) -> Result<i64, String>
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
pub mod auth;
|
||||
pub mod database;
|
||||
pub mod email;
|
||||
pub mod ebpf;
|
||||
pub mod infrastructure;
|
||||
#[cfg(feature = "license")]
|
||||
|
||||
@ -87,8 +87,13 @@ impl System {
|
||||
// Create default admin user if no users exist
|
||||
if db.user_count().unwrap_or(0) == 0 {
|
||||
let hash = password::hash_password("admin")?;
|
||||
db.insert_user("admin", &hash, "admin")?;
|
||||
tracing::warn!("Default admin user created with password 'admin' — change it immediately");
|
||||
db.insert_user("admin", &hash, "admin", true)?;
|
||||
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, app_config.http.jwt_expiry_hours)?);
|
||||
@ -264,12 +269,21 @@ impl System {
|
||||
let egress_ifname = self.app_config.network.egress_ifname.clone();
|
||||
Self::set_memory_limit()?;
|
||||
|
||||
Self::attach_xdp(&mut self.ingress_ebpf, &ingress_ifname, true)?;
|
||||
Self::attach_xdp(&mut self.egress_ebpf, &egress_ifname, false)?;
|
||||
let ingress_mode = Self::attach_xdp(&mut self.ingress_ebpf, &ingress_ifname, true)?;
|
||||
let egress_mode = Self::attach_xdp(&mut self.egress_ebpf, &egress_ifname, false)?;
|
||||
|
||||
// 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);
|
||||
}
|
||||
if let Err(e) = self.db.set_setting("xdp_egress_mode", &egress_mode) {
|
||||
tracing::warn!("Failed to store XDP egress mode: {}", e);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn attach_xdp(ebpf: &mut Ebpf, ifname: &str, already_loaded: bool) -> Result<(), Error> {
|
||||
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)?
|
||||
@ -278,9 +292,42 @@ impl System {
|
||||
if !already_loaded {
|
||||
xdp.load().map_err(EbpfError::LoadProgramFailed)?;
|
||||
}
|
||||
xdp.attach(ifname, XdpFlags::DRV_MODE)
|
||||
.map_err(EbpfError::AttachProgramFailed)?;
|
||||
Ok(())
|
||||
|
||||
// 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_http_server(&self) -> Result<(), Error> {
|
||||
@ -303,8 +350,7 @@ impl System {
|
||||
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_origin()
|
||||
.allow_any_method()
|
||||
.allow_any_header()
|
||||
.max_age(3600);
|
||||
|
||||
@ -14,3 +14,15 @@ traceable! {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,11 +18,35 @@ struct RegisterRequest {
|
||||
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))
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
async fn login(
|
||||
@ -32,28 +56,47 @@ async fn login(
|
||||
) -> 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"}))
|
||||
.json(serde_json::json!({"error": "Invalid credentials"}));
|
||||
}
|
||||
};
|
||||
|
||||
let (id, username, hash, role) = user;
|
||||
let (id, username, hash, 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"}))
|
||||
.json(serde_json::json!({"error": "Invalid credentials"}));
|
||||
}
|
||||
}
|
||||
|
||||
// Clear login failures on success
|
||||
let _ = db.clear_login_failures(&req.username);
|
||||
|
||||
match jwt.create_token(id, &username, &role) {
|
||||
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"})),
|
||||
@ -71,12 +114,20 @@ async fn register(
|
||||
Some(c) if c.role == "admin" => {}
|
||||
_ => {
|
||||
return HttpResponse::Forbidden()
|
||||
.json(serde_json::json!({"error": "Admin access required"}))
|
||||
.json(serde_json::json!({"error": "Admin access required"}));
|
||||
}
|
||||
}
|
||||
|
||||
let reg = body.into_inner();
|
||||
|
||||
// Validate input
|
||||
if let Err(msg) = validate_username(®.username) {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({"error": msg}));
|
||||
}
|
||||
if let Err(msg) = validate_password(®.password) {
|
||||
return HttpResponse::BadRequest().json(serde_json::json!({"error": msg}));
|
||||
}
|
||||
|
||||
// Validate role
|
||||
if reg.role != "admin" && reg.role != "viewer" {
|
||||
return HttpResponse::BadRequest()
|
||||
@ -87,11 +138,11 @@ async fn register(
|
||||
Ok(h) => h,
|
||||
Err(_) => {
|
||||
return HttpResponse::InternalServerError()
|
||||
.json(serde_json::json!({"error": "Failed to hash password"}))
|
||||
.json(serde_json::json!({"error": "Failed to hash password"}));
|
||||
}
|
||||
};
|
||||
|
||||
match db.insert_user(®.username, &hash, ®.role) {
|
||||
match db.insert_user(®.username, &hash, ®.role, false) {
|
||||
Ok(_) => HttpResponse::Created()
|
||||
.json(serde_json::json!({"username": reg.username, "role": reg.role})),
|
||||
Err(e) => {
|
||||
@ -111,3 +162,59 @@ async fn me(req: HttpRequest) -> impl Responder {
|
||||
.json(serde_json::json!({"error": "Not authenticated"})),
|
||||
}
|
||||
}
|
||||
|
||||
async fn change_password(
|
||||
req: HttpRequest,
|
||||
body: web::Json<ChangePasswordRequest>,
|
||||
db: web::Data<Database>,
|
||||
) -> impl Responder {
|
||||
let claims = match req.extensions().get::<Claims>().cloned() {
|
||||
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()})),
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,8 +1,19 @@
|
||||
use actix_web::{web, HttpResponse, Responder, Scope};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::core::database::Database;
|
||||
|
||||
#[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("/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));
|
||||
@ -14,6 +25,47 @@ async fn get_boot_time() -> impl Responder {
|
||||
HttpResponse::Ok().json(crate::utils::boot_time::boot_time())
|
||||
}
|
||||
|
||||
async fn get_enforce_mode(db: web::Data<Database>) -> impl Responder {
|
||||
match db.get_setting("enforce_mode") {
|
||||
Ok(Some(mode)) => HttpResponse::Ok().json(serde_json::json!({"mode": mode})),
|
||||
Ok(None) => HttpResponse::Ok().json(serde_json::json!({"mode": "monitor"})),
|
||||
Err(e) => HttpResponse::InternalServerError()
|
||||
.json(serde_json::json!({"error": e.to_string()})),
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_enforce_mode(
|
||||
body: web::Json<EnforceModeRequest>,
|
||||
db: web::Data<Database>,
|
||||
) -> 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 db.set_setting("enforce_mode", mode) {
|
||||
Ok(_) => {
|
||||
tracing::info!("Enforce mode changed to: {}", mode);
|
||||
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<Database>) -> 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())
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user