Files
vacuum-wall/install.sh
T

669 lines
25 KiB
Bash
Executable File

#!/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 registration email (required)" \
" --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, ACME_EMAIL, WAN_IFACE, LAN_IFACES." \
" CLI flags take precedence over env vars." \
"" \
"Example (dev):" \
" ./install.sh --dev --mgmt-pass pass --acme-email me@example.com" \
"" \
"Example (prod):" \
" MGMT_PASS=pass ACME_EMAIL=me@example.com ./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="${_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)")
[[ -z "$ACME_EMAIL" ]] && missing+=("ACME_EMAIL (--acme-email)")
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';;
"ACME_EMAIL (--acme-email)") echo " export ACME_EMAIL=\"you@example.com\" # or --acme-email";;
esac
done
printf '\nTo run: MGMT_PASS=pass ACME_EMAIL=you@example.com ./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 ---
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 && \
log "Added service http to public zone" || \
warn "Could not add service http to public zone (already exists?)"
firewall-cmd --permanent --add-service=https >/dev/null 2>&1 && \
log "Added service https to public zone" || \
warn "Could not add service https to public zone (already exists?)"
firewall-cmd --permanent --add-service=ssh >/dev/null 2>&1 && \
log "Added service ssh to public zone" || \
warn "Could not add service ssh to public zone (already exists?)"
firewall-cmd --reload >/dev/null 2>&1 && \
log "Firewalld rules reloaded" || \
warn "Could not reload firewalld rules"
# --- 9. Configure dnsmasq ---
log "Configuring dnsmasq..."
systemctl enable dnsmasq >/dev/null 2>&1 && log "Enabled dnsmasq" || warn "Could not enable dnsmasq"
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 ---
mkdir -p "$ACME_HOME/$DOMAIN"
if [[ -f "$ACME_HOME/$DOMAIN/$DOMAIN.key" ]]; then
log "SSL certificate already exists for $DOMAIN, skipping."
else
log "Generating self-signed certificate for management 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"
fi
chown -R "$USER_DAEMON_NAME:$USER_GROUP" "$ACME_HOME"
# Generate/update htpasswd directly in data/nginx/
HTPASSWD_FILE="${PROJECT_DIR}/data/nginx/.htpasswd"
if [[ -f "$HTPASSWD_FILE" ]]; then
htpasswd -b "$HTPASSWD_FILE" "$MGMT_USER" "$MGMT_PASS" 2>/dev/null || \
warn "Could not update htpasswd (install apache2-utils)"
else
htpasswd -cb "$HTPASSWD_FILE" "$MGMT_USER" "$MGMT_PASS" 2>/dev/null || \
MGMT_USER="$MGMT_USER" MGMT_PASS="$MGMT_PASS" HTFILE="$HTPASSWD_FILE" 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)"
fi
chown "$USER_NAME:$USER_GROUP" "${PROJECT_DIR}/data/nginx/.htpasswd"
# Remove default nginx site so vacuum-wall management config takes precedence
rm -f /etc/nginx/sites-enabled/default
# Write initial management proxy config directly to /etc/nginx/conf.d/.
# This bootstrap config is needed before the WebUI is running. Once the
# WebUI is up, it manages proxy configs from config/nginx/config.json
# and renders them to data/nginx/sites-enabled/.
# 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 <<MGMTSITEEOF
# Vacuum Wall - Management Proxy
# Auto-generated by install.sh
server {
listen 80;
server_name $DOMAIN;
return 301 https://\$host\$request_uri;
}
server {
listen 443 ssl;
server_name $DOMAIN;
ssl_certificate $ACME_HOME/$DOMAIN/fullchain.cer;
ssl_certificate_key $ACME_HOME/$DOMAIN/$DOMAIN.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
auth_basic "Vacuum Wall";
auth_basic_user_file ${PROJECT_DIR}/data/nginx/.htpasswd;
location / {
proxy_pass http://127.0.0.1:9090;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
# WebSocket support
proxy_http_version 1.1;
proxy_set_header Upgrade \$http_upgrade;
proxy_set_header Connection \$connection_upgrade;
}
}
MGMTSITEEOF
# --- 11. Write initial nginx config.json (skip if user has customized it) ---
NGINX_CFG="${PROJECT_DIR}/config/nginx/config.json"
if [[ -f "$NGINX_CFG" ]]; then
log "Nginx config already exists, skipping initial write."
else
log "Writing initial nginx configuration..."
MGMT_DOMAIN="$DOMAIN" \
MGMT_USER="$MGMT_USER" \
INSTALL_DIR="$PROJECT_DIR" \
"${PROJECT_DIR}/.venv/bin/python3" -c "
import json, os
d = os.environ['MGMT_DOMAIN']
u = os.environ['MGMT_USER']
p = os.environ['INSTALL_DIR']
cfg = {
'domains': {},
'management': {
'domain': d,
'backend': {
'host': '127.0.0.1',
'port': 9090,
'proto': 'http'
},
'auth': {
'user': u,
'htpasswd': p + '/data/nginx/.htpasswd'
}
},
'ssl': {
'protocols': 'TLSv1.2 TLSv1.3',
'ciphers': 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305',
'prefer_server_ciphers': False
}
}
with open(os.path.join(p, 'config/nginx/config.json'), 'w') as f:
json.dump(cfg, f, indent=4)
f.write('\n')
"
fi
# --- 12. Auto-detect interfaces and setup initial firewalld zones ---
log "Detecting network interfaces..."
# Auto-detect WAN (interface with default gateway)
WAN_IFACE="${WAN_IFACE:-}"
if [[ -z "$WAN_IFACE" ]]; then
# Strip @if<port> 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
# Generate config/firewall/config.json (skip if user has customized it)
FIREWALL_CFG="${PROJECT_DIR}/config/firewall/config.json"
if [[ -f "$FIREWALL_CFG" ]]; then
log "Firewall config already exists, skipping initial write."
else
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')
"
fi
# Apply zones via firewall-cmd (Python venv not yet fully available for apply_config)
firewall-cmd --permanent --new-zone=internal >/dev/null 2>&1 && \
log "Created firewalld zone: internal" || \
warn "firewalld zone 'internal' may already exist"
firewall-cmd --permanent --zone=internal --set-target=ACCEPT >/dev/null 2>&1 || \
warn "Could not set target ACCEPT on internal zone"
firewall-cmd --permanent --zone=internal --add-service=dhcp >/dev/null 2>&1 && \
log "Added service dhcp to internal zone" || \
warn "Could not add service dhcp to internal zone"
firewall-cmd --permanent --zone=internal --add-service=dns >/dev/null 2>&1 && \
log "Added service dns to internal zone" || \
warn "Could not add service dns to internal zone"
firewall-cmd --permanent --zone=internal --add-service=ntp >/dev/null 2>&1 && \
log "Added service ntp to internal zone" || \
warn "Could not add service ntp to internal zone"
firewall-cmd --permanent --new-zone=vpn >/dev/null 2>&1 && \
log "Created firewalld zone: vpn" || \
warn "firewalld zone 'vpn' may already exist"
firewall-cmd --permanent --zone=vpn --set-target=ACCEPT >/dev/null 2>&1 || \
warn "Could not set target ACCEPT on vpn zone"
# Apply masquerade on public/WAN
if [[ -n "$WAN_IFACE" ]]; then
firewall-cmd --permanent --zone=public --add-masquerade >/dev/null 2>&1 && \
log "Enabled masquerade on public zone ($WAN_IFACE)" || \
warn "Could not enable masquerade on public zone"
firewall-cmd --permanent --zone=public --add-interface="$WAN_IFACE" >/dev/null 2>&1 && \
log "Assigned $WAN_IFACE to public zone" || \
warn "Could not assign $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 && \
log "Assigned $iface to internal zone" || \
warn "Could not assign $iface to internal zone"
done
fi
firewall-cmd --reload >/dev/null 2>&1 && \
log "Firewalld rules reloaded" || \
warn "Could not reload firewalld rules"
# --- 13. Enable and start services ---
log "Enabling services..."
systemctl enable nginx >/dev/null 2>&1 && log "Enabled nginx" || warn "Could not enable nginx"
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"
systemctl start avahi-daemon >/dev/null 2>&1 && log "Started avahi-daemon" || warn "Could not start avahi-daemon"
# Start daemon first, then web UI
# Stop vacuum-wall first. vacuum-wall.service Requires=vacuum-walld.service,
# so stopping vacuum-wall triggers a cascade stop of vacuum-walld. The explicit
# stop of vacuum-walld below is redundant but ensures clean teardown.
systemctl stop vacuum-wall >/dev/null 2>&1 || true
systemctl stop vacuum-walld >/dev/null 2>&1 || true
systemctl start vacuum-walld >/dev/null 2>&1 && log "Started vacuum-walld daemon" || warn "Could not start vacuum-walld daemon"
# Wait for daemon socket
_SOCKET="$PROJECT_DIR/data/daemon.sock"
for _i in $(seq 1 10); do
[[ -S "$_SOCKET" ]] && break
sleep 0.5
done
if [[ ! -S "$_SOCKET" ]]; then
warn "Daemon socket not found at $_SOCKET"
fi
# Set socket ownership so web UI user can connect
if [[ -S "$_SOCKET" ]]; then
chown "$USER_DAEMON_NAME:$USER_GROUP" "$_SOCKET" 2>/dev/null || true
chmod 0660 "$_SOCKET" 2>/dev/null || true
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)"
# --- 14. Configure acme.sh default email ---
if [[ -f "$ACME_HOME/account.conf" ]] && grep -q '^ACME_LEEMAIL=' "$ACME_HOME/account.conf" 2>/dev/null; then
log "acme.sh account already registered, skipping."
else
# acme.sh must never run as root — always as the service user via sudo -u.
# This prevents acme.sh from running any command as root and limits its
# ability to modify system files.
log "Registering acme.sh account with email $ACME_EMAIL..."
mkdir -p "$ACME_HOME/www"
chown "$USER_DAEMON_NAME:$USER_GROUP" "$ACME_HOME/www"
sudo -u "$USER_DAEMON_NAME" env ACME_HOME="$ACME_HOME" HOME="$PROJECT_DIR" \
"$ACME_HOME/acme.sh" --home "$ACME_HOME" --config-home "$ACME_HOME" \
--register-account -m "$ACME_EMAIL" 2>/dev/null || \
warn "Could not register acme.sh account (will be done from WebUI)"
fi
# --- 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. 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 ""