#!/usr/bin/env bash # Vacuum Wall - SSL Proxy Firewall Appliance Installer # Run as root on a fresh Debian 13 (trixie) system set -euo pipefail RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' NC='\033[0m' log() { echo -e "${GREEN}[OK]${NC} $*"; } warn() { echo -e "${YELLOW}[!!]${NC} $*"; } err() { echo -e "${RED}[!!]${NC} $*"; exit 1; } # --- Configurable via environment --- REPO_DIR="$(cd "$(dirname "$0")" && pwd)" USER_NAME="${USER_NAME:-vacuum-wall}" # --- Validate required env vars --- missing=() [[ -z "${MGMT_PASS:-}" ]] && missing+=(MGMT_PASS) [[ -z "${ACME_EMAIL:-}" ]] && missing+=(ACME_EMAIL) if (( ${#missing[@]} )); then echo -e "${RED}[!!]${NC} Missing required environment variables:" for v in "${missing[@]}"; do case "$v" in MGMT_PASS) echo ' export MGMT_PASS="your-password" # WebUI basic auth password';; ACME_EMAIL) echo " export ACME_EMAIL=\"you@example.com\" # ACME (ZeroSSL) registration email";; esac done printf '\nTo run: MGMT_PASS=pass ACME_EMAIL=you@example.com ./install.sh\n' exit 1 fi # Auto-detect MGMT_DOMAIN from system hostname if not provided if [[ -z "${MGMT_DOMAIN:-}" ]]; then HOSTNAME_F=$(hostname -f 2>/dev/null || hostname 2>/dev/null || true) if [[ -z "$HOSTNAME_F" ]]; then err "Cannot determine system hostname — set MGMT_DOMAIN env var." fi DOMAIN="${HOSTNAME_F}.local" else DOMAIN="$MGMT_DOMAIN" fi MGMT_USER="${MGMT_USER:-admin}" # --- Pre-flight checks --- [[ $EUID -eq 0 ]] || err "This script must be run as root." [[ -f /etc/debian_version ]] || warn "This script is designed for Debian/Ubuntu." # --- Deploy to /opt/vacuum-wall --- INSTALL_DIR="/opt/vacuum-wall" # Install rsync first if not available (needed for deploy) if ! command -v rsync &>/dev/null; then apt-get update -qq apt-get install -y -qq rsync fi if [[ -d "$INSTALL_DIR" ]]; then if [[ -L "$INSTALL_DIR" ]]; then log "Symbolic link already exists at $INSTALL_DIR, skipping deploy." elif [[ "$INSTALL_DIR" == "$REPO_DIR" ]]; then log "Installed from repo location, skipping deploy." else err "Installation directory $INSTALL_DIR already exists." fi else log "Deploying $REPO_DIR → $INSTALL_DIR" rsync -a --delete \ --exclude='.venv' \ --exclude='__pycache__' \ --exclude='*.pyc' \ --exclude='.git' \ --exclude='build' \ "$REPO_DIR/" "$INSTALL_DIR/" chown -R "$USER_NAME:$USER_NAME" "$INSTALL_DIR" fi PROJECT_DIR="$INSTALL_DIR" ACME_HOME="$PROJECT_DIR/data/acme" echo "============================================" echo " Vacuum Wall Appliance Installer" echo " Install dir: $PROJECT_DIR" echo " System user: $USER_NAME" echo " Management domain: $DOMAIN" echo "============================================" # --- 1. Install packages --- log "Installing system packages..." apt-get update -qq apt-get install -y -qq \ firewalld \ nginx \ dnsmasq \ wireguard-tools \ python3 \ python3-pip \ jq \ curl \ iptables \ nftables \ apache2-utils \ avahi-daemon # --- 2. Create system user (home set to PROJECT_DIR for env, no-create) --- if ! id "$USER_NAME" &>/dev/null; then log "Creating system user $USER_NAME..." useradd --system --home-dir "$PROJECT_DIR" --no-create-home --shell /usr/sbin/nologin "$USER_NAME" else log "User $USER_NAME already exists." fi # --- 2b. Setup Python venv --- log "Setting up Python virtual environment..." python3 -m venv "${PROJECT_DIR}/.venv" "${PROJECT_DIR}/.venv/bin/pip" install -q "${PROJECT_DIR}" # --- 2c. Install acme.sh --- if [[ ! -d "$ACME_HOME" ]]; then log "Installing acme.sh..." mkdir -p "$ACME_HOME" chown "$USER_NAME:$USER_NAME" "$ACME_HOME" sudo -u "$USER_NAME" env ACME_HOME="$ACME_HOME" HOME="$PROJECT_DIR" \ sh -c 'curl -sS https://get.acme.sh | sh' else log "acme.sh already installed." fi # Ensure the acme deploy hook script has correct permissions chmod 0755 "${PROJECT_DIR}/system/acme-deploy.sh" # --- 3. Setup directories --- log "Creating config and data directories..." mkdir -p "${PROJECT_DIR}/config"/{dnsmasq,nginx,wireguard,firewall} mkdir -p "${PROJECT_DIR}/data"/{nginx/sites-enabled,dnsmasq,firewall,wireguard,acme} mkdir -p /etc/wireguard mkdir -p /etc/dnsmasq # --- 4. Template rendering function --- render_template() { export USER_NAME PROJECT_DIR ACME_HOME "${PROJECT_DIR}/.venv/bin/python3" -c " import sys, os from jinja2 import Template text = open(sys.argv[1]).read() env = { 'USER_NAME': os.environ['USER_NAME'], 'PROJECT_DIR': os.environ['PROJECT_DIR'], 'ACME_HOME': os.environ['ACME_HOME'], } print(Template(text).render(**env), end='') " "$1" } # --- 5. Install sudoers --- log "Installing sudoers whitelist..." render_template "${PROJECT_DIR}/system/sudoers.d/vacuum-wall" \ | install -m 0440 /dev/stdin /etc/sudoers.d/vacuum-wall visudo -cf /etc/sudoers.d/vacuum-wall || err "Invalid sudoers file!" # --- 6. Install systemd units --- log "Installing systemd units..." render_template "${PROJECT_DIR}/system/systemd/vacuum-wall.service" \ | install -m 0644 /dev/stdin /etc/systemd/system/vacuum-wall.service render_template "${PROJECT_DIR}/system/systemd/vacuum-wall-acme.service" \ | install -m 0644 /dev/stdin /etc/systemd/system/vacuum-wall-acme.service install -m 0644 "${PROJECT_DIR}/system/systemd/vacuum-wall-acme.timer" /etc/systemd/system/vacuum-wall-acme.timer systemctl daemon-reload # --- 7. Enable IP forwarding --- log "Enabling IP forwarding..." if ! grep -q "^net.ipv4.ip_forward=1" /etc/sysctl.conf 2>/dev/null; then echo "net.ipv4.ip_forward=1" >> /etc/sysctl.conf fi sysctl -w net.ipv4.ip_forward=1 2>/dev/null || warn "Could not enable IP forwarding (may need kernel access)" # --- 8. Start and configure firewalld --- log "Enabling firewalld..." systemctl enable firewalld >/dev/null 2>&1 || warn "Could not enable firewalld (already running?)" systemctl start firewalld >/dev/null 2>&1 || warn "Could not start firewalld (may need D-Bus)" firewall-cmd --permanent --add-service=http >/dev/null 2>&1 || true log "Added service http to public zone" firewall-cmd --permanent --add-service=https >/dev/null 2>&1 || true log "Added service https to public zone" firewall-cmd --permanent --add-service=ssh >/dev/null 2>&1 || true log "Added service ssh to public zone" firewall-cmd --reload >/dev/null 2>&1 || true log "Firewalld rules reloaded" # --- 9. Configure dnsmasq --- log "Configuring dnsmasq..." systemctl enable dnsmasq >/dev/null 2>&1 || true systemctl start dnsmasq >/dev/null 2>&1 || warn "Could not start dnsmasq (no interfaces configured yet)" log "dnsmasq configured (will fully start after DHCP ranges are set)" # --- 10. Setup nginx management proxy --- log "Generating self-signed certificate for management domain..." mkdir -p "$ACME_HOME/$DOMAIN" openssl req -x509 -nodes -days 365 -newkey rsa:2048 \ -keyout "$ACME_HOME/$DOMAIN/$DOMAIN.key" \ -out "$ACME_HOME/$DOMAIN/fullchain.cer" \ -subj "/CN=$DOMAIN" \ -addext "subjectAltName=DNS:$DOMAIN" chown -R "$USER_NAME:$USER_NAME" "$ACME_HOME" # Generate htpasswd directly in data/nginx/ htpasswd -cb "${PROJECT_DIR}/data/nginx/.htpasswd" "$MGMT_USER" "$MGMT_PASS" 2>/dev/null || \ MGMT_USER="$MGMT_USER" MGMT_PASS="$MGMT_PASS" HTFILE="${PROJECT_DIR}/data/nginx/.htpasswd" python3 -c " import os, crypt, base64 password = os.environ['MGMT_PASS'] user = os.environ['MGMT_USER'] salt = '\$6\$' + base64.b64encode(os.urandom(16)).decode().rstrip('=')[:16] hashed = crypt.crypt(password, salt) with open(os.environ['HTFILE'], 'w') as f: f.write(user + ':' + hashed + '\n') " 2>/dev/null || \ warn "Could not generate htpasswd (install apache2-utils or python3-crypt)" chown "$USER_NAME:$USER_NAME" "${PROJECT_DIR}/data/nginx/.htpasswd" # Remove default nginx site so vacuum-wall management config takes precedence rm -f /etc/nginx/sites-enabled/default # Write WebSocket upgrade map (nginx conf.d/ is already inside http {} context) cat > /etc/nginx/conf.d/vacuum-wall-map.conf <<'MAPEOF' # Vacuum Wall - WebSocket upgrade map map $http_upgrade $connection_upgrade { default upgrade; '' close; } MAPEOF # Write the management site block (conf.d/ is inside http {}, no extra http {} needed) cat > /etc/nginx/conf.d/vacuum-wall-mgmt.conf </dev/null | awk '/default/ {print $5; exit}') if [[ -n "$DETECTED_WAN" ]]; then WAN_IFACE="$DETECTED_WAN" log "Auto-detected WAN interface: $WAN_IFACE" else warn "Could not auto-detect WAN interface — set WAN_IFACE env var" fi fi # Auto-detect LAN (all non-loopback, non-Docker, non-WAN, non-virtual interfaces) LAN_IFACES="${LAN_IFACES:-}" if [[ -z "$LAN_IFACES" ]]; then DETECTED_LANS=$(ls /sys/class/net/ 2>/dev/null \ | grep -vE "^(lo|docker|br-|virbr|${WAN_IFACE})$") if [[ -n "$DETECTED_LANS" ]]; then LAN_IFACES=$(echo "$DETECTED_LANS" | paste -sd ',' -) log "Auto-detected LAN interfaces: $LAN_IFACES" else warn "Could not auto-detect LAN interfaces — set LAN_IFACES env var" fi fi # Generate config/firewall/config.json log "Writing initial firewall configuration..." WAN_IFACE="$WAN_IFACE" \ LAN_IFACES="$LAN_IFACES" \ INSTALL_DIR="$PROJECT_DIR" \ "${PROJECT_DIR}/.venv/bin/python3" -c " import json, os wan = os.environ.get('WAN_IFACE', '').strip() or None lan = os.environ.get('LAN_IFACES', '').strip() or None p = os.environ['INSTALL_DIR'] cfg = {'zones': {}} if wan: cfg['zones']['public'] = { 'target': 'DEFAULT', 'interfaces': [i for i in wan.split(',') if i], 'services': ['http', 'https', 'ssh'], 'masquerade': True, } if lan: cfg['zones']['internal'] = { 'target': 'ACCEPT', 'interfaces': [i for i in lan.split(',') if i], 'services': ['dhcp', 'dns', 'ntp'], 'masquerade': False, } # Always create vpn zone skeleton for later WireGuard setup cfg['zones']['vpn'] = { 'target': 'ACCEPT', 'interfaces': [], 'services': [], 'masquerade': False, } with open(os.path.join(p, 'config/firewall/config.json'), 'w') as f: json.dump(cfg, f, indent=2) f.write('\n') " # Apply zones via firewall-cmd (Python venv not yet fully available for apply_config) firewall-cmd --permanent --new-zone=internal >/dev/null 2>&1 || true log "Created firewalld zone: internal" firewall-cmd --permanent --zone=internal --set-target=ACCEPT >/dev/null 2>&1 || true firewall-cmd --permanent --zone=internal --add-service=dhcp >/dev/null 2>&1 || true log "Added service dhcp to internal zone" firewall-cmd --permanent --zone=internal --add-service=dns >/dev/null 2>&1 || true log "Added service dns to internal zone" firewall-cmd --permanent --zone=internal --add-service=ntp >/dev/null 2>&1 || true log "Added service ntp to internal zone" firewall-cmd --permanent --new-zone=vpn >/dev/null 2>&1 || true log "Created firewalld zone: vpn" firewall-cmd --permanent --zone=vpn --set-target=ACCEPT >/dev/null 2>&1 || true # Apply masquerade on public/WAN if [[ -n "$WAN_IFACE" ]]; then firewall-cmd --permanent --zone=public --add-masquerade >/dev/null 2>&1 || true log "Enabled masquerade on public zone ($WAN_IFACE)" firewall-cmd --permanent --zone=public --add-interface="$WAN_IFACE" >/dev/null 2>&1 || true log "Assigned $WAN_IFACE to public zone" fi # Assign LAN interfaces to internal zone if [[ -n "$LAN_IFACES" ]]; then IFS=',' read -ra LAN_ARRAY <<< "$LAN_IFACES" for iface in "${LAN_ARRAY[@]}"; do iface=$(echo "$iface" | xargs) [[ -z "$iface" ]] && continue firewall-cmd --permanent --zone=internal --add-interface="$iface" >/dev/null 2>&1 || true log "Assigned $iface to internal zone" done fi firewall-cmd --reload >/dev/null 2>&1 || true log "Firewalld rules reloaded" # --- 13. Enable and start services --- log "Enabling services..." systemctl enable nginx >/dev/null 2>&1 || true log "Enabled nginx" systemctl enable vacuum-wall >/dev/null 2>&1 || true log "Enabled vacuum-wall" systemctl enable vacuum-wall-acme.timer >/dev/null 2>&1 || true log "Enabled vacuum-wall-acme.timer" systemctl enable avahi-daemon >/dev/null 2>&1 || true log "Enabled avahi-daemon" systemctl start avahi-daemon >/dev/null 2>&1 && log "Started avahi-daemon" || warn "Could not start avahi-daemon" systemctl start nginx >/dev/null 2>&1 && log "Started nginx" || warn "Could not start nginx (check config)" systemctl start vacuum-wall >/dev/null 2>&1 && log "Started vacuum-wall WebUI" || warn "Could not start vacuum-wall WebUI" # --- 14. Configure acme.sh default email --- log "Configuring acme.sh default email..." sudo -u "$USER_NAME" env ACME_HOME="$ACME_HOME" HOME="$PROJECT_DIR" \ "$ACME_HOME/acme.sh" --register-account -m "$ACME_EMAIL" 2>/dev/null || \ warn "Could not register acme.sh account (will be done from WebUI)" # --- Done --- echo "" echo "============================================" echo -e " ${GREEN}Vacuum Wall installed successfully!${NC}" echo "============================================" echo "" echo " Management UI: https://$DOMAIN" echo " User: $MGMT_USER" echo " WebUI service: vacuum-wall.service" echo " ACME renewal: vacuum-wall-acme.timer" echo "" echo " Firewall zones:" if [[ -n "$WAN_IFACE" ]]; then echo " public (WAN) → $WAN_IFACE" else echo " public (WAN) → not assigned" fi if [[ -n "$LAN_IFACES" ]]; then echo " internal (LAN) → $LAN_IFACES" else echo " internal (LAN) → not assigned" fi echo "" echo " Next steps:" echo " 1. Verify zone assignments at https://$DOMAIN/interfaces" echo " 2. Configure DHCP ranges for your LAN" echo " 3. Add proxy domains with ACME certificates" echo " 4. Set up WireGuard tunnel (optional)" echo "" echo " NOTE: A self-signed certificate was generated." echo " From the WebUI, issue a real certificate for $DOMAIN" echo " when DNS points to this appliance." echo ""