From cb683f7e61d134d5f5ed512e78e08e8f02092843 Mon Sep 17 00:00:00 2001 From: Mike Teehan Date: Fri, 29 May 2026 22:29:54 +0000 Subject: [PATCH] install: two-user model ownership and deployment --- docs/deployment.md | 37 +++++- install.sh | 168 +++++++++++++++------------- system/sudoers.d/vacuum-wall | 2 - system/systemd/vacuum-walld.service | 2 +- 4 files changed, 123 insertions(+), 86 deletions(-) delete mode 100644 system/sudoers.d/vacuum-wall diff --git a/docs/deployment.md b/docs/deployment.md index 5da3b92..ff9b39f 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -26,7 +26,7 @@ Download the Vacuum Wall repository onto the target machine, then run the instal MGMT_DOMAIN=wall.example.com \ MGMT_PASS="strongpassword" \ ACME_EMAIL="admin@example.com" \ -bash install.sh +./install.sh --user vacuum-wall # Dev mode: CLI flags, auto-detects repo owner ./install.sh --dev --mgmt-pass strongpassword --acme-email "admin@example.com" @@ -46,7 +46,7 @@ All settings that can be passed as an environment variable also have a CLI flag | `--mgmt-pass` | `MGMT_PASS` | Yes | Password for HTTP basic auth protecting the WebUI. | | `--mgmt-user` | `MGMT_USER` | No | Username for WebUI access. Defaults to `admin`. | | `--acme-email` | `ACME_EMAIL` | Yes | Email for ACME provider (ZeroSSL by default). | -| `--user, -u` | `USER_NAME` | No | System user for the WebUI service. Defaults to `vacuum-wall`. | +| `--user, -u` | `USER_NAME` | Yes* | WebUI service user (created if it does not exist). Required for non-dev mode. In `--dev` mode, auto-detected from repo owner. | | `--path, -p` | `INSTALL_DIR` | No | Install directory. Defaults to repo root. Set to deploy from a custom path (e.g., `/opt/vacuum-wall`). | | `--dev` | -- | No | Development mode: auto-detects repo owner as service user, skips safety warning. | | `--wan-iface` | `WAN_IFACE` | No | WAN interface name. Auto-detected from default gateway. | @@ -57,6 +57,33 @@ Run `./install.sh --help` for full usage. --- +## Dev Mode + +The `--dev` flag is designed for developers working in a git clone. It auto-detects the repo owner and uses that user as the WebUI service user. + +### Ownership Model + +In dev mode, the ownership model preserves the developer's ability to work with the repository: + +- **Project directory**: Owned by the repo owner (e.g., `wall`), group is the repo owner's primary group (e.g., `wall`). The developer retains full control — `git add`, `git commit`, editing code and config files all work normally. +- **Daemon access**: The daemon user (`vacuum-walld`) has the repo owner's primary group as its own primary group, granting read access to all project files. The project directory has the setgid bit (`g+s`) on all subdirectories, ensuring new files inherit the group. +- **`.venv/` and `data/`**: Owned by the repo owner, group is the repo owner's primary group. The developer can run `pip install`, inspect logs, and manage runtime artifacts. The daemon reads `.venv/` (Python interpreter) and writes to `data/` (runtime files) via group permissions. +- **Daemon socket** (`data/daemon.sock`): Owned by `vacuum-walld:` (mode `0660`). The repo owner accesses it via primary group membership. + +### Running the Installer in Dev Mode + +```bash +./install.sh --dev --mgmt-pass strongpassword --acme-email "dev@example.com" +``` + +The script detects the repo owner (e.g., `wall`), creates the `vacuum-walld` daemon user with the repo owner's primary group, and sets up the ownership model described above. + +### Idempotent Re-Runs + +Running `--dev` again is safe. The ownership is idempotent (`chown -R` to the same owner), supplementary group membership is deduplicated by the OS, and setgid bits are applied recursively each time. + +--- + ## Container / Custom Deployment You can deploy Vacuum Wall in a container or at any custom path. Use `--path` (or `INSTALL_DIR`) for the mount or bind path, and `--user` (or `USER_NAME`) for whatever system user exists: @@ -77,14 +104,14 @@ The systemd service unit files and sudoers whitelist are rendered from Jinja2 te The installer performs the following steps automatically: - **Package installation**: Installs firewalld, nginx, dnsmasq, avahi-daemon, wireguard-tools, python3, python3-pip, jq, curl, iptables, nftables, and apache2-utils. -- **Shared group creation**: Creates a shared system group (`vacuum-wall`) both service users belong to. +- **WebUI user creation**: Creates the WebUI user (from `--user`) as a system user if it does not exist. +- **Shared group**: Uses the WebUI user's primary group as the shared group between both service users. - **Daemon user creation**: Creates `vacuum-walld` (derived from WebUI user name) — a system user with `NOPASSWD` sudo access for privileged operations. Owns the project directory and daemon socket. -- **WebUI user creation**: Creates a dedicated system user (default: `vacuum-wall`, configurable via `USER_NAME`) with zero sudo access. Communicates with the daemon via Unix socket. - **Python venv**: Creates the Python virtual environment and installs project dependencies. Skips if already present (use `--force-venv` to recreate). - **acme.sh installation**: Copies the vendored acme.sh client to the data directory for ACME certificate management. Skips if already installed. - **Directory setup**: Creates config directories under `config/` for each subsystem's declarative JSON, and data directories under `data/` for generated files (nginx sites, dnsmasq fragments, firewall backup, WireGuard config). - **Template rendering**: Renders system template files (`systemd/*.service`, `sudoers.d/`) via Jinja2, substituting `USER_NAME`, `INSTALL_DIR`, and `ACME_HOME`. Installed systemd and sudoers files contain no hardcoded values. -- **Sudoers whitelist**: Installs a restrictive sudoers file at `/etc/sudoers.d/vacuum-walld` granting the daemon user `NOPASSWD` sudo for only the specific privileged commands needed for firewall, nginx, dnsmasq, and acme.sh management. Validates syntax with `visudo -cf`. The WebUI user's sudoers file (`/etc/sudoers.d/vacuum-wall`) is empty — it has no sudo access. +- **Sudoers whitelist**: Installs a restrictive sudoers file at `/etc/sudoers.d/vacuum-walld` granting the daemon user `NOPASSWD` sudo for only the specific privileged commands needed for firewall, nginx, dnsmasq, and acme.sh management. Validates syntax with `visudo -cf`. - **IP forwarding**: Enables `net.ipv4.ip_forward=1` in sysctl.conf and applies it at runtime, required for routing traffic between zones. Appends only if not already present. - **Firewalld initialization**: Starts and enables firewalld. Opens HTTP, HTTPS, and SSH services on the public zone for management access. - **Dnsmasq initialization**: Starts and enables dnsmasq for future DHCP/DNS serving on internal interfaces. diff --git a/install.sh b/install.sh index a29d49d..b6cf341 100755 --- a/install.sh +++ b/install.sh @@ -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 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" diff --git a/system/sudoers.d/vacuum-wall b/system/sudoers.d/vacuum-wall deleted file mode 100644 index 49d0e41..0000000 --- a/system/sudoers.d/vacuum-wall +++ /dev/null @@ -1,2 +0,0 @@ -# WebUI user ({{ USER_NAME }}) no longer has sudo access. -# Privileged operations are handled by vacuum-walld via the daemon API. diff --git a/system/systemd/vacuum-walld.service b/system/systemd/vacuum-walld.service index 63ceed7..c1f7ca4 100644 --- a/system/systemd/vacuum-walld.service +++ b/system/systemd/vacuum-walld.service @@ -33,7 +33,7 @@ LockPersonality=yes SystemCallFilter=@system-service PrivateDevices=yes -RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK IPAddressDeny=any IPAddressAllow=localhost NoNewPrivileges=yes