- install.sh: the traversal-chmod loop assigned _d but looped over the
never-set $d; under set -u every fresh install aborted with
"d: unbound variable" at that line. Loop over $_d.
- acme collector: the self-heal normalize (sudo chmod g+rwX) now runs
only when a no-sudo group-read-bit probe detects a lost bit — acme.sh
re-hardens the tree 600 on every run, so the steady-state poll makes
no sudo call. The group bit (not daemon readability) is what the
two-user model keeps for the WebUI user.
- lib.acme: new get_acme_home() accessor (ACME_HOME env, default
data/acme), reused by _run_acme; _summarize_acme_output preserves a
"Permission denied" line even when it is not among the final two, so
the collector's actionable-error matcher keeps firing.
- nginx template: emit location /static/ for any is_management path
(not only '/'); the SPA references /static/... at the domain root
regardless of the management backend path.
- tests: probe, summarizer, and nginx-subpath cases in
test_state.py, test_acme.py, test_nginx.py.
Generated management-domain server blocks now include a
location /static/ aliasing webui/static/ with Cache-Control: no-cache
(ETag revalidation -> 304), nosniff, and a restrictive CSP, so SPA
asset requests no longer reach Flask. Flask's static route remains
the dev-mode fallback.
- lib/nginx.py + daemon/handlers/nginx.py: static_root render context
(the handler renders the template directly, so both paths need it)
- template: alias uses a trailing slash (nginx concatenates the
location remainder onto the alias value)
- install.sh: a+rX on webui/static plus execute-up-the-parent-chain
so the nginx worker (www-data) can traverse repo-in-$HOME installs
- tests: mgmt static location assertions (positive + non-mgmt negative)
- docs: AGENTS.md, architecture.md, security.md static-serving notes
- move state collectors from lib/state.py to daemon/collectors/ (7
modules, registration side-effect; daemon/server.py imports the
package before the first populate())
- webui/api: new daemon_route() decorator factory in common.py
collapses the try/except daemon-proxy boilerplate in all 8
blueprints (rules/params/body/transform keep responses identical)
- firewall: interface-coverage invariant — config is the source of
truth for zone interfaces (absent key = empty, no hands-off
zones); pure validate_coverage() enforced at save (400) and apply
(409, force: true overrides), top-level `unmanaged` exemption
- lib: get_config() reads are now pure (no dir creation or writes);
new lib/bootstrap.py creates runtime dirs and persists the
one-shot nginx legacy migration at daemon start, after
system_import (lib.nginx.migrate_config_file)
- lib/common: compute_pending() apply-bookkeeping helper
- daemon: emit_and_refresh() handler helper; refresh_state(bump=) so
/status/refresh no longer bumps versions (poll/mutation only)
- acme: move --log last so acme.sh never treats a real arg as the
log-file argument
- docs: AGENTS.md, config.md, state-model.md, api.md updated;
HARDEN.md dropped (plan implemented); apply-confirm force wording
Tests: 917 passed; ruff check + format clean.
acme:
- acme.sh chmods its tree to owner-only (700/600) every run, which
broke the two-user model: a tree left owner-only by one user made
every acme.sh call of the other exit 2
- normalize_acme_home() reopens group access (sudo chmod g+rwX,
files only — setgid dirs trip RestrictSUIDSGID); _run_acme_preflight
is the choke point before every daemon acme.sh call + startup
- acme service now runs as the daemon user; --log persists the raw CA
transcript; SYS_LOG=6 journals manual issue/renew runs
- timer daily-only: two runs/day landed inside ZeroSSL's 24h
validation backoff (Retry-After: 86400) — a permanent renewal lockout
- _collect_acme no longer raises on cert-list failure; reports
status.error (AcmeState.status) so the certs page can surface it
firewall: re-stamp the applied baseline on live zone mutations
(interfaces/services/rich-rules/masquerade/forward-ports) so cancel-all
reverts to post-mutation state, not a stale install-era snapshot;
set_masquerade syncs the declarative config for existing zones;
add_forward_port records toaddr only with toport
status: apply-all accepts {"force": true} (forwarded to the firewall
apply only); ApplyConfirm force checkbox; applyResultToasts() — the
errors map wins over the 200; ActionButton checks errors before the
success toast; dashboard uses ApplyConfirm
system_import: drift re-imports carry the existing apply-meta; first
import stamps the adopted content as applied (it is the running state)
— no phantom pending changes
nginx: get_config only re-saves when migration actually changed the
config (no more owner/mtime churn on every read)
install: repair mis-owned top-level system dirs (tmpfiles
unsafe-path-transition), warn with a full-repair command for deeper
mis-ownership
daemon/server: loop.get_exception_handler() (aiohttp API fix)
tests: 888 pytest + 24 node passing; ruff clean
Replace the legacy top-level management key with a unified paths-based
model. Each domain now contains a paths map where each entry defines its
own backend, auth, headers, and flags (is_management, is_websocket).
- Add _migrate_config() to auto-migrate legacy formats on first load
- Remove set_management_proxy() and POST_NGINX_MANAGEMENT endpoint
- Update server_block.conf template to iterate paths with per-location auth
- Update daemon handler, API blueprint, state collector, and install script
- Add server config generation tests for paths, WebSocket, auth inheritance
- Update frontend proxy page to display per-path rows with flags
- Fix WireGuard private key leak in API responses and config updates
- Update systemd service to serve from repo root with adjusted sandbox
- Add CLI flags, idempotency, and dev mode to install.sh
- Extract common utilities to lib/common.py and webui/api/common.py
- Migrate frontend to htmx for simpler, more maintainable UI
- Update docs to reflect current architecture and deployment model
- Vendor htmx dependencies per project requirements
Replace all hardcoded /home/wall/vacuum-wall paths in lib/ with Path(__file__).resolve()
auto-discovery. Move config files from data/ to config/<subsystem>/config.json.
ACME now uses ACME_HOME env var and data/acme/ for cert storage. Systemd units
and sudoers use {{ USER_NAME }}, {{ PROJECT_DIR }}, {{ ACME_HOME }} Jinja2
template variables for install-time substitution. Remove sys.path.insert boot
strap from test files.