Files
vacuum-wall/scripts/install.sh
T
mteehan 0ed275835d fix: auth review fixes — token revocation, WS auth, seeding, and hardening
Refresh/logout and token robustness
- drop the post-rotation refresh_tokens row delete in auth_refresh so
  logout blacklists the current (rotated) refresh token; remove the
  dead _clear_refresh_token_after_rotation helper and clear_active_refresh_token
- reject non-object JWT payloads in _extract_unverified_sub so crafted
  Authorization headers return 401 instead of crashing with 500

SQLite user store
- make builtin-admin seeding idempotent: on a concurrent first start the
  losing seeder re-checks, finds the winner, and returns instead of
  raising IntegrityError
- per-thread sqlite connections + busy_timeout so Flask worker threads
  don't hit cross-thread ProgrammingError / SQLITE_BUSY
- add LogsDirectory + /var/log/vacuum-wall to ReadWritePaths in both
  systemd units so the fallback admin password actually lands on disk

Frontend
- skip apiFetch 401-recovery for public auth endpoints so a failed
  login no longer logs out a valid session
- add /passkeys to the nav (passkey registration was unreachable);
  remove the dead checkWebAuthnCapable export
- drop the CSP-blocked inline WS-URL script and the
  __WS_URL_PLACEHOLDER__ plumbing; the WS URL is derived from location

Daemon / WS
- parse Sec-WebSocket-Protocol manually (web.Request.get_subprotocols
  does not exist in aiohttp 3.13); X-Auth-Token is a custom-nginx
  fallback only — docstring and security docs corrected

Install / system
- bootstrap_auth.py is now idempotent: preserves existing auth config
  and syncs the admin password on re-runs (new reset_password helper)
- WebUI server block renders auth_basic off (the UI is JWT-protected)
- install.sh chown/chmod skips .git to avoid git dubious-ownership
  breakage
- tolerate unreadable /etc/wireguard during system import

Contracts / docs
- create_user returns 409 on duplicate username per docs/api.md
- correct docs/api.md response shapes, docs/security.md blacklist
  cleanup wording + one-refresh-per-user caveat, stale WS-URL
  references, and the .htpasswd description

Tests: +7 regression tests (rotation/logout revocation, crafted-token
401, concurrent seeding); placeholder-substitution tests replaced with
serve-as-is SPA root tests.
2026-08-17 01:45:15 +00:00

470 lines
18 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_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 ;;
--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: scripts/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 Initial admin password (required)" \
" --mgmt-user USER Initial admin username (default: admin)" \
" --mgmt-domain DOMAIN Management domain (auto-detected)" \
" --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):" \
" ./scripts/install.sh --dev --mgmt-pass pass" \
"" \
"Example (prod):" \
" MGMT_PASS=pass ./scripts/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)"
# Optional settings with defaults
# MGMT_PASS is strictly required — admin user is created at install time
MGMT_PASS="${_cli_mgmt_pass:-${MGMT_PASS:-}}"
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 ---
[[ -n "$MGMT_PASS" ]] || err "MGMT_PASS is required (set --mgmt-pass or MGMT_PASS env var)"
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
# --- 1b. Vendored libraries ---
log "Downloading vendored libraries..."
bash "${PROJECT_DIR}/scripts/update-vendor.sh" || err "update-vendor.sh failed"
# --- 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
# Install the deploy hook into acme.sh's deploy directory
# (acme.sh only resolves hooks from $ACME_HOME/deploy/)
mkdir -p "$ACME_HOME/deploy"
cp "${PROJECT_DIR}/system/acme-deploy.sh" "$ACME_HOME/deploy/acme-deploy.sh"
chown "$USER_DAEMON_NAME:$USER_GROUP" "$ACME_HOME/deploy/acme-deploy.sh"
chmod 0755 "$ACME_HOME/deploy/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.
# The top-level .git (directory or worktree pointer file) is left untouched so
# the repo owner's git isn't tripped by git's dubious-ownership check.
if [[ "$_cli_is_dev" == true ]]; then
_dev_owner="$USER_NAME"
else
_dev_owner="$USER_DAEMON_NAME"
fi
(
shopt -s dotglob nullglob
for _entry in "$PROJECT_DIR"/*; do
[[ "$(basename "$_entry")" == ".git" ]] && continue
chown -R "$_dev_owner:$USER_GROUP" "$_entry"
chmod -R g+rwX "$_entry"
find "$_entry" -type d -exec chmod g+s '{}' +
done
# Top dir: ownership + shared-group access (never .git)
chown "$_dev_owner:$USER_GROUP" "$PROJECT_DIR"
chmod g+rwX,g+s "$PROJECT_DIR"
)
# --- 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 + runtime apply) ---
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
# Apply immediately so NAT works without reboot
if [ "$(cat /proc/sys/net/ipv4/ip_forward 2>/dev/null)" != "1" ]; then
sysctl -w net.ipv4.ip_forward=1 >/dev/null 2>&1 && log "IP forwarding enabled at runtime" || \
warn "Could not enable IP forwarding at runtime"
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<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
# --- 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
echo ""
echo " Setting up initial management configuration..."
# Bootstrap auth: generate config + seed admin user. bootstrap_auth.py
# is idempotent — on re-run it preserves the existing config and
# updates the admin password to MGMT_PASS (docs/deployment.md).
if [[ ! -f "${PROJECT_DIR}/config/auth/config.json" ]]; then
echo ""
echo " Bootstrapping auth (creating admin user: $MGMT_USER)..."
else
echo ""
echo " Syncing admin password for existing user: $MGMT_USER..."
fi
"${PROJECT_DIR}/.venv/bin/python3" "${PROJECT_DIR}/scripts/bootstrap_auth.py" \
--project-dir "$PROJECT_DIR" \
--username "$MGMT_USER" \
--password "$MGMT_PASS" \
--domain "$DOMAIN"
chown -R "$USER_DAEMON_NAME:$USER_GROUP" "${PROJECT_DIR}/config/auth"
chown -R "$USER_DAEMON_NAME:$USER_GROUP" "${PROJECT_DIR}/data/auth.db" 2>/dev/null || true
# Helper: POST JSON to daemon API over Unix socket
_daemon_post() {
local endpoint="$1"
local json="$2"
local label="${3:-POST $endpoint}"
local resp
if resp=$(curl -s -f --unix-socket "$_SOCKET" \
"http://localhost${endpoint}" \
-H "Content-Type: application/json" \
-d "$json" 2>&1); then
log "$label"
return 0
else
warn "$label: $resp"
return 1
fi
}
# 1A. Self-signed certificate for management domain
_daemon_post "/acme/self-signed" "{\"domain\":\"$DOMAIN\"}" "Self-signed certificate"
# 1C. Management proxy domain (no auth — JWT auth is handled by Flask)
mgmt_json="$(jq -n \
--arg domain "$DOMAIN" \
'{
domain: $domain,
backend: "webui",
cert: "selfsigned",
force_ssl: true
}')"
_daemon_post "/nginx/domains/update" "$mgmt_json" "Management domain configured" || \
_daemon_post "/nginx/domains/add" "$mgmt_json" "Management domain configured"
_daemon_post "/nginx/apply" "{}" "Nginx config applied"
# Firewall zone assignment
if [[ -n "$WAN_IFACE" ]]; then
_daemon_post "/firewall/zones/interfaces" \
"$(jq -n --arg zone "public" --arg iface "$WAN_IFACE" \
'{zone: $zone, interfaces: [$iface]}')" \
"WAN interface assigned to public zone"
fi
if [[ -n "$LAN_IFACES" ]]; then
# Convert comma-separated list to JSON array
LAN_JSON=$(echo "$LAN_IFACES" | tr ',' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | jq -R . | jq -s '.')
_daemon_post "/firewall/zones/interfaces" \
"$(jq -n --arg zone "internal" --argjson ifaces "$LAN_JSON" \
'{zone: $zone, interfaces: $ifaces}')" \
"LAN interfaces assigned to internal zone"
fi
unset _daemon_post
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 " Admin 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. Register your ACME account at https://$DOMAIN/certs"
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 ""