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:
DaLaw2 2026-03-21 19:42:46 +08:00
parent 34d1832e21
commit 8138c5d751
20 changed files with 1990 additions and 67 deletions

259
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,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
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"
]
}
}

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

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
pub mod report;
pub mod scheduler;
pub use report::generate_weekly_report;
pub use scheduler::ReportScheduler;

View 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 &mdash; Automated Weekly Report
</div>
</div>
</body>
</html>"#
);
Ok(html)
}

View 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"
}

View File

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

View File

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

View File

@ -1,5 +1,6 @@
pub mod auth;
pub mod database;
pub mod email;
pub mod ebpf;
pub mod infrastructure;
#[cfg(feature = "license")]

View File

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

View File

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

View File

@ -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(&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()
@ -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(&reg.username, &hash, &reg.role) {
match db.insert_user(&reg.username, &hash, &reg.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()})),
}
}

View File

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