Files
vacuum-wall/AGENTS.md
T

127 lines
7.9 KiB
Markdown

# Vacuum Wall — Agent Instructions
## What This Is
SSL proxy / firewall appliance. Python 3 Flask WebUI behind nginx reverse proxy.
Deploys on Debian 13 (trixie). Serves from repo root by default.
## Architecture
```
Client ──→ nginx (SSL + basic auth) ──→ Flask (127.0.0.1:9090)
Flask ──→ daemon/client.py (Unix socket) ──→ vacuum-walld (aiohttp, daemon.sock)
vacuum-walld ──→ daemon/handlers/*.py ──→ sudo <cmd> ──→ system service
```
### 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. 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
- `webui/server.py` — Flask app entry point. **Only** file that creates the `app`.
- `webui/api/*.py` — Flask blueprints, one per subsystem. Routes prefix `/api/<subsystem>/`. All call `daemon.client` instead of `lib/` directly.
- `webui/api/common.py` — Shared `_ok()` / `_error()` response helpers used by all blueprints.
- `daemon/server.py` — aiohttp server, cache engine, batch routing, handler registry.
- `daemon/client.py` — Sync HTTP client over Unix socket using `requests_unixsocket.Session`.
- `daemon/handlers/*.py` — Privileged operation handlers (all `sudo` calls live here).
- `lib/common.py` — Shared utilities: `run()`, `run_proc()`, `load_json()`, `save_json()`, `deep_merge()`, `ensure_dirs()`. All `lib/` modules use these instead of defining local helpers.
- `lib/*.py` — Backend modules (parsing, config, shared logic). All have full type hints and `__all__` exports. No sudo calls — privilege escalation is handled by `daemon/handlers/*.py`.
- `data/` — Runtime artifacts (generated .confs, `.htpasswd`, ACME certs, firewall backup, dnsmasq fragments).
- `config/<subsystem>/config.json` — Declarative JSON configs (source of truth). Generated `.conf` in `data/nginx/sites-enabled/`. Certs in `data/acme/`.
- `system/` — System file templates. `systemd/` (service units installed to `/etc/systemd/system/`), `sudoers.d/`, `nginx/`.
- `webui/static/` — Vendored frontend libraries (JS + CSS). Flask auto-serves at `/static/`.
Project uses `.venv`. Install deps with `pip install -e .` (from `pyproject.toml`). `__init__.py` files in `webui/` and `lib/` are intentionally empty — no `sys.path` boilerplate needed.
**No CDN packages.** All frontend libraries (JS and CSS) must be vendored in `webui/static/`. Never reference `unpkg.com`, `cdn.jsdelivr.net`, or similar. To add/update a library, edit the version in `scripts/update-vendor.sh` and run it.
| Library | Version | Vendor file | Symlink (active) | CDN source |
| ------- | ------- | ------------------------------------|--------------------------------| ---------- |
| htmx | 2.0.4 | `vendor/htmx-2.0.4.min.js` | `webui/static/htmx.min.js` | `npm:htmx.org@2.0.4` |
| htmx-ext-json-enc | 2.0.0 | `vendor/json-enc-2.0.0.js` | `webui/static/json-enc.js` | `npm:htmx-ext-json-enc@2.0.0` |
## 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 (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/`.
## Local Dev
```bash
.venv/bin/python webui/server.py # binds 127.0.0.1:9090
```
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.
## Blueprint ↔ lib Mapping (Naming Is Not 1:1)
| Blueprint | URL prefix | Backend module |
|-----------------------|-------------------|------------------|
| `webui/api/firewall` | `/api/firewall/` | `lib.firewall` |
| `webui/api/dhcp` | `/api/dhcp/` | `lib.dnsmasq` |
| `webui/api/proxy` | `/api/proxy/` | `lib.nginx` |
| `webui/api/certs` | `/api/certs/` | `lib.acme` |
| `webui/api/wireguard` | `/api/wireguard/` | `lib.wireguard` |
## Privileged Operations
`daemon/handlers/*.py` call `sudo` for everything that touches system services. Whitelist is `system/sudoers.d/vacuum-walld`.
**acme.sh must never run as root** — always as the service user via `sudo -u`.
Pattern for mutations: write JSON → render native config → `sudo <cmd>` to apply.
Adding a new privileged command requires a sudoers entry **and** the `daemon/handlers/` code.
## API Response Contract
- Success: `{"ok": true, "data": <value>}` — helper `_ok(data)` from `webui.api.common`
- Error: `{"ok": false, "error": "msg"}` — helper `_error(msg, code=400)` from `webui.api.common`
- `acme.issue()` / `acme.renew()` raise `RuntimeError` on failure — API layer wraps in try/except
- HTTP codes: `400` bad request, `404` not found, `500` internal failure
## Page Routes vs API
`server.py` serves HTML pages with Jinja templates. All data is wrapped in `_safely(fn, default)` so page routes never 500 — they render with fallback values instead.
## Deploy
`install.sh` is the single deploy script. Run as root, requires `MGMT_DOMAIN`, `MGMT_PASS`, `ACME_EMAIL` env vars.
## Lint and Tests
**Linter / formatter:** Ruff (`ruff check` + `ruff format`). Config in `pyproject.toml` under `[tool.ruff]`.
**Tests:** pytest in `tests/`. Run with `python -m pytest`. Tests mock out subprocess calls (firewalld, acme.sh, WireGuard, nginx, dnsmasq) — no system services required.
```bash
.venv/bin/ruff check lib/ webui/ tests/ # lint
.venv/bin/ruff format lib/ webui/ tests/ # format
.venv/bin/python -m pytest tests/ -v # test (212 tests)
```
Install dev tooling with `pip install -e ".[dev]"`.
## Docs
`docs/` contains the authoritative reference for each subsystem.
| Doc | Contents |
|-----|----------|
| `docs/architecture.md` | Request flow, subsystem communication, two-user model, zone model, state management |
| `docs/security.md` | Privilege model, sudo whitelist, systemd hardening, TLS config, zone trust levels |
| `docs/deployment.md` | Install script options, what install.sh does, post-install setup, troubleshooting |
| `docs/config.md` | JSON schema for each subsystem config (dnsmasq, nginx, wireguard, cert types) |
| `docs/api.md` | REST API endpoint reference, request/response contracts, route patterns |
| `docs/overview.md` | Subsystem summaries, tech stack, complete project directory tree |
## Important Rules
1. Ask, don't assume. If something is unclear, ask before writing a single line. Never make silent assumptions about intent, architecture, or requirements.
2. Simplest solution first. Always implement the simplest thing that could work. Do not add abstractions or flexibility that weren't explicitly requested.
3. Don't touch unrelated code. If a file or function is not directly part of the current task, do not modify it, even if you think it could be improved.
4. Flag uncertainty explicitly. If you are not confident about an approach or technical detail, say so before proceeding. Confidence without certainty causes more damage than admitting a gap.