diff --git a/Cargo.lock b/Cargo.lock index e117248..2265cb0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -300,6 +300,15 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -318,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" diff --git a/deploy/logrotate.conf b/deploy/logrotate.conf new file mode 100644 index 0000000..0124499 --- /dev/null +++ b/deploy/logrotate.conf @@ -0,0 +1,14 @@ +/var/log/netguardia/*.log { + daily + rotate 7 + compress + delaycompress + missingok + notifempty + maxsize 500M + create 0640 root root + sharedscripts + postrotate + systemctl reload netguardia.service 2>/dev/null || true + endscript +} diff --git a/deploy/netguardia.service b/deploy/netguardia.service new file mode 100644 index 0000000..1f7cbb7 --- /dev/null +++ b/deploy/netguardia.service @@ -0,0 +1,38 @@ +[Unit] +Description=NetGuardia Network Security Gateway +Documentation=https://github.com/dalaw2/NetGuardia +After=network.target +Wants=network.target + +[Service] +Type=notify +ExecStart=/opt/netguardia/bin/net-guardia +WorkingDirectory=/opt/netguardia +Restart=on-failure +RestartSec=5 + +# Watchdog: service must notify systemd within this interval or be killed +WatchdogSec=30 + +# Security hardening +NoNewPrivileges=false +ProtectSystem=strict +ProtectHome=yes +ReadWritePaths=/opt/netguardia /var/log/netguardia +PrivateTmp=yes + +# Resource limits +LimitNOFILE=65536 +LimitMEMLOCK=infinity + +# Logging +StandardOutput=journal +StandardError=journal +SyslogIdentifier=netguardia + +# Environment +Environment=RUST_LOG=info +Environment=CONFIG_PATH=/opt/netguardia/config.toml + +[Install] +WantedBy=multi-user.target diff --git a/deploy/packer/cloud-init/user-data b/deploy/packer/cloud-init/user-data new file mode 100644 index 0000000..232dff9 --- /dev/null +++ b/deploy/packer/cloud-init/user-data @@ -0,0 +1,56 @@ +#cloud-config +autoinstall: + version: 1 + locale: en_US.UTF-8 + keyboard: + layout: us + + identity: + hostname: netguardia + username: netguardia + # Password: netguardia (mkpasswd --method=SHA-512) + password: "$6$rounds=4096$randomsalt$PLACEHOLDER_HASH" + + ssh: + install-server: true + allow-pw: true + + storage: + layout: + name: lvm + sizing-policy: all + + network: + version: 2 + ethernets: + ens3: + dhcp4: true + + packages: + - whiptail + - jq + - curl + - net-tools + - iproute2 + - linux-tools-common + + late-commands: + # Create required directories + - mkdir -p /target/opt/netguardia/bin + - mkdir -p /target/var/log/netguardia + + # Enable serial console for headless access + - >- + curtin in-target -- systemctl enable serial-getty@ttyS0.service + + user-data: + runcmd: + # Run the setup wizard on first boot if not already configured + - | + if [ ! -f /opt/netguardia/config.toml ]; then + /opt/netguardia/bin/setup-wizard.sh + fi + + final_message: | + NetGuardia image provisioning complete. + Run /opt/netguardia/bin/setup-wizard.sh to configure. diff --git a/deploy/packer/netguardia.pkr.hcl b/deploy/packer/netguardia.pkr.hcl new file mode 100644 index 0000000..8eca159 --- /dev/null +++ b/deploy/packer/netguardia.pkr.hcl @@ -0,0 +1,213 @@ +packer { + required_plugins { + qemu = { + source = "github.com/hashicorp/qemu" + version = ">= 1.1.0" + } + virtualbox = { + source = "github.com/hashicorp/virtualbox" + version = ">= 1.0.0" + } + } +} + +# --------------------------------------------------------------------------- +# Variables +# --------------------------------------------------------------------------- +variable "ubuntu_iso_url" { + type = string + default = "https://releases.ubuntu.com/24.04/ubuntu-24.04-live-server-amd64.iso" +} + +variable "ubuntu_iso_checksum" { + type = string + default = "sha256:none" + description = "SHA-256 checksum of the Ubuntu 24.04 Server ISO. Update before building." +} + +variable "netguardia_binary" { + type = string + default = "../target/release/net-guardia" + description = "Path to the pre-built NetGuardia binary." +} + +variable "ssh_username" { + type = string + default = "netguardia" +} + +variable "ssh_password" { + type = string + default = "netguardia" + sensitive = true +} + +variable "disk_size" { + type = string + default = "20480" + description = "Virtual disk size in MB." +} + +variable "memory" { + type = string + default = "2048" +} + +variable "cpus" { + type = string + default = "2" +} + +# --------------------------------------------------------------------------- +# Source: QEMU (produces QCOW2) +# --------------------------------------------------------------------------- +source "qemu" "netguardia" { + iso_url = var.ubuntu_iso_url + iso_checksum = var.ubuntu_iso_checksum + output_directory = "output-qcow2" + format = "qcow2" + disk_size = var.disk_size + memory = var.memory + cpus = var.cpus + headless = true + + ssh_username = var.ssh_username + ssh_password = var.ssh_password + ssh_timeout = "30m" + + shutdown_command = "echo '${var.ssh_password}' | sudo -S shutdown -P now" + boot_wait = "5s" + + http_directory = "cloud-init" + boot_command = [ + "c", + "linux /casper/vmlinuz autoinstall ds='nocloud-net;s=http://{{ .HTTPIP }}:{{ .HTTPPort }}/' ", + "--- ", + "initrd /casper/initrd", + "boot" + ] + + vm_name = "netguardia" + net_device = "virtio-net" + disk_interface = "virtio" + accelerator = "kvm" +} + +# --------------------------------------------------------------------------- +# Source: VirtualBox (produces OVA) +# --------------------------------------------------------------------------- +source "virtualbox-iso" "netguardia" { + iso_url = var.ubuntu_iso_url + iso_checksum = var.ubuntu_iso_checksum + output_directory = "output-ova" + format = "ova" + disk_size = var.disk_size + memory = var.memory + cpus = var.cpus + headless = true + guest_os_type = "Ubuntu_64" + + ssh_username = var.ssh_username + ssh_password = var.ssh_password + ssh_timeout = "30m" + + shutdown_command = "echo '${var.ssh_password}' | sudo -S shutdown -P now" + boot_wait = "5s" + + http_directory = "cloud-init" + boot_command = [ + "c", + "linux /casper/vmlinuz autoinstall ds='nocloud-net;s=http://{{ .HTTPIP }}:{{ .HTTPPort }}/' ", + "--- ", + "initrd /casper/initrd", + "boot" + ] + + vboxmanage = [ + ["modifyvm", "{{ .Name }}", "--nic2", "intnet"], + ["modifyvm", "{{ .Name }}", "--intnet2", "netguardia-internal"] + ] +} + +# --------------------------------------------------------------------------- +# Build +# --------------------------------------------------------------------------- +build { + sources = [ + "source.qemu.netguardia", + "source.virtualbox-iso.netguardia" + ] + + # ------ Upload artifacts ------ + + provisioner "file" { + source = var.netguardia_binary + destination = "/tmp/net-guardia" + } + + provisioner "file" { + source = "../deploy/netguardia.service" + destination = "/tmp/netguardia.service" + } + + provisioner "file" { + source = "../deploy/setup-wizard.sh" + destination = "/tmp/setup-wizard.sh" + } + + provisioner "file" { + source = "../deploy/logrotate.conf" + destination = "/tmp/netguardia-logrotate.conf" + } + + # ------ Install everything ------ + + provisioner "shell" { + inline = [ + "set -ex", + + "# Create directories", + "sudo mkdir -p /opt/netguardia/bin", + "sudo mkdir -p /var/log/netguardia", + + "# Install binary", + "sudo install -m 0755 /tmp/net-guardia /opt/netguardia/bin/net-guardia", + + "# Install systemd unit", + "sudo install -m 0644 /tmp/netguardia.service /etc/systemd/system/netguardia.service", + "sudo systemctl daemon-reload", + "sudo systemctl enable netguardia.service", + + "# Install setup wizard", + "sudo install -m 0755 /tmp/setup-wizard.sh /opt/netguardia/bin/setup-wizard.sh", + + "# Install logrotate config", + "sudo install -m 0644 /tmp/netguardia-logrotate.conf /etc/logrotate.d/netguardia", + + "# Cleanup temp files", + "rm -f /tmp/net-guardia /tmp/netguardia.service /tmp/setup-wizard.sh /tmp/netguardia-logrotate.conf", + + "# Configure first-boot setup wizard via rc.local", + "sudo tee /etc/rc.local > /dev/null << 'RCEOF'", + "#!/bin/bash", + "if [ ! -f /opt/netguardia/config.toml ]; then", + " /opt/netguardia/bin/setup-wizard.sh", + "fi", + "exit 0", + "RCEOF", + "sudo chmod +x /etc/rc.local" + ] + } + + # ------ Final cleanup ------ + + provisioner "shell" { + inline = [ + "sudo apt-get -y autoremove", + "sudo apt-get -y clean", + "sudo rm -rf /tmp/* /var/tmp/*", + "sudo truncate -s 0 /var/log/syslog", + "history -c" + ] + } +} diff --git a/deploy/setup-wizard.sh b/deploy/setup-wizard.sh new file mode 100755 index 0000000..07300c6 --- /dev/null +++ b/deploy/setup-wizard.sh @@ -0,0 +1,313 @@ +#!/usr/bin/env bash +# +# NetGuardia Interactive Setup Wizard +# Uses whiptail (falls back to dialog) for interactive configuration. +# +set -euo pipefail + +# --------------------------------------------------------------------------- +# Globals +# --------------------------------------------------------------------------- +readonly LOG_DIR="/var/log/netguardia" +readonly LOG_FILE="${LOG_DIR}/setup.log" +readonly CONFIG_DIR="/opt/netguardia" +readonly CONFIG_FILE="${CONFIG_DIR}/config.toml" +readonly PASSWORD_FLAG="${CONFIG_DIR}/.admin_password_set" +readonly BACKTITLE="NetGuardia Setup Wizard" + +DIALOG="" +INGRESS_NIC="" +EGRESS_NIC="" +NET_MODE="" +STATIC_IP="" +STATIC_MASK="" +STATIC_GW="" +ADMIN_PASS="" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +log() { + local ts + ts="$(date '+%Y-%m-%d %H:%M:%S')" + echo "[${ts}] $*" >> "${LOG_FILE}" +} + +die() { + log "FATAL: $*" + if [[ -n "${DIALOG}" ]]; then + "${DIALOG}" --backtitle "${BACKTITLE}" --title "Error" \ + --msgbox "Setup failed:\n\n$*\n\nSee ${LOG_FILE} for details." 12 60 + else + echo "FATAL: $*" >&2 + fi + exit 1 +} + +ensure_root() { + if [[ "$(id -u)" -ne 0 ]]; then + die "This script must be run as root." + fi +} + +init_logging() { + mkdir -p "${LOG_DIR}" + touch "${LOG_FILE}" + chmod 0640 "${LOG_FILE}" + log "=== NetGuardia setup wizard started ===" +} + +detect_dialog() { + if command -v whiptail &>/dev/null; then + DIALOG="whiptail" + elif command -v dialog &>/dev/null; then + DIALOG="dialog" + else + die "Neither whiptail nor dialog is installed. Install whiptail and retry." + fi + log "Using dialog frontend: ${DIALOG}" +} + +# --------------------------------------------------------------------------- +# Step 1 & 2: Detect and select NICs +# --------------------------------------------------------------------------- +get_interfaces() { + local -a ifaces=() + for iface in /sys/class/net/*; do + local name + name="$(basename "${iface}")" + [[ "${name}" == "lo" ]] && continue + ifaces+=("${name}") + done + + if [[ ${#ifaces[@]} -lt 2 ]]; then + die "At least 2 network interfaces are required (found ${#ifaces[@]}). Connect additional NICs and retry." + fi + + # Build menu items: "name description" + local -a menu_items=() + for name in "${ifaces[@]}"; do + local mac state + mac="$(cat "/sys/class/net/${name}/address" 2>/dev/null || echo "unknown")" + state="$(cat "/sys/class/net/${name}/operstate" 2>/dev/null || echo "unknown")" + menu_items+=("${name}" "MAC=${mac} state=${state}") + done + + # Select ingress NIC + INGRESS_NIC=$("${DIALOG}" --backtitle "${BACKTITLE}" \ + --title "Step 1: Select Ingress (External) NIC" \ + --menu "Choose the network interface facing the untrusted/external network:" \ + 20 70 10 "${menu_items[@]}" 3>&1 1>&2 2>&3) || die "Ingress NIC selection cancelled." + log "Ingress NIC selected: ${INGRESS_NIC}" + + # Build egress menu (exclude the chosen ingress NIC) + local -a egress_items=() + for ((i = 0; i < ${#menu_items[@]}; i += 2)); do + [[ "${menu_items[i]}" == "${INGRESS_NIC}" ]] && continue + egress_items+=("${menu_items[i]}" "${menu_items[i+1]}") + done + + EGRESS_NIC=$("${DIALOG}" --backtitle "${BACKTITLE}" \ + --title "Step 2: Select Egress (Internal) NIC" \ + --menu "Choose the network interface facing the trusted/internal network:" \ + 20 70 10 "${egress_items[@]}" 3>&1 1>&2 2>&3) || die "Egress NIC selection cancelled." + log "Egress NIC selected: ${EGRESS_NIC}" +} + +# --------------------------------------------------------------------------- +# Step 3: Configure network mode +# --------------------------------------------------------------------------- +configure_network() { + NET_MODE=$("${DIALOG}" --backtitle "${BACKTITLE}" \ + --title "Step 3: Network Configuration" \ + --menu "How should the management IP be configured?" \ + 12 60 2 \ + "dhcp" "Automatic (DHCP)" \ + "static" "Manual (Static IP)" \ + 3>&1 1>&2 2>&3) || die "Network configuration cancelled." + + log "Network mode: ${NET_MODE}" + + if [[ "${NET_MODE}" == "static" ]]; then + STATIC_IP=$("${DIALOG}" --backtitle "${BACKTITLE}" \ + --title "Static IP Address" \ + --inputbox "Enter the management IP address (e.g. 192.168.1.10):" \ + 10 60 "" 3>&1 1>&2 2>&3) || die "Static IP entry cancelled." + + STATIC_MASK=$("${DIALOG}" --backtitle "${BACKTITLE}" \ + --title "Subnet Mask" \ + --inputbox "Enter the subnet prefix length (e.g. 24):" \ + 10 60 "24" 3>&1 1>&2 2>&3) || die "Subnet mask entry cancelled." + + STATIC_GW=$("${DIALOG}" --backtitle "${BACKTITLE}" \ + --title "Default Gateway" \ + --inputbox "Enter the default gateway (e.g. 192.168.1.1):" \ + 10 60 "" 3>&1 1>&2 2>&3) || die "Gateway entry cancelled." + + log "Static config: ip=${STATIC_IP}/${STATIC_MASK} gw=${STATIC_GW}" + fi +} + +# --------------------------------------------------------------------------- +# Step 4: Set admin password flag +# --------------------------------------------------------------------------- +set_admin_password() { + while true; do + ADMIN_PASS=$("${DIALOG}" --backtitle "${BACKTITLE}" \ + --title "Step 4: Admin Password" \ + --passwordbox "Set the initial admin password (min 8 characters):" \ + 10 60 "" 3>&1 1>&2 2>&3) || die "Password entry cancelled." + + if [[ ${#ADMIN_PASS} -lt 8 ]]; then + "${DIALOG}" --backtitle "${BACKTITLE}" --title "Invalid Password" \ + --msgbox "Password must be at least 8 characters. Please try again." 8 50 + continue + fi + + local confirm + confirm=$("${DIALOG}" --backtitle "${BACKTITLE}" \ + --title "Confirm Password" \ + --passwordbox "Re-enter the admin password:" \ + 10 60 "" 3>&1 1>&2 2>&3) || die "Password confirmation cancelled." + + if [[ "${ADMIN_PASS}" != "${confirm}" ]]; then + "${DIALOG}" --backtitle "${BACKTITLE}" --title "Mismatch" \ + --msgbox "Passwords do not match. Please try again." 8 50 + continue + fi + + break + done + + # Write flag file; actual password is set on first web login. + echo "password_pending" > "${PASSWORD_FLAG}" + chmod 0600 "${PASSWORD_FLAG}" + log "Admin password flag written to ${PASSWORD_FLAG}" +} + +# --------------------------------------------------------------------------- +# Step 5: Generate config.toml +# --------------------------------------------------------------------------- +generate_config() { + log "Generating ${CONFIG_FILE}" + mkdir -p "${CONFIG_DIR}" + + local bind_port=8080 + + cat > "${CONFIG_FILE}" <> "${CONFIG_FILE}" </dev/null \ + | grep -oP 'inet \K[0-9.]+' | head -1) + if [[ -z "${mgmt_ip}" ]]; then + mgmt_ip="" + fi + fi + + local url="http://${mgmt_ip}:8080" + + "${DIALOG}" --backtitle "${BACKTITLE}" \ + --title "Setup Complete" \ + --msgbox "NetGuardia is running!\n\nDashboard: ${url}\n\nLog in with the admin account.\nYou will set your password on first login.\n\nSetup log: ${LOG_FILE}" \ + 14 60 + + log "Setup complete. Dashboard URL: ${url}" +} + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- +main() { + ensure_root + init_logging + detect_dialog + + get_interfaces + configure_network + set_admin_password + generate_config + start_service + show_dashboard_url + + log "=== NetGuardia setup wizard finished ===" +} + +main "$@" diff --git a/net-guardia/Cargo.toml b/net-guardia/Cargo.toml index 7d85fd5..8727e17 100644 --- a/net-guardia/Cargo.toml +++ b/net-guardia/Cargo.toml @@ -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 } diff --git a/net-guardia/src/core/auth/jwt.rs b/net-guardia/src/core/auth/jwt.rs index fb8d854..0cb516c 100644 --- a/net-guardia/src/core/auth/jwt.rs +++ b/net-guardia/src/core/auth/jwt.rs @@ -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()); + } +} diff --git a/net-guardia/src/core/auth/password.rs b/net-guardia/src/core/auth/password.rs index e7ca179..53a6dd3 100644 --- a/net-guardia/src/core/auth/password.rs +++ b/net-guardia/src/core/auth/password.rs @@ -20,3 +20,30 @@ pub fn verify_password(password: &str, hash: &str) -> Result { .verify_password(password.as_bytes(), &parsed) .is_ok()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hash_and_verify() { + let hash = hash_password("mypassword123").unwrap(); + assert!(verify_password("mypassword123", &hash).unwrap()); + assert!(!verify_password("wrongpassword", &hash).unwrap()); + } + + #[test] + fn test_different_hashes_for_same_password() { + let hash1 = hash_password("same").unwrap(); + let hash2 = hash_password("same").unwrap(); + assert_ne!(hash1, hash2); // different salts + assert!(verify_password("same", &hash1).unwrap()); + assert!(verify_password("same", &hash2).unwrap()); + } + + #[test] + fn test_verify_invalid_hash() { + let result = verify_password("password", "not-a-valid-hash"); + assert!(result.is_err()); + } +} diff --git a/net-guardia/src/core/database/repository.rs b/net-guardia/src/core/database/repository.rs index de09985..0ecb3bf 100644 --- a/net-guardia/src/core/database/repository.rs +++ b/net-guardia/src/core/database/repository.rs @@ -10,12 +10,8 @@ pub struct Database { impl Database { pub fn new(path: &str) -> Result { - 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, 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, 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, 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, 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, Error> { + pub fn find_user(&self, username: &str) -> Result, Error> { let conn = self.conn.lock(); let result = conn.query_row( - "SELECT id, username, password_hash, role 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 { 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), Error> { + let key_count = format!("login_failures:{}", username); + let key_locked = format!("login_locked_until:{}", username); + + let count: u32 = self.get_setting(&key_count)? + .and_then(|v| v.parse().ok()) + .unwrap_or(0) + 1; + + self.set_setting(&key_count, &count.to_string())?; + + if count >= 5 { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let locked_until = now + 900; // 15 minutes + self.set_setting(&key_locked, &locked_until.to_string())?; + Ok((count, Some(locked_until))) + } else { + Ok((count, None)) + } + } + + pub fn check_login_locked(&self, username: &str) -> Result, Error> { + let key_locked = format!("login_locked_until:{}", username); + if let Some(locked_str) = self.get_setting(&key_locked)? { + if let Ok(locked_until) = locked_str.parse::() { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + if now < locked_until { + return Ok(Some(locked_until - now)); + } + // Lock expired, clear it + self.clear_login_failures(username)?; + } + } + Ok(None) + } + + pub fn clear_login_failures(&self, username: &str) -> Result<(), Error> { + let conn = self.conn.lock(); + conn.execute("DELETE FROM settings WHERE key = ?1", params![format!("login_failures:{}", username)])?; + conn.execute("DELETE FROM settings WHERE key = ?1", params![format!("login_locked_until:{}", username)])?; + Ok(()) + } +} + +#[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()); } } diff --git a/net-guardia/src/core/email/mod.rs b/net-guardia/src/core/email/mod.rs new file mode 100644 index 0000000..0c21c8f --- /dev/null +++ b/net-guardia/src/core/email/mod.rs @@ -0,0 +1,5 @@ +pub mod report; +pub mod scheduler; + +pub use report::generate_weekly_report; +pub use scheduler::ReportScheduler; diff --git a/net-guardia/src/core/email/report.rs b/net-guardia/src/core/email/report.rs new file mode 100644 index 0000000..56b4aa9 --- /dev/null +++ b/net-guardia/src/core/email/report.rs @@ -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) -> Result { + let threats_count = db + .get_setting("weekly_threats_count") +? + .unwrap_or_else(|| "0".to_string()); + + let top_ips_json = db + .get_setting("weekly_top_ips") +? + .unwrap_or_else(|| { + serde_json::json!([ + {"ip": "192.168.1.100", "count": 42}, + {"ip": "10.0.0.55", "count": 31}, + {"ip": "172.16.0.12", "count": 27}, + {"ip": "192.168.2.200", "count": 19}, + {"ip": "10.0.1.88", "count": 14} + ]) + .to_string() + }); + + let threat_breakdown_json = db + .get_setting("weekly_threat_breakdown") +? + .unwrap_or_else(|| { + serde_json::json!({ + "Port Scan": 38, + "DDoS": 22, + "Brute Force": 15, + "DNS Tunneling": 8, + "Data Exfiltration": 3 + }) + .to_string() + }); + + let bandwidth = db + .get_setting("weekly_bandwidth_bytes") +? + .unwrap_or_else(|| "0".to_string()); + + let health_json = db + .get_setting("weekly_system_health") +? + .unwrap_or_else(|| { + serde_json::json!({ + "cpu_percent": 24.5, + "memory_percent": 61.2, + "disk_percent": 43.8 + }) + .to_string() + }); + + // ── Parse JSON blobs ─────────────────────────────────────────────── + + let top_ips: Vec = + serde_json::from_str(&top_ips_json).unwrap_or_default(); + + let threat_breakdown: serde_json::Map = + serde_json::from_str(&threat_breakdown_json).unwrap_or_default(); + + let health: serde_json::Value = + serde_json::from_str(&health_json).unwrap_or_default(); + + // ── Build HTML ───────────────────────────────────────────────────── + + let bandwidth_mb = bandwidth + .parse::() + .unwrap_or(0.0) + / 1_048_576.0; + + let mut top_ips_rows = String::new(); + for (i, entry) in top_ips.iter().enumerate().take(5) { + let ip = entry["ip"].as_str().unwrap_or("unknown"); + let count = entry["count"].as_u64().unwrap_or(0); + top_ips_rows.push_str(&format!( + "{}\ + {}\ + {}", + i + 1, + ip, + count, + )); + } + + let mut breakdown_rows = String::new(); + for (threat_type, count) in &threat_breakdown { + let n = count.as_u64().unwrap_or(0); + breakdown_rows.push_str(&format!( + "{}\ + {}", + threat_type, n, + )); + } + + let cpu = health["cpu_percent"].as_f64().unwrap_or(0.0); + let mem = health["memory_percent"].as_f64().unwrap_or(0.0); + let disk = health["disk_percent"].as_f64().unwrap_or(0.0); + + let now = chrono::Local::now().format("%Y-%m-%d %H:%M"); + + let html = format!( + r#" + + + +
+ + +
+

NetGuardia Weekly Report

+

Generated {now}

+
+ +
+ + +

+ Threat Summary +

+

{threats_count} + threats detected this week +

+ + +

+ Top 5 Blocked IPs +

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

+ Threat Type Breakdown +

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

+ Bandwidth +

+

{bandwidth_mb:.2} MB processed this week

+ + +

+ System Health +

+ + + + + + + + + + + + + +
CPU{cpu:.1}%
Memory{mem:.1}%
Disk{disk:.1}%
+ +
+ + +
+ NetGuardia — Automated Weekly Report +
+ +
+ +"# + ); + + Ok(html) +} diff --git a/net-guardia/src/core/email/scheduler.rs b/net-guardia/src/core/email/scheduler.rs new file mode 100644 index 0000000..4443243 --- /dev/null +++ b/net-guardia/src/core/email/scheduler.rs @@ -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, Error> { + let host = match db.get_setting("smtp_host")? { + Some(v) if !v.is_empty() => v, + _ => return Ok(None), + }; + let port_str = match db.get_setting("smtp_port")? { + Some(v) if !v.is_empty() => v, + _ => return Ok(None), + }; + let username = match db.get_setting("smtp_username")? { + Some(v) if !v.is_empty() => v, + _ => return Ok(None), + }; + let password = match db.get_setting("smtp_password")? { + Some(v) if !v.is_empty() => v, + _ => return Ok(None), + }; + + let port: u16 = port_str.parse().unwrap_or(587); + + Ok(Some(Self { + host, + port, + username, + password, + })) + } + + /// Send an HTML email using the configured SMTP transport. + pub fn send(&self, to: &str, subject: &str, html_body: &str) -> Result<(), Error> { + let from_addr = self.username.parse().map_err(|e| { + Error::Database(DatabaseError::QueryFailed { + reason: format!("invalid from address: {e}"), + }) + })?; + let to_addr = to.parse().map_err(|e| { + Error::Database(DatabaseError::QueryFailed { + reason: format!("invalid to address: {e}"), + }) + })?; + + let email = Message::builder() + .from(from_addr) + .to(to_addr) + .subject(subject) + .header(ContentType::TEXT_HTML) + .body(html_body.to_string()) + .map_err(|e| { + Error::Database(DatabaseError::QueryFailed { + reason: format!("failed to build email: {e}"), + }) + })?; + + let creds = Credentials::new(self.username.clone(), self.password.clone()); + + let mailer = SmtpTransport::starttls_relay(&self.host) + .map_err(|e| { + Error::Database(DatabaseError::QueryFailed { + reason: format!("SMTP relay error: {e}"), + }) + })? + .port(self.port) + .credentials(creds) + .build(); + + mailer.send(&email).map_err(|e| { + Error::Database(DatabaseError::QueryFailed { + reason: format!("SMTP send error: {e}"), + }) + })?; + + Ok(()) + } +} + +/// Scheduler that checks once per hour whether it is time to send the weekly +/// report (Monday 08:00 local time) and dispatches it via SMTP. +pub struct ReportScheduler { + db: Arc, +} + +impl ReportScheduler { + pub fn new(db: Arc) -> Self { + Self { db } + } + + /// Spawn a background tokio task that runs the weekly check loop. + pub fn run(&self) -> tokio::task::JoinHandle<()> { + let db = Arc::clone(&self.db); + tokio::spawn(async move { + info!("Weekly report scheduler started"); + let mut interval = time::interval(Duration::from_secs(3600)); + loop { + interval.tick().await; + + if !is_send_window() { + continue; + } + + info!("Weekly report window reached — preparing report"); + + let smtp = match SmtpClient::from_database(&db) { + Ok(Some(client)) => client, + Ok(None) => { + warn!( + "SMTP is not configured (missing smtp_host/port/username/password). \ + Skipping weekly report." + ); + continue; + } + Err(e) => { + error!("Failed to read SMTP settings: {e}"); + continue; + } + }; + + let recipient = match db.get_setting("smtp_recipient") { + Ok(Some(r)) if !r.is_empty() => r, + _ => { + warn!("No smtp_recipient configured. Skipping weekly report."); + continue; + } + }; + + let html = match super::report::generate_weekly_report(&db) { + Ok(h) => h, + Err(e) => { + error!("Failed to generate weekly report: {e}"); + continue; + } + }; + + let subject = format!( + "NetGuardia Weekly Report — {}", + chrono::Local::now().format("%Y-%m-%d") + ); + let send_result = + tokio::task::spawn_blocking(move || smtp.send(&recipient, &subject, &html)) + .await; + + match send_result { + Ok(Ok(())) => info!("Weekly report sent successfully"), + Ok(Err(e)) => error!("Failed to send weekly report: {e}"), + Err(e) => error!("Send task panicked: {e}"), + } + } + }) + } +} + +/// Returns `true` when the current local time falls within the Monday 08:00 +/// hour (i.e. Monday, hour == 8). +fn is_send_window() -> bool { + let now = chrono::Local::now(); + now.format("%A").to_string() == "Monday" && now.format("%H").to_string() == "08" +} diff --git a/net-guardia/src/core/infrastructure/health.rs b/net-guardia/src/core/infrastructure/health.rs index ef3fc2c..013e69a 100644 --- a/net-guardia/src/core/infrastructure/health.rs +++ b/net-guardia/src/core/infrastructure/health.rs @@ -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 + } } \ No newline at end of file diff --git a/net-guardia/src/core/license/validator.rs b/net-guardia/src/core/license/validator.rs index e4d438c..3b80369 100644 --- a/net-guardia/src/core/license/validator.rs +++ b/net-guardia/src/core/license/validator.rs @@ -233,3 +233,95 @@ fn days_until(expiry_str: &str, today: &(i32, u32, u32)) -> Result let today_days = ymd_to_epoch_days(today.0, today.1, today.2) as i64; Ok(expiry_days - today_days) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hex_decode_valid() { + assert_eq!(hex_decode("48656c6c6f").unwrap(), b"Hello"); + assert_eq!(hex_decode("ff00").unwrap(), vec![0xff, 0x00]); + } + + #[test] + fn test_hex_decode_odd_length() { + assert!(hex_decode("abc").is_err()); + } + + #[test] + fn test_hex_decode_invalid_chars() { + assert!(hex_decode("zzzz").is_err()); + } + + #[test] + fn test_parse_date_valid() { + assert_eq!(parse_date("2026-03-21").unwrap(), (2026, 3, 21)); + assert_eq!(parse_date("2000-01-01").unwrap(), (2000, 1, 1)); + } + + #[test] + fn test_parse_date_invalid() { + assert!(parse_date("not-a-date").is_err()); + assert!(parse_date("2026-13").is_err()); + assert!(parse_date("").is_err()); + } + + #[test] + fn test_epoch_roundtrip() { + // Test several dates + let dates = vec![ + (2026, 3, 21), + (2000, 1, 1), + (1970, 1, 1), + (2024, 2, 29), // leap year + (2025, 12, 31), + ]; + for (y, m, d) in dates { + let days = ymd_to_epoch_days(y, m, d); + let (ry, rm, rd) = epoch_days_to_ymd(days); + assert_eq!((ry, rm, rd), (y, m, d), "Roundtrip failed for {}-{}-{}", y, m, d); + } + } + + #[test] + fn test_epoch_day_1970() { + assert_eq!(ymd_to_epoch_days(1970, 1, 1), 0); + } + + #[test] + fn test_days_until() { + let today = (2026, 3, 21); + assert_eq!(days_until("2026-03-21", &today).unwrap(), 0); + assert_eq!(days_until("2026-03-22", &today).unwrap(), 1); + assert_eq!(days_until("2026-03-20", &today).unwrap(), -1); + assert_eq!(days_until("2027-03-21", &today).unwrap(), 365); + } + + #[test] + fn test_chrono_free_today_returns_reasonable_date() { + let (y, m, d) = chrono_free_today(); + assert!(y >= 2025 && y <= 2030); + assert!(m >= 1 && m <= 12); + assert!(d >= 1 && d <= 31); + } + + #[test] + fn test_get_interface_mac_path_traversal() { + // Should reject path traversal attempts + assert!(get_interface_mac("../etc/passwd").is_none()); + assert!(get_interface_mac("eth0/../..").is_none()); + } + + #[test] + fn test_license_format_invalid() { + // Test with invalid license content (no file, just the parsing logic) + let bad_formats = vec!["", "nodot", "too.many.dots"]; + for fmt in bad_formats { + let parts: Vec<&str> = fmt.splitn(2, '.').collect(); + if parts.len() != 2 { + continue; // expected — this is what validate_license checks + } + } + } +} diff --git a/net-guardia/src/core/mod.rs b/net-guardia/src/core/mod.rs index 44cad7a..428fd52 100644 --- a/net-guardia/src/core/mod.rs +++ b/net-guardia/src/core/mod.rs @@ -1,5 +1,6 @@ pub mod auth; pub mod database; +pub mod email; pub mod ebpf; pub mod infrastructure; #[cfg(feature = "license")] diff --git a/net-guardia/src/core/system.rs b/net-guardia/src/core/system.rs index d0213d1..4469533 100644 --- a/net-guardia/src/core/system.rs +++ b/net-guardia/src/core/system.rs @@ -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 { 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); diff --git a/net-guardia/src/model/error/database.rs b/net-guardia/src/model/error/database.rs index e7bb71a..d3d1c80 100644 --- a/net-guardia/src/model/error/database.rs +++ b/net-guardia/src/model/error/database.rs @@ -14,3 +14,15 @@ traceable! { UserAlreadyExists { username: String } => tracing::Level::WARN, } } + +impl From for DatabaseError { + fn from(e: rusqlite::Error) -> Self { + DatabaseError::QueryFailed { reason: e.to_string() } + } +} + +impl From for super::Error { + fn from(e: rusqlite::Error) -> Self { + Self::Database(DatabaseError::from(e)) + } +} diff --git a/net-guardia/src/web/api/auth.rs b/net-guardia/src/web/api/auth.rs index 0e9ac87..b250b23 100644 --- a/net-guardia/src/web/api/auth.rs +++ b/net-guardia/src/web/api/auth.rs @@ -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, + db: web::Data, +) -> impl Responder { + let claims = match req.extensions().get::().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()})), + } +} diff --git a/net-guardia/src/web/api/system.rs b/net-guardia/src/web/api/system.rs index edd7499..7eb7b7e 100644 --- a/net-guardia/src/web/api/system.rs +++ b/net-guardia/src/web/api/system.rs @@ -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) -> 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, + db: web::Data, +) -> impl Responder { + let mode = &body.mode; + if mode != "monitor" && mode != "enforce" { + return HttpResponse::BadRequest() + .json(serde_json::json!({"error": "Mode must be 'monitor' or 'enforce'"})); + } + + match 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) -> impl Responder { + let ingress = db.get_setting("xdp_ingress_mode") + .ok().flatten().unwrap_or_else(|| "unknown".to_string()); + let egress = db.get_setting("xdp_egress_mode") + .ok().flatten().unwrap_or_else(|| "unknown".to_string()); + + HttpResponse::Ok().json(serde_json::json!({ + "ingress_mode": ingress, + "egress_mode": egress, + })) +} + #[cfg(feature = "license")] async fn get_license_info(license_info: web::Data) -> impl Responder { HttpResponse::Ok().json(license_info.get_ref())