refactor: unify project structure, improve security, and enhance deployment

- Fix WireGuard private key leak in API responses and config updates
- Update systemd service to serve from repo root with adjusted sandbox
- Add CLI flags, idempotency, and dev mode to install.sh
- Extract common utilities to lib/common.py and webui/api/common.py
- Migrate frontend to htmx for simpler, more maintainable UI
- Update docs to reflect current architecture and deployment model
- Vendor htmx dependencies per project requirements
This commit is contained in:
2026-05-25 00:53:32 +00:00
parent 8829ac579d
commit d1ab717c0f
36 changed files with 857 additions and 626 deletions
+197 -89
View File
@@ -12,75 +12,146 @@ 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";;
# --- 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 System user for service (default: vacuum-wall)" \
" --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"
exit 0
;;
*)
err "Unknown argument: $1 (use --help for usage)"
;;
esac
done
printf '\nTo run: MGMT_PASS=pass ACME_EMAIL=you@example.com ./install.sh\n'
exit 1
fi
done
# Auto-detect MGMT_DOMAIN from system hostname if not provided
if [[ -z "${MGMT_DOMAIN:-}" ]]; then
# --- 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."
err "Cannot determine system hostname — set MGMT_DOMAIN env var or --mgmt-domain."
fi
DOMAIN="${HOSTNAME_F}.local"
else
DOMAIN="$MGMT_DOMAIN"
fi
MGMT_USER="${MGMT_USER:-admin}"
# 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."
# --- Deploy to /opt/vacuum-wall ---
INSTALL_DIR="/opt/vacuum-wall"
# --- Validate required settings ---
missing=()
[[ -z "$MGMT_PASS" ]] && missing+=("MGMT_PASS (--mgmt-pass)")
[[ -z "$ACME_EMAIL" ]] && missing+=("ACME_EMAIL (--acme-email)")
# 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
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
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"
# 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
# Optional settings with defaults
USER_NAME="${_cli_user:-${USER_NAME:-vacuum-wall}}"
# --- Safety check: running service as a non-system regular user ---
if [[ "$_cli_is_dev" != true ]] && [[ "$USER_NAME" != "vacuum-wall" ]] && id "$USER_NAME" &>/dev/null; then
_uid=$(id -u "$USER_NAME")
_gid=$(id -g "$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 grants NOPASSWD sudo and runs the web service as your login account."
warn "Only use for development. For production, use --user vacuum-wall."
fi
fi
echo "============================================"
echo " Vacuum Wall Appliance Installer"
echo " Install dir: $PROJECT_DIR"
@@ -114,17 +185,23 @@ else
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}"
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_NAME:$USER_NAME" "${PROJECT_DIR}/.venv"
fi
# --- 2c. Install acme.sh ---
if [[ ! -d "$ACME_HOME" ]]; then
log "Installing acme.sh..."
# --- 2c. Install acme.sh (vendored) ---
if [[ ! -x "$ACME_HOME/acme.sh" ]]; then
log "Installing acme.sh (vendored)..."
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'
cp "${PROJECT_DIR}/vendor/acme.sh" "$ACME_HOME/acme.sh"
chmod +x "$ACME_HOME/acme.sh"
chown -R "$USER_NAME:$USER_NAME" "$ACME_HOME"
else
log "acme.sh already installed."
fi
@@ -201,20 +278,29 @@ systemctl start dnsmasq >/dev/null 2>&1 || warn "Could not start dnsmasq (no int
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"
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_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 "
# 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']
@@ -223,7 +309,8 @@ 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)"
warn "Could not generate htpasswd (install apache2-utils or python3-crypt)"
fi
chown "$USER_NAME:$USER_NAME" "${PROJECT_DIR}/data/nginx/.htpasswd"
@@ -279,12 +366,16 @@ server {
}
MGMTSITEEOF
# --- 11. Write initial nginx config.json ---
log "Writing initial nginx configuration..."
MGMT_DOMAIN="$DOMAIN" \
MGMT_USER="$MGMT_USER" \
INSTALL_DIR="$PROJECT_DIR" \
"${PROJECT_DIR}/.venv/bin/python3" -c "
# --- 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']
@@ -313,6 +404,7 @@ 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..."
@@ -342,12 +434,16 @@ if [[ -z "$LAN_IFACES" ]]; then
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 "
# 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
@@ -384,6 +480,7 @@ 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 || true
@@ -435,14 +532,25 @@ 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 stop vacuum-wall >/dev/null 2>&1 || true
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 ---
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)"
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
log "Registering acme.sh account with email $ACME_EMAIL..."
mkdir -p "$ACME_HOME/www"
chown "$USER_NAME:$USER_NAME" "$ACME_HOME/www"
sudo -u "$USER_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 ""