diff --git a/AGENTS.md b/AGENTS.md index c75b7c4..71128e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,9 +15,9 @@ vacuum-walld ──→ daemon/handlers/*.py ──→ sudo ──→ syste ### Two-User Model with Shared Group -- **`vacuum-walld`** (daemon user): runs the privileged background daemon with `NOPASSWD sudo` whitelist (`/etc/sudoers.d/vacuum-walld`). Owns project directory and socket. -- **`vacuum-wall`** (WebUI user): runs the Flask process with **zero sudo** access. Communicates with the daemon via Unix socket. -- **`vacuum-wall`** (shared group): both users belong to this group. Socket is `vacuum-walld:vacuum-wall` with mode `0660`. Project dir is owned by `vacuum-walld:vacuum-wall` with group-read+execute. +- **`vacuum-walld`** (daemon user): runs the privileged background daemon with `NOPASSWD sudo` whitelist (`/etc/sudoers.d/vacuum-walld`). Owns project directory and socket. Primary group is the WebUI user's primary group. +- **WebUI user** (default: repo owner in `--dev` mode): runs the Flask process with **zero sudo** access. Communicates with the daemon via Unix socket. +- **Shared group**: both users share the WebUI user's primary group. Socket is `vacuum-walld:` with mode `0660`. Project dir is owned by the WebUI user with group-read+execute. ### Code Layout @@ -45,7 +45,7 @@ Project uses `.venv`. Install deps with `pip install -e .` (from `pyproject.toml ## Deployment -`install.sh` installs only system components and configures them; the project serves from the repo root by default. All options can be set via env vars or CLI flags (CLI takes precedence). Set `INSTALL_DIR` or `--path` to override install directory. Use `--dev` to auto-detect repo owner as service user. The `vacuum-wall` system user has `HOME=$INSTALL_DIR` but no actual home directory (`--no-create-home`). +`install.sh` installs only system components and configures them; the project serves from the repo root by default. All options can be set via env vars or CLI flags (CLI takes precedence). Set `INSTALL_DIR` or `--path` to override install directory. Use `--dev` to auto-detect repo owner as service user (non-dev mode requires `--user`). All Python modules use `Path(__file__).resolve().parent.parent` for `PROJECT_DIR` — no hardcoded paths. ACME certs live at `PROJECT_DIR/data/acme/`. @@ -55,7 +55,7 @@ All Python modules use `Path(__file__).resolve().parent.parent` for `PROJECT_DIR .venv/bin/python webui/server.py # binds 127.0.0.1:9090 ``` -In production the systemd unit runs as the `vacuum-wall` system user (`NoNewPrivileges`, `ProtectSystem=strict`, loopback-only networking). +In production the systemd unit runs as the configured service user (`NoNewPrivileges`, `ProtectSystem=strict`, loopback-only networking). When `install.sh --dev` is used, the repo owner gets NOPASSWD sudo for system service commands (`nginx -t`, `nginx -s reload`, `firewall-cmd`, `wg`, `systemctl reload dnsmasq`, etc.). This allows invoking those commands directly in bash to inspect or test live system state during debugging, without relying on the mocked test suite. diff --git a/daemon/client.py b/daemon/client.py index 3fe6ad3..232c651 100644 --- a/daemon/client.py +++ b/daemon/client.py @@ -5,6 +5,7 @@ Communicates with vacuum-walld over a Unix socket using requests-unixsocket. import json import logging +import urllib.parse from typing import Any import requests @@ -64,12 +65,11 @@ def request( Raises NotFound on HTTP 404. Raises BadRequest on HTTP 400. """ sp = socket_path or _get_socket_path() - url = f"http://localhost{path}" + url = f"http+unix://{urllib.parse.quote(sp, safe='')}{path}" sess = requests_unixsocket.Session() try: kwargs: dict[str, Any] = { "timeout": timeout, - "unix_socket": sp, } if method == "GET": if query_params: diff --git a/daemon/server.py b/daemon/server.py index bcc61e8..8584e6f 100644 --- a/daemon/server.py +++ b/daemon/server.py @@ -250,7 +250,7 @@ def create_app() -> web.Application: app = web.Application() app.router.add_route("GET", "/health", _health) app.router.add_route("POST", "/batch", _handle_batch) - app.router.add_route("{tail:.*}", _catch_all) + app.router.add_route("*", "/{tail:.*}", _catch_all) return app diff --git a/docs/architecture.md b/docs/architecture.md index 3d67712..ad2de3e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -47,12 +47,14 @@ vacuum-walld ──→ daemon/handlers/logs.py ──→ sudo journalctl ── Vacuum Wall uses two distinct system users bridged by a shared group: -- **`vacuum-walld`** (daemon user): Runs the privileged background daemon. Holds the NOPASSWD sudo whitelist for all system-level commands. Owns the project directory and data files. Runs with `NoNewPrivileges=yes` (satisfiable since sudo is called directly by the daemon process). -- **`vacuum-wall`** (web UI user): Runs the Flask web serving process. Has **zero** sudo access. Communicates with the daemon via a Unix socket at `data/daemon.sock`. Runs with `NoNewPrivileges=yes`. -- **`vacuum-wall`** (shared group): Both users belong to this group. The daemon socket is owned by `vacuum-walld:vacuum-wall` with mode `0660`, allowing the web UI user to connect via group permission. The project directory is owned by `vacuum-walld:vacuum-wall` with group-read+execute, giving the web UI user read access to configs and shared files. +- **`vacuum-walld`** (daemon user): Runs the privileged background daemon. Holds the NOPASSWD sudo whitelist for all system-level commands. Runs with `NoNewPrivileges=yes` (satisfiable since sudo is called directly by the daemon process). +- **WebUI user** (default: repo owner in `--dev` mode): Runs the Flask web serving process. Has **zero** sudo access. Communicates with the daemon via a Unix socket at `data/daemon.sock`. Runs with `NoNewPrivileges=yes`. +- **Shared group**: Both users share the WebUI user's primary group. The daemon socket is owned by `vacuum-walld:` with mode `0660`, allowing the web UI user to connect via group permission. The project directory is owned by the WebUI user with group-read+execute, giving the daemon read access to configs and shared files. This design isolates privilege escalation entirely within the daemon, so a compromised Flask process cannot invoke sudo directly. The `lib/` modules no longer contain sudo calls; all privileged command execution lives in `daemon/handlers/*.py`. +**Dev mode variant**: When `install.sh --dev` is used, the repo owner (e.g., `wall`) becomes the WebUI user. The project directory remains owned by the repo owner, preserving git operations and code editing. The daemon user (`vacuum-walld`) has the repo owner's primary group as its own primary group, granting read access to project files. All subdirectories carry the setgid bit (`g+s`) so new files inherit the group regardless of the creator's primary group. + The `lib/` modules auto-discover the project root at runtime via `Path(__file__).resolve().parent.parent`. This works because `install.sh` performs an editable pip install (`pip install -e .`), keeping module files in the project directory rather than copying them to `site-packages/`. ## Install-Time Templating @@ -61,7 +63,6 @@ System configuration files in `system/` are Jinja2 templates rendered by `instal - **`systemd/vacuum-wall.service`**, **`systemd/vacuum-walld.service`**, **`systemd/vacuum-wall-acme.service`** — `{{ USER_NAME }}`, `{{ USER_DAEMON_NAME }}`, `{{ USER_GROUP }}`, `{{ PROJECT_DIR }}`, `{{ ACME_HOME }}` are substituted to produce the final systemd unit files installed to `/etc/systemd/system/`. The `PROJECT_DIR` template variable is set from the `INSTALL_DIR` environment variable (defaults to the repo root). - **`sudoers.d/vacuum-walld`** — `{{ USER_DAEMON_NAME }}` is substituted to produce the sudoers whitelist for the daemon user. -- **`sudoers.d/vacuum-wall`** — Reserved for the WebUI user; currently contains no sudo rules (privilege escalation is handled entirely by the daemon). - The timer file (`vacuum-wall-acme.timer`) contains no variable paths and is installed as-is. Runtime templates (`system/nginx/*.conf`, `system/dnsmasq.conf`, `system/wireguard*.conf`) are rendered at runtime by `lib/` modules via Jinja2 with Python data. @@ -127,7 +128,7 @@ The following file system locations are used for integration with system service | `/etc/nginx/snippets/vacuum-wall-ssl.conf` | Shared SSL configuration snippet (protocols, ciphers, DH parameters, OCSP). Included by all HTTPS server blocks. | Vacuum Wall (lib/nginx.py) | | `/etc/dnsmasq.d/vacuum-wall.conf` | Generated dnsmasq configuration file. Written from `config/dnsmasq/config.json`. | Vacuum Wall (lib/dnsmasq.py) | | `/etc/wireguard/wg0.conf` | Generated WireGuard interface configuration. Written from `config/wireguard/config.json`. | Vacuum Wall (lib/wireguard.py) | -| `/etc/sudoers.d/vacuum-wall` | Sudo whitelist for the configured system user. Defines all permitted privilege escalations. | Install script (rendered from Jinja2 template) | +| `/etc/sudoers.d/vacuum-walld` | Sudo whitelist for the daemon user. Defines all permitted privilege escalations. | Install script (rendered from Jinja2 template) | The `/etc/nginx/conf.d/vacuum-wall.conf` include file ensures that all domain-specific configurations in `sites-enabled/` are loaded by nginx without modifying the main `nginx.conf`. The SSL snippet keeps TLS settings consistent across all managed domains and allows global updates from a single location. diff --git a/docs/security.md b/docs/security.md index 83a3df4..299dd3c 100644 --- a/docs/security.md +++ b/docs/security.md @@ -2,22 +2,22 @@ ## Privilege Model -Vacuum Wall uses two distinct system users bridged by a shared group (`vacuum-wall`): +Vacuum Wall uses two distinct system users bridged by a shared group (the WebUI user's primary group): - **`vacuum-walld`** (daemon user): Runs the `vacuum-walld` background daemon, which is the only process with sudo access. The daemon communicates with the WebUI over a Unix socket at `data/daemon.sock`. All privileged operations — firewall rule changes, nginx reloads, dnsmasq config writes, WireGuard tunnel management — are executed by the daemon through a restricted sudo whitelist at `/etc/sudoers.d/vacuum-walld`. -- **`vacuum-wall`** (WebUI user): Runs the Flask management WebUI. Has **zero** sudo access. If the WebUI process is compromised, an attacker cannot invoke sudo directly — they are confined to the sandboxed Flask process with no privilege escalation path. +- **WebUI user** (default: repo owner in `--dev` mode): Runs the Flask management WebUI. Has **zero** sudo access. If the WebUI process is compromised, an attacker cannot invoke sudo directly — they are confined to the sandboxed Flask process with no privilege escalation path. -ACME certificate operations via `acme.sh` run as the WebUI user (`vacuum-wall`) — not as root, and not as the daemon user. The automated renewal timer (`vacuum-wall-acme.timer`) runs `acme.sh --cron` as `{{ USER_NAME }}`. When triggered from the WebUI or daemon, acme.sh also runs as the non-root process invoking it, using webroot validation that does not require binding to privileged ports. +ACME certificate operations via `acme.sh` run as the WebUI user — not as root, and not as the daemon user. The automated renewal timer (`vacuum-wall-acme.timer`) runs `acme.sh --cron` as `{{ USER_NAME }}`. When triggered from the WebUI or daemon, acme.sh also runs as the non-root process invoking it, using webroot validation that does not require binding to privileged ports. This design follows the principle of least privilege: only the daemon process holds sudo access, and only for explicitly enumerated commands. The WebUI user is completely isolated from sudo. ## Communication Between WebUI and Daemon -The WebUI communicates with the daemon via synchronous HTTP requests over a Unix socket (`data/daemon.sock`), owned by `vacuum-walld:vacuum-wall` with mode `0660`. The shared group membership allows the WebUI user to connect to the socket. The daemon runs an `aiohttp` server that routes requests to handler modules (`daemon/handlers/*.py`), which execute the privileged commands. +The WebUI communicates with the daemon via synchronous HTTP requests over a Unix socket (`data/daemon.sock`), owned by `vacuum-walld:` with mode `0660`. The shared group membership allows the WebUI user to connect to the socket. The daemon runs an `aiohttp` server that routes requests to handler modules (`daemon/handlers/*.py`), which execute the privileged commands. ## Sudo Whitelist -The file `/etc/sudoers.d/vacuum-walld` grants the daemon user (`vacuum-walld`) passwordless sudo access to a strict set of commands. The WebUI user (`/etc/sudoers.d/vacuum-wall`) has no sudo entries. Each daemon entry is scoped to a single binary with allowed arguments: +The file `/etc/sudoers.d/vacuum-walld` grants the daemon user (`vacuum-walld`) passwordless sudo access to a strict set of commands. The WebUI user has no sudo access. Each daemon entry is scoped to a single binary with allowed arguments: | Category | Whitelisted Command | Purpose | |---|---|---| diff --git a/webui/templates/rules.html b/webui/templates/rules.html index fbe212d..c031ad3 100644 --- a/webui/templates/rules.html +++ b/webui/templates/rules.html @@ -18,7 +18,7 @@