feat(web): service management panel over a sudo allow-list (stage D)
Adds operations/services.py: a fixed ManagedService allow-list (act-runner, act-runner-rust, vmware-host, ci-web) with find_service/service_states/control_service. control_service builds an argument list only (never shell=True), validates the action against ALLOWED_ACTIONS (ValueError->400), refuses stop on restart-only services (ci-web, allow_stop=False) without building a command, and defers ci-web's own restart via 'sudo -n systemd-run --on-active=2 systemctl restart ci-web.service' so the HTTP response returns first.
Route POST /services/{service_id}/{action} in web/routes.py mirrors vms_remove: require_user + require_csrf + two-step confirm gate + one audit entry per stage; unknown id / bad action / disallowed-stop short-circuit to 400 before any subprocess or audit; the vmware-host confirm step warns with the active-VM count. New /services read partial with state-aware Start/Stop/Restart buttons, polled every 15s.
deploy/sudoers.d/ci-web grants ci-runner exact-command NOPASSWD for exactly these nine systemctl lines plus the systemd-run deferral — no wildcards, no bare systemctl, no ci-web stop; setup-web-linux.sh installs it mode 0440 and validates with 'visudo -cf', rolling back on failure. Config gains [web].managed_services override; docs/WEB-UI.md documents the panel + security model.
Gates: 1044 tests pass (46 new security/unit tests), coverage 94.20%, ruff + mypy --strict clean. Not yet deployed: sudoers install + prod-venv reinstall + ci-web restart are a human privilege-boundary step.
This commit is contained in:
+87
-1
@@ -186,7 +186,93 @@ see `plans/archived/2026-05-23/B3-closeout.md`):
|
||||
|
||||
---
|
||||
|
||||
## 7. Troubleshooting
|
||||
## 7. Service management (Stage D)
|
||||
|
||||
The dashboard's **Services** panel lets an authenticated web user start, stop,
|
||||
or restart a fixed allow-list of systemd units on the host — the same
|
||||
confirm-gated, CSRF-protected, audited mutating-action shape as VM
|
||||
remove/cleanup, cache purge, and template backup (§5), applied to `sudo
|
||||
systemctl`.
|
||||
|
||||
**Allow-list (default, `operations/services.py::MANAGED_SERVICES`):**
|
||||
|
||||
| Slug | Unit | Stop allowed? |
|
||||
| --- | --- | --- |
|
||||
| `act-runner` | `act-runner.service` | yes |
|
||||
| `act-runner-rust` | `act-runner-rust.service` | yes |
|
||||
| `vmware-host` | `vmware-host.service` | yes |
|
||||
| `ci-web` | `ci-web.service` | **no** — restart-only |
|
||||
|
||||
Override it with `[web].managed_services` in `config.toml` (see
|
||||
`config.example.toml`'s commented example) — a list of `{id, unit, label,
|
||||
allow_stop}` tables. A malformed row is skipped, never crashes startup.
|
||||
|
||||
### Security model
|
||||
|
||||
- **Allow-list by slug, not by unit name.** The URL is
|
||||
`POST /services/{service_id}/{action}` where `service_id` is one of the
|
||||
slugs above (`find_service()`), never a raw systemd unit name — there is no
|
||||
code path that builds a controllable unit from unvalidated request input.
|
||||
- **Exact-command sudoers, no wildcards.** `deploy/sudoers.d/ci-web` grants
|
||||
`ci-runner` passwordless `sudo` for exactly nine literal command lines (one
|
||||
per unit × action, plus the `ci-web` restart special case) — no bare
|
||||
`systemctl`, no `*`, no `ALL` in the command position. Even if the web app
|
||||
itself were compromised, `ci-runner`'s sudo grant cannot be used to run any
|
||||
command beyond those nine lines.
|
||||
- **Arg list, never a shell.** `operations/services.py::control_service`
|
||||
builds `["sudo", "-n", "systemctl", action, unit]` (or the `systemd-run`
|
||||
variant below) as a Python list handed straight to `subprocess.run` —
|
||||
`shell=True` is never used anywhere in this path.
|
||||
- **`ci-web` is restart-only.** `allow_stop=False` — a `stop` request against
|
||||
it is refused with a 400 *before* any subprocess or audit entry, both at
|
||||
the route (`web/routes.py::services_control`) and defensively again inside
|
||||
`control_service` itself. Rationale: stopping the service that renders this
|
||||
very panel would lock the operator out with no way to start it again from
|
||||
the UI.
|
||||
- **`ci-web` restart is deferred.** Restarting `ci-web.service` from inside
|
||||
its own worker would kill the HTTP response before it could be sent, so
|
||||
that one case runs `sudo -n systemd-run --on-active=2 systemctl restart
|
||||
ci-web.service` instead of a direct `systemctl restart` — the
|
||||
`systemd-run` call itself returns almost immediately (it only schedules
|
||||
the restart ~2s out), so the confirming request completes normally and the
|
||||
dashboard briefly drops a couple of seconds later.
|
||||
- **`vmware-host` stop/restart interrupts running jobs.** The confirm step
|
||||
shows the current count of active VMs ("N active VM(s) — this interrupts
|
||||
running jobs") when stopping or restarting `vmware-host.service`, computed
|
||||
from the same VM backend the Live VMs panel uses — best-effort, degrades
|
||||
to no warning if the backend is unavailable, never blocks the action.
|
||||
- **Every action is audited exactly once per stage** (`service.start` /
|
||||
`service.stop` / `service.restart`, `target` = the unit name), same as
|
||||
every other mutating route — visible at `/audit`.
|
||||
|
||||
### Sudoers install
|
||||
|
||||
`setup-web-linux.sh` installs `deploy/sudoers.d/ci-web` to
|
||||
`/etc/sudoers.d/ci-web` (mode `0440`, owner `root:root`) as step 6/7,
|
||||
validating it with `visudo -cf` and **removing it again if validation
|
||||
fails** — a broken `sudoers.d` fragment can lock out `sudo` entirely, so this
|
||||
step never leaves an unvalidated file in place. Before installing on a new
|
||||
host, confirm `systemctl`/`systemd-run` are actually at `/usr/bin` on that
|
||||
host (`command -v systemctl systemd-run`) — edit both
|
||||
`deploy/sudoers.d/ci-web` and the installed copy if not.
|
||||
|
||||
To install just the sudoers grant (e.g. after editing
|
||||
`deploy/sudoers.d/ci-web`) without touching anything else:
|
||||
|
||||
```bash
|
||||
sudo bash setup-web-linux.sh --skip-pip --skip-secrets
|
||||
```
|
||||
|
||||
### Deploy note
|
||||
|
||||
After merging a Stage D change: re-run `pip install .` into the production
|
||||
venv (per `CLAUDE.md`), re-run `setup-web-linux.sh` (installs/updates the
|
||||
sudoers grant and restarts `ci-web.service`), then confirm the sudoers file
|
||||
on the live host with `sudo visudo -c` before relying on the panel.
|
||||
|
||||
---
|
||||
|
||||
## 8. Troubleshooting
|
||||
|
||||
**Login loop (bounces back to `/login` after authenticating with Gitea)**
|
||||
: The registered Redirect URI in Gitea does not exactly match
|
||||
|
||||
Reference in New Issue
Block a user