install: two-user model ownership and deployment

This commit is contained in:
2026-05-29 22:29:54 +00:00
parent 200e078bc5
commit cb683f7e61
4 changed files with 123 additions and 86 deletions
+90 -78
View File
@@ -40,7 +40,7 @@ while [[ $# -gt 0 ]]; do
"Usage: install.sh [OPTIONS]" \
"" \
"Options:" \
" --user, -u USER System user for service (default: vacuum-wall)" \
" --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)" \
@@ -60,7 +60,7 @@ while [[ $# -gt 0 ]]; do
" ./install.sh --dev --mgmt-pass pass --acme-email me@example.com" \
"" \
"Example (prod):" \
" MGMT_PASS=pass ACME_EMAIL=me@example.com ./install.sh"
" MGMT_PASS=pass ACME_EMAIL=me@example.com ./install.sh --user vacuum-wall"
exit 0
;;
*)
@@ -137,25 +137,31 @@ if [[ "$_cli_is_dev" == true ]]; then
fi
fi
# Optional settings with defaults
USER_NAME="${_cli_user:-${USER_NAME:-vacuum-wall}}"
# 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 non-system regular user ---
if [[ "$_cli_is_dev" != true ]] && [[ "$USER_NAME" != "vacuum-wall" ]] && id "$USER_NAME" &>/dev/null; then
# --- Safety check: running service as a regular user ---
if [[ "$_cli_is_dev" != true ]] && 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 runs the web service as your login account."
warn "Sudo access is held only by the daemon user ($USER_DAEMON_NAME)."
warn "Only use for development. For production, use --user vacuum-wall."
fi
fi
# Shared group for both users to access project files and socket
USER_GROUP="vacuum-wall"
# 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"
@@ -183,13 +189,8 @@ apt-get install -y -qq \
apache2-utils \
avahi-daemon
# --- 2. Create shared group ---
if ! getent group "$USER_GROUP" &>/dev/null; then
log "Creating shared group $USER_GROUP..."
groupadd --system "$USER_GROUP"
else
log "Group $USER_GROUP already exists."
fi
# --- 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
@@ -201,17 +202,7 @@ else
usermod -g "$USER_GROUP" "$USER_DAEMON_NAME" 2>/dev/null || true
fi
# --- 2b. Create web UI user (no sudo, communicates with daemon) ---
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 \
--gid "$USER_GROUP" "$USER_NAME"
else
log "User $USER_NAME already exists."
usermod -g "$USER_GROUP" "$USER_NAME" 2>/dev/null || true
fi
# --- 2c. Setup Python venv ---
# --- 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
@@ -220,9 +211,10 @@ else
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
# --- 2d. Install acme.sh (vendored) ---
# --- 2c. Install acme.sh (vendored) ---
if [[ ! -x "$ACME_HOME/acme.sh" ]]; then
log "Installing acme.sh (vendored)..."
mkdir -p "$ACME_HOME"
@@ -242,11 +234,19 @@ 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, web UI user is group member
chown -R "$USER_DAEMON_NAME:$USER_GROUP" "$PROJECT_DIR"
chmod -R g+rX "$PROJECT_DIR"
# 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 "
@@ -272,12 +272,6 @@ render_template "${PROJECT_DIR}/system/sudoers.d/vacuum-walld" \
visudo -cf /etc/sudoers.d/vacuum-walld || err "Invalid sudoers file!"
# WebUI user no longer has sudo — write minimal sudoers file
install -m 0440 /dev/null /etc/sudoers.d/vacuum-wall 2>/dev/null || true
echo "# WebUI user ($USER_NAME) has no sudo access." > /etc/sudoers.d/vacuum-wall
echo "# Privileged operations are handled by $USER_DAEMON_NAME via the daemon API." >> /etc/sudoers.d/vacuum-wall
visudo -cf /etc/sudoers.d/vacuum-wall || true
# --- 6. Install systemd units ---
log "Installing systemd units..."
export USER_GROUP
@@ -305,18 +299,22 @@ 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 || true
log "Added service http to public zone"
firewall-cmd --permanent --add-service=https >/dev/null 2>&1 || true
log "Added service https to public zone"
firewall-cmd --permanent --add-service=ssh >/dev/null 2>&1 || true
log "Added service ssh to public zone"
firewall-cmd --reload >/dev/null 2>&1 || true
log "Firewalld rules reloaded"
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 || true
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)"
@@ -360,6 +358,10 @@ 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
@@ -455,7 +457,8 @@ log "Detecting network interfaces..."
# Auto-detect WAN (interface with default gateway)
WAN_IFACE="${WAN_IFACE:-}"
if [[ -z "$WAN_IFACE" ]]; then
DETECTED_WAN=$(ip route show default 2>/dev/null | awk '/default/ {print $5; exit}')
# 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"
@@ -526,26 +529,35 @@ with open(os.path.join(p, 'config/firewall/config.json'), 'w') as f:
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
log "Created firewalld zone: internal"
firewall-cmd --permanent --zone=internal --set-target=ACCEPT >/dev/null 2>&1 || true
firewall-cmd --permanent --zone=internal --add-service=dhcp >/dev/null 2>&1 || true
log "Added service dhcp to internal zone"
firewall-cmd --permanent --zone=internal --add-service=dns >/dev/null 2>&1 || true
log "Added service dns to internal zone"
firewall-cmd --permanent --zone=internal --add-service=ntp >/dev/null 2>&1 || true
log "Added service ntp to internal zone"
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 || true
log "Created firewalld zone: vpn"
firewall-cmd --permanent --zone=vpn --set-target=ACCEPT >/dev/null 2>&1 || true
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 || true
log "Enabled masquerade on public zone ($WAN_IFACE)"
firewall-cmd --permanent --zone=public --add-interface="$WAN_IFACE" >/dev/null 2>&1 || true
log "Assigned $WAN_IFACE to public zone"
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
@@ -554,27 +566,24 @@ if [[ -n "$LAN_IFACES" ]]; then
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 || true
log "Assigned $iface to internal zone"
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 || true
log "Firewalld rules reloaded"
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 || true
log "Enabled nginx"
systemctl enable vacuum-walld >/dev/null 2>&1 || true
log "Enabled vacuum-walld"
systemctl enable vacuum-wall >/dev/null 2>&1 || true
log "Enabled vacuum-wall"
systemctl enable vacuum-wall-acme.timer >/dev/null 2>&1 || true
log "Enabled vacuum-wall-acme.timer"
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 || true
log "Enabled avahi-daemon"
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
@@ -611,6 +620,9 @@ nginx -t 2>/dev/null && nginx -s reload 2>/dev/null && log "Reloaded nginx" || \
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"