From bab0f417de01376ca85668a0565bfc6cfc7d80a6 Mon Sep 17 00:00:00 2001 From: TimInTech Date: Fri, 3 Oct 2025 02:15:56 +0200 Subject: [PATCH 1/2] fix(install): clean up installer script and resolve syntax errors --- install.sh | 756 +++++------------------------------------------------ 1 file changed, 67 insertions(+), 689 deletions(-) diff --git a/install.sh b/install.sh index 420ea24..05d2dbf 100755 --- a/install.sh +++ b/install.sh @@ -2,10 +2,7 @@ set -euo pipefail # Pi-hole + Unbound + NetAlertX + Python Suite One-Click Installer -# Modern, idempotent installer for complete DNS security stack -# -# Author: TimInTech -# License: MIT +# Author: TimInTech | License: MIT # Repository: https://github.com/TimInTech/Pi-hole-Unbound-PiAlert-Setup # πŸ”§ Configuration @@ -13,305 +10,77 @@ readonly UNBOUND_PORT=5335 readonly NETALERTX_PORT=20211 readonly PYTHON_SUITE_PORT=8090 readonly NETALERTX_IMAGE="techxartisan/netalertx:latest" +readonly SUITE_API_KEY=${SUITE_API_KEY:-$(openssl rand -hex 16)} +readonly INSTALL_USER=${SUDO_USER:-$(whoami)} +readonly INSTALL_HOME=$(getent passwd "$INSTALL_USER" | cut -d: -f6) +readonly PROJECT_DIR="$INSTALL_HOME/Pi-hole-Unbound-PiAlert-Setup" # 🎨 Colors -readonly RED='\033[0;31m' -readonly GREEN='\033[0;32m' -readonly YELLOW='\033[1;33m' -readonly BLUE='\033[0;34m' -readonly PURPLE='\033[0;35m' -readonly CYAN='\033[0;36m' -readonly NC='\033[0m' +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m' -# πŸ“ Logging functions -log() { echo -e "${BLUE}[INFO]${NC} $*"; } -success() { echo -e "${GREEN}[βœ“]${NC} $*"; } -warn() { echo -e "${YELLOW}[⚠]${NC} $*"; } -error() { echo -e "${RED}[βœ—]${NC} $*"; } -step() { echo -e "\n${PURPLE}[STEP]${NC} $*"; } +# πŸ“ Logging helpers +log() { echo -e "${BLUE}[INFO]${NC} $*"; } +success() { echo -e "${GREEN}[βœ“]${NC} $*"; } +warn() { echo -e "${YELLOW}[⚠]${NC} $*"; } +error() { echo -e "${RED}[βœ—]${NC} $*"; } +step() { echo -e "\n${YELLOW}[STEP]${NC} $*"; } # πŸ›‘οΈ Error handler -cleanup() { - if [[ $? -ne 0 ]]; then - error "Installation failed! Check logs above." - error "You can re-run this script to retry installation." - fi -} -trap cleanup EXIT +trap 'error "Installation failed. See logs above."' ERR +# --------------------------------------------------------------------------- # πŸ” System checks check_system() { step "Performing system checks" - - # Check if running as root - if [[ $EUID -ne 0 ]]; then - error "This script must be run as root or with sudo" - exit 1 - fi - - # Check for Debian/Ubuntu - if ! command -v apt-get >/dev/null 2>&1; then - error "This installer requires Debian/Ubuntu (apt-get not found)" - exit 1 - fi - - # Detect OS - if [[ -f /etc/os-release ]]; then - . /etc/os-release - log "Detected: $PRETTY_NAME" - fi - - # Check project directory - if [[ ! -f requirements.txt ]]; then - error "requirements.txt not found. Please run from project directory" - exit 1 - fi - - # Check internet connectivity - if ! curl -s --connect-timeout 5 google.com >/dev/null; then - warn "Internet connectivity check failed - continuing anyway" - else - success "Internet connectivity confirmed" - fi - + [[ $EUID -eq 0 ]] || { error "Run as root or with sudo"; exit 1; } + command -v apt-get >/dev/null || { error "Debian/Ubuntu required"; exit 1; } + [[ -f requirements.txt ]] || { error "Run script in project directory"; exit 1; } success "System checks passed" } -# πŸ”Œ Port conflict check +# πŸ”Œ Port conflicts check_ports() { - step "Checking port availability" - + step "Checking ports" local ports=($UNBOUND_PORT $NETALERTX_PORT $PYTHON_SUITE_PORT 53) - local conflicts=() - for port in "${ports[@]}"; do - if ss -tuln 2>/dev/null | grep -q ":$port "; then - conflicts+=($port) + if ss -tuln | grep -q ":$port "; then + warn "Port $port already in use" fi done - - if [[ ${#conflicts[@]} -gt 0 ]]; then - warn "Ports in use: ${conflicts[*]}" - warn "Installation will continue but may require manual intervention" - else - success "All required ports available" - fi } -# πŸ“¦ Package installation +# πŸ“¦ Packages install_packages() { step "Installing system packages" - - log "Updating package lists..." apt-get update -qq - - log "Installing core packages..." - apt-get install -y \ - unbound \ - unbound-anchor \ -# This script installs and configures: -# - Unbound DNS resolver on 127.0.0.1:5335 -# - Pi-hole using Unbound as upstream -# - NetAlertX as Docker container on port 20211 -# - Python monitoring suite as systemd service - -# Configuration -UNBOUND_PORT=5335 -NETALERTX_PORT=20211 -PYTHON_SUITE_PORT=8090 -SUITE_API_KEY=${SUITE_API_KEY:-$(openssl rand -hex 16)} -INSTALL_USER=${SUDO_USER:-$(whoami)} -INSTALL_HOME=$(getent passwd "$INSTALL_USER" | cut -d: -f6) -PROJECT_DIR="$INSTALL_HOME/Pi-hole-Unbound-PiAlert-Setup" - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -# Logging functions -log_info() { - echo -e "${BLUE}[INFO]${NC} $1" -} - -log_success() { - echo -e "${GREEN}[SUCCESS]${NC} $1" -} - -log_warning() { - echo -e "${YELLOW}[WARNING]${NC} $1" -} - -log_error() { - echo -e "${RED}[ERROR]${NC} $1" -} - -# Check if running as root or with sudo -check_privileges() { - if [[ $EUID -ne 0 ]]; then - log_error "This script must be run as root or with sudo" - exit 1 - fi -} - -# System checks -check_system() { - log_info "Performing system checks..." - - # Check if this is Debian/Ubuntu - if ! command -v apt-get &> /dev/null; then - log_error "This script requires a Debian/Ubuntu system with apt-get" - exit 1 - fi - - # Check system version - if [[ -f /etc/os-release ]]; then - . /etc/os-release - log_info "Detected system: $NAME $VERSION" - fi - - # Check if we're in the project directory - if [[ ! -f "start_suite.py" || ! -f "requirements.txt" ]]; then - log_error "This script must be run from the Pi-hole-Unbound-PiAlert-Setup directory" - log_info "Please cd to the project directory and run: sudo ./install.sh" - exit 1 - fi - - PROJECT_DIR=$(pwd) - log_info "Project directory: $PROJECT_DIR" -} - -# Check for port conflicts -check_ports() { - log_info "Checking for port conflicts..." - - local ports_to_check=("$UNBOUND_PORT" "$NETALERTX_PORT" "$PYTHON_SUITE_PORT") - local conflicts=false - - for port in "${ports_to_check[@]}"; do - if ss -tuln | grep -q ":$port "; then - log_warning "Port $port is already in use" - conflicts=true - fi - done - - if [[ "$conflicts" == true ]]; then - log_warning "Some ports are in use. The installer will attempt to work around this." - log_info "You may need to stop conflicting services manually if issues arise." - fi -} - -# Install system packages -install_packages() { - log_info "Installing system packages..." - - # Update package lists - apt-get update - - # Install required packages - apt-get install -y \ - unbound \ - ca-certificates \ - curl \ - dnsutils \ - python3 \ - python3-venv \ - python3-pip \ - git \ - docker.io \ - openssl \ - systemd \ - sqlite3 - + apt-get install -y unbound unbound-anchor ca-certificates curl dnsutils \ + python3 python3-venv python3-pip git docker.io openssl systemd sqlite3 success "System packages installed" } -# πŸ” Unbound configuration +# πŸ” Unbound config configure_unbound() { - step "Configuring Unbound DNS resolver" - - # Create unbound directory + step "Configuring Unbound" install -d -m 0755 /var/lib/unbound - - # Download root hints - log "Downloading DNS root hints..." curl -fsSL https://www.internic.net/domain/named.root -o /var/lib/unbound/root.hints - - # Create Pi-hole configuration - log "Creating Unbound configuration..." - systemctl - - log_success "System packages installed" -} -# Configure Unbound -configure_unbound() { - log_info "Configuring Unbound DNS resolver..." - - # Create unbound directory if it doesn't exist - install -d -m 0755 /var/lib/unbound - - # Download root hints - if [[ ! -f /var/lib/unbound/root.hints ]] || [[ $(find /var/lib/unbound/root.hints -mtime +30 2>/dev/null) ]]; then - log_info "Downloading DNS root hints..." - curl -fsSL https://www.internic.net/domain/named.root -o /var/lib/unbound/root.hints - fi - - # Create Pi-hole specific Unbound configuration - cat > /etc/unbound/unbound.conf.d/pi-hole.conf << 'EOF' + cat > /etc/unbound/unbound.conf.d/pi-hole.conf </dev/null \ + && success "Unbound OK" || warn "Unbound may not be working" } -# πŸ•³οΈ Pi-hole installation +# πŸ•³οΈ Pi-hole install_pihole() { step "Installing Pi-hole" - - if command -v pihole >/dev/null 2>&1; then - log "Pi-hole already installed" - else - log "Downloading Pi-hole installer..." - # Create minimal setupVars for unattended install - mkdir -p /etc/pihole - cat > /etc/pihole/setupVars.conf << EOF -PIHOLE_INTERFACE=eth0 -IPV4_ADDRESS=127.0.0.1/8 -IPV6_ADDRESS= -PIHOLE_DNS_1=127.0.0.1#$UNBOUND_PORT -PIHOLE_DNS_2= -QUERY_LOGGING=true -INSTALL_WEB_SERVER=true -INSTALL_WEB_INTERFACE=true -LIGHTTPD_ENABLED=true -CACHE_SIZE=10000 -DNS_FQDN_REQUIRED=true -DNS_BOGUS_PRIV=true -DNSMASQ_LISTENING=local -WEBPASSWORD= -BLOCKING_ENABLED=true -EOF - - # Run Pi-hole installer + if ! command -v pihole >/dev/null; then curl -sSL https://install.pi-hole.net | bash /dev/stdin --unattended fi - - # Ensure Pi-hole uses Unbound - log "Configuring Pi-hole upstream DNS..." - log_success "Unbound is working correctly" - else - log_warning "Unbound test failed, but continuing..." - fi -} -# Install Pi-hole -install_pihole() { - log_info "Installing Pi-hole..." - - # Check if Pi-hole is already installed - if command -v pihole &> /dev/null; then - log_info "Pi-hole is already installed, configuring..." - else - log_info "Downloading and installing Pi-hole..." - # Use Pi-hole's official installer - curl -sSL https://install.pi-hole.net | bash /dev/stdin --unattended - fi - - # Configure Pi-hole to use Unbound - log_info "Configuring Pi-hole to use Unbound..." - - # Set upstream DNS to Unbound - if [[ -f /etc/pihole/setupVars.conf ]]; then - sed -i "s/^PIHOLE_DNS_1=.*/PIHOLE_DNS_1=127.0.0.1#$UNBOUND_PORT/" /etc/pihole/setupVars.conf - sed -i "s/^PIHOLE_DNS_2=.*/PIHOLE_DNS_2=/" /etc/pihole/setupVars.conf - fi - - # Restart Pi-hole + sed -i "s/^PIHOLE_DNS_1=.*/PIHOLE_DNS_1=127.0.0.1#$UNBOUND_PORT/" /etc/pihole/setupVars.conf + sed -i "s/^PIHOLE_DNS_2=.*/PIHOLE_DNS_2=/" /etc/pihole/setupVars.conf pihole restartdns - - success "Pi-hole configured with Unbound upstream" + success "Pi-hole configured with Unbound" } -# 🐳 NetAlertX installation +# 🐳 NetAlertX install_netalertx() { step "Installing NetAlertX" - - # Start Docker systemctl enable docker systemctl start docker - - # Create data directories mkdir -p /opt/netalertx/{config,db} - - # Stop existing container - docker stop netalertx 2>/dev/null || true - docker rm netalertx 2>/dev/null || true - - # Run NetAlertX - log "Starting NetAlertX container..." - # Restart Pi-hole DNS - pihole restartdns - - log_success "Pi-hole configured to use Unbound" -} - -# Install NetAlertX -install_netalertx() { - log_info "Installing NetAlertX..." - - # Start Docker service - systemctl enable docker - systemctl start docker - - # Create NetAlertX data directory - mkdir -p /opt/netalertx/{config,db} - chown -R "$INSTALL_USER:$INSTALL_USER" /opt/netalertx - - # Stop any existing NetAlertX container - docker stop netalertx 2>/dev/null || true - docker rm netalertx 2>/dev/null || true - - # Run NetAlertX container - docker run -d \ - --name netalertx \ - --restart unless-stopped \ + docker rm -f netalertx 2>/dev/null || true + docker run -d --name netalertx --restart unless-stopped \ -p $NETALERTX_PORT:20211 \ -v /opt/netalertx/config:/app/config \ -v /opt/netalertx/db:/app/db \ - -e TZ=$(timedatectl show --property=Timezone --value 2>/dev/null || echo "UTC") \ $NETALERTX_IMAGE - - success "NetAlertX installed on port $NETALERTX_PORT" + success "NetAlertX running on :$NETALERTX_PORT" } -# 🐍 Python suite setup +# 🐍 Python Suite setup_python_suite() { - step "Setting up Python monitoring suite" - - local project_dir=$(pwd) - local install_user=${SUDO_USER:-$(logname 2>/dev/null || echo "root")} - - # Create virtual environment - if [[ ! -d .venv ]]; then - log "Creating Python virtual environment..." - python3 -m venv .venv - chown -R $install_user:$install_user .venv 2>/dev/null || true - fi - - # Install dependencies - log "Installing Python dependencies..." - .venv/bin/pip install -U pip - .venv/bin/pip install -r requirements.txt - - # Generate API key - local api_key=$(openssl rand -hex 16) - - # Create environment file - cat > .env << EOF -# Pi-hole Suite Configuration -SUITE_API_KEY=$api_key -SUITE_DATA_DIR=$project_dir/data -SUITE_LOG_LEVEL=INFO -EOF - - # Create data directory - mkdir -p data - chown -R $install_user:$install_user . 2>/dev/null || true - - # Create systemd service - log "Creating systemd service..." - cat > /etc/systemd/system/pihole-suite.service << EOF -[Unit] -Description=Pi-hole Suite (API + monitoring) - -e TZ=$(timedatectl show --property=Timezone --value) \ - jokobsk/netalertx:latest - - log_success "NetAlertX installed and running on port $NETALERTX_PORT" -} - -# Setup Python suite -setup_python_suite() { - log_info "Setting up Python monitoring suite..." - - # Ensure we're in the project directory + step "Setting up Python suite" cd "$PROJECT_DIR" - - # Create virtual environment - if [[ ! -d .venv ]]; then - sudo -u "$INSTALL_USER" python3 -m venv .venv - fi - - # Install Python dependencies + [[ -d .venv ]] || sudo -u "$INSTALL_USER" python3 -m venv .venv sudo -u "$INSTALL_USER" .venv/bin/pip install -U pip sudo -u "$INSTALL_USER" .venv/bin/pip install -r requirements.txt - - # Initialize database - sudo -u "$INSTALL_USER" .venv/bin/python scripts/bootstrap.py 2>/dev/null || true - - # Create systemd service - cat > /etc/systemd/system/pihole-suite.service << EOF + sudo -u "$INSTALL_USER" .venv/bin/python scripts/bootstrap.py || true + + cat > /etc/systemd/system/pihole-suite.service < .env systemctl daemon-reload - systemctl enable pihole-suite.service - systemctl start pihole-suite.service - - success "Python suite installed and started" - log "API Key: $api_key" + systemctl enable --now pihole-suite.service + success "Python suite running on :$PYTHON_SUITE_PORT" } # 🩺 Health checks run_health_checks() { step "Running health checks" - - local failed=0 - - # Test Unbound - echo -n "Testing Unbound... " - if dig +short @127.0.0.1 -p $UNBOUND_PORT example.com | grep -q "."; then - echo -e "${GREEN}βœ“${NC}" - else - echo -e "${RED}βœ—${NC}" - ((failed++)) - fi - - # Test Pi-hole - echo -n "Testing Pi-hole... " - if systemctl is-active --quiet pihole-FTL; then - echo -e "${GREEN}βœ“${NC}" - else - echo -e "${RED}βœ—${NC}" - ((failed++)) - fi - - # Test NetAlertX - echo -n "Testing NetAlertX... " - if docker ps | grep -q netalertx; then - echo -e "${GREEN}βœ“${NC}" - else - echo -e "${RED}βœ—${NC}" - ((failed++)) - fi - - # Test Python suite - echo -n "Testing Python suite... " - sleep 3 # Give service time to start - if systemctl is-active --quiet pihole-suite; then - echo -e "${GREEN}βœ“${NC}" - else - echo -e "${RED}βœ—${NC}" - ((failed++)) - fi - - if [[ $failed -eq 0 ]]; then - success "All health checks passed!" - else - warn "$failed health check(s) failed - manual investigation needed" - fi -} - -# πŸ“Š Installation summary -show_summary() { - echo - echo -e "${CYAN}╔══════════════════════════════════════════════════════════════╗${NC}" - echo -e "${CYAN}β•‘${NC} ${GREEN}Installation Complete!${NC} ${CYAN}β•‘${NC}" - echo -e "${CYAN}β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•${NC}" - echo - echo -e "πŸ”§ ${BLUE}Services installed:${NC}" - echo -e " β€’ ${GREEN}Unbound${NC} DNS resolver: 127.0.0.1:$UNBOUND_PORT" - echo -e " β€’ ${GREEN}Pi-hole${NC} web interface: http://$(hostname -I | awk '{print $1}')/admin" - echo -e " β€’ ${GREEN}NetAlertX${NC} dashboard: http://$(hostname -I | awk '{print $1}'):$NETALERTX_PORT" - echo -e " β€’ ${GREEN}Python Suite${NC} API: http://127.0.0.1:$PYTHON_SUITE_PORT" - echo - echo -e "πŸ”‘ ${BLUE}Configuration:${NC}" - echo -e " β€’ API Key: $(grep SUITE_API_KEY .env 2>/dev/null | cut -d= -f2 || echo 'Check .env file')" - echo -e " β€’ Config file: $(pwd)/.env" - echo - echo -e "πŸ› οΈ ${BLUE}Service management:${NC}" - echo -e " β€’ systemctl status pihole-suite" - echo -e " β€’ journalctl -u pihole-suite -f" - echo -e " β€’ docker logs netalertx" - echo - echo -e "πŸ“ ${BLUE}Next steps:${NC}" - echo -e " 1. Configure devices to use $(hostname -I | awk '{print $1}') as DNS" - echo -e " 2. Access Pi-hole admin to review settings" - echo -e " 3. Check NetAlertX for network monitoring" - echo -e " 4. Test API: curl -H \"X-API-Key: \$SUITE_API_KEY\" http://127.0.0.1:$PYTHON_SUITE_PORT/health" - echo -} - -# πŸš€ Main installation -main() { - echo -e "${CYAN}" - echo "╔═══════════════════════════════════════════════════════════════════════════════╗" - echo "β•‘ Pi-hole + Unbound + NetAlertX β•‘" - echo "β•‘ One-Click Installer β•‘" - echo "β•‘ β•‘" - echo "β•‘ πŸ›‘οΈ DNS Security β€’ πŸ“Š Network Monitoring β€’ πŸ”§ Python Suite β•‘" - echo "β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•" - echo -e "${NC}" - - check_system - check_ports - log_success "Python suite installed and running" + dig +short @127.0.0.1 -p $UNBOUND_PORT example.com | grep -q "." && success "Unbound OK" || error "Unbound FAIL" + pihole status | grep -q "blocking is enabled" && success "Pi-hole OK" || warn "Pi-hole status unclear" + docker ps | grep -q netalertx && success "NetAlertX OK" || warn "NetAlertX missing" + systemctl is-active --quiet pihole-suite && success "Python suite OK" || warn "Python suite not active" } -# Health checks -run_health_checks() { - log_info "Running health checks..." - - local all_healthy=true - - # Test Unbound - log_info "Testing Unbound..." - if dig +short @127.0.0.1 -p $UNBOUND_PORT example.com | grep -q "."; then - log_success "βœ“ Unbound is responding" - else - log_error "βœ— Unbound is not responding" - all_healthy=false - fi - - # Test Pi-hole - log_info "Testing Pi-hole..." - if pihole status | grep -q "Pi-hole blocking is enabled"; then - log_success "βœ“ Pi-hole is running" - else - log_warning "⚠ Pi-hole status unclear" - fi - - # Test NetAlertX - log_info "Testing NetAlertX..." - if curl -s -m 5 "http://127.0.0.1:$NETALERTX_PORT" >/dev/null 2>&1; then - log_success "βœ“ NetAlertX is responding" - else - log_warning "⚠ NetAlertX not responding (may still be starting)" - fi - - # Test Python suite - log_info "Testing Python suite..." - sleep 5 # Give the service time to start - if curl -s -H "X-API-Key: $SUITE_API_KEY" "http://127.0.0.1:$PYTHON_SUITE_PORT/health" | grep -q '"ok":true'; then - log_success "βœ“ Python suite API is responding" - else - log_warning "⚠ Python suite not responding (may still be starting)" - fi - - if [[ "$all_healthy" == true ]]; then - log_success "All components are healthy!" - else - log_warning "Some components may need manual attention" - fi -} - -# Display summary +# πŸ“Š Summary show_summary() { echo - echo " Installation Complete!" - echo - echo "πŸ”§ Components installed:" - echo " β€’ Unbound DNS resolver: 127.0.0.1:$UNBOUND_PORT" - echo " β€’ Pi-hole web interface: http://$(hostname -I | awk '{print $1}')/admin" - echo " β€’ NetAlertX dashboard: http://$(hostname -I | awk '{print $1}'):$NETALERTX_PORT" - echo " β€’ Python suite API: http://127.0.0.1:$PYTHON_SUITE_PORT" - echo - echo "πŸ”‘ API Configuration:" - echo " β€’ API Key: $SUITE_API_KEY" - echo " β€’ Test command: curl -H \"X-API-Key: $SUITE_API_KEY\" http://127.0.0.1:$PYTHON_SUITE_PORT/health" - echo - echo "πŸ“ Important paths:" - echo " β€’ Project directory: $PROJECT_DIR" - echo " β€’ Data directory: $PROJECT_DIR/data" - echo " β€’ Unbound config: /etc/unbound/unbound.conf.d/pi-hole.conf" - echo " β€’ Pi-hole config: /etc/pihole/" - echo " β€’ NetAlertX data: /opt/netalertx/" - echo - echo "πŸ› οΈ Service management:" - echo " β€’ systemctl status pihole-suite" - echo " β€’ systemctl status unbound" - echo " β€’ docker logs netalertx" - echo - echo "πŸ“ Next steps:" - echo " 1. Access Pi-hole admin at http://$(hostname -I | awk '{print $1}')/admin" - echo " 2. Verify DNS settings point to 127.0.0.1#$UNBOUND_PORT" - echo " 3. Configure your devices to use this Pi-hole as DNS server" - echo " 4. Check NetAlertX for network device monitoring" - echo - echo "⚠️ Save this API key: $SUITE_API_KEY" - echo + success "Installation Complete!" + echo "Pi-hole admin: http://$(hostname -I | awk '{print $1}')/admin" + echo "NetAlertX: http://$(hostname -I | awk '{print $1}'):$NETALERTX_PORT" + echo "Python Suite: http://127.0.0.1:$PYTHON_SUITE_PORT" + echo "API Key: $SUITE_API_KEY" } -# Main installation function +# πŸš€ Main main() { - echo "Pi-hole + Unbound + NetAlertX + Python Suite Installer" - echo - - check_privileges check_system check_ports - - log_info "Starting installation..." - install_packages configure_unbound install_pihole @@ -802,20 +195,5 @@ main() { setup_python_suite run_health_checks show_summary - - success "Installation completed successfully! πŸŽ‰" } - -# Execute main function - - log_info "Running health checks..." - run_health_checks - - show_summary - - log_success "Installation completed successfully!" - echo "You can now configure your devices to use $(hostname -I | awk '{print $1}') as their DNS server." -} - -# Run main function -main "$@" \ No newline at end of file +main "$@" From faa6103eab2e0ff6873fce71bf5b6b4781574c08 Mon Sep 17 00:00:00 2001 From: TimInTech Date: Fri, 3 Oct 2025 03:00:52 +0200 Subject: [PATCH 2/2] Entferne Konfliktmarker und doppelte Implementierungen --- README.de.md | 7 -- README.md | 254 +++++++++++++++++++++++++++++++++++++++- api/__init__.py | 1 - api/main.py | 114 +++++------------- api/schemas.py | 62 +++------- install.sh | 9 +- pyhole/dns_monitor.py | 87 -------------- scripts/bootstrap.py | 24 ---- scripts/healthcheck.py | 19 --- shared/db.py | 38 ------ shared/shared_config.py | 12 +- start_suite.py | 63 ++-------- tests/test_api.py | 175 ++++++--------------------- 13 files changed, 349 insertions(+), 516 deletions(-) diff --git a/README.de.md b/README.de.md index 02f7c16..a550bf4 100644 --- a/README.de.md +++ b/README.de.md @@ -250,10 +250,3 @@ Siehe [CHANGELOG.md](CHANGELOG.md) fΓΌr Versionshistorie und Updates. [πŸ’¬ Diskussionen](https://github.com/TimInTech/Pi-hole-Unbound-PiAlert-Setup/discussions) -# Pi-hole + Unbound + NetAlertX β€” Ein-Klick-Setup - - - -- **Issues**: [GitHub Issues](https://github.com/TimInTech/Pi-hole-Unbound-PiAlert-Setup/issues) -- **Diskussionen**: [GitHub Discussions](https://github.com/TimInTech/Pi-hole-Unbound-PiAlert-Setup/discussions) -- **Dokumentation**: Diese README und Inline-Code-Kommentare diff --git a/README.md b/README.md index 2d3967a..d5a84b1 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,251 @@ ->>>>>> main +
-- **Issues**: [GitHub Issues](https://github.com/TimInTech/Pi-hole-Unbound-PiAlert-Setup/issues) -- **Discussions**: [GitHub Discussions](https://github.com/TimInTech/Pi-hole-Unbound-PiAlert-Setup/discussions) -- **Documentation**: This README and inline code comments \ No newline at end of file +# πŸ›‘οΈ Pi-hole + Unbound + NetAlertX +### **One-Click DNS Security & Monitoring Stack** + +[![Build Status](https://img.shields.io/github/actions/workflow/status/TimInTech/Pi-hole-Unbound-PiAlert-Setup/ci.yml?branch=main&style=for-the-badge&logo=github)](https://github.com/TimInTech/Pi-hole-Unbound-PiAlert-Setup/actions) +[![License](https://img.shields.io/github/license/TimInTech/Pi-hole-Unbound-PiAlert-Setup?style=for-the-badge&color=blue)](LICENSE) +[![Pi-hole](https://img.shields.io/badge/Pi--hole-v6.x-red?style=for-the-badge&logo=pihole)](https://pi-hole.net/) +[![Unbound](https://img.shields.io/badge/Unbound-DNS-orange?style=for-the-badge)](https://nlnetlabs.nl/projects/unbound/) +[![NetAlertX](https://img.shields.io/badge/NetAlertX-Monitor-green?style=for-the-badge)](https://github.com/jokob-sk/NetAlertX) +[![Debian](https://img.shields.io/badge/Debian-Compatible-red?style=for-the-badge&logo=debian)](https://debian.org/) +[![Python](https://img.shields.io/badge/Python-3.12+-blue?style=for-the-badge&logo=python)](https://python.org/) + +**🧰 Tech Stack** +Tech Stack + +**🌐 Languages:** πŸ‡¬πŸ‡§ English (this file) β€’ [πŸ‡©πŸ‡ͺ Deutsch](README.de.md) + +
+ +--- + +## ✨ Features + +βœ… **One-Click Installation** - Single command setup +βœ… **DNS Security** - Pi-hole + Unbound with DNSSEC +βœ… **Network Monitoring** - NetAlertX device tracking +βœ… **API Monitoring** - Python FastAPI + SQLite +βœ… **Production Ready** - Systemd hardening & auto-restart +βœ… **Idempotent** - Safe to re-run anytime + +--- + +## ⚑ Quickstart + +```bash +git clone https://github.com/TimInTech/Pi-hole-Unbound-PiAlert-Setup.git +cd Pi-hole-Unbound-PiAlert-Setup +chmod +x install.sh +sudo ./install.sh +```` + +**Done!** πŸŽ‰ Your complete DNS security stack is now running. + +--- + +## 🧰 What’s Installed + +| Component | Purpose | Access | +| ----------------- | ------------------------- | ------------------------ | +| **πŸ•³οΈ Pi-hole** | DNS ad-blocker & web UI | `http://[your-ip]/admin` | +| **πŸ” Unbound** | Recursive DNS + DNSSEC | `127.0.0.1:5335` | +| **πŸ“‘ NetAlertX** | Network device monitoring | `http://[your-ip]:20211` | +| **🐍 Python API** | Monitoring & stats API | `http://127.0.0.1:8090` | + +--- + +## πŸ—ΊοΈ Architecture + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Clients │───▢│ Pi-hole │───▢│ Unbound β”‚ +β”‚ 192.168.x.x β”‚ β”‚ :53 β”‚ β”‚ :5335 β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ β”‚ + β–Ό β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ NetAlertX β”‚ β”‚ Root Serversβ”‚ + β”‚ :20211 β”‚ β”‚ + Quad9 β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Python API β”‚ + β”‚ :8090 β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +**Data Flow:** + +1. **Clients** β†’ Pi-hole (DNS filtering) +2. **Pi-hole** β†’ Unbound (recursive resolution) +3. **Unbound** β†’ Root servers (DNSSEC validation) +4. **NetAlertX** β†’ Network monitoring +5. **Python API** β†’ Aggregated monitoring data + +--- + +## πŸ”Œ API Reference + +### Authentication + +All endpoints require the `X-API-Key` header: + +```bash +curl -H "X-API-Key: your-api-key" http://127.0.0.1:8090/endpoint +``` + +### Endpoints + +#### `GET /health` + +```json +{ + "ok": true, + "message": "Pi-hole Suite API is running", + "version": "1.0.0" +} +``` + +#### `GET /dns?limit=50` + +```json +[ + { + "timestamp": "Dec 21 10:30:45", + "client": "192.168.1.100", + "query": "example.com", + "action": "query" + } +] +``` + +#### `GET /devices` + +```json +[ + { + "id": 1, + "ip": "192.168.1.100", + "mac": "aa:bb:cc:dd:ee:ff", + "hostname": "laptop", + "last_seen": "2024-12-21 10:30:00" + } +] +``` + +#### `GET /stats` + +```json +{ + "total_dns_logs": 1250, + "total_devices": 15, + "recent_queries": 89 +} +``` + +--- + +## πŸ› οΈ Optional Manual Steps + +### Pi-hole + +1. Open `http://[your-ip]/admin` +2. Go to **Settings β†’ DNS** +3. Verify **Custom upstream**: `127.0.0.1#5335` +4. Configure devices to use Pi-hole as DNS server + +### NetAlertX + +* Dashboard: `http://[your-ip]:20211` +* Configure scan schedules and notifications +* Review network topology and device list + +--- + +## πŸ§ͺ Health Checks & Troubleshooting + +### Quick Checks + +```bash +dig @127.0.0.1 -p 5335 example.com # Test Unbound +pihole status # Test Pi-hole +docker logs netalertx # Test NetAlertX +curl -H "X-API-Key: $SUITE_API_KEY" http://127.0.0.1:8090/health # Test API +``` + +### Service Management + +```bash +systemctl status pihole-suite unbound pihole-FTL +journalctl -u pihole-suite -f +journalctl -u unbound -f +docker ps +``` + +### Common Issues + +| Issue | Solution | +| ----------------------- | -------------------------------------------------- | +| **Port 53 in use** | `sudo systemctl stop systemd-resolved` | +| **Missing API key** | Check `.env` file or regenerate with installer | +| **Database errors** | Run `python scripts/bootstrap.py` | +| **Unbound won’t start** | Inspect `/etc/unbound/unbound.conf.d/pi-hole.conf` | + +--- + +## 🧯 Security Notes + +### πŸ” API Security + +* Auto-generated API keys (16-byte hex) +* CORS restricted to localhost +* Authentication required for all endpoints + +### πŸ›‘οΈ Systemd Hardening + +* **NoNewPrivileges** prevents escalation +* **ProtectSystem=strict** read-only protection +* **PrivateTmp** isolated temp dirs +* **Memory limits** to prevent exhaustion + +### πŸ”’ Network Security + +* Unbound bound to localhost only +* DNS over TLS to upstream resolvers +* DNSSEC validation enabled + +--- + +## 🀝 Contributing + +1. **Fork** the repository +2. **Create** a feature branch: `git checkout -b feature/amazing-feature` +3. **Commit** changes: `git commit -m 'feat: add amazing feature'` +4. **Run tests**: `ruff check . && pytest` +5. **Push** and create a Pull Request + +--- + +## πŸ“œ License + +This project is licensed under the **MIT License** - see [LICENSE](LICENSE). + +--- + +## πŸ“ˆ Changelog + +See [CHANGELOG.md](CHANGELOG.md) for history and updates. + +--- + +
+ +**Made with ❀️ for the Pi-hole community** + +[πŸ› Report Bug](https://github.com/TimInTech/Pi-hole-Unbound-PiAlert-Setup/issues) β€’ +[✨ Request Feature](https://github.com/TimInTech/Pi-hole-Unbound-PiAlert-Setup/issues) β€’ +[πŸ’¬ Discussions](https://github.com/TimInTech/Pi-hole-Unbound-PiAlert-Setup/discussions) + +
diff --git a/api/__init__.py b/api/__init__.py index 57a924d..8ba89ed 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -1,2 +1 @@ -"""FastAPI API module.""" """API package for the Pi-hole suite.""" diff --git a/api/main.py b/api/main.py index 675f74d..9da9ba5 100644 --- a/api/main.py +++ b/api/main.py @@ -12,10 +12,10 @@ from shared.db import init_db from .schemas import ( DeviceResponse, - DNSLogResponse, + DNSLogResponse, HealthResponse, LeaseResponse, - StatsResponse + StatsResponse, ) @@ -26,17 +26,15 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: yield -# FastAPI app with modern configuration app = FastAPI( title="Pi-hole Suite API", description="Monitoring and management API for Pi-hole + Unbound + NetAlertX stack", version="1.0.0", docs_url="/docs", redoc_url="/redoc", - lifespan=lifespan + lifespan=lifespan, ) -# Add CORS middleware app.add_middleware( CORSMiddleware, allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"], @@ -47,19 +45,12 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: def get_api_key() -> str: - """Get configured API key.""" -"""FastAPI app exposing Pi-hole suite data.""" -import os -import sqlite3 - - - -def _get_api_key() -> str: + """Get configured API key from environment.""" return os.getenv("SUITE_API_KEY", "") def get_db() -> Generator[sqlite3.Connection, None, None]: - """Database dependency.""" + """FastAPI dependency to provide a DB connection.""" conn = sqlite3.connect(config.DB_PATH) conn.row_factory = sqlite3.Row try: @@ -72,72 +63,56 @@ def require_api_key(x_api_key: Optional[str] = Header(None)) -> None: """Require valid API key for authentication.""" api_key = get_api_key() if not api_key: - raise HTTPException( - status_code=500, - detail="API key not configured on server" - ) + raise HTTPException(status_code=500, detail="API key not configured on server") if not x_api_key: - raise HTTPException( - status_code=401, - detail="Missing API key header" - ) + raise HTTPException(status_code=401, detail="Missing API key header") if x_api_key != api_key: - raise HTTPException( - status_code=401, - detail="Invalid API key" - ) + raise HTTPException(status_code=401, detail="Invalid API key") @app.get("/", include_in_schema=False) async def root(): - """Root endpoint redirect.""" + """Root endpoint information.""" return {"message": "Pi-hole Suite API", "docs": "/docs"} @app.get( - "/health", - dependencies=[Depends(require_api_key)], + "/health", + dependencies=[Depends(require_api_key)], response_model=HealthResponse, summary="Health Check", - description="Simple health check endpoint to verify API is running" + description="Simple health check endpoint to verify API is running", ) def health() -> HealthResponse: - """Health check endpoint.""" - return HealthResponse( - ok=True, - message="Pi-hole Suite API is running", - version="1.0.0" - ) + return HealthResponse(ok=True, message="Pi-hole Suite API is running", version="1.0.0") @app.get( - "/dns", - dependencies=[Depends(require_api_key)], + "/dns", + dependencies=[Depends(require_api_key)], response_model=List[DNSLogResponse], summary="DNS Query Logs", - description="Get recent DNS query logs from Pi-hole" + description="Get recent DNS query logs from Pi-hole", ) def get_dns_logs( limit: int = Query(50, ge=1, le=1000, description="Maximum number of logs to return"), - db: sqlite3.Connection = Depends(get_db) + db: sqlite3.Connection = Depends(get_db), ) -> List[DNSLogResponse]: - """Get recent DNS query logs.""" cursor = db.execute( "SELECT timestamp, client, query, action FROM dns_logs ORDER BY id DESC LIMIT ?", - (limit,) + (limit,), ) return [DNSLogResponse(**dict(row)) for row in cursor.fetchall()] @app.get( - "/leases", + "/leases", dependencies=[Depends(require_api_key)], response_model=List[LeaseResponse], - summary="DHCP Leases", - description="Get current DHCP lease information" + summary="DHCP Leases", + description="Get current DHCP lease information", ) def get_ip_leases(db: sqlite3.Connection = Depends(get_db)) -> List[LeaseResponse]: - """Get IP lease information.""" cursor = db.execute( "SELECT ip, mac, hostname, lease_start, lease_end FROM ip_leases ORDER BY lease_start DESC" ) @@ -145,14 +120,13 @@ def get_ip_leases(db: sqlite3.Connection = Depends(get_db)) -> List[LeaseRespons @app.get( - "/devices", - dependencies=[Depends(require_api_key)], + "/devices", + dependencies=[Depends(require_api_key)], response_model=List[DeviceResponse], summary="Network Devices", - description="Get list of known network devices" + description="Get list of known network devices", ) def get_devices(db: sqlite3.Connection = Depends(get_db)) -> List[DeviceResponse]: - """Get network devices list.""" cursor = db.execute( "SELECT id, ip, mac, hostname, last_seen FROM devices ORDER BY last_seen DESC" ) @@ -164,42 +138,14 @@ def get_devices(db: sqlite3.Connection = Depends(get_db)) -> List[DeviceResponse dependencies=[Depends(require_api_key)], response_model=StatsResponse, summary="System Statistics", - description="Get basic statistics about the monitored system" + description="Get basic statistics about the monitored system", ) def get_stats(db: sqlite3.Connection = Depends(get_db)) -> StatsResponse: - """Get system statistics.""" - # Get DNS log count - dns_cursor = db.execute("SELECT COUNT(*) as count FROM dns_logs") - dns_count = dns_cursor.fetchone()["count"] - - # Get device count - device_cursor = db.execute("SELECT COUNT(*) as count FROM devices") - device_count = device_cursor.fetchone()["count"] - - # Get recent queries (last hour) - recent_cursor = db.execute( + dns_count = db.execute("SELECT COUNT(*) as count FROM dns_logs").fetchone()["count"] + device_count = db.execute("SELECT COUNT(*) as count FROM devices").fetchone()["count"] + recent_queries = db.execute( "SELECT COUNT(*) as count FROM dns_logs WHERE timestamp > datetime('now', '-1 hour')" - ) - recent_queries = recent_cursor.fetchone()["count"] - - return StatsResponse( - total_dns_logs=dns_count, - total_devices=device_count, - recent_queries=recent_queries - ) - - - cur = db.execute( - "SELECT timestamp, client, query, action FROM dns_logs ORDER BY id DESC LIMIT ?", - (limit,), - ) - - - -@app.get("/leases", dependencies=[Depends(require_key)]) -def get_ip_leases(db=Depends(get_db)): - cur = db.execute("SELECT ip, mac, hostname, lease_start, lease_end FROM ip_leases") - return [dict(row) for row in cur.fetchall()] - + ).fetchone()["count"] + return StatsResponse(total_dns_logs=dns_count, total_devices=device_count, recent_queries=recent_queries) diff --git a/api/schemas.py b/api/schemas.py index 08e10f7..cea1886 100644 --- a/api/schemas.py +++ b/api/schemas.py @@ -1,19 +1,18 @@ -"""Pydantic schemas for API responses.""" +"""Pydantic models for API request/response validation.""" +import ipaddress from typing import Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator class HealthResponse(BaseModel): - """Health check response.""" ok: bool = Field(..., description="Whether the service is healthy") message: Optional[str] = Field(None, description="Optional status message") version: Optional[str] = Field(None, description="API version") class DNSLogResponse(BaseModel): - """DNS log entry response.""" timestamp: str = Field(..., description="Query timestamp") client: str = Field(..., description="Client IP address") query: str = Field(..., description="DNS query domain") @@ -21,7 +20,6 @@ class DNSLogResponse(BaseModel): class LeaseResponse(BaseModel): - """DHCP lease response.""" ip: str = Field(..., description="IP address") mac: str = Field(..., description="MAC address") hostname: Optional[str] = Field(None, description="Device hostname") @@ -30,7 +28,6 @@ class LeaseResponse(BaseModel): class DeviceResponse(BaseModel): - """Network device response.""" id: int = Field(..., description="Device ID") ip: str = Field(..., description="IP address") mac: str = Field(..., description="MAC address") @@ -39,62 +36,33 @@ class DeviceResponse(BaseModel): class StatsResponse(BaseModel): - """System statistics response.""" total_dns_logs: int = Field(..., description="Total DNS log entries") total_devices: int = Field(..., description="Total known devices") recent_queries: int = Field(..., description="Queries in the last hour") -"""Pydantic models for API request/response validation.""" -from typing import Optional -from pydantic import BaseModel, Field, field_validator -import ipaddress class DeviceRequest(BaseModel): - """Request model for device operations.""" + """Request model for device operations with validation.""" ip_address: str = Field(..., description="IP address of the device") status: bool = Field(..., description="Device status (active/inactive)") hostname: Optional[str] = Field(None, description="Device hostname") mac_address: Optional[str] = Field(None, description="MAC address") - - @field_validator('ip_address') + + @field_validator("ip_address") @classmethod - def validate_ip_address(cls, v): - """Validate IP address format.""" + def validate_ip_address(cls, v: str) -> str: try: ipaddress.ip_address(v) - except ValueError: - raise ValueError('Invalid IP address format') + except ValueError as exc: # noqa: TRY003 - re-raise as ValueError for pydantic + raise ValueError("Invalid IP address format") from exc return v - - @field_validator('mac_address') + + @field_validator("mac_address") @classmethod - def validate_mac_address(cls, v): - """Validate MAC address format if provided.""" + def validate_mac_address(cls, v: Optional[str]) -> Optional[str]: if v is None: return v - # Simple MAC address validation (XX:XX:XX:XX:XX:XX format) - if not (len(v) == 17 and v.count(':') == 5): - raise ValueError('Invalid MAC address format') + # Simple MAC address validation (XX:XX:XX:XX:XX:XX) + if not (len(v) == 17 and v.count(":") == 5): + raise ValueError("Invalid MAC address format") return v.lower() - - -class DeviceResponse(BaseModel): - """Response model for device data.""" - id: Optional[int] = None - ip: str - mac: Optional[str] = None - hostname: Optional[str] = None - last_seen: Optional[str] = None - - -class DNSLogResponse(BaseModel): - """Response model for DNS log entries.""" - timestamp: str - client: str - query: str - action: str - - -class HealthResponse(BaseModel): - """Response model for health check.""" - ok: bool diff --git a/install.sh b/install.sh index 05d2dbf..7efdb6e 100755 --- a/install.sh +++ b/install.sh @@ -13,7 +13,7 @@ readonly NETALERTX_IMAGE="techxartisan/netalertx:latest" readonly SUITE_API_KEY=${SUITE_API_KEY:-$(openssl rand -hex 16)} readonly INSTALL_USER=${SUDO_USER:-$(whoami)} readonly INSTALL_HOME=$(getent passwd "$INSTALL_USER" | cut -d: -f6) -readonly PROJECT_DIR="$INSTALL_HOME/Pi-hole-Unbound-PiAlert-Setup" +readonly PROJECT_DIR="$(pwd)" # 🎨 Colors RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m' @@ -159,7 +159,12 @@ PrivateTmp=yes WantedBy=multi-user.target EOF - echo "SUITE_API_KEY=$SUITE_API_KEY" > .env + cat > .env < None: def is_running() -> bool: """Check if the monitor is currently running.""" return _monitor_thread is not None and _monitor_thread.is_alive() -logger = logging.getLogger(__name__) - -PIHOLE_LOG = Path("/var/log/pihole.log") -_stop_event = threading.Event() - - -def parse_line(line: str) -> Optional[Tuple[str, str, str, str]]: - """Parse a Pi-hole log line into components.""" - parts = line.strip().split() - if len(parts) < 5: - return None - timestamp = " ".join(parts[:2]) - action = parts[2] - client = parts[3].rstrip(":") - query = parts[4] - return timestamp, client, query, action - - -def monitor(conn, log_path: Optional[Path] = None) -> None: - """Monitor Pi-hole log file with log rotation support.""" - log_path = log_path or PIHOLE_LOG - logger.info("Starting DNS monitor on %s", log_path) - - last_pos = 0 - last_inode = None - - while not _stop_event.is_set(): - try: - if log_path.exists(): - # Check if log file was rotated (inode changed) - current_stat = log_path.stat() - current_inode = current_stat.st_ino - - if last_inode is not None and current_inode != last_inode: - logger.info("Log rotation detected, resetting position") - last_pos = 0 - - last_inode = current_inode - - # Check if file was truncated - if current_stat.st_size < last_pos: - logger.info("Log file truncated, resetting position") - last_pos = 0 - - with log_path.open() as handle: - handle.seek(last_pos) - lines_processed = 0 - - for line in handle: - parsed = parse_line(line) - if parsed: - try: - conn.execute( - "INSERT INTO dns_logs(timestamp, client, query, action) VALUES(?,?,?,?)", - parsed, - ) - lines_processed += 1 - except Exception as e: - logger.warning("Failed to insert DNS log entry: %s", e) - - if lines_processed > 0: - conn.commit() - logger.debug("Processed %d DNS log entries", lines_processed) - - last_pos = handle.tell() - else: - logger.warning("Pi-hole log file not found: %s", log_path) - - except Exception as e: - logger.error("Error monitoring DNS log: %s", e) - - time.sleep(5) - - -def start(conn): - """Start the DNS monitor in a background thread.""" - _stop_event.clear() - thread = threading.Thread(target=monitor, args=(conn,), daemon=True) - thread.start() - return thread - - -def stop() -> None: - """Stop the DNS monitor.""" - _stop_event.set() diff --git a/scripts/bootstrap.py b/scripts/bootstrap.py index ba885db..91fea3a 100755 --- a/scripts/bootstrap.py +++ b/scripts/bootstrap.py @@ -58,27 +58,3 @@ def main(): if __name__ == "__main__": sys.exit(main()) -"""Check suite dependencies.""" -import importlib.util -import sys - -REQUIRED = [ - "fastapi", - "uvicorn", - "pydantic", - "sqlite3", - "ipaddress", -] - - -def main() -> None: - missing = [mod for mod in REQUIRED if importlib.util.find_spec(mod) is None] - if missing: - print("Missing: " + ", ".join(missing)) - print("Run: pip install -r requirements.txt") - sys.exit(1) - print("Dependencies OK") - - -if __name__ == "__main__": - main() diff --git a/scripts/healthcheck.py b/scripts/healthcheck.py index 2cd7cdd..73f1a86 100755 --- a/scripts/healthcheck.py +++ b/scripts/healthcheck.py @@ -100,22 +100,3 @@ def main(): if __name__ == "__main__": sys.exit(main()) -"""Verify database connectivity.""" -import sqlite3 -import sys -from pathlib import Path - - - -def main() -> None: - try: - with sqlite3.connect(config.DB_PATH) as conn: - conn.execute("SELECT 1") - print("Database healthy") - except Exception as exc: # noqa: BLE001 - we want to show any failure - print(f"Failed: {exc}") - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/shared/db.py b/shared/db.py index 3d48fdc..296d5d4 100644 --- a/shared/db.py +++ b/shared/db.py @@ -60,39 +60,6 @@ ); CREATE INDEX IF NOT EXISTS idx_stats_timestamp ON system_stats(timestamp); CREATE INDEX IF NOT EXISTS idx_stats_metric ON system_stats(metric_name); -"""SQLite helpers for the Pi-hole suite.""" -import sqlite3 -from pathlib import Path - -from .shared_config import DB_PATH - -SCHEMA = """ -CREATE TABLE IF NOT EXISTS dns_logs( - id INTEGER PRIMARY KEY AUTOINCREMENT, - timestamp TEXT, - client TEXT, - query TEXT, - action TEXT -); -CREATE INDEX IF NOT EXISTS idx_dns_timestamp ON dns_logs(timestamp); - -CREATE TABLE IF NOT EXISTS ip_leases( - id INTEGER PRIMARY KEY AUTOINCREMENT, - ip TEXT, - mac TEXT, - hostname TEXT, - lease_start TEXT, - lease_end TEXT -); - -CREATE TABLE IF NOT EXISTS devices( - id INTEGER PRIMARY KEY AUTOINCREMENT, - ip TEXT, - mac TEXT, - hostname TEXT, - last_seen TEXT -); -CREATE INDEX IF NOT EXISTS idx_devices_ip ON devices(ip); """ @@ -120,8 +87,3 @@ def get_connection() -> sqlite3.Connection: conn = sqlite3.connect(str(DB_PATH)) conn.row_factory = sqlite3.Row return conn - Path(DB_PATH).parent.mkdir(parents=True, exist_ok=True) - conn = sqlite3.connect(DB_PATH, check_same_thread=False) - conn.executescript(SCHEMA) - conn.commit() - return conn diff --git a/shared/shared_config.py b/shared/shared_config.py index d1dfc97..83a9a1e 100644 --- a/shared/shared_config.py +++ b/shared/shared_config.py @@ -1,6 +1,6 @@ """Shared configuration for the Pi-hole suite.""" -"""Shared configuration helpers for the suite.""" +""" import logging import os from pathlib import Path @@ -29,13 +29,3 @@ # Module logger logger = logging.getLogger(__name__) logger.info(f"Configuration loaded - Data directory: {DATA_DIR}, Log level: {LOG_LEVEL}") -INTERFACE = os.getenv("SUITE_INTERFACE", "eth0") -DNS_PORT = int(os.getenv("SUITE_DNS_PORT", "5335")) -LOG_LEVEL = os.getenv("SUITE_LOG_LEVEL", "INFO") -DATA_DIR = Path(os.getenv("SUITE_DATA_DIR", "data")) - -DATA_DIR.mkdir(parents=True, exist_ok=True) - -DB_PATH = DATA_DIR / "shared.sqlite" - -logging.basicConfig(level=LOG_LEVEL, format="%(asctime)s [%(levelname)s] %(message)s") diff --git a/start_suite.py b/start_suite.py index 65e5135..b6d95ad 100644 --- a/start_suite.py +++ b/start_suite.py @@ -28,7 +28,6 @@ # Optional demo component - disabled by default ENABLE_PYALLOC_DEMO = os.getenv("ENABLE_PYALLOC_DEMO", "false").lower() == "true" - if ENABLE_PYALLOC_DEMO: try: from pyalloc.main import start as alloc_start @@ -42,33 +41,8 @@ async def run_api() -> None: """Run the FastAPI application.""" port = int(os.getenv("SUITE_PORT", "8090")) host = os.getenv("SUITE_HOST", "127.0.0.1") - - config = uvicorn.Config( - api_app, - host=host, - port=port, - log_level="info", - access_log=True - ) -import asyncio -import os -import threading - -import uvicorn - -from api.main import app as api_app -from pyhole.dns_monitor import start as dns_start -from shared.db import init_db -# Optional demo component - disabled by default for one-click installer -ENABLE_PYALLOC_DEMO = os.getenv("ENABLE_PYALLOC_DEMO", "false").lower() == "true" - -if ENABLE_PYALLOC_DEMO: - from pyalloc.main import start as alloc_start - - -async def run_api() -> None: - config = uvicorn.Config(api_app, host="127.0.0.1", port=8090, log_level="info") + config = uvicorn.Config(api_app, host=host, port=port, log_level="info", access_log=True) server = uvicorn.Server(config) await server.serve() @@ -76,14 +50,14 @@ async def run_api() -> None: def main() -> None: """Main application entry point.""" logger.info("Starting Pi-hole Suite...") - + # Verify API key api_key = os.environ.get("SUITE_API_KEY") if not api_key: logger.error("SUITE_API_KEY environment variable must be set") logger.info("Generate one with: openssl rand -hex 16") sys.exit(1) - + # Initialize database try: conn = init_db() @@ -91,20 +65,20 @@ def main() -> None: except Exception as e: logger.error(f"Failed to initialize database: {e}") sys.exit(1) - + # Start core DNS monitoring try: dns_thread = threading.Thread( - target=dns_start, - args=(conn,), + target=dns_start, + args=(conn,), daemon=True, - name="DNSMonitor" + name="DNSMonitor", ) dns_thread.start() logger.info("DNS monitor started") except Exception as e: logger.error(f"Failed to start DNS monitor: {e}") - + # Start optional demo allocator if enabled if ENABLE_PYALLOC_DEMO: try: @@ -112,28 +86,16 @@ def main() -> None: target=alloc_start, args=(conn,), daemon=True, - name="AllocDemo" + name="AllocDemo", ) alloc_thread.start() logger.info("PyAlloc demo component started") except Exception as e: logger.warning(f"Failed to start PyAlloc demo: {e}") - + logger.info(f"API Key: {api_key[:8]}...") logger.info("Starting API server...") - conn = init_db() - - # Start core DNS monitoring - threading.Thread(target=dns_start, args=(conn,), daemon=True).start() - - # Start optional demo allocator if enabled - if ENABLE_PYALLOC_DEMO: - threading.Thread(target=alloc_start, args=(conn,), daemon=True).start() - print("βœ“ Started with pyalloc demo component") - else: - print("βœ“ Started in production mode (pyalloc demo disabled)") - try: asyncio.run(run_api()) except KeyboardInterrupt: @@ -143,10 +105,5 @@ def main() -> None: sys.exit(1) -if __name__ == "__main__": - main() - print("Shutting down…") - - if __name__ == "__main__": main() diff --git a/tests/test_api.py b/tests/test_api.py index d210021..703fe57 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -7,207 +7,104 @@ from fastapi.testclient import TestClient # Set test environment before importing -os.environ['SUITE_API_KEY'] = 'test-api-key' -os.environ['SUITE_DATA_DIR'] = tempfile.mkdtemp() +os.environ["SUITE_API_KEY"] = "test-api-key" +os.environ["SUITE_DATA_DIR"] = tempfile.mkdtemp() from api.main import app from shared.db import init_db -@pytest.fixture(scope="module") +@pytest.fixture(scope="module") def setup_database(): - """Initialize test database once for all tests.""" init_db() yield - # Cleanup would happen here if needed @pytest.fixture def client(setup_database): - """Create test client with initialized database.""" return TestClient(app) @pytest.fixture def api_headers(): - """Return API headers with test key.""" return {"X-API-Key": "test-api-key"} +def test_root_endpoint(client): + resp = client.get("/") + assert resp.status_code == 200 + assert "message" in resp.json() + + def test_health_endpoint(client, api_headers): - """Test the health endpoint.""" - response = client.get("/health", headers=api_headers) - assert response.status_code == 200 - data = response.json() + resp = client.get("/health", headers=api_headers) + assert resp.status_code == 200 + data = resp.json() assert data["ok"] is True assert "message" in data -import pytest -from fastapi.testclient import TestClient -import os -import tempfile -import sqlite3 -from pathlib import Path - -from api.main import app - - -@pytest.fixture -def test_db(): - """Create a temporary test database.""" - with tempfile.NamedTemporaryFile(suffix='.db', delete=False) as tmp: - test_db_path = tmp.name - - # Set environment variable for test database - os.environ['SUITE_DATA_DIR'] = str(Path(test_db_path).parent) - os.environ['SUITE_API_KEY'] = 'test-api-key' - - # Initialize test database - conn = sqlite3.connect(test_db_path) - conn.executescript(""" - CREATE TABLE IF NOT EXISTS devices( - id INTEGER PRIMARY KEY AUTOINCREMENT, - ip TEXT, - mac TEXT, - hostname TEXT, - last_seen TEXT - ); - CREATE TABLE IF NOT EXISTS dns_logs( - id INTEGER PRIMARY KEY AUTOINCREMENT, - timestamp TEXT, - client TEXT, - query TEXT, - action TEXT - ); - CREATE TABLE IF NOT EXISTS ip_leases( - id INTEGER PRIMARY KEY AUTOINCREMENT, - ip TEXT, - mac TEXT, - hostname TEXT, - lease_start TEXT, - lease_end TEXT - ); - """) - conn.commit() - conn.close() - - yield test_db_path - - # Cleanup - Path(test_db_path).unlink(missing_ok=True) - - -@pytest.fixture -def client(test_db): - """Create test client with temporary database.""" - return TestClient(app) - - -def test_health_endpoint(client): - """Test the health endpoint.""" - response = client.get("/health", headers={"X-API-Key": "test-api-key"}) - assert response.status_code == 200 - assert response.json() == {"ok": True} + assert "version" in data def test_health_endpoint_no_auth(client): - """Test health endpoint without authentication.""" - response = client.get("/health") - assert response.status_code == 401 # API key validation returns 401 + resp = client.get("/health") + assert resp.status_code == 401 def test_health_endpoint_bad_auth(client): - """Test health endpoint with bad authentication.""" - response = client.get("/health", headers={"X-API-Key": "wrong-key"}) - assert response.status_code == 401 + resp = client.get("/health", headers={"X-API-Key": "wrong-key"}) + assert resp.status_code == 401 def test_dns_logs_endpoint(client, api_headers): - """Test DNS logs endpoint.""" - response = client.get("/dns", headers=api_headers) - assert response.status_code == 422 # Unprocessable Entity due to missing required header - - -def test_dns_logs_endpoint(client): - """Test DNS logs endpoint.""" - response = client.get("/dns", headers={"X-API-Key": "test-api-key"}) - assert response.status_code == 200 - assert isinstance(response.json(), list) + resp = client.get("/dns", headers=api_headers) + assert resp.status_code == 200 + assert isinstance(resp.json(), list) def test_dns_logs_with_limit(client, api_headers): - """Test DNS logs endpoint with limit parameter.""" - response = client.get("/dns?limit=10", headers=api_headers) - assert response.status_code == 200 - data = response.json() + resp = client.get("/dns?limit=10", headers=api_headers) + assert resp.status_code == 200 + data = resp.json() assert isinstance(data, list) assert len(data) <= 10 def test_devices_endpoint(client, api_headers): - """Test devices endpoint.""" - response = client.get("/devices", headers=api_headers) -def test_devices_endpoint(client): - """Test devices endpoint.""" - response = client.get("/devices", headers={"X-API-Key": "test-api-key"}) - assert response.status_code == 200 - assert isinstance(response.json(), list) + resp = client.get("/devices", headers=api_headers) + assert resp.status_code == 200 + assert isinstance(resp.json(), list) def test_leases_endpoint(client, api_headers): - """Test IP leases endpoint.""" - response = client.get("/leases", headers=api_headers) -def test_leases_endpoint(client): - """Test IP leases endpoint.""" - response = client.get("/leases", headers={"X-API-Key": "test-api-key"}) - assert response.status_code == 200 - assert isinstance(response.json(), list) + resp = client.get("/leases", headers=api_headers) + assert resp.status_code == 200 + assert isinstance(resp.json(), list) def test_stats_endpoint(client, api_headers): - """Test statistics endpoint.""" - response = client.get("/stats", headers=api_headers) - assert response.status_code == 200 - data = response.json() + resp = client.get("/stats", headers=api_headers) + assert resp.status_code == 200 + data = resp.json() assert "total_dns_logs" in data assert "total_devices" in data assert "recent_queries" in data - assert isinstance(data["total_dns_logs"], int) - assert isinstance(data["total_devices"], int) - assert isinstance(data["recent_queries"], int) -def test_root_endpoint(client): - """Test root endpoint.""" - response = client.get("/") - assert response.status_code == 200 - data = response.json() - assert "message" in data -def test_root_status(): - """Test basic application status.""" - # This is a placeholder test for basic functionality - assert True # Replace with actual status checks - - -# Example of how to test with the new Pydantic schemas def test_device_request_validation(): - """Test DeviceRequest model validation.""" from api.schemas import DeviceRequest - - # Valid request + valid_data = { "ip_address": "192.168.1.100", "status": True, "hostname": "test-device", - "mac_address": "aa:bb:cc:dd:ee:ff" + "mac_address": "aa:bb:cc:dd:ee:ff", } device_req = DeviceRequest(**valid_data) assert device_req.ip_address == "192.168.1.100" assert device_req.status is True - - # Invalid IP address + with pytest.raises(ValueError): DeviceRequest(ip_address="invalid-ip", status=True) - - # Invalid MAC address + with pytest.raises(ValueError): DeviceRequest(ip_address="192.168.1.100", status=True, mac_address="invalid-mac")