fix: two-user model bug fixes and docs
This commit is contained in:
@@ -15,9 +15,9 @@ vacuum-walld ──→ daemon/handlers/*.py ──→ sudo <cmd> ──→ 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:<group>` 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.
|
||||
|
||||
|
||||
+2
-2
@@ -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:
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:<group>` 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.
|
||||
|
||||
|
||||
+5
-5
@@ -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:<group>` 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 |
|
||||
|---|---|---|
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<select id="rule-zone" name="zone" required>
|
||||
<option value="">— Select zone —</option>
|
||||
{% for zone in (zones or []) %}
|
||||
<option value="{{ zone.get('name', '') }}">{{ zone.get('name', '') }}</option>
|
||||
<option value="{{ zone }}">{{ zone }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user