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:
+197
-89
@@ -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 ""
|
||||
|
||||
Reference in New Issue
Block a user