#!/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; } # --- CLI argument parsing --- _cli_user="" _cli_is_dev=false _cli_path="" _cli_mgmt_pass="" _cli_mgmt_user="" _cli_mgmt_domain="" _cli_acme_email="" _cli_force_venv=false _cli_wan_iface="" _cli_lan_ifaces="" while [[ $# -gt 0 ]]; do case "$1" in --user|-u) _cli_user="$2"; shift 2 ;; --path|-p) _cli_path="$2"; shift 2 ;; --dev) _cli_is_dev=true; shift ;; --mgmt-pass) _cli_mgmt_pass="$2"; shift 2 ;; --mgmt-user) _cli_mgmt_user="$2"; shift 2 ;; --mgmt-domain) _cli_mgmt_domain="$2"; shift 2 ;; --acme-email) _cli_acme_email="$2"; shift 2 ;; --force-venv) _cli_force_venv=true; shift ;; --wan-iface) _cli_wan_iface="$2"; shift 2 ;; --lan-ifaces) _cli_lan_ifaces="$2"; shift 2 ;; -h|--help) printf '%s\n' \ "Usage: install.sh [OPTIONS]" \ "" \ "Options:" \ " --user, -u USER WebUI user (created if it does not exist, required for non-dev mode)" \ " --path, -p DIR Install directory (default: repo root)" \ " --dev Dev mode: auto-detect repo owner, skip safety warning" \ " --mgmt-pass PASS WebUI basic auth password (required)" \ " --mgmt-user USER WebUI basic auth username (default: admin)" \ " --mgmt-domain DOMAIN Management domain (auto-detected)" \ " --acme-email EMAIL ACME contact email (optional, deprecated — use WebUI)" \ " --wan-iface IFACE WAN interface name (auto-detected)" \ " --lan-ifaces IFC,... LAN interface names, comma-separated (auto-detected)" \ " -h, --help Show this help" \ "" \ "All options also have environment variable equivalents:" \ " USER_NAME, INSTALL_DIR, MGMT_PASS, MGMT_USER," \ " MGMT_DOMAIN, WAN_IFACE, LAN_IFACES." \ " CLI flags take precedence over env vars." \ "" \ "Example (dev):" \ " ./install.sh --dev --mgmt-pass pass" \ "" \ "Example (prod):" \ " MGMT_PASS=pass ./install.sh --user vacuum-wall" exit 0 ;; *) err "Unknown argument: $1 (use --help for usage)" ;; esac done # --- Resolve config: CLI flag > env var > default --- REPO_DIR="$(cd "$(dirname "$0")" && pwd)" # Required settings (no defaults — must be provided) MGMT_PASS="${_cli_mgmt_pass:-${MGMT_PASS:-}}" # ACME_EMAIL is optional — will be configured from the WebUI ACME_EMAIL="${_cli_acme_email:-${ACME_EMAIL:-}}" # Optional settings with defaults MGMT_USER="${_cli_mgmt_user:-${MGMT_USER:-admin}}" # MGMT_DOMAIN — CLI > env > auto-detect from hostname if [[ -n "$_cli_mgmt_domain" ]]; then DOMAIN="$_cli_mgmt_domain" elif [[ -n "${MGMT_DOMAIN:-}" ]]; then DOMAIN="$MGMT_DOMAIN" else 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 or --mgmt-domain." fi DOMAIN="${HOSTNAME_F}.local" fi # Install directory (CLI > env > repo root) INSTALL_DIR="${_cli_path:-${INSTALL_DIR:-}}" if [[ -n "$INSTALL_DIR" ]]; then PROJECT_DIR="$INSTALL_DIR" else PROJECT_DIR="$REPO_DIR" fi # Network interfaces (CLI > env — auto-detect happens later if still unset) WAN_IFACE="${_cli_wan_iface:-${WAN_IFACE:-}}" LAN_IFACES="${_cli_lan_ifaces:-${LAN_IFACES:-}}" # --- 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." # --- Validate required settings --- missing=() [[ -z "$MGMT_PASS" ]] && missing+=("MGMT_PASS (--mgmt-pass)") if (( ${#missing[@]} )); then echo -e "${RED}[!!]${NC} Missing required settings:" for v in "${missing[@]}"; do case "$v" in "MGMT_PASS (--mgmt-pass)") echo ' export MGMT_PASS="your-password" # or --mgmt-pass';; esac done printf '\nTo run: MGMT_PASS=pass ./install.sh\n' exit 1 fi ACME_HOME="$PROJECT_DIR/data/acme" # Dev mode: auto-detect repo owner as service user if [[ "$_cli_is_dev" == true ]]; then _repo_owner=$(stat -c '%U' "$REPO_DIR" 2>/dev/null) || true if [[ -n "$_repo_owner" && "$_repo_owner" != "root" ]]; then _cli_user="$_repo_owner" log "Dev mode: using repo owner '$_repo_owner' as service user" else err "Dev mode: cannot determine repo owner (root or unavailable)." fi fi # Resolve USER_NAME: dev mode auto-detects, non-dev requires --user USER_NAME="${_cli_user:-${USER_NAME:-}}" if [[ -z "$USER_NAME" ]]; then err "WebUI user is required. Use --dev to auto-detect repo owner, or set --user / USER_NAME." fi # Daemon user name (derived from web UI user name) USER_DAEMON_NAME="${USER_NAME}d" # --- Safety check: running service as a regular user --- if [[ "$_cli_is_dev" != true ]] && id "$USER_NAME" &>/dev/null; then _uid=$(id -u "$USER_NAME") _shell=$(getent passwd "$USER_NAME" | cut -d: -f7) if [[ "$_uid" -ge 1000 ]] && [[ "$_shell" != "/usr/sbin/nologin" && "$_shell" != "/bin/false" ]]; then warn "USER_NAME='$USER_NAME' is a regular user (UID=$_uid, shell=$_shell)!" warn "This runs the web service as your login account." warn "Sudo access is held only by the daemon user ($USER_DAEMON_NAME)." fi fi # Create WebUI user if it does not exist 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" fi # Shared group: use the WebUI user's primary group USER_GROUP=$(id -gn "$USER_NAME") echo "============================================" echo " Vacuum Wall Appliance Installer" echo " Install dir: $PROJECT_DIR" echo " WebUI user: $USER_NAME" echo " Daemon user: $USER_DAEMON_NAME" echo " Shared group: $USER_GROUP" 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. Setup users --- log "WebUI user: $USER_NAME (group: $USER_GROUP)" # --- 2a. Create daemon user (has sudo for privileged operations) --- if ! id "$USER_DAEMON_NAME" &>/dev/null; then log "Creating system user $USER_DAEMON_NAME..." useradd --system --home-dir "$PROJECT_DIR" --no-create-home --shell /usr/sbin/nologin \ --gid "$USER_GROUP" "$USER_DAEMON_NAME" else log "User $USER_DAEMON_NAME already exists." usermod -g "$USER_GROUP" "$USER_DAEMON_NAME" 2>/dev/null || true fi # --- 2b. Setup Python venv --- if [[ -x "${PROJECT_DIR}/.venv/bin/python3" ]] && [[ "$_cli_force_venv" != true ]]; then log "Python venv already exists, skipping (use --force-venv to recreate)." else log "Setting up Python virtual environment..." rm -rf "${PROJECT_DIR}/.venv" python3 -m venv "${PROJECT_DIR}/.venv" "${PROJECT_DIR}/.venv/bin/pip" install -qe "${PROJECT_DIR}" chown -R "$USER_DAEMON_NAME:$USER_GROUP" "${PROJECT_DIR}/.venv" chmod -R g+x "${PROJECT_DIR}/.venv" fi # --- 2c. Install acme.sh (vendored) --- if [[ ! -x "$ACME_HOME/acme.sh" ]]; then log "Installing acme.sh (vendored)..." mkdir -p "$ACME_HOME" cp "${PROJECT_DIR}/vendor/acme.sh" "$ACME_HOME/acme.sh" chmod +x "$ACME_HOME/acme.sh" chown -R "$USER_DAEMON_NAME:$USER_GROUP" "$ACME_HOME" 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 # Set ownership: daemon owns project dir in prod, repo owner keeps ownership in dev if [[ "$_cli_is_dev" == true ]]; then _dev_owner="$USER_NAME" else _dev_owner="$USER_DAEMON_NAME" fi chown -R "$_dev_owner:$USER_GROUP" "$PROJECT_DIR" chmod -R g+rwX "$PROJECT_DIR" find "$PROJECT_DIR" -type d -exec chmod g+s '{}' + # --- 4. Template rendering function --- # Renders Jinja2 templates by injecting env vars as template context. # Used for systemd units and sudoers files. render_template() { export USER_NAME USER_DAEMON_NAME USER_GROUP 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'], 'USER_DAEMON_NAME': os.environ.get('USER_DAEMON_NAME', ''), 'USER_GROUP': os.environ.get('USER_GROUP', ''), '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..." export USER_DAEMON_NAME render_template "${PROJECT_DIR}/system/sudoers.d/vacuum-walld" \ | install -m 0440 /dev/stdin /etc/sudoers.d/vacuum-walld visudo -cf /etc/sudoers.d/vacuum-walld || err "Invalid sudoers file!" # --- 6. Install systemd units --- log "Installing systemd units..." export USER_GROUP render_template "${PROJECT_DIR}/system/systemd/vacuum-walld.service" \ | install -m 0644 /dev/stdin /etc/systemd/system/vacuum-walld.service 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 (persistent via sysctl.conf) --- 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 # --- 8. Detect network interfaces --- log "Detecting network interfaces..." # Auto-detect WAN (interface with default gateway) WAN_IFACE="${WAN_IFACE:-}" if [[ -z "$WAN_IFACE" ]]; then # Strip @if suffix — physical port index can change on reboot DETECTED_WAN=$(ip route show default 2>/dev/null | awk '/default/ {print $5; exit}' | cut -d'@' -f1) 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 # --- 9. Enable and start core services --- log "Enabling services..." systemctl enable firewalld >/dev/null 2>&1 && log "Enabled firewalld" || warn "Could not enable firewalld" systemctl enable nginx >/dev/null 2>&1 && log "Enabled nginx" || warn "Could not enable nginx" systemctl enable dnsmasq >/dev/null 2>&1 && log "Enabled dnsmasq" || warn "Could not enable dnsmasq" systemctl enable vacuum-walld >/dev/null 2>&1 && log "Enabled vacuum-walld" || warn "Could not enable vacuum-walld" systemctl enable vacuum-wall >/dev/null 2>&1 && log "Enabled vacuum-wall" || warn "Could not enable vacuum-wall" systemctl enable vacuum-wall-acme.timer >/dev/null 2>&1 && log "Enabled vacuum-wall-acme.timer" || warn "Could not enable vacuum-wall-acme.timer" systemctl enable avahi-daemon >/dev/null 2>&1 && log "Enabled avahi-daemon" || warn "Could not enable avahi-daemon" # Clean up old nginx bootstrap configs (replaced by daemon-generated config) rm -f /etc/nginx/conf.d/vacuum-wall-map.conf /etc/nginx/conf.d/vacuum-wall-mgmt.conf # Stop all services to ensure clean start order systemctl stop vacuum-wall >/dev/null 2>&1 || true systemctl stop vacuum-walld >/dev/null 2>&1 || true # Start services in dependency order systemctl start firewalld >/dev/null 2>&1 && log "Started firewalld" || warn "Could not start firewalld" systemctl start avahi-daemon >/dev/null 2>&1 && log "Started avahi-daemon" || warn "Could not start avahi-daemon" systemctl start dnsmasq >/dev/null 2>&1 || warn "Could not start dnsmasq (no interfaces configured yet)" # Start daemon and wait for socket systemctl start vacuum-walld >/dev/null 2>&1 && log "Started vacuum-walld daemon" || warn "Could not start vacuum-walld daemon" _SOCKET="$PROJECT_DIR/data/daemon.sock" for _i in $(seq 1 30); do [[ -S "$_SOCKET" ]] && break sleep 0.5 done if [[ ! -S "$_SOCKET" ]]; then warn "Daemon socket not found at $_SOCKET — skipping API configuration" else chown "$USER_DAEMON_NAME:$USER_GROUP" "$_SOCKET" 2>/dev/null || true chmod 0660 "$_SOCKET" 2>/dev/null || true # --- 10. Configure subsystems via daemon API --- log "Configuring subsystems via daemon API..." WAN_IFACE="$WAN_IFACE" \ LAN_IFACES="$LAN_IFACES" \ MGMT_DOMAIN="$DOMAIN" \ MGMT_USER="$MGMT_USER" \ MGMT_PASS="$MGMT_PASS" \ ACME_EMAIL="$ACME_EMAIL" \ "${PROJECT_DIR}/.venv/bin/python3" -c " import daemon.client as c from daemon.iface import ( POST_ACME_SELF_SIGNED, POST_NGINX_MANAGEMENT, POST_NGINX_APPLY, POST_FIREWALL_CONFIG, POST_FIREWALL_CONFIG_APPLY, POST_NETWORK_SYSCTL_SET, POST_ACME_EMAIL, GET_NETWORK_INFER_DHCP_RANGES, ) import sys domain = '${DOMAIN}' mgmt_user = '${MGMT_USER}' mgmt_pass = '${MGMT_PASS}' acme_email = '${ACME_EMAIL}' wan_iface = '${WAN_IFACE}' lan_ifaces = '${LAN_IFACES}' # Self-signed cert for management domain try: res = c.post(POST_ACME_SELF_SIGNED, {'domain': domain, 'days': 365}) print(f' [cert] Self-signed: {\"generated\" if res.get(\"generated\") else \"exists\"}') except Exception as e: print(f' [cert] Warning: {e}', file=sys.stderr) # Management proxy + htpasswd try: c.post(POST_NGINX_MANAGEMENT, { 'domain': domain, 'flask_host': '127.0.0.1', 'flask_port': 9090, 'auth_user': mgmt_user, 'auth_pass': mgmt_pass, }) c.post(POST_NGINX_APPLY) print(f' [proxy] Management proxy configured for {domain}') except Exception as e: print(f' [proxy] Warning: {e}', file=sys.stderr) # Firewall config (interface detection done in bash above) import json as _json zones = {} if wan_iface: zones['public'] = { 'target': 'DEFAULT', 'interfaces': [i for i in wan_iface.split(',') if i], 'services': ['http', 'https', 'ssh'], 'masquerade': True, } if lan_ifaces: zones['internal'] = { 'target': 'ACCEPT', 'interfaces': [i for i in lan_ifaces.split(',') if i], 'services': ['dhcp', 'dns', 'ntp'], 'masquerade': False, } # Always create vpn zone skeleton for later WireGuard setup zones['vpn'] = { 'target': 'ACCEPT', 'interfaces': [], 'services': [], 'masquerade': False, } try: c.post(POST_FIREWALL_CONFIG, {'zones': zones}) c.post(POST_FIREWALL_CONFIG_APPLY) print(' [firewall] Zones configured and applied') except Exception as e: print(f' [firewall] Warning: {e}', file=sys.stderr) # IP forwarding try: c.post(POST_NETWORK_SYSCTL_SET, {'name': 'net.ipv4.ip_forward', 'value': '1'}) print(' [network] IP forwarding enabled') except Exception as e: print(f' [network] Warning: {e}', file=sys.stderr) # ACME email (optional) if acme_email: try: c.post(POST_ACME_EMAIL, {'email': acme_email}) print(f' [acme] Email set to {acme_email}') except Exception as e: print(f' [acme] Warning: {e}', file=sys.stderr) # Infer DHCP ranges (logged for user reference) try: ranges = c.get(GET_NETWORK_INFER_DHCP_RANGES) for iface, rng in ranges.get('ranges', {}).items(): print(f' [suggestion] DHCP range for {iface}: {rng.get(\"start\")}-{rng.get(\"end\")}') except Exception: pass " log "Subsystem configuration complete" fi systemctl start vacuum-wall >/dev/null 2>&1 && log "Started vacuum-wall WebUI" || warn "Could not start vacuum-wall WebUI" nginx -t 2>/dev/null && nginx -s reload 2>/dev/null && log "Reloaded nginx" || \ systemctl restart nginx >/dev/null 2>&1 && log "Restarted nginx" || \ warn "Could not restart nginx (check config)" # --- Done --- echo "" echo "============================================" echo -e " ${GREEN}Vacuum Wall installed successfully!${NC}" echo "============================================" echo "" echo " Management UI: https://$DOMAIN" echo " User: $MGMT_USER" echo " Daemon service: vacuum-walld.service" 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. Set ACME contact email at https://$DOMAIN/certs/settings" echo " 2. Verify zone assignments at https://$DOMAIN/interfaces" echo " 3. Configure DHCP ranges for your LAN" echo " 4. Add proxy domains with ACME certificates" echo " 5. 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 ""