669 lines
25 KiB
Bash
Executable File
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 ""
|