refactor: introduce two-user daemon architecture with socket-based communication

- Add daemon/ module with aiohttp server, sync client, and handler registry
- Add daemon/handlers/ for privileged operations (acme, dnsmasq, firewall, logs, nginx, wireguard)
- Add system/acme-deploy.py, vacuum-walld sudoers and systemd service
- Update API routes to use daemon client instead of lib/ directly
- Update lib/, tests/, and webui/ for new architecture
- Update docs and deployment scripts
This commit is contained in:
2026-05-27 23:38:23 +00:00
parent 5ac69dfa7e
commit 200e078bc5
39 changed files with 4671 additions and 1810 deletions
+91 -22
View File
@@ -139,6 +139,8 @@ fi
# Optional settings with defaults
USER_NAME="${_cli_user:-${USER_NAME:-vacuum-wall}}"
# 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
@@ -147,15 +149,20 @@ if [[ "$_cli_is_dev" != true ]] && [[ "$USER_NAME" != "vacuum-wall" ]] && id "$U
_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 "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"
echo "============================================"
echo " Vacuum Wall Appliance Installer"
echo " Install dir: $PROJECT_DIR"
echo " System user: $USER_NAME"
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 "============================================"
@@ -176,15 +183,35 @@ apt-get install -y -qq \
apache2-utils \
avahi-daemon
# --- 2. Create system user (home set to PROJECT_DIR for env, no-create) ---
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"
# --- 2. Create shared group ---
if ! getent group "$USER_GROUP" &>/dev/null; then
log "Creating shared group $USER_GROUP..."
groupadd --system "$USER_GROUP"
else
log "User $USER_NAME already exists."
log "Group $USER_GROUP already exists."
fi
# --- 2b. Setup Python venv ---
# --- 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. 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 ---
if [[ -x "${PROJECT_DIR}/.venv/bin/python3" ]] && [[ "$_cli_force_venv" != true ]]; then
log "Python venv already exists, skipping (use --force-venv to recreate)."
else
@@ -192,16 +219,16 @@ else
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"
chown -R "$USER_DAEMON_NAME:$USER_GROUP" "${PROJECT_DIR}/.venv"
fi
# --- 2c. Install acme.sh (vendored) ---
# --- 2d. Install acme.sh (vendored) ---
if [[ ! -x "$ACME_HOME/acme.sh" ]]; then
log "Installing acme.sh (vendored)..."
mkdir -p "$ACME_HOME"
cp "${PROJECT_DIR}/vendor/acme.sh" "$ACME_HOME/acme.sh"
chmod +x "$ACME_HOME/acme.sh"
chown -R "$USER_NAME:$USER_NAME" "$ACME_HOME"
chown -R "$USER_DAEMON_NAME:$USER_GROUP" "$ACME_HOME"
else
log "acme.sh already installed."
fi
@@ -215,16 +242,21 @@ 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"
# --- 4. Template rendering function ---
render_template() {
export USER_NAME PROJECT_DIR ACME_HOME
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'],
}
@@ -234,13 +266,24 @@ print(Template(text).render(**env), end='')
# --- 5. Install sudoers ---
log "Installing sudoers whitelist..."
render_template "${PROJECT_DIR}/system/sudoers.d/vacuum-wall" \
| install -m 0440 /dev/stdin /etc/sudoers.d/vacuum-wall
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-wall || err "Invalid sudoers file!"
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
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
@@ -291,7 +334,7 @@ else
-addext "subjectAltName=DNS:$DOMAIN"
fi
chown -R "$USER_NAME:$USER_NAME" "$ACME_HOME"
chown -R "$USER_DAEMON_NAME:$USER_GROUP" "$ACME_HOME"
# Generate/update htpasswd directly in data/nginx/
HTPASSWD_FILE="${PROJECT_DIR}/data/nginx/.htpasswd"
@@ -312,7 +355,7 @@ with open(os.environ['HTFILE'], 'w') as f:
warn "Could not generate htpasswd (install apache2-utils or python3-crypt)"
fi
chown "$USER_NAME:$USER_NAME" "${PROJECT_DIR}/data/nginx/.htpasswd"
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
@@ -523,6 +566,8 @@ log "Firewalld rules reloaded"
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
@@ -532,7 +577,30 @@ 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"
# Start daemon first, then web UI
# Stop vacuum-wall first. vacuum-wall.service Requires=vacuum-walld.service,
# so stopping vacuum-wall triggers a cascade stop of vacuum-walld. The explicit
# stop of vacuum-walld below is redundant but ensures clean teardown.
systemctl stop vacuum-wall >/dev/null 2>&1 || true
systemctl stop vacuum-walld >/dev/null 2>&1 || true
systemctl start vacuum-walld >/dev/null 2>&1 && log "Started vacuum-walld daemon" || warn "Could not start vacuum-walld daemon"
# Wait for daemon socket
_SOCKET="$PROJECT_DIR/data/daemon.sock"
for _i in $(seq 1 10); do
[[ -S "$_SOCKET" ]] && break
sleep 0.5
done
if [[ ! -S "$_SOCKET" ]]; then
warn "Daemon socket not found at $_SOCKET"
fi
# Set socket ownership so web UI user can connect
if [[ -S "$_SOCKET" ]]; then
chown "$USER_DAEMON_NAME:$USER_GROUP" "$_SOCKET" 2>/dev/null || true
chmod 0660 "$_SOCKET" 2>/dev/null || true
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" || \
@@ -545,8 +613,8 @@ if [[ -f "$ACME_HOME/account.conf" ]] && grep -q '^ACME_LEEMAIL=' "$ACME_HOME/ac
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" \
chown "$USER_DAEMON_NAME:$USER_GROUP" "$ACME_HOME/www"
sudo -u "$USER_DAEMON_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)"
@@ -560,8 +628,9 @@ echo "============================================"
echo ""
echo " Management UI: https://$DOMAIN"
echo " User: $MGMT_USER"
echo " WebUI service: vacuum-wall.service"
echo " ACME renewal: vacuum-wall-acme.timer"
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