diff --git a/back/speedtest-cli b/back/speedtest-cli index 186b5292..1fc0b46b 100755 --- a/back/speedtest-cli +++ b/back/speedtest-cli @@ -957,7 +957,7 @@ class SpeedtestResults(object): self.client = client or {} self._share = None - self.timestamp = '%sZ' % datetime.datetime.utcnow().isoformat() + self.timestamp = datetime.datetime.now(datetime.timezone.utc).isoformat().replace('+00:00', 'Z') self.bytes_received = 0 self.bytes_sent = 0 diff --git a/docs/HW_INSTALL.md b/docs/HW_INSTALL.md index 9fa7e60b..e96248de 100755 --- a/docs/HW_INSTALL.md +++ b/docs/HW_INSTALL.md @@ -65,11 +65,11 @@ wget https://raw.githubusercontent.com/jokob-sk/NetAlertX/main/install/debian12/ ### Installation via curl ```bash -curl -o install.ubuntu24.sh https://raw.githubusercontent.com/jokob-sk/NetAlertX/main/install/ubuntu24/install.ubuntu24.sh && sudo chmod +x install.ubuntu24.sh && sudo ./install.ubuntu24.sh +curl -o install.sh https://raw.githubusercontent.com/jokob-sk/NetAlertX/main/install/ubuntu24/install.sh && sudo chmod +x install.sh && sudo ./install.sh ``` ### Installation via wget ```bash -wget https://raw.githubusercontent.com/jokob-sk/NetAlertX/main/install/ubuntu24/install.ubuntu24.sh -O install.ubuntu24.sh && sudo chmod +x install.ubuntu24.sh && sudo ./install.ubuntu24.sh +wget https://raw.githubusercontent.com/jokob-sk/NetAlertX/main/install/ubuntu24/install.sh -O install.sh && sudo chmod +x install.sh && sudo ./install.sh ``` diff --git a/install/proxmox/README.md b/install/proxmox/README.md new file mode 100644 index 00000000..b179479c --- /dev/null +++ b/install/proxmox/README.md @@ -0,0 +1,195 @@ +# NetAlertX Proxmox Installer + +An installer script for deploying NetAlertX on Proxmox VE (Debian-based) systems. This installer automates the complete setup including dependencies, NGINX configuration, systemd service, and security hardening. + +## 🚀 Quick Start + +### Prerequisites +- Fresh LXC or VM of Debian 13 or Ubuntu 24 +- Root access +- Internet connection + +### Installation + +```bash +# Download and run the installer +wget https://raw.githubusercontent.com/jokob-sk/NetAlertX/refs/heads/main/install/proxmox/proxmox-install-netalertx.sh -O proxmox-install-netalertx.sh && chmod +x proxmox-install-netalertx.sh && ./proxmox-install-netalertx.sh +``` + +## 📋 What This Installer Does + +### System Dependencies +- **PHP 8.4** with FPM, SQLite3, cURL extensions +- **NGINX** with custom configuration +- **Python 3** with virtual environment +- **Network tools**: nmap, arp-scan, traceroute, mtr, speedtest-cli +- **Additional tools**: git, build-essential, avahi-daemon + +### Security Features +- **Hardened permissions**: Proper user/group ownership +- **TMPFS mounts**: Log and API directories mounted as tmpfs for security + +### Service Management +- **Systemd service**: Auto-start on boot with restart policies +- **Service monitoring**: Built-in health checks and logging +- **Dependency management**: Waits for network and NGINX + +## 🔧 Configuration + +### Port Configuration +The installer will prompt for a custom port, or defaultto 20211 after 10-seconds: + +``` +Enter HTTP port for NetAlertX [20211] (auto-continue in 10s): +``` + +### Service Management +```bash +# Check service status +systemctl status netalertx + +# View logs +journalctl -u netalertx -f + +# Restart service +systemctl restart netalertx + +# Stop service +systemctl stop netalertx +``` + +## 🌐 Access + +After installation, access NetAlertX at: +``` +http://[SERVER_IP]:[PORT] +``` + +## 🔒 Security Considerations + +### TMPFS Mounts +- `/app/log` - Mounted as tmpfs (no persistent logs) +- `/app/api` - Mounted as tmpfs (temporary API data) + +### File Permissions +- Application files: `www-data:www-data` with appropriate permissions +- NGINX runs as `www-data` user +- Log directories: Secure permissions with tmpfs + +### Network Security +- NGINX configured for internal network access +- No external firewall rules added (configure manually if needed) + +## 🛠️ Troubleshooting + +### Common Issues + +#### 403 Forbidden Error +```bash +# Check file permissions +ls -la /var/www/html/netalertx +ls -la /app/front + +# Fix permissions +chown -R www-data:www-data /app/front +chmod -R 755 /app/front +``` + +#### Service Won't Start +```bash +# Check service status +systemctl status netalertx + +# View detailed logs +journalctl -u netalertx --no-pager -l + +# Check if port is in use +ss -tlnp | grep :20211 +``` + +#### GraphQL Connection Issues +```bash +# Check API token in config +grep API_TOKEN /app/config/app.conf + +# Verify GraphQL port +grep GRAPHQL_PORT /app/config/app.conf + +# Check backend logs +tail -f /app/log/app.log +``` + +### Log Locations +- **Service logs**: `journalctl -u netalertx` +- **Application logs**: `/app/log/` (tmpfs) +- **NGINX logs**: `/var/log/nginx/` +- **PHP logs**: `/app/log/app.php_errors.log` + +### Manual Service Start +If systemd service fails: +```bash +# Activate Python environment +source /opt/myenv/bin/activate + +# Start manually +cd /app +python server/ +``` +or +``` +./start.netalertx.sh +``` +## 🔄 Updates + +### Updating NetAlertX +```bash +# Stop service +systemctl stop netalertx + +# Update from repository +cd /app +git pull origin main + +# Restart service +systemctl start netalertx +``` + + +## 📁 File Structure + +``` +/app/ # Main application directory +├── front/ # Web interface (symlinked to /var/www/html/netalertx) +├── server/ # Python backend +├── config/ # Configuration files +├── db/ # Database files +├── log/ # Log files (tmpfs) +├── api/ # API files (tmpfs) +└── start.netalertx.sh # Service startup script + +/etc/systemd/system/ +└── netalertx.service # Systemd service definition + +/etc/nginx/conf.d/ +└── netalertx.conf # NGINX configuration +``` + +## 🤝 Contributing +This installer will need a maintainer + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Test thoroughly +5. Submit a pull request + +## 🙏 Acknowledgments + +- NetAlertX development team +- Proxmox VE community +- Debian/Ubuntu maintainers +- Open source contributors + +--- + +**Note**: This installer was designed for a Proxmox LXC Debian 13 or Ubuntu 24 containers. For other systems, please use the appropriate installer or manual installation instructions. diff --git a/install/proxmox/netalertx.conf b/install/proxmox/netalertx.conf new file mode 100644 index 00000000..01f94157 --- /dev/null +++ b/install/proxmox/netalertx.conf @@ -0,0 +1,33 @@ +server { + listen 20211; + server_name _; #change this to your custom domain if you have one + + # Web-interface files location + root /var/www/html/netalertx; + + # Main page + index index.php; + + #rewrite /app/(.*) / permanent; + add_header X-Forwarded-Prefix "/netalertx" always; + proxy_set_header X-Forwarded-Prefix "/netalertx"; + + # Specify a character set + charset utf-8; + + location / { + # Try to serve files directly, fallback to index.php + try_files $uri $uri/ /index.php?$query_string; + } + + # FastCGI configuration for PHP + location ~ \.php$ { + # Use a Unix socket for better performance + fastcgi_pass unix:/var/run/php/php-fpm.sock; + fastcgi_index index.php; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + fastcgi_param PATH_INFO $fastcgi_path_info; + fastcgi_param QUERY_STRING $query_string; + include fastcgi_params; + } +} diff --git a/install/proxmox/proxmox-install-netalertx.sh b/install/proxmox/proxmox-install-netalertx.sh new file mode 100755 index 00000000..f78740b6 --- /dev/null +++ b/install/proxmox/proxmox-install-netalertx.sh @@ -0,0 +1,425 @@ +#!/usr/bin/env bash + +# Exit immediately if a command exits with a non-zero status. +set -e +# Treat unset variables as an error when substituting +set -u +# Consider failures in a pipeline +set -o pipefail +# Safe IFS +IFS=$' \t\n' + +# 🛑 Important: This is only used for the bare-metal install 🛑 +# Colors (guarded) +if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then + RESET='\e[0m' + GREEN='\e[1;38;5;2m' + RED='\e[31m' +else + RESET=''; GREEN=''; RED='' +fi + +printf "%b\n" "--------------------------------------------------------------------------" +printf "%b\n" "${GREEN}[UPDATING] ${RESET}Making sure the system is up to date" +printf "%b\n" "--------------------------------------------------------------------------" + +printf "%b\n" "--------------------------------------------------------------------------" +printf "%b\n" "${GREEN}[INSTALLING] ${RESET}Running proxmox-install-netalertx.sh" +printf "%b\n" "--------------------------------------------------------------------------" + +# Set environment variables +INSTALL_DIR=/app # default installation directory + +# DO NOT CHANGE ANYTHING BELOW THIS LINE! +INSTALLER_DIR="$INSTALL_DIR/install/proxmox" +CONF_FILE=app.conf +DB_FILE=app.db +NGINX_CONF_FILE=netalertx.conf +WEB_UI_DIR=/var/www/html/netalertx +NGINX_CONFIG=/etc/nginx/conf.d/$NGINX_CONF_FILE +OUI_FILE="/usr/share/arp-scan/ieee-oui.txt" +FILEDB=$INSTALL_DIR/db/$DB_FILE +# DO NOT CHANGE ANYTHING ABOVE THIS LINE! + +# Check if script is run as root +if [[ $EUID -ne 0 ]]; then + echo "This script must be run as root." + exit 1 +fi + +# Interactive confirmation: warn about overwriting/removing existing installation and NGINX config +if [ -z "${NETALERTX_ASSUME_YES:-}" ] && [ -z "${ASSUME_YES:-}" ] && [ -z "${NETALERTX_FORCE:-}" ]; then + printf "%b\n" "------------------------------------------------------------------------" + printf "%b\n" "${RED}[WARNING] ${RESET}This script should be run on a fresh server" + printf "%b\n" "${RED}[WARNING] ${RESET}This script will install NetAlertX and will:" + printf "%b\n" "${RED}[WARNING] ${RESET}• Update OS with apt-get update/upgrade" + printf "%b\n" "${RED}[WARNING] ${RESET}• Overwrite existing files under ${INSTALL_DIR} " + printf "%b\n" "${RED}[WARNING] ${RESET}• Wipe any existing database" + printf "%b\n" "${RED}[WARNING] ${RESET}• Wipe/Set up NGINX configuration under /etc/nginx" + printf "%b\n" "${RED}[WARNING] ${RESET}• Set up systemd services." + read -r -p "Proceed with installation? [y/N]: " _reply + case "${_reply}" in + y|Y|yes|YES) ;; + *) echo "Aborting by user choice."; exit 1;; + esac +else + printf "%b\n" "--------------------------------------------------------------------------" + printf "%b\n" "${GREEN}[INSTALLING] ${RESET}Non-interactive mode detected; proceeding without confirmation." + printf "%b\n" "--------------------------------------------------------------------------" +fi + +# Getting up to date +apt-get update -y +apt-get upgrade -y + +# Prompt for HTTP port (default 20211) with countdown fallback +DEFAULT_PORT=20211 +if [ -z "${NETALERTX_ASSUME_YES:-}" ] && [ -z "${ASSUME_YES:-}" ] && [ -z "${NETALERTX_FORCE:-}" ]; then + printf "%b\n" "--------------------------------------------------------------------------" + # Countdown-based prompt + _entered_port="" + for _sec in 10 9 8 7 6 5 4 3 2 1; do + printf "\rEnter HTTP port for NetAlertX [${DEFAULT_PORT}] (auto-continue in %2ds): " "${_sec}" + if read -t 1 -r _entered_port; then + break + fi + done + printf "\n" + if [ -z "${_entered_port}" ]; then + PORT="${DEFAULT_PORT}" + elif printf '%s' "${_entered_port}" | grep -Eq '^[0-9]+$' && [ "${_entered_port}" -ge 1 ] && [ "${_entered_port}" -le 65535 ]; then + PORT="${_entered_port}" + else + printf "%b\n" "${RED}[WARNING] ${RESET}Invalid port. Falling back to ${DEFAULT_PORT}" + PORT="${DEFAULT_PORT}" + fi +else + PORT="${PORT-}"; PORT="${PORT:-${DEFAULT_PORT}}" +fi +export PORT + +# Detect primary server IP +SERVER_IP="$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if ($i=="src") {print $(i+1); exit}}')" +if [ -z "${SERVER_IP}" ]; then + SERVER_IP="$(hostname -I 2>/dev/null | awk '{print $1}')" +fi +if [ -z "${SERVER_IP}" ]; then + SERVER_IP="127.0.0.1" +fi +export SERVER_IP +# Ensure tmpfs mounts are cleaned up on exit/failure +trap 'umount "${INSTALL_DIR}/log" 2>/dev/null || true; umount "${INSTALL_DIR}/api" 2>/dev/null || true' EXIT + +# Making sure the system is clean +if [ -d "$INSTALL_DIR" ]; then + printf "%b\n" "Removing existing directory: $INSTALL_DIR" + rm -rf "$INSTALL_DIR" +fi + +# 1. INSTALL SYSTEM DEPENDENCIES & ADD PHP REPOSITORY +printf "%b\n" "--------------------------------------------------------------------------" +printf "%b\n" "${GREEN}[INSTALLING] ${RESET}Installing system dependencies" +printf "%b\n" "--------------------------------------------------------------------------" +export DEBIAN_FRONTEND=noninteractive +apt-get update -y +# software-properties-common is not available and not needed +apt-get install -y --no-install-recommends \ + ca-certificates lsb-release curl gnupg + +# Detect OS +. /etc/os-release +OS_ID="${ID:-}" +OS_VER="${VERSION_ID:-}" + +printf "%b\n" "--------------------------------------------------------------------------" +printf "%b\n" "${GREEN}[INSTALLING] ${RESET}Detected OS: ${OS_ID} ${OS_VER}" +printf "%b\n" "--------------------------------------------------------------------------" + +if + [ "${OS_ID}" = "ubuntu" ] && printf '%s' "${OS_VER}" | grep -q '^24'; then + # Ubuntu 24.x typically ships PHP 8.3; add ondrej/php PPA and set 8.4 + printf "%b\n" "--------------------------------------------------------------------------" + printf "%b\n" "${GREEN}[INSTALLING] ${RESET}Ubuntu 24 detected - enabling ondrej/php PPA for PHP 8.4" + printf "%b\n" "--------------------------------------------------------------------------" + apt-get install -y --no-install-recommends software-properties-common || true + add-apt-repository ppa:ondrej/php -y + apt update -y +elif + [ "${OS_ID}" = "debian" ] && printf '%s' "${OS_VER}" | grep -q '^13'; then + printf "%b\n" "--------------------------------------------------------------------------" + printf "%b\n" "${GREEN}[INSTALLING] ${RESET}Debian 13 detected - using built-in PHP 8.4" + printf "%b\n" "--------------------------------------------------------------------------" +fi + +apt-get install -y --no-install-recommends \ + tini snmp ca-certificates curl libwww-perl arp-scan perl apt-utils cron sudo \ + php8.4 php8.4-cgi php8.4-fpm php8.4-sqlite3 php8.4-curl sqlite3 dnsutils net-tools mtr \ + python3 python3-dev iproute2 nmap python3-pip zip usbutils traceroute nbtscan \ + avahi-daemon avahi-utils build-essential git gnupg2 lsb-release \ + debian-archive-keyring python3-venv + +if + [ "${OS_ID}" = "ubuntu" ] && printf '%s' "${OS_VER}" | grep -q '^24'; then # Set PHP 8.4 as the default alternatives where applicable + update-alternatives --set php /usr/bin/php8.4 || true + systemctl enable php8.4-fpm || true + systemctl restart php8.4-fpm || true +fi + +printf "%b\n" "--------------------------------------------------------------------------" +printf "%b\n" "${GREEN}[INSTALLING] ${RESET}Setting up NGINX - Might take a minute!" +printf "%b\n" "--------------------------------------------------------------------------" + +apt-get install -y nginx + +# Enable and start nginx +if command -v systemctl >/dev/null 2>&1; then + systemctl enable nginx || true + systemctl restart nginx || true +fi + +# 3. CLONE OR UPDATE APPLICATION REPOSITORY +printf "%b\n" "--------------------------------------------------------------------------" +printf "%b\n" "${GREEN}[INSTALLING] ${RESET}Cloning application repository and setup" +printf "%b\n" "--------------------------------------------------------------------------" + +mkdir -p "$INSTALL_DIR" +git clone -b baremetal-installer https://github.com/jokob-sk/NetAlertX.git "$INSTALL_DIR/" #change after testing + +if [ ! -f "$INSTALL_DIR/front/buildtimestamp.txt" ]; then + date +%s > "$INSTALL_DIR/front/buildtimestamp.txt" +fi + +printf "%b\n" "--------------------------------------------------------------------------" +printf "%b\n" "${GREEN}[FINISHED] ${RESET}NetAlertX Installation complete" +printf "%b\n" "--------------------------------------------------------------------------" +printf "%b\n" "${GREEN}[CONFIGURATION] ${RESET}Configuring the web server" +printf "%b\n" "--------------------------------------------------------------------------" + +# Stop any existing NetAlertX python server process (narrow pattern) +pkill -f "^python(3)?\s+.*${INSTALL_DIR}/server/?$" 2>/dev/null || true + +# 4. SET UP PYTHON VIRTUAL ENVIRONMENT & DEPENDENCIES +printf "%b\n" "--------------------------------------------------------------------------" +printf "%b\n" "${GREEN}[INSTALLING] ${RESET}Setting up Python environment" +printf "%b\n" "--------------------------------------------------------------------------" +python3 -m venv /opt/myenv +source /opt/myenv/bin/activate +python -m pip install --upgrade pip +python -m pip install -r "${INSTALLER_DIR}/requirements.txt" + +# Backup default NGINX site just in case +if [ -L /etc/nginx/sites-enabled/default ] ; then + rm /etc/nginx/sites-enabled/default +elif [ -f /etc/nginx/sites-enabled/default ]; then + mv /etc/nginx/sites-enabled/default /etc/nginx/sites-available/default.bkp_netalertx +fi + +# Clear existing directories and files +if [ -d "$WEB_UI_DIR" ]; then + printf "%b\n" "--------------------------------------------------------------------------" + printf "%b\n" "${GREEN}[CHECKING] ${RESET}Removing existing NetAlertX web-UI" + printf "%b\n" "--------------------------------------------------------------------------" + rm -R "$WEB_UI_DIR" +fi + +printf "%b\n" "--------------------------------------------------------------------------" +printf "%b\n" "${GREEN}[CHECKING] ${RESET}Removing existing NetAlertX NGINX config" +printf "%b\n" "--------------------------------------------------------------------------" +rm "$NGINX_CONFIG" 2>/dev/null || true + +# Create web directory if it doesn't exist +mkdir -p /var/www/html + +# create symbolic link to the installer directory +ln -sfn "${INSTALL_DIR}/front" "$WEB_UI_DIR" + +# Copy NGINX configuration to NetAlertX config directory +cp "${INSTALLER_DIR}/${NGINX_CONF_FILE}" "${INSTALL_DIR}/config/${NGINX_CONF_FILE}" + +# Use selected port (may be default 20211) +if [ -n "${PORT-}" ]; then + printf "%b\n" "--------------------------------------------------------------------------" + printf "%b\n" "Setting webserver to port ($PORT)" + printf "%b\n" "--------------------------------------------------------------------------" + # Update the template to reflect the right port + sed -i "s/listen 20211;/listen ${PORT};/g" "${INSTALL_DIR}/config/${NGINX_CONF_FILE}" + # Warn if port is already in use + if ss -ltn | awk '{print $4}' | grep -q ":${PORT}$"; then + printf "%b\n" "--------------------------------------------------------------------------" + printf "%b\n" "${RED}[WARNING] ${RESET}Port ${PORT} appears in use. NGINX may fail to bind." + printf "%b\n" "--------------------------------------------------------------------------" + fi +fi + +# Create symbolic link to NGINX configuration coming with NetAlertX +ln -sfn "${INSTALL_DIR}/config/${NGINX_CONF_FILE}" "${NGINX_CONFIG}" + +# Run the hardware vendors update at least once +printf "%b\n" "--------------------------------------------------------------------------" +printf "%b\n" "${GREEN}[VENDORS UPDATE] ${RESET}Run the hardware vendors update" +printf "%b\n" "--------------------------------------------------------------------------" + +# Check if ieee-oui.txt or ieee-iab.txt exist +if [ -f "$OUI_FILE" ]; then + printf "%b\n" "--------------------------------------------------------------------------" + printf "%b\n" "The file ieee-oui.txt exists. Skipping update_vendors.sh..." + printf "%b\n" "--------------------------------------------------------------------------" +else + printf "%b\n" "--------------------------------------------------------------------------" + printf "%b\n" "The file ieee-oui.txt does not exist. Running update_vendors..." + printf "%b\n" "--------------------------------------------------------------------------" + + # Run the update_vendors.sh script + if [ -f "${INSTALL_DIR}/back/update_vendors.sh" ]; then + "${INSTALL_DIR}/back/update_vendors.sh" + else + printf "%b\n" "--------------------------------------------------------------------------" + printf "%b\n" " update_vendors.sh script not found in $INSTALL_DIR." + printf "%b\n" "--------------------------------------------------------------------------" + fi +fi + +# Create empty log files and plugin folders +printf "%b\n" "--------------------------------------------------------------------------" +printf "%b\n" "${GREEN}[INSTALLING] ${RESET}Creating mounts and file structure" +printf "%b\n" "--------------------------------------------------------------------------" + +printf "%b\n" "Cleaning up old mounts if any" +umount "${INSTALL_DIR}/log" 2>/dev/null || true +umount "${INSTALL_DIR}/api" 2>/dev/null || true + +printf "%b\n" "Creating log api folders if they don't exist" +mkdir -p "${INSTALL_DIR}/log" "${INSTALL_DIR}/api" + +printf "%b\n" "--------------------------------------------------------------------------" +printf "%b\n" "${GREEN}[INSTALLING] ${RESET}Mounting log and api folders as tmpfs" +printf "%b\n" "--------------------------------------------------------------------------" +mountpoint -q "${INSTALL_DIR}/log" || mount -t tmpfs -o noexec,nosuid,nodev tmpfs "${INSTALL_DIR}/log" +mountpoint -q "${INSTALL_DIR}/api" || mount -t tmpfs -o noexec,nosuid,nodev tmpfs "${INSTALL_DIR}/api" +chown -R www-data:www-data "${INSTALL_DIR}/log" "${INSTALL_DIR}/api" + +# Ensure plugins directory exists within the tmpfs mount +mkdir -p "${INSTALL_DIR}"/log/plugins +chown -R www-data:www-data "${INSTALL_DIR}"/log/plugins + +# Create the execution_queue.log file if it doesn't exist +touch ${INSTALL_DIR}/log/{app.log,execution_queue.log,app_front.log,app.php_errors.log,stderr.log,stdout.log,db_is_locked.log} +touch ${INSTALL_DIR}/api/user_notifications.json +chown -R www-data:www-data "${INSTALL_DIR}"/log "${INSTALL_DIR}"/api +chmod -R ug+rwX "${INSTALL_DIR}"/log "${INSTALL_DIR}"/api + +# Set ownership of the tmpfs mountpoints first. +chown www-data:www-data "${INSTALL_DIR}/log" "${INSTALL_DIR}/api" + +# Ensure plugins directory exists within the tmpfs mount +mkdir -p "${INSTALL_DIR}/log/plugins" + +# Create log and api files directly as the www-data user to ensure correct ownership from the start. +sudo -u www-data touch ${INSTALL_DIR}/log/{app.log,execution_queue.log,app_front.log,app.php_errors.log,stderr.log,stdout.log,db_is_locked.log} +sudo -u www-data touch ${INSTALL_DIR}/api/user_notifications.json + +# Set final permissions for all created files and directories. +chown -R www-data:www-data "${INSTALL_DIR}/log" "${INSTALL_DIR}/api" +chmod -R ug+rwX "${INSTALL_DIR}/log" "${INSTALL_DIR}/api" + +printf "%b\n" "--------------------------------------------------------------------------" +printf "%b\n" "${GREEN}[INSTALLING] ${RESET}Setting up DB and CONF files" +printf "%b\n" "--------------------------------------------------------------------------" + +# Copy starter $DB_FILE and $CONF_FILE +mkdir -p "${INSTALL_DIR}/config" "${INSTALL_DIR}/db" +cp -u "${INSTALL_DIR}/back/${CONF_FILE}" "${INSTALL_DIR}/config/${CONF_FILE}" +cp -u "${INSTALL_DIR}/back/${DB_FILE}" "${FILEDB}" + +printf "%b\n" "--------------------------------------------------------------------------" +printf "%b\n" "${GREEN}[CONFIGURING] ${RESET}Setting File Permissions" +printf "%b\n" "--------------------------------------------------------------------------" +# Restrict wide permissions; allow owner/group access +chgrp -R www-data "$INSTALL_DIR" +chmod -R ug+rwX,o-rwx "$INSTALL_DIR" +chmod -R ug+rwX,o-rwx "$WEB_UI_DIR" +# chmod -R ug+rwX "$INSTALL_DIR/log" "$INSTALL_DIR/config" +chown -R www-data:www-data "$FILEDB" 2>/dev/null || true + +# start PHP +printf "%b\n" "--------------------------------------------------------------------------" +printf "%b\n" "${GREEN}[STARTING] ${RESET}Starting PHP and NGINX" +printf "%b\n" "--------------------------------------------------------------------------" +/etc/init.d/php8.4-fpm start +nginx -t || { + printf "%b\n" "--------------------------------------------------------------------------" + printf "%b\n" "${RED}[ERROR] ${RESET}NGINX config test failed!" + printf "%b\n" "--------------------------------------------------------------------------"; exit 1; } +/etc/init.d/nginx start + +# Make a start script +cat > "$INSTALL_DIR/start.netalertx.sh" << EOF +#!/usr/bin/env bash + +# Activate the virtual python environment +source /opt/myenv/bin/activate + +echo -e "--------------------------------------------------------------------------" +echo -e "Starting NetAlertX - navigate to http://${SERVER_IP}:${PORT}" +echo -e "--------------------------------------------------------------------------" + +# Start the NetAlertX python script +python server/ +EOF + +chmod +x "$INSTALL_DIR/start.netalertx.sh" + +# Install and manage systemd service if available, otherwise fallback to direct start +if command -v systemctl >/dev/null 2>&1; then + printf "%b\n" "--------------------------------------------------------------------------" + printf "%b\n" "${GREEN}[INSTALLING] ${RESET}Setting up systemd service" + printf "%b\n" "--------------------------------------------------------------------------" + +cat > /etc/systemd/system/netalertx.service << 'EOF' +[Unit] +Description=NetAlertX Service +After=network-online.target nginx.service +Wants=network-online.target + +[Service] +Type=simple +User=www-data +Group=www-data +ExecStart=/app/start.netalertx.sh +WorkingDirectory=/app +Restart=on-failure +RestartSec=5 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target +EOF + +# Reload systemd and enable/start service +systemctl daemon-reload +systemctl enable netalertx.service +systemctl start netalertx.service +systemctl restart nginx + + # Verify service is running + if systemctl is-active --quiet netalertx.service; then + printf "%b\n" "--------------------------------------------------------------------------" + printf "%b\n" "${GREEN}[SUCCESS] ${RESET}NetAlertX service started successfully" + printf "%b\n" "--------------------------------------------------------------------------" + else + printf "%b\n" "--------------------------------------------------------------------------" + printf "%b\n" "${RED}[WARNING] ${RESET}NetAlertX service may not have started properly" + printf "%b\n" "--------------------------------------------------------------------------" + systemctl status netalertx.service --no-pager -l + fi +else + printf "%b\n" "--------------------------------------------------------------------------" + printf "%b\n" "${GREEN}[INSTALLING] ${RESET}Starting NetAlertX (no systemd)" + printf "%b\n" "--------------------------------------------------------------------------" + "$INSTALL_DIR/start.netalertx.sh" & +fi + +echo -e "--------------------------------------------------------------------------" +echo -e "${GREEN}[Service] 🚀 Starting app - navigate to http://${SERVER_IP}:${PORT}" +echo -e "--------------------------------------------------------------------------" diff --git a/install/proxmox/requirements.txt b/install/proxmox/requirements.txt new file mode 100644 index 00000000..2525b62e --- /dev/null +++ b/install/proxmox/requirements.txt @@ -0,0 +1,25 @@ +openwrt-luci-rpc +asusrouter +aiohttp +graphene +flask +flask-cors +unifi-sm-api +tplink-omada-client +wakeonlan +pycryptodome +requests +paho-mqtt +scapy +cron-converter +pytz +json2table +dhcp-leases +pyunifi +speedtest-cli +chardet +python-nmap +dnspython +librouteros +yattag +git+https://github.com/foreign-sub/aiofreepybox.git diff --git a/server/db/sql_safe_builder.py b/server/db/sql_safe_builder.py index 5548561f..ce8c5360 100755 --- a/server/db/sql_safe_builder.py +++ b/server/db/sql_safe_builder.py @@ -153,47 +153,259 @@ class SafeConditionBuilder: def _parse_condition(self, condition: str) -> Tuple[str, Dict[str, Any]]: """ Parse a condition string into safe SQL with parameters. - - This method handles basic patterns like: - - AND devName = 'value' - - AND devComments LIKE '%value%' - - AND eve_EventType IN ('type1', 'type2') - + + This method handles both single and compound conditions: + - Single: AND devName = 'value' + - Compound: AND devName = 'value' AND devVendor = 'Apple' + - Multiple clauses with AND/OR operators + Args: condition: Condition string to parse - + Returns: Tuple of (safe_sql_snippet, parameters_dict) """ condition = condition.strip() - + + # Handle empty conditions + if not condition: + return "", {} + + # Check if this is a compound condition (multiple clauses) + if self._is_compound_condition(condition): + return self._parse_compound_condition(condition) + + # Single condition: extract leading logical operator if present + logical_op = None + clause_text = condition + + # Check for leading AND + if condition.upper().startswith('AND ') or condition.upper().startswith('AND\t'): + logical_op = 'AND' + clause_text = condition[3:].strip() + # Check for leading OR + elif condition.upper().startswith('OR ') or condition.upper().startswith('OR\t'): + logical_op = 'OR' + clause_text = condition[2:].strip() + + # Parse the single condition + return self._parse_single_condition(clause_text, logical_op) + + def _is_compound_condition(self, condition: str) -> bool: + """ + Determine if a condition contains multiple clauses (compound condition). + + A compound condition has multiple logical operators (AND/OR) connecting + separate comparison clauses. + + Args: + condition: Condition string to check + + Returns: + True if compound (multiple clauses), False if single clause + """ + # Track if we're inside quotes to avoid counting operators in quoted strings + in_quotes = False + logical_op_count = 0 + i = 0 + + while i < len(condition): + char = condition[i] + + # Toggle quote state + if char == "'": + in_quotes = not in_quotes + i += 1 + continue + + # Only count logical operators outside of quotes + if not in_quotes: + # Look for AND or OR as whole words + remaining = condition[i:].upper() + + # Check for AND (must be word boundary) + if remaining.startswith('AND ') or remaining.startswith('AND\t'): + logical_op_count += 1 + i += 3 + continue + + # Check for OR (must be word boundary) + if remaining.startswith('OR ') or remaining.startswith('OR\t'): + logical_op_count += 1 + i += 2 + continue + + i += 1 + + # A compound condition has more than one logical operator + # (first AND/OR starts the condition, subsequent ones connect clauses) + return logical_op_count > 1 + + def _parse_compound_condition(self, condition: str) -> Tuple[str, Dict[str, Any]]: + """ + Parse a compound condition with multiple clauses. + + Splits the condition into individual clauses, parses each one, + and reconstructs the full condition with all parameters. + + Args: + condition: Compound condition string + + Returns: + Tuple of (safe_sql_snippet, parameters_dict) + """ + # Split the condition into individual clauses while preserving logical operators + clauses = self._split_by_logical_operators(condition) + + # Parse each clause individually + parsed_parts = [] + all_params = {} + + for clause_text, logical_op in clauses: + # Parse this single clause + sql_part, params = self._parse_single_condition(clause_text, logical_op) + + if sql_part: + parsed_parts.append(sql_part) + all_params.update(params) + + if not parsed_parts: + raise ValueError("No valid clauses found in compound condition") + + # Join all parsed parts + final_sql = " ".join(parsed_parts) + + return final_sql, all_params + + def _split_by_logical_operators(self, condition: str) -> List[Tuple[str, Optional[str]]]: + """ + Split a compound condition into individual clauses. + + Returns a list of tuples: (clause_text, logical_operator) + The logical operator is the AND/OR that precedes the clause. + + Args: + condition: Compound condition string + + Returns: + List of (clause_text, logical_op) tuples + """ + clauses = [] + current_clause = [] + current_logical_op = None + in_quotes = False + i = 0 + + while i < len(condition): + char = condition[i] + + # Toggle quote state + if char == "'": + in_quotes = not in_quotes + current_clause.append(char) + i += 1 + continue + + # Only look for logical operators outside of quotes + if not in_quotes: + remaining = condition[i:].upper() + + # Check if we're at a word boundary (start of string or after whitespace) + at_word_boundary = (i == 0 or condition[i-1] in ' \t') + + # Check for AND (must be at word boundary) + if at_word_boundary and (remaining.startswith('AND ') or remaining.startswith('AND\t')): + # Save current clause if we have one + if current_clause: + clause_text = ''.join(current_clause).strip() + if clause_text: + clauses.append((clause_text, current_logical_op)) + current_clause = [] + + # Set the logical operator for the next clause + current_logical_op = 'AND' + i += 3 # Skip 'AND' + + # Skip whitespace after AND + while i < len(condition) and condition[i] in ' \t': + i += 1 + continue + + # Check for OR (must be at word boundary) + if at_word_boundary and (remaining.startswith('OR ') or remaining.startswith('OR\t')): + # Save current clause if we have one + if current_clause: + clause_text = ''.join(current_clause).strip() + if clause_text: + clauses.append((clause_text, current_logical_op)) + current_clause = [] + + # Set the logical operator for the next clause + current_logical_op = 'OR' + i += 2 # Skip 'OR' + + # Skip whitespace after OR + while i < len(condition) and condition[i] in ' \t': + i += 1 + continue + + # Add character to current clause + current_clause.append(char) + i += 1 + + # Don't forget the last clause + if current_clause: + clause_text = ''.join(current_clause).strip() + if clause_text: + clauses.append((clause_text, current_logical_op)) + + return clauses + + def _parse_single_condition(self, condition: str, logical_op: Optional[str] = None) -> Tuple[str, Dict[str, Any]]: + """ + Parse a single condition clause into safe SQL with parameters. + + This method handles basic patterns like: + - devName = 'value' (with optional AND/OR prefix) + - devComments LIKE '%value%' + - eve_EventType IN ('type1', 'type2') + + Args: + condition: Single condition string to parse + logical_op: Optional logical operator (AND/OR) to prepend + + Returns: + Tuple of (safe_sql_snippet, parameters_dict) + """ + condition = condition.strip() + # Handle empty conditions if not condition: return "", {} # Simple pattern matching for common conditions - # Pattern 1: AND/OR column operator value (supporting Unicode in quoted strings) - pattern1 = r'^\s*(AND|OR)?\s+(\w+)\s+(=|!=|<>|<|>|<=|>=|LIKE|NOT\s+LIKE)\s+\'([^\']*)\'\s*$' + # Pattern 1: [AND/OR] column operator value (supporting Unicode in quoted strings) + pattern1 = r'^\s*(\w+)\s+(=|!=|<>|<|>|<=|>=|LIKE|NOT\s+LIKE)\s+\'([^\']*)\'\s*$' match1 = re.match(pattern1, condition, re.IGNORECASE | re.UNICODE) - + if match1: - logical_op, column, operator, value = match1.groups() + column, operator, value = match1.groups() return self._build_simple_condition(logical_op, column, operator, value) - # Pattern 2: AND/OR column IN ('val1', 'val2', ...) - pattern2 = r'^\s*(AND|OR)?\s+(\w+)\s+(IN|NOT\s+IN)\s+\(([^)]+)\)\s*$' + # Pattern 2: [AND/OR] column IN ('val1', 'val2', ...) + pattern2 = r'^\s*(\w+)\s+(IN|NOT\s+IN)\s+\(([^)]+)\)\s*$' match2 = re.match(pattern2, condition, re.IGNORECASE) - + if match2: - logical_op, column, operator, values_str = match2.groups() + column, operator, values_str = match2.groups() return self._build_in_condition(logical_op, column, operator, values_str) - # Pattern 3: AND/OR column IS NULL/IS NOT NULL - pattern3 = r'^\s*(AND|OR)?\s+(\w+)\s+(IS\s+NULL|IS\s+NOT\s+NULL)\s*$' + # Pattern 3: [AND/OR] column IS NULL/IS NOT NULL + pattern3 = r'^\s*(\w+)\s+(IS\s+NULL|IS\s+NOT\s+NULL)\s*$' match3 = re.match(pattern3, condition, re.IGNORECASE) - + if match3: - logical_op, column, operator = match3.groups() + column, operator = match3.groups() return self._build_null_condition(logical_op, column, operator) # If no patterns match, reject the condition for security diff --git a/test/test_compound_conditions.py b/test/test_compound_conditions.py new file mode 100644 index 00000000..e7d15557 --- /dev/null +++ b/test/test_compound_conditions.py @@ -0,0 +1,326 @@ +""" +Unit tests for SafeConditionBuilder compound condition parsing. + +Tests the fix for Issue #1210 - compound conditions with multiple AND/OR clauses. +""" + +import sys +import unittest +from unittest.mock import MagicMock + +# Mock the logger module before importing SafeConditionBuilder +sys.modules['logger'] = MagicMock() + +# Add parent directory to path for imports +sys.path.insert(0, '/tmp/netalertx_hotfix/server/db') + +from sql_safe_builder import SafeConditionBuilder + + +class TestCompoundConditions(unittest.TestCase): + """Test compound condition parsing functionality.""" + + def setUp(self): + """Create a fresh builder instance for each test.""" + self.builder = SafeConditionBuilder() + + def test_user_failing_filter_six_and_clauses(self): + """Test the exact user-reported failing filter from Issue #1210.""" + condition = ( + "AND devLastIP NOT LIKE '192.168.50.%' " + "AND devLastIP NOT LIKE '192.168.60.%' " + "AND devLastIP NOT LIKE '192.168.70.2' " + "AND devLastIP NOT LIKE '192.168.70.5' " + "AND devLastIP NOT LIKE '192.168.70.3' " + "AND devLastIP NOT LIKE '192.168.70.4'" + ) + + sql, params = self.builder.build_safe_condition(condition) + + # Should successfully parse + self.assertIsNotNone(sql) + self.assertIsNotNone(params) + + # Should have 6 parameters (one per clause) + self.assertEqual(len(params), 6) + + # Should contain all 6 AND operators + self.assertEqual(sql.count('AND'), 6) + + # Should contain all 6 NOT LIKE operators + self.assertEqual(sql.count('NOT LIKE'), 6) + + # Should have 6 parameter placeholders + self.assertEqual(sql.count(':param_'), 6) + + # Verify all IP patterns are in parameters + param_values = list(params.values()) + self.assertIn('192.168.50.%', param_values) + self.assertIn('192.168.60.%', param_values) + self.assertIn('192.168.70.2', param_values) + self.assertIn('192.168.70.5', param_values) + self.assertIn('192.168.70.3', param_values) + self.assertIn('192.168.70.4', param_values) + + def test_multiple_and_clauses_simple(self): + """Test multiple AND clauses with simple equality operators.""" + condition = "AND devName = 'Device1' AND devVendor = 'Apple' AND devFavorite = '1'" + + sql, params = self.builder.build_safe_condition(condition) + + # Should have 3 parameters + self.assertEqual(len(params), 3) + + # Should have 3 AND operators + self.assertEqual(sql.count('AND'), 3) + + # Verify all values are parameterized + param_values = list(params.values()) + self.assertIn('Device1', param_values) + self.assertIn('Apple', param_values) + self.assertIn('1', param_values) + + def test_multiple_or_clauses(self): + """Test multiple OR clauses.""" + condition = "OR devName = 'Device1' OR devName = 'Device2' OR devName = 'Device3'" + + sql, params = self.builder.build_safe_condition(condition) + + # Should have 3 parameters + self.assertEqual(len(params), 3) + + # Should have 3 OR operators + self.assertEqual(sql.count('OR'), 3) + + # Verify all device names are parameterized + param_values = list(params.values()) + self.assertIn('Device1', param_values) + self.assertIn('Device2', param_values) + self.assertIn('Device3', param_values) + + def test_mixed_and_or_clauses(self): + """Test mixed AND/OR logical operators.""" + condition = "AND devName = 'Device1' OR devName = 'Device2' AND devFavorite = '1'" + + sql, params = self.builder.build_safe_condition(condition) + + # Should have 3 parameters + self.assertEqual(len(params), 3) + + # Should preserve the logical operator order + self.assertIn('AND', sql) + self.assertIn('OR', sql) + + # Verify all values are parameterized + param_values = list(params.values()) + self.assertIn('Device1', param_values) + self.assertIn('Device2', param_values) + self.assertIn('1', param_values) + + def test_single_condition_backward_compatibility(self): + """Test that single conditions still work (backward compatibility).""" + condition = "AND devName = 'TestDevice'" + + sql, params = self.builder.build_safe_condition(condition) + + # Should have 1 parameter + self.assertEqual(len(params), 1) + + # Should match expected format + self.assertIn('AND devName = :param_', sql) + + # Parameter should contain the value + self.assertIn('TestDevice', params.values()) + + def test_single_condition_like_operator(self): + """Test single LIKE condition for backward compatibility.""" + condition = "AND devComments LIKE '%important%'" + + sql, params = self.builder.build_safe_condition(condition) + + # Should have 1 parameter + self.assertEqual(len(params), 1) + + # Should contain LIKE operator + self.assertIn('LIKE', sql) + + # Parameter should contain the pattern + self.assertIn('%important%', params.values()) + + def test_compound_with_like_patterns(self): + """Test compound conditions with LIKE patterns.""" + condition = "AND devLastIP LIKE '192.168.%' AND devVendor LIKE '%Apple%'" + + sql, params = self.builder.build_safe_condition(condition) + + # Should have 2 parameters + self.assertEqual(len(params), 2) + + # Should have 2 LIKE operators + self.assertEqual(sql.count('LIKE'), 2) + + # Verify patterns are parameterized + param_values = list(params.values()) + self.assertIn('192.168.%', param_values) + self.assertIn('%Apple%', param_values) + + def test_compound_with_inequality_operators(self): + """Test compound conditions with various inequality operators.""" + condition = "AND eve_DateTime > '2024-01-01' AND eve_DateTime < '2024-12-31'" + + sql, params = self.builder.build_safe_condition(condition) + + # Should have 2 parameters + self.assertEqual(len(params), 2) + + # Should have both operators + self.assertIn('>', sql) + self.assertIn('<', sql) + + # Verify dates are parameterized + param_values = list(params.values()) + self.assertIn('2024-01-01', param_values) + self.assertIn('2024-12-31', param_values) + + def test_empty_condition(self): + """Test empty condition string.""" + condition = "" + + sql, params = self.builder.build_safe_condition(condition) + + # Should return empty results + self.assertEqual(sql, "") + self.assertEqual(params, {}) + + def test_whitespace_only_condition(self): + """Test condition with only whitespace.""" + condition = " \t\n " + + sql, params = self.builder.build_safe_condition(condition) + + # Should return empty results + self.assertEqual(sql, "") + self.assertEqual(params, {}) + + def test_invalid_column_name_rejected(self): + """Test that invalid column names are rejected.""" + condition = "AND malicious_column = 'value'" + + with self.assertRaises(ValueError): + self.builder.build_safe_condition(condition) + + def test_invalid_operator_rejected(self): + """Test that invalid operators are rejected.""" + condition = "AND devName EXECUTE 'DROP TABLE'" + + with self.assertRaises(ValueError): + self.builder.build_safe_condition(condition) + + def test_sql_injection_attempt_blocked(self): + """Test that SQL injection attempts are blocked.""" + condition = "AND devName = 'value'; DROP TABLE devices; --" + + # Should either reject or sanitize the dangerous input + # The semicolon and comment should not appear in the final SQL + try: + sql, params = self.builder.build_safe_condition(condition) + # If it doesn't raise an error, it should sanitize the input + self.assertNotIn('DROP', sql.upper()) + self.assertNotIn(';', sql) + except ValueError: + # Rejection is also acceptable + pass + + def test_quoted_string_with_spaces(self): + """Test that quoted strings with spaces are handled correctly.""" + condition = "AND devName = 'My Device Name' AND devComments = 'Has spaces here'" + + sql, params = self.builder.build_safe_condition(condition) + + # Should have 2 parameters + self.assertEqual(len(params), 2) + + # Verify values with spaces are preserved + param_values = list(params.values()) + self.assertIn('My Device Name', param_values) + self.assertIn('Has spaces here', param_values) + + def test_compound_condition_with_not_equal(self): + """Test compound conditions with != operator.""" + condition = "AND devName != 'Device1' AND devVendor != 'Unknown'" + + sql, params = self.builder.build_safe_condition(condition) + + # Should have 2 parameters + self.assertEqual(len(params), 2) + + # Should have != operators (or converted to <>) + self.assertTrue('!=' in sql or '<>' in sql) + + # Verify values are parameterized + param_values = list(params.values()) + self.assertIn('Device1', param_values) + self.assertIn('Unknown', param_values) + + def test_very_long_compound_condition(self): + """Test handling of very long compound conditions (10+ clauses).""" + clauses = [] + for i in range(10): + clauses.append(f"AND devName != 'Device{i}'") + + condition = " ".join(clauses) + sql, params = self.builder.build_safe_condition(condition) + + # Should have 10 parameters + self.assertEqual(len(params), 10) + + # Should have 10 AND operators + self.assertEqual(sql.count('AND'), 10) + + # Verify all device names are parameterized + param_values = list(params.values()) + for i in range(10): + self.assertIn(f'Device{i}', param_values) + + +class TestParameterGeneration(unittest.TestCase): + """Test parameter generation and naming.""" + + def setUp(self): + """Create a fresh builder instance for each test.""" + self.builder = SafeConditionBuilder() + + def test_parameters_have_unique_names(self): + """Test that all parameters get unique names.""" + condition = "AND devName = 'A' AND devName = 'B' AND devName = 'C'" + + sql, params = self.builder.build_safe_condition(condition) + + # All parameter names should be unique + param_names = list(params.keys()) + self.assertEqual(len(param_names), len(set(param_names))) + + def test_parameter_values_match_condition(self): + """Test that parameter values correctly match the condition values.""" + condition = "AND devLastIP NOT LIKE '192.168.1.%' AND devLastIP NOT LIKE '10.0.0.%'" + + sql, params = self.builder.build_safe_condition(condition) + + # Should have exactly the values from the condition + param_values = sorted(params.values()) + expected_values = sorted(['192.168.1.%', '10.0.0.%']) + self.assertEqual(param_values, expected_values) + + def test_parameters_referenced_in_sql(self): + """Test that all parameters are actually referenced in the SQL.""" + condition = "AND devName = 'Device1' AND devVendor = 'Apple'" + + sql, params = self.builder.build_safe_condition(condition) + + # Every parameter should appear in the SQL + for param_name in params.keys(): + self.assertIn(f':{param_name}', sql) + + +if __name__ == '__main__': + unittest.main()